- 홈+page 사이트맵에서 원문 페이지 발견 → 경로 우선·단어 경계 분류(주의사항·애프터케어·검진·안전·의료진·오시는길·시설·소개·예약·시술) - 본문 컨테이너 선택 + 페이지 간 반복 줄(메뉴·푸터) 제거, 헤딩·문단·목록·표·이미지·링크 구조화 - doctors.json: 이름·직함·약력. 약력이 이미지 글자면 textInImages, 2줄 미만이면 '확인 대기' (뷰성형외과 27명 전원 확인 대기) - facts.draft.json: 전화·주소·사업자번호·대표·진료시간 정규식 후보 + 출처 URL - home_text.txt: 전 페이지 본문(29만 자). check.mjs --home-text= 로 40자 중복 검사 기준 교체 가능 → 18편 0오류 - --posts 옵션: 글에 인용된 병원 URL 커버리지 리포트 (뷰성형외과 16/16, /face/main/은 301 추적) - robots.txt Disallow 존중, UA 명시, 요청 간 400ms Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
6.2 KiB
JavaScript
105 lines
6.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 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));
|
||
// 구조 지문
|
||
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);
|