o2o-infinith-demo/templates/supporters-astro/scripts/check.mjs
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵
- 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다
- 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건)
- 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:27:59 +09:00

127 lines
8.1 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';
import { parse as parseHtml } from 'node-html-parser';
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;
}
function withoutRecoveryQuote(html) {
const root = parseHtml(html);
root.querySelectorAll('#recovery-quote').forEach((el) => el.remove());
return root.toString();
}
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, '/');
// astro.config 의 redirects 가 만든 meta refresh 페이지(/stay → /recovery). 본문이 없으니 검사하지 않는다.
if (/<meta http-equiv="refresh"/.test(html) && !/<h1\b/.test(html)) continue;
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));
// 홈페이지 40자 연속 일치(§3-11)는 글이 홈페이지 문장을 베끼는 것을 막는 규칙이다. /stay 의 "시술 후 관리 안내"(#recovery-quote)는
// 출처 링크·확인일을 붙인 병원 원문 인용이라 설계상 일치하므로 그 블록만 빼고 본다. 금칙어·운영자 어휘 검사는 인용 블록에도 그대로 적용한다.
add(rel, R.checkHomeDuplicate(/id="recovery-quote"/.test(html) ? R.stripHtml(withoutRecoveryQuote(html)) : 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);