o2o-infinith-demo/supporters/scripts/check.mjs
Haewon Kam 49c5e46f23 feat(supporters): 발행 게이트 확장 — v2 §3 검사 11종을 규칙 모듈·픽스처 테스트로 분리
- scripts/gate/rules.mjs: 운영자 어휘·구조 지문·화법·근거 type·규제 원문·결론 문장·수치 한정·확인일 일관성·검토자/검토일·영상 제목·표/체크리스트 (순수 함수)
- scripts/check.mjs: dist + 소스 frontmatter + 데이터 검사 러너, --update-layout, error/warn 분리
- scripts/gate/test.mjs + fixtures 9개: 실패 재현 + 18편 정답지 회귀 + 부정문 통과
- corrections.json 「답변엔진」 표현 고객 화면에서 제거, 반말 문장 1건 수정
- audit_aeo_geo.py: 표본 URL 5개 미만 경고(§3-10)
- 미결: 「채널 인기 전체」 탭은 경고만 (haewon 결정 대기)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:57:31 +09:00

100 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 발행 게이트 러너. 빌드 결과(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
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 HOME_TEXT = existsSync(here('./home_text.txt')) ? readFileSync(here('./home_text.txt'), '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 LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set', '/posts/anesthesia-choice.html': 'sequence' };
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 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));
// 구조 지문
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 결정 대기(v2 §9-1). 결정 전까지 경고만.
add('data/videos.json', R.checkVideoTitles(videos.top, { level: R.W, label: '채널 인기 전체 탭(결정 대기)' }));
// ---------- 출력 ----------
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);