/** * 공식 홈페이지 vs 서포터즈 사이트 · v2.0 AEO/GEO 비교 데이터. * npx tsx scripts/build_supporters_v2_compare.ts * 입력 * - 공식: src/data/discovery_viewclinic.ts (v1.0 실측 + v2 사람 판정) * - 서포터즈: docs/reports/viewclinic/04_supporters/v1_levels/*.json (2026-09-04 v1.0 실측, 기존 비교 엑셀에서 추출) * + 아래 SUPPORTERS_V2 사람 판정 (2026-09-07, 사이트 소스 기준) * 출력 JSON은 scripts/build_supporters_audit_v2_xlsx.py 가 엑셀로 만든다. */ import { readFileSync, writeFileSync } from 'node:fs'; import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric'; import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2'; import { DISCOVERY_VIEWCLINIC } from '../src/data/discovery_viewclinic'; import { scoreDiscovery } from '../src/lib/discoveryScore'; import { scoreDiscoveryV2 } from '../src/lib/discoveryScoreV2'; import type { CriterionResult, DiscoveryResult } from '../src/types/discovery'; const out = process.argv[2] ?? 'docs/reports/viewclinic/04_supporters/v2_compare.json'; const base = new URL('../docs/reports/viewclinic/04_supporters/v1_levels/', import.meta.url); const load = (f: string) => JSON.parse(readFileSync(new URL(f, base), 'utf8')) as { source: string; results: CriterionResult[] }; // 서포터즈 사이트 v2 사람 판정. 근거는 supporters/ 소스와 2026-09-07 게이트 결과. const SUPPORTERS_V2: CriterionResult[] = [ { criterionId: 'AEO6', level: 3, evidence: '헤더 상담 예약 버튼, /visit 페이지에 전화·카카오톡·온라인 예약 3경로가 표로, FactBlock에 tel: 링크. 전부 텍스트 링크', source: 'supporters/src/pages/visit.astro · layouts/Base.astro' }, { criterionId: 'GEO1', level: 2, evidence: '18편 전편의 참고 자료에 유형(병원 제공·원장 설명·제품·규제·플랫폼·2차)과 확인일이 붙고, FDA·ISO 주장은 원문 URL로 연결. 수치 문단 5곳에 개인차·사례 한정이 빠져 게이트 경고 → "대부분 명시"', source: 'supporters/scripts/check.mjs 2026-09-07 (NUMERIC_NO_QUALIFIER 5)' }, { criterionId: 'GEO6', level: 3, evidence: '원천 콘텐츠 5유형 중 4유형 존재: 상담 질문 체크리스트, 회복 일정·마취 선택·검진 주기 조건 표(18편 전편에 표), 협진·동선 설명(이비인후과 협진·방문 안내), 원장 발언 인용(쇼츠 정리 답). 실측 기록(방문 취재)은 미착수', source: 'docs/reports/viewclinic/04_supporters/Viewclinic_Supporters_Content_Plan_Phase2.md' }, { criterionId: 'GEO7', level: 2, evidence: '운영 주체(AI오투오·편집 책임 안성민)·정정 이메일·병원 지원 관계를 모든 글 하단 Disclosure와 /about·푸터에 명시. 후기·이벤트 없음. 다만 고지가 글 첫 부분이 아니라 하단에 있어 심사지침의 "첫 부분" 기준에는 못 미침', source: 'supporters/src/components/Disclosure.astro · v2 §12-2' }, ]; function asResult(id: string, name: string, url: string, results: CriterionResult[], v2: CriterionResult[]): DiscoveryResult { return { id, clinicName: name, url, industry: '', auditedAt: '2026-09-04', rubricVersion: '2.0', conditions: [], results, keyFindings: [], actions: [], notDoing: [], v2Results: v2 }; } const official = DISCOVERY_VIEWCLINIC; const noindex = load('supporters_noindex.json'); const indexable = load('supporters_indexable.json'); const variants = [ { key: 'official', label: '공식 홈페이지 (viewclinic.com)', result: official }, { key: 'supporters_noindex', label: '서포터즈 샘플 (현재 · noindex)', result: asResult('supporters-noindex', '뷰 서포터즈 (noindex)', 'https://view-supporters-sample.vercel.app', noindex.results, SUPPORTERS_V2) }, { key: 'supporters_indexable', label: '서포터즈 색인 허용 빌드', result: asResult('supporters-indexable', '뷰 서포터즈 (색인 허용)', 'https://view-supporters-sample.vercel.app', indexable.results, SUPPORTERS_V2) }, ]; const v1names = Object.fromEntries(AEO_GEO_RUBRIC.criteria.map((c) => [c.id, c.name])); const data = { generatedAt: new Date().toISOString().slice(0, 10), rubricVersion: AEO_GEO_RUBRIC_V2.version, note: '점수는 INFINITH 제품용 진단 기준이며 검색엔진의 공식 점수나 인용 확률이 아니다. 서포터즈 v1.0 레벨은 2026-09-04 실측(기존 비교 엑셀), 공식은 2026-09-07 v1.1 재검증. 사람 판정 4항목은 2026-09-07 초안.', axes: AEO_GEO_RUBRIC_V2.axes, items: AEO_GEO_RUBRIC_V2.items.map((i) => ({ id: i.id, axis: i.axis, name: i.name, weight: i.weight, method: i.method, question: i.question, derivedFrom: i.derivedFrom.map((d) => `${d.criterionId} ${v1names[d.criterionId]}(${d.weight})`).join(', ') })), gates: AEO_GEO_RUBRIC_V2.gates, variants: variants.map((v) => { const s1 = scoreDiscovery(AEO_GEO_RUBRIC, v.result); const s2 = scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, v.result); const judged = new Map((v.result.v2Results ?? []).map((r) => [r.criterionId, r])); return { key: v.key, label: v.label, v1: { score: s1.score, grade: s1.grade }, aeo: { score: s2.aeo.score, raw: s2.aeo.rawScore, grade: s2.aeo.grade, verified: s2.aeo.verifiedCount, capped: s2.aeo.cappedBy.map((g) => g.gate.id) }, geo: { score: s2.geo.score, raw: s2.geo.rawScore, grade: s2.geo.grade, verified: s2.geo.verifiedCount, capped: s2.geo.cappedBy.map((g) => g.gate.id) }, gates: s2.gates.map((g) => ({ id: g.gate.id, passed: g.passed, failedBy: g.failedBy })), items: [...s2.aeo.items, ...s2.geo.items].map((it) => ({ id: it.item.id, pct: it.pct === null ? null : Math.round(it.pct * 100), earned: it.earned, basis: it.basis, signals: it.signals.map((s) => `${s.criterionId}=${s.level}`).join(', '), evidence: it.basis === 'judged' ? judged.get(it.item.id)?.evidence ?? '' : it.signals.map((s) => `${s.criterionId}: ${(v.result.results.find((r) => r.criterionId === s.criterionId)?.evidence ?? '').slice(0, 140)}`).join(' / ') })), }; }), }; writeFileSync(out, JSON.stringify(data, null, 1)); for (const v of data.variants) console.log(`${v.label}: v1 ${v.v1.score}/${v.v1.grade} · AEO ${v.aeo.score}/${v.aeo.grade}${v.aeo.capped.length ? ' (상한 ' + v.aeo.capped.join(',') + ', 원점수 ' + v.aeo.raw + ')' : ''} · GEO ${v.geo.score}/${v.geo.grade}${v.geo.capped.length ? ' (상한, 원점수 ' + v.geo.raw + ')' : ''}`); console.log('written', out);