- collect_news.mjs: 네이버 뉴스 검색 다중 질의(상호·원장·의료진·주제어, 429 백오프) + 언론보도 게시판 자동 발견/clinic.json newsBoard. 관련성 = 제목·요약에 병원 이름(v2 §8). 제목·매체·날짜·원문 링크만 저장. news_outlets.mjs 는 build_news.py 매체 표 이식 - gate: BANNED_NEWS_TITLE(전후 사진·비교, 후기, 변신)·NEGATIVE_NEWS(규제·사건, "무사고" 통과)·checkNewsItems → check.mjs·test.mjs(19/19) - 워커 news 단계(youtube 뒤, images 앞), --news-board/--no-news, status.report.news - 뷰 news.json: 렛미인 "변신" 기사 41건 규칙으로 제외(excludedByGate 에 목록). 회귀 1,105건(수작업 1,096, 제목 일치 1,033), 원진 1,049건 - launchd 폴러 설치(haewon 지시): PATH 에 프레임워크 python3, bootstrap/bootout 안내 - 함정: JS \W 는 한글을 지움(제목 중복 판정 붕괴), 워드프레스 article:published_time 은 게시판 생성일, 네이버 질의당 1,000건 - v3 문서 §1·§2·§3·§5·§6 갱신, 템플릿 재내보내기 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
116 lines
7.2 KiB
JavaScript
116 lines
7.2 KiB
JavaScript
// 발행 게이트 러너. 빌드 결과(dist)와 소스(글 frontmatter·데이터)를 검사해 error가 하나라도 있으면 배포를 막는다.
|
||
// 규칙 본체는 scripts/gate/rules.mjs. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3.
|
||
//
|
||
// node scripts/check.mjs 검사
|
||
// node scripts/check.mjs --update-layout 구조 기준선(§3-2) 갱신. 룩앤필 변경을 haewon이 승인한 뒤에만
|
||
// node scripts/check.mjs --today=YYYY-MM-DD
|
||
// node scripts/check.mjs --home-text=../evidence/<clinic>/home_text.txt 40자 중복 검사 기준을 수집기 산출물로
|
||
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import * as R from './gate/rules.mjs';
|
||
|
||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||
const DIST = here('../dist/');
|
||
const POSTS = here('../src/content/posts/');
|
||
const LAYOUT_BASELINE = here('./gate/layout-baseline.json');
|
||
const args = process.argv.slice(2);
|
||
const updateLayout = args.includes('--update-layout');
|
||
const today = args.find((a) => a.startsWith('--today='))?.slice(8) ?? new Date().toISOString().slice(0, 10);
|
||
|
||
const homeTextPath = args.find((a) => a.startsWith('--home-text='))?.slice(12) ?? here('./home_text.txt');
|
||
const HOME_TEXT = existsSync(homeTextPath) ? readFileSync(homeTextPath, 'utf8') : '';
|
||
const authors = JSON.parse(readFileSync(here('../src/data/authors.json'), 'utf8'));
|
||
const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8'));
|
||
const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8'));
|
||
const site = JSON.parse(readFileSync(here('../src/data/site.json'), 'utf8'));
|
||
const INDEXABLE = process.env.PUBLIC_INDEXABLE === 'true';
|
||
|
||
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
|
||
// 글 상세는 사전순 첫 글 하나를 대표로 본다(템플릿·다른 병원에서도 같은 규칙).
|
||
const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set' };
|
||
|
||
function walk(dir, out = []) {
|
||
for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); }
|
||
return out;
|
||
}
|
||
|
||
const findings = []; // { where, level, code, msg }
|
||
const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }));
|
||
|
||
// ---------- 1. dist 검사 ----------
|
||
if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); }
|
||
const files = walk(DIST);
|
||
const firstPost = files.map((f) => f.replace(DIST, '/')).filter((r) => r.startsWith('/posts/')).sort()[0];
|
||
if (firstPost) LAYOUT_PAGES[firstPost] = 'sequence';
|
||
const layoutNow = {};
|
||
for (const f of files) {
|
||
const html = readFileSync(f, 'utf8');
|
||
const rel = f.replace(DIST, '/');
|
||
const text = R.stripHtml(html);
|
||
|
||
// JSON-LD
|
||
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
||
for (const b of blocks) {
|
||
try {
|
||
const types = JSON.stringify(JSON.parse(b));
|
||
if (rel.startsWith('/posts/') && !/FAQPage|Article/.test(types)) add(rel, [{ level: R.E, code: 'SCHEMA_MISSING', msg: 'Article/FAQPage 스키마 없음' }]);
|
||
} catch (e) { add(rel, [{ level: R.E, code: 'JSONLD_PARSE', msg: `JSON-LD 파싱 실패: ${e.message}` }]); }
|
||
}
|
||
if (!blocks.length && !/404/.test(rel)) add(rel, [{ level: R.E, code: 'JSONLD_MISSING', msg: 'JSON-LD 없음' }]);
|
||
// alt
|
||
for (const img of html.matchAll(/<img\b[^>]*>/g)) if (!/\balt="[^"]+"/.test(img[0])) add(rel, [{ level: R.E, code: 'IMG_ALT', msg: `alt 없는 이미지: ${img[0].slice(0, 80)}` }]);
|
||
// H1
|
||
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) add(rel, [{ level: R.E, code: 'H1_COUNT', msg: `H1 ${h1}개 (1개여야 함)` }]);
|
||
// 금칙 표현·홈페이지 중복·운영자 어휘·제목 화법
|
||
add(rel, R.checkBannedBody(text));
|
||
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
|
||
add(rel, R.checkOperatorVocab(text, rel));
|
||
add(rel, R.checkHeadingEndings(html));
|
||
// §12-2 (1) 글 첫 부분 지원 고지. site.sponsorNotice 가 켜져 있으면 error, 아니면 warn
|
||
if (rel.startsWith('/posts/') && rel !== '/posts.html') add(rel, R.checkTopDisclosure(html, { required: Boolean(site.sponsorNotice) }));
|
||
// §12-2 (2) 색인 허용 빌드에 미승인 글이 섞이면 error
|
||
if (INDEXABLE && rel.startsWith('/posts/') && rel !== '/posts.html') {
|
||
const slug = rel.replace(/^\/posts\//, '').replace(/\.html$/, '');
|
||
const src = join(POSTS, `${slug}.md`);
|
||
if (existsSync(src) && !R.parseMarkdown(readFileSync(src, 'utf8')).data.approvedAt) add(rel, [{ level: R.E, code: 'UNAPPROVED_IN_INDEXABLE_BUILD', msg: '색인 허용 빌드인데 게시 승인(approvedAt) 없는 글이 포함됨' }]);
|
||
}
|
||
// 구조 지문
|
||
if (LAYOUT_PAGES[rel]) layoutNow[rel] = R.layoutFingerprint(html);
|
||
}
|
||
|
||
// §3-2 구조 기준선
|
||
if (updateLayout) {
|
||
writeFileSync(LAYOUT_BASELINE, JSON.stringify(layoutNow, null, 1));
|
||
console.log(`구조 기준선 갱신: ${Object.keys(layoutNow).join(', ')}`);
|
||
} else {
|
||
const base = existsSync(LAYOUT_BASELINE) ? JSON.parse(readFileSync(LAYOUT_BASELINE, 'utf8')) : {};
|
||
for (const [rel, mode] of Object.entries(LAYOUT_PAGES)) if (layoutNow[rel]) add(rel, R.checkLayout(layoutNow[rel], base[rel], rel, { mode }));
|
||
}
|
||
|
||
// ---------- 2. 소스 검사 (글 frontmatter·본문) ----------
|
||
const posts = readdirSync(POSTS).filter((f) => f.endsWith('.md'));
|
||
for (const f of posts) {
|
||
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
|
||
add(`posts/${f}`, R.checkPostSource(parsed, { physicians: authors.physicians, supporters: authors.supporters }));
|
||
}
|
||
|
||
// ---------- 3. 데이터 검사 ----------
|
||
add('data/factSheet.json', R.checkNextCheck(fact, today));
|
||
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
|
||
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
|
||
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거됨. videos.top 은 화면에 나오지 않으므로 검사하지 않는다.
|
||
const news = existsSync(here('../src/data/news.json')) ? JSON.parse(readFileSync(here('../src/data/news.json'), 'utf8')) : { items: [] };
|
||
add('data/news.json', R.checkNewsItems(news.items)); // 뉴스룸: 후기·전후 비교·변신 제목, 규제·사건 보도, 날짜 형식
|
||
|
||
// ---------- 출력 ----------
|
||
const errors = findings.filter((x) => x.level === R.E);
|
||
const warns = findings.filter((x) => x.level === R.W);
|
||
const byCode = (list) => Object.entries(list.reduce((m, x) => ((m[x.code] = (m[x.code] ?? 0) + 1), m), {})).map(([k, v]) => `${k}×${v}`).join(', ');
|
||
for (const x of errors) console.error(` ✗ ${x.where}: [${x.code}] ${x.msg}`);
|
||
const shownWarn = warns.slice(0, 40);
|
||
for (const x of shownWarn) console.warn(` △ ${x.where}: [${x.code}] ${x.msg}`);
|
||
if (warns.length > shownWarn.length) console.warn(` △ … 경고 ${warns.length - shownWarn.length}건 더 있음`);
|
||
console.log(`검사 파일 ${files.length}개 · 글 ${posts.length}편 · 오류 ${errors.length}건${errors.length ? ` (${byCode(errors)})` : ''} · 경고 ${warns.length}건${warns.length ? ` (${byCode(warns)})` : ''}`);
|
||
if (errors.length) process.exit(1);
|