o2o-infinith-demo/scripts/build_supporters_v2_compare.ts
Haewon Kam 76ec63bc1e feat(discovery): 기준표 v2.0 정식 채택 — 배지·헤더·랜딩 카피·비교 엑셀·덱 v2.0
- aeoGeoRubricV2 status active/2.0, 결과 rubricVersion 2.0. 리포트 헤더는 AEO·GEO 링 2개가 주, v1.0 종합은 보조 표기
- 랜딩: Hero 카피, ABMR 칩(AEO/GEO 목표), SampleReport(두 링 + 축별 8항목 바), Roadmap 제목, HowItWorks를 v2 계산값에서 파생. 발견 칩 수치 v1.1 스냅샷으로 정정
- 비교 엑셀 v2: scripts/build_supporters_v2_compare.ts(점수 계산 TS 단일 원본) → build_supporters_audit_v2_xlsx.py. 공식 AEO 42/GEO 56 · 서포터즈 noindex 상한 40(G1) · 색인 허용 AEO 94/GEO 77
- 덱 v2.0: build_viewclinic_deck_v2_0.py (17장, 표지 배지 v2.0, KPI AEO/GEO, 스코어카드 2장, Method v2.0) + 표준캔버스 + PDF. v1.0·v1.1 파일은 그대로
- docs/AEO_GEO_RUBRIC.md에 v2.0 채택 주석, AEO_GEO_RUBRIC_v2.md 재생성, 리포트 페이지 PDF 재출력

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

68 lines
6.3 KiB
TypeScript

/**
* 공식 홈페이지 vs 서포터즈 사이트 · v2.0 AEO/GEO 비교 데이터.
* npx tsx scripts/build_supporters_v2_compare.ts <out.json>
* 입력
* - 공식: 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);