o2o-infinith-demo/supporters/scripts/check.mjs
Haewon Kam f0857420e1 fix(supporters): 배포를 막던 게이트 두 건 · 대표 이미지 alt 의 효과 주장 제거
뷰 배포가 Vercel 빌드에서 멈췄다. 워커는 통과했는데 Vercel 이 막힌 이유는
--from deploy 가 빌드 게이트를 건너뛰고 바로 배포로 갔고, Vercel 은 자체 npm run build
(astro build && check.mjs)를 돌리기 때문이다. 실패는 두 가지였다.

1. HOME_DUPLICATE: 검토자 표시줄의 원장 이력이 병원 홈페이지 문장과 40자 일치했다.
   이 자리는 병원 의료진 페이지의 이력을 프로필 링크와 함께 그대로 옮기는 귀속 표시라
   설계상 일치한다. 다른 말로 바꿔 쓰면 이력이 사실과 달라진다.
   /stay 의 인용 블록(#recovery-quote)을 이미 같은 이유로 빼고 있어서, 같은 규칙으로
   .doctor-strip 을 QUOTED_BLOCKS 에 넣었다. 금칙어·운영자 어휘 검사는 그대로 적용된다.

2. 대표 이미지 alt 의 효과 주장: 배정된 alt 가 "손상된 피부 세포 재생과 콜라겐 생성으로
   흉터와 색소침착 개선" 처럼 결과를 주장했다. 의료법 56조 2항 8호 대상이고 주제도 맞지 않았다.
   이미지 자체는 문제가 없으므로 빼지 않고, 효과·결과 어휘가 있으면 출처만 밝히는 중립
   문구로 바꾼다. 수집기의 분류를 믿지 않는다(일러스트가 시설로 들어온다)는 이유로
   "시설 사진" 처럼 유형을 단정하지 않고 "홈페이지 이미지"로 쓴다.

검증: 세 사이트 모두 npm run build 종료코드 0(Vercel 이 돌리는 명령과 같다).
게이트 41/41, 샘플 check.mjs 오류 0건, tsc 0 에러, 내보내기 0.
연속 중복은 뷰 1·원진 1 이고, 오라클은 수집 이미지가 없어 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 16:27:30 +09:00

134 lines
8.6 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;
}
// 홈페이지 40자 일치 검사에서 뺄 블록. 둘 다 출처를 밝히고 병원 원문을 그대로 옮기는 자리라 설계상 일치한다.
// #recovery-quote : /stay 의 "시술 후 관리 안내" 인용(출처 링크·확인일 표시)
// .doctor-strip : 글의 검토자 표시줄. 원장 이력을 병원 의료진 페이지에서 옮기고 프로필 링크를 단다.
// 다른 말로 바꿔 쓰면 이력이 사실과 달라지므로 원문 그대로 두는 것이 맞다.
const QUOTED_BLOCKS = ['#recovery-quote', '.doctor-strip'];
function withoutQuotedBlocks(html) {
const root = parseHtml(html);
for (const sel of QUOTED_BLOCKS) root.querySelectorAll(sel).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)는 글이 홈페이지 문장을 베끼는 것을 막는 규칙이다.
// 출처를 밝힌 인용 블록(QUOTED_BLOCKS)은 설계상 일치하므로 빼고 본다.
// 금칙어·운영자 어휘 검사는 인용 블록에도 그대로 적용한다.
const quoted = /id="recovery-quote"|class="[^"]*doctor-strip/.test(html);
add(rel, R.checkHomeDuplicate(quoted ? R.stripHtml(withoutQuotedBlocks(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);