o2o-infinith-demo/supporters/scripts/check.mjs
Haewon Kam 03ed725481 feat: 뷰성형외과 서포터즈 샘플 사이트 v3 + 설계 v0.1 + 공식 사이트 대비 AEO 감사
- supporters/: Astro 사이트 소스 (pages·components·posts·factSheet/authors/videos 데이터, 뉴스룸 916건 빌더, 발행 게이트 check.mjs, vercel.json)
- supporters/docs/: v1~v3 스크린샷 기록
- docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.1.md: 서포터즈 사이트 설계 문서
- docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD.md: 다음 세션 자동 빌드 프롬프트
- docs/reports/Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx + scripts/build_supporters_audit_xlsx.py: 서포터즈 vs 공식 사이트 AEO/GEO 감사 비교

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

50 lines
2.8 KiB
JavaScript

// 발행 게이트. 빌드 결과(dist)를 검사해 하나라도 실패하면 배포를 막는다.
// 1) JSON-LD 문법·필수 타입 2) 이미지 alt 3) 금칙 표현(의료법 56조·효과 보장) 4) H1 1개 5) 홈페이지 문장 연속 일치
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DIST = fileURLToPath(new URL('../dist/', import.meta.url));
const BANNED = [
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
];
const HOME_TEXT = existsSync(fileURLToPath(new URL('./home_text.txt', import.meta.url))) ? readFileSync(fileURLToPath(new URL('./home_text.txt', import.meta.url)), 'utf8') : '';
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 files = walk(DIST);
let errors = 0;
const fail = (f, m) => { errors++; console.error(` ✗ ${f.replace(DIST, '')}: ${m}`); };
for (const f of files) {
const html = readFileSync(f, 'utf8');
const rel = f.replace(DIST, '');
// 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 j = JSON.parse(b);
const types = JSON.stringify(j);
if (rel.startsWith('/posts/') && rel !== '/posts.html' && !/FAQPage|Article/.test(types)) fail(f, 'Article/FAQPage 스키마 없음');
} catch (e) { fail(f, `JSON-LD 파싱 실패: ${e.message}`); }
}
if (!blocks.length && !/404/.test(rel)) fail(f, 'JSON-LD 없음');
// alt
for (const img of html.matchAll(/<img\b[^>]*>/g)) { if (!/\balt="[^"]+"/.test(img[0])) fail(f, `alt 없는 이미지: ${img[0].slice(0, 80)}`); }
// H1
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) fail(f, `H1 ${h1}개 (1개여야 함)`);
// banned phrases (본문만)
const text = html.replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, ' ');
for (const re of BANNED) { const m = text.match(re); if (m) fail(f, `금칙 표현: "${m[0]}"`); }
// 홈페이지 문장 연속 일치(40자 이상)
if (HOME_TEXT) {
const clean = text.replace(/\s+/g, '');
for (let i = 0; i + 40 <= clean.length; i += 20) { const chunk = clean.slice(i, i + 40); if (HOME_TEXT.includes(chunk)) { fail(f, `홈페이지 문장 40자 연속 일치: "${chunk}"`); break; } }
}
}
console.log(`검사 파일 ${files.length}개, 오류 ${errors}건`);
if (errors) process.exit(1);