- 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>
50 lines
2.8 KiB
JavaScript
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);
|