diff --git a/scripts/audit_aeo_geo.py b/scripts/audit_aeo_geo.py index 77c7aae..edf7398 100644 --- a/scripts/audit_aeo_geo.py +++ b/scripts/audit_aeo_geo.py @@ -1053,13 +1053,23 @@ def main() -> int: auditor = Auditor(args.main_url, args.service_url, args.name) results = auditor.run() + # 실측 규칙(NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §3-10): 표본 URL이 5개 미만이면 사이트 전체로 일반화하지 않는다. + # 최소 표본 = 홈 + 시술 상세 3개 + 오시는길 + 블로그 서브도메인. 이 채점기는 아직 2개(홈·대표 시술)만 읽는다. + sample_urls = [args.main_url, args.service_url] + sample_warning = None + if len(sample_urls) < 5: + sample_warning = (f"표본 URL {len(sample_urls)}개 (<5). 판정은 홈·대표 시술 페이지 기준이며 사이트 전체로 일반화하지 말 것. " + f"시술 상세 3개·오시는길·블로그를 curl+파서로 추가 확인할 것 (2026-08-28 뷰성형외과 오판 3건의 원인)") + print(f"\n ⚠ {sample_warning}") + auto_scored = [r for r in results if r["level"] != "unverified"] unv = [r for r in results if r["level"] == "unverified"] print(f"\n[3/3] 결과: 자동 판정 {len(auto_scored)}항목 / 미검증 {len(unv)}항목 (semi·manual 포함)") if args.json: with open(args.json, "w", encoding="utf-8") as f: - json.dump(results, f, ensure_ascii=False, indent=2) + json.dump({"_meta": {"sampleUrls": sample_urls, "sampleWarning": sample_warning}, "results": results} + if sample_warning else results, f, ensure_ascii=False, indent=2) print(f" → JSON: {args.json}") if not args.skip_ts: diff --git a/supporters/package-lock.json b/supporters/package-lock.json index f5ba0d6..c5f36e1 100644 --- a/supporters/package-lock.json +++ b/supporters/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "astro": "^7.3.1" }, + "devDependencies": { + "js-yaml": "^4.3.2" + }, "engines": { "node": ">=22.12.0" } diff --git a/supporters/package.json b/supporters/package.json index 231101d..57f4f76 100644 --- a/supporters/package.json +++ b/supporters/package.json @@ -10,12 +10,16 @@ "build": "astro build && node scripts/check.mjs", "preview": "astro preview", "astro": "astro", - "check": "node scripts/check.mjs" + "check": "node scripts/gate/test.mjs && node scripts/check.mjs", + "gate:test": "node scripts/gate/test.mjs" }, "dependencies": { "astro": "^7.3.1" }, "allowScripts": { "esbuild": true + }, + "devDependencies": { + "js-yaml": "^4.3.2" } } diff --git a/supporters/scripts/check.mjs b/supporters/scripts/check.mjs index 1894ac7..4b639d2 100644 --- a/supporters/scripts/check.mjs +++ b/supporters/scripts/check.mjs @@ -1,49 +1,99 @@ -// 발행 게이트. 빌드 결과(dist)를 검사해 하나라도 실패하면 배포를 막는다. -// 1) JSON-LD 문법·필수 타입 2) 이미지 alt 3) 금칙 표현(의료법 56조·효과 보장) 4) H1 1개 5) 홈페이지 문장 연속 일치 -import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; +// 발행 게이트 러너. 빌드 결과(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 +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 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') : ''; +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 HOME_TEXT = existsSync(here('./home_text.txt')) ? readFileSync(here('./home_text.txt'), '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', '/posts/anesthesia-choice.html': 'sequence' }; 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}`); }; +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 layoutNow = {}; for (const f of files) { const html = readFileSync(f, 'utf8'); - const rel = f.replace(DIST, ''); + const rel = f.replace(DIST, '/'); + const text = R.stripHtml(html); + // JSON-LD const blocks = [...html.matchAll(/