o2o-infinith-demo/templates/supporters-astro/scripts/check.mjs
Haewon Kam 7e515b70ae feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)
- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16)
- 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A]
- default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs
- 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시)
- v2 §11-6·§11-7 기록, E2E 리포트

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

115 lines
7.0 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
// 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 결정 대기(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);