- 템플릿: 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>
127 lines
8.1 KiB
JavaScript
127 lines
8.1 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';
|
||
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);
|