o2o-infinith-demo/scripts/build_discovery_report.ts
Haewon Kam 8776a15183 feat(ga4): AI 유입 트래픽 연동 뼈대 + 리포트 "측정 가능 범위" 절
2026-09-08 작업분. GA4 에서 AI 답변엔진 유입을 채널로 갈라 보는 뼈대를 세웠다.

- data/ai_channels.json: AI 답변엔진 리퍼러 목록(채널 그룹 정의의 근거)
- scripts/ga4_ai_traffic.py: GA4 Data API 로 AI 유입을 뽑는 러너. --mock 으로 합성
  데이터 검증까지 마쳤다. 실제 속성 연결은 GCP 계정이 정해진 뒤다
- supporters/src/layouts/Base.astro + 템플릿 site.json 2곳: ga4MeasurementId 태그.
  쿠키 고지 배너 결정 전까지 미리보기에는 ID 를 넣지 않는다
- scripts/build_discovery_report.ts: Integrity 절에 "측정 가능 범위" 를 넣어
  무엇을 재고 무엇을 못 재는지 리포트가 스스로 밝히게 했다
- 원진 진단 리포트 v2.1(HTML·PDF) 재생성분

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:53:54 +09:00

205 lines
23 KiB
TypeScript

/**
* AI Answer Readiness 리포트 HTML 빌더. /discovery/:id 와 같은 데이터(discoveryResults + 기준표 v1/v2 + 채점 lib)로
* 독립 HTML 한 파일을 만든다. PDF 는 Chrome headless 로 이 HTML 을 인쇄한다.
*
* npx esbuild scripts/build_discovery_report.ts --bundle --platform=node --format=esm --outfile=<tmp>/report.mjs
* node <tmp>/report.mjs --id wonjin --out docs/reports/wonjin/01_readiness_report/Wonjin_AI_Answer_Readiness_Report_v2.0_2026-09-08.html
* [--status ~/supporters-builds/wonjin/status.json] [--site ~/supporters-builds/wonjin/site] [--briefs ~/supporters-builds/wonjin/briefs.json]
*
* 화법: 고객용. 판단 제목은 "~합니다", 목차성 제목은 명사구. 엠대시 없음. 숫자는 표와 배지에만.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import AI_CHANNELS from '../data/ai_channels.json';
import { DISCOVERY_RESULTS } from '../src/data/discoveryResults';
import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2';
import { scoreDiscovery } from '../src/lib/discoveryScore';
import { scoreDiscoveryV2 } from '../src/lib/discoveryScoreV2';
const args = process.argv.slice(2);
const opt = (k: string, d?: string) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const id = opt('id', 'wonjin')!;
const out = opt('out')!;
const result = DISCOVERY_RESULTS[id];
if (!result || !out) { console.error('--id <등록된 id> --out <html> 필요'); process.exit(2); }
const v1 = scoreDiscovery(AEO_GEO_RUBRIC, result);
const v2 = scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result);
const today = new Date().toISOString().slice(0, 10);
const readJson = (p?: string) => (p && existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null);
const status = readJson(opt('status'));
const site = opt('site');
const briefs = readJson(opt('briefs'));
const videos = readJson(site ? `${site}/src/data/videos.json` : undefined);
const images = readJson(site ? `${site}/src/data/images.json` : undefined);
const authors = readJson(site ? `${site}/src/data/authors.json` : undefined);
const esc = (s: unknown) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const criterionById = new Map(AEO_GEO_RUBRIC.criteria.map((c) => [c.id, c]));
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
const levelLabel = (cid: string, level: number | 'unverified') => {
if (level === 'unverified') return '미검증';
const c = criterionById.get(cid); return c?.levels.find((l) => l.level === level)?.label ?? String(level);
};
const gradeStyle: Record<string, string> = { A: 'background:#E9F7EF;color:#2E7D4F;border-color:#BFE6CE', B: 'background:#EEF3FF;color:#2F4FBF;border-color:#C9D7FF', C: 'background:#FFF4E5;color:#B26A00;border-color:#FFD9A8', D: 'background:#FDECEF;color:#B84C5F;border-color:#F7C3CD' };
const sevStyle: Record<string, string> = { critical: '#D4889A', warning: '#D4A872', good: '#9B8AD4' };
const sevLabel: Record<string, string> = { critical: '핵심 과제', warning: '보완', good: '강점' };
const ring = (score: number, label: string, grade: string) => {
const r = 52, c = 2 * Math.PI * r, off = c * (1 - score / 100);
return `<div class="ring"><svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="${r}" fill="none" stroke="#E8ECF7" stroke-width="10"/><circle cx="60" cy="60" r="${r}" fill="none" stroke="#6C5CE7" stroke-width="10" stroke-linecap="round" stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}" transform="rotate(-90 60 60)"/><text x="60" y="66" text-anchor="middle" font-family="Playfair Display, serif" font-weight="700" font-size="30" fill="#0A1128">${score}</text><text x="60" y="84" text-anchor="middle" font-size="10" fill="#7A8399">/ 100</text></svg><span class="badge" style="${gradeStyle[grade]}">${label} 등급 ${grade}</span></div>`;
};
const verified = v1.categories.reduce((s, c) => s + c.verifiedCount, 0);
const total = AEO_GEO_RUBRIC.criteria.length;
const P = { P0: '지금 고칠 것 (1주 안)', P1: '4주 안에 만들 것', P2: '이어서 할 것' } as Record<string, string>;
// 서포터즈 자동 빌드 섹션 데이터
const posts = (status?.posts ?? []) as any[];
const briefById = new Map(((briefs?.posts ?? []) as any[]).map((b) => [b.id, b]));
const imgCounts = images?.counts ?? null;
const physicians = authors?.physicians ? Object.keys(authors.physicians).length : 0;
const INPUTS = ['작성자 실명·직함·공개 프로필', '글별 의학 검토 원장과 검토일', '지원 관계 문구', '견적 기본 포함 항목·결제 방식', '회복 일정표의 병원 확정값', '재수술 정책', '페이지 간 표기 불일치의 정답', '진료 분야별 담당 원장', '정식 도메인·색인 허용 시점·홈페이지 링크 위치'];
const axisTable = (ax: typeof v2.aeo) => `
<table class="tbl"><thead><tr><th style="width:52px">항목</th><th>무엇을 보는가</th><th style="width:70px">점수</th><th style="width:72px">근거</th><th>파생된 실측 항목</th></tr></thead><tbody>
${ax.items.map((it) => `<tr><td class="mono">${esc(it.item.id)}</td><td><b>${esc(it.item.name)}</b><div class="sub">${esc(it.item.question)}</div></td><td>${it.pct === null ? '<span class="pill gray">미검증</span>' : `<b>${Math.round(it.pct * 100)}</b><span class="sub"> / 100</span>`}</td><td>${it.basis === 'derived' ? '실측 파생' : it.basis === 'judged' ? '사람 판정' : '미검증'}</td><td class="sub">${it.signals.map((s) => `${s.criterionId} ${s.level === 'unverified' ? '미검증' : 'L' + s.level}`).join(' · ')}</td></tr>`).join('')}
</tbody></table>
${ax.cappedBy?.length ? `<p class="note">게이트 상한 적용: ${ax.cappedBy.map((g) => esc(g.gate.name)).join(', ')}. 상한 전 점수 ${ax.rawScore}.</p>` : ''}`;
const html = `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(result.clinicName)} AI Answer Readiness Report v${esc(result.rubricVersion)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
:root{--ink:#0A1128;--navy:#021341;--violet:#4F1DA1;--accent:#6C5CE7;--light:#F7F8FC;--slate:#4A5268;--muted:#7A8399;--line:#E3E7F2}
*{box-sizing:border-box}body{margin:0;font-family:Pretendard,-apple-system,sans-serif;color:var(--ink);background:#fff;word-break:keep-all;line-height:1.62;font-size:14px}
.serif{font-family:'Playfair Display',serif}
.page{padding:48px 56px;max-width:1080px;margin:0 auto}
.dark{background:var(--ink);color:#fff}.dark .sub,.dark .note{color:#B9C0D4}.light{background:var(--light)}
.eyebrow{font-size:12px;font-weight:700;letter-spacing:.24em;color:var(--accent);text-transform:uppercase}.dark .eyebrow{color:#C4B5FD}
h1{font-size:44px;line-height:1.1;margin:10px 0 6px;letter-spacing:-.02em}h2{font-size:30px;margin:8px 0 12px;line-height:1.2}h3{font-size:17px;margin:0 0 6px}
.accent{background:linear-gradient(90deg,#C4B5FD,#93C5FD);-webkit-background-clip:text;background-clip:text;color:transparent}
.lede{font-size:16px;color:var(--slate);max-width:760px}.dark .lede{color:#D6DBEA}
.sub{font-size:12.5px;color:var(--muted)}.note{font-size:12.5px;color:var(--muted);margin-top:8px}
.badge{display:inline-block;border:1px solid;border-radius:999px;padding:3px 12px;font-size:13px;font-weight:700}
.pill{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700}.pill.gray{background:#EEF0F6;color:#6B7280}
.grid{display:grid;gap:16px}.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:repeat(3,1fr)}
.card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:20px 22px;box-shadow:3px 4px 12px rgba(0,0,0,.05)}.dark .card{color:var(--ink)}
.rings{display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap}.ring{display:flex;flex-direction:column;align-items:center;gap:8px}
.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--line)}.tbl th{background:#F1F3FA;text-align:left;padding:9px 11px;font-size:12px;color:var(--slate);border-bottom:1px solid var(--line)}.tbl td{padding:9px 11px;border-bottom:1px solid #EEF0F6;vertical-align:top}.tbl tr:last-child td{border-bottom:0}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700}
.lv{display:inline-block;min-width:26px;text-align:center;border-radius:6px;padding:1px 6px;font-weight:700;font-size:12px}.lv3{background:#E9F7EF;color:#2E7D4F}.lv2{background:#EEF3FF;color:#2F4FBF}.lv1{background:#FFF4E5;color:#B26A00}.lv0{background:#FDECEF;color:#B84C5F}.lvu{background:#EEF0F6;color:#6B7280}
.find{border-left:5px solid;padding-left:16px}.find .tag{font-size:11.5px;font-weight:700;letter-spacing:.08em}
.act .pri{font-family:'Playfair Display',serif;font-size:22px;font-weight:700;color:var(--accent)}.act .eff{float:right}
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.kpi .k{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:14px 16px}.kpi b{font-family:'Playfair Display',serif;font-size:30px;color:#AF90FF;display:block;line-height:1}.kpi span{font-size:12.5px;color:#C7CDE0}
a{color:var(--accent)}ul{margin:6px 0 0 18px;padding:0}li{margin:3px 0}
footer{padding:28px 56px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}
@media print{@page{size:A4;margin:12mm}body{font-size:12.5px}.page{padding:22px 10px;max-width:none}.pb{page-break-before:always}h1{font-size:34px}h2{font-size:24px}.dark{-webkit-print-color-adjust:exact;print-color-adjust:exact}.card,.tbl,.lv,.badge,.pill,.kpi .k{-webkit-print-color-adjust:exact;print-color-adjust:exact}tr,.card{page-break-inside:avoid}}
</style></head><body>
<section class="dark"><div class="page" style="padding-top:64px;padding-bottom:56px">
<div class="eyebrow">INFINITH AI Discovery · AEO / GEO Audit v${esc(result.rubricVersion)}</div>
<h1 class="serif"><span class="accent">AI Answer Readiness</span></h1>
<div style="font-size:26px;font-weight:700;margin:4px 0 2px">${esc(result.clinicName)}</div>
<div class="sub" style="color:#C4B5FD">${esc(result.industry)} · <a href="${esc(result.url)}" style="color:#C4B5FD">${esc(result.url)}</a></div>
<p class="lede" style="margin-top:18px">AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.</p>
<div style="display:flex;gap:32px;align-items:center;flex-wrap:wrap;margin-top:26px">
<div class="card" style="display:flex;gap:26px;padding:22px 30px">${ring(v2.aeo.score, 'AEO', v2.aeo.grade)}${ring(v2.geo.score, 'GEO', v2.geo.grade)}</div>
<div class="kpi" style="flex:1;min-width:420px">
<div class="k"><b>${verified}</b><span>실측 항목 / ${total}</span></div>
<div class="k"><b>${v1.score}</b><span>v1.0 종합 (${v1.grade})</span></div>
<div class="k"><b>${result.keyFindings.length}</b><span>핵심 발견</span></div>
<div class="k"><b>${result.actions.length}</b><span>실행 항목</span></div>
</div>
</div>
<p class="note" style="margin-top:22px">실측일 ${esc(result.auditedAt)} · 리포트 작성 ${today} · 미검증 ${total - verified}개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.</p>
</div></section>
<section class="light pb"><div class="page">
<div class="eyebrow">Findings</div><h2>AI가 지금 ${esc(result.clinicName)}를 어떻게 읽는지 정리했습니다</h2>
<div class="grid g2" style="margin-top:14px">
${result.keyFindings.map((f) => `<div class="card find" style="border-left-color:${sevStyle[f.severity]}"><div class="tag" style="color:${sevStyle[f.severity]}">${sevLabel[f.severity]}</div><h3>${esc(f.title)}</h3><p style="margin:0;color:var(--slate)">${esc(f.detail)}</p></div>`).join('')}
</div>
</div></section>
<section class="pb"><div class="page">
<div class="eyebrow">AEO · Answer Readiness</div><h2>답변 준비도 ${v2.aeo.items.length}개 항목</h2>
<p class="lede">${esc(v2.aeo.axis.description)}</p>
${axisTable(v2.aeo)}
<div class="eyebrow" style="margin-top:34px">GEO · Source Readiness</div><h2>출처 준비도 ${v2.geo.items.length}개 항목</h2>
<p class="lede">${esc(v2.geo.axis.description)}</p>
${axisTable(v2.geo)}
</div></section>
<section class="pb dark"><div class="page">
<div class="eyebrow">Category Detail</div><h2>카테고리별 실측 ${total}개 항목</h2>
<div class="grid g3" style="margin-top:12px">
${v1.categories.map((c) => `<div class="card"><div class="sub">${esc(c.category.code)} · ${esc(c.category.nameEn)}</div><h3 style="margin-top:2px">${esc(c.category.name)}</h3><div><span class="serif" style="font-size:28px;font-weight:700;color:var(--accent)">${c.pct}</span><span class="sub"> / 100 · 실측 ${c.verifiedCount}/${c.totalCount}</span></div></div>`).join('')}
</div>
</div></section>
<section><div class="page">
${AEO_GEO_RUBRIC.categories.map((cat) => {
const rows = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id);
return `<h3 style="margin-top:22px;font-size:18px">${esc(cat.code)} · ${esc(cat.name)} <span class="sub">${esc(cat.description)}</span></h3>
<table class="tbl"><thead><tr><th style="width:44px">ID</th><th style="width:22%">항목</th><th style="width:88px">판정</th><th>실측 근거</th></tr></thead><tbody>
${rows.map((c) => { const r = resultById.get(c.id); const lv = r?.level ?? 'unverified'; const cls = lv === 'unverified' ? 'lvu' : `lv${lv}`; return `<tr><td class="mono">${c.id}</td><td><b>${esc(c.name)}</b><div class="sub">배점 ${c.weight}</div></td><td><span class="lv ${cls}">${lv === 'unverified' ? '미검증' : 'L' + lv}</span><div class="sub">${esc(levelLabel(c.id, lv))}</div></td><td class="sub" style="color:var(--slate)">${esc(r?.evidence ?? '')}</td></tr>`; }).join('')}
</tbody></table>`;
}).join('')}
</div></section>
<section class="pb light"><div class="page">
<div class="eyebrow">Action Plan</div><h2>지금 고칠 것과 4주 안에 만들 것을 나눴습니다</h2>
${(['P0', 'P1', 'P2'] as const).map((p) => { const list = result.actions.filter((a) => a.priority === p); if (!list.length) return ''; return `<h3 style="margin-top:20px">${p} · ${P[p]}</h3><div class="grid g2">${list.map((a) => `<div class="card act"><span class="pill gray eff">${esc(a.effort)}</span><div class="pri">${p}</div><h3>${esc(a.title)}</h3><p style="margin:0 0 8px;color:var(--slate)">${esc(a.detail)}</p><div class="sub">연결 항목 ${a.criterionIds.map((c) => `<span class="mono">${c}</span>`).join(' ')}</div></div>`).join('')}</div>`; }).join('')}
</div></section>
${status ? `<section class="pb dark"><div class="page">
<div class="eyebrow">Supporters Preview</div><h2>홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다</h2>
<p class="lede">병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.</p>
<div class="kpi" style="margin-top:18px">
<div class="k"><b>${posts.length}</b><span>글 (게이트 통과)</span></div>
<div class="k"><b>${videos?.channel?.videos ?? '-'}</b><span>유튜브 영상 집계</span></div>
<div class="k"><b>${imgCounts ? imgCounts.doctors + imgCounts.clinic + imgCounts.procedure + imgCounts.equipment : '-'}</b><span>사용 가능 이미지 (전후·사례 제외)</span></div>
<div class="k"><b>${physicians}</b><span>의료진 프로필</span></div>
</div>
<p class="note" style="margin-top:14px">미리보기 ${status.previewUrl ? `<a href="${esc(status.previewUrl)}" style="color:#C4B5FD">${esc(status.previewUrl)}</a>` : '준비 중'} · 병원 확인 항목 입력 <a href="https://infinith-demo.vercel.app/supporters/${esc(id)}" style="color:#C4B5FD">infinith-demo.vercel.app/supporters/${esc(id)}</a></p>
</div></section>
<section><div class="page">
<h3 style="font-size:18px">자동 생성한 글 ${posts.length}편</h3>
<table class="tbl"><thead><tr><th style="width:54px">분류</th><th>제목</th><th style="width:70px">근거 영상</th><th style="width:86px">병원 확인 대기</th><th style="width:90px">상태</th></tr></thead><tbody>
${posts.map((p: any) => { const b = briefById.get(p.id); return `<tr><td class="mono">${esc(p.category)}</td><td><b>${esc(p.title)}</b>${status.previewUrl ? `<div class="sub"><a href="${esc(status.previewUrl)}/posts/${esc(p.id)}">${esc(status.previewUrl)}/posts/${esc(p.id)}</a></div>` : ''}</td><td>${(b?.videos ?? []).length}편</td><td>${(p.pending ?? []).length}건</td><td><span class="pill gray">의학 검토 대기</span></td></tr>`; }).join('')}
</tbody></table>
${status.report?.channels ? `<h3 style="font-size:18px;margin-top:26px">발견한 채널과 확인 결과</h3>
<p class="sub" style="color:var(--slate)">홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.</p>
<table class="tbl"><thead><tr><th style="width:110px">플랫폼</th><th>확인됨</th><th>확인 필요</th></tr></thead><tbody>
${(['youtube', 'instagram', 'facebook', 'naverBlog', 'tiktok', 'kakao', 'gangnamunni', 'naverPlace', 'babitalk'] as const).map((p) => { const L: Record<string, string> = { youtube: '유튜브', instagram: '인스타그램', facebook: '페이스북', naverBlog: '네이버 블로그', tiktok: '틱톡', kakao: '카카오톡 채널', gangnamunni: '강남언니', naverPlace: '네이버 플레이스', babitalk: '바비톡' }; const ok = status.report.channels.confirmed[p] ?? []; const cd = status.report.channels.candidates[p] ?? []; return `<tr><td><b>${L[p]}</b></td><td>${ok.length ? ok.map((c: any) => `<a href="${esc(c.url)}">${esc(c.title || c.key)}</a>${c.subscribers ? ` <span class="sub">(구독 ${Number(c.subscribers).toLocaleString('ko-KR')})</span>` : ''}`).join('<br>') : '<span class="sub">찾지 못함</span>'}</td><td class="sub">${cd.map((c: any) => `<a href="${esc(c.url)}" style="color:var(--muted)">${esc(c.title || c.key)}</a>`).join('<br>') || ''}</td></tr>`; }).join('')}
</tbody></table>` : ''}
<h3 style="font-size:18px;margin-top:26px">병원이 확인해 주실 것 ${INPUTS.length}가지</h3>
<p class="sub" style="color:var(--slate)">홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.</p>
<div class="grid g3" style="margin-top:10px">${INPUTS.map((t, i) => `<div class="card" style="padding:14px 16px"><span class="mono">${i + 1}</span> <b>${esc(t)}</b></div>`).join('')}</div>
<p class="note">싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.</p>
</div></section>` : ''}
<section class="pb light"><div class="page">
<div class="eyebrow">Integrity</div><h2>근거 없는 것은 약속하지 않습니다</h2>
<table class="tbl" style="margin-top:12px"><thead><tr><th style="width:30%">하지 않는 것</th><th>이유</th></tr></thead><tbody>
${result.notDoing.map((n) => `<tr><td><b>${esc(n.item)}</b></td><td style="color:var(--slate)">${esc(n.reason)}</td></tr>`).join('')}
</tbody></table>
<h3 style="margin-top:26px;font-size:18px">측정 조건</h3>
<ul style="color:var(--slate)">${result.conditions.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>
<h3 style="margin-top:26px;font-size:18px">측정 가능 범위</h3>
<p style="color:var(--slate);margin:0 0 8px">무엇을 어떤 방법으로 재고, 무엇은 잴 수 없는지 먼저 밝힙니다. 잴 수 없는 것은 숫자를 만들지 않고 비워 둡니다.</p>
<table class="tbl"><thead><tr><th style="width:26%">재는 것</th><th style="width:22%">방법</th><th style="width:80px">가능</th><th>비고</th></tr></thead><tbody>
<tr><td><b>AI 답변 준비도 · 출처 준비도</b></td><td>공개 페이지 실측(크롤러·렌더링·스키마·표면)</td><td><span class="pill gray">가능</span></td><td class="sub">오너 계정이 필요한 항목(GA4·GSC·서버 로그)은 미검증으로 표시</td></tr>
<tr><td><b>답변엔진 언급률 · 감성</b></td><td>질문 뱅크를 공식 API로 반복 조회</td><td><span class="pill gray">가능</span></td><td class="sub">ChatGPT · Perplexity 기본, 단발 조회는 쓰지 않음. 소비자 화면 자동 조회는 약관 위반이라 하지 않음</td></tr>
${(AI_CHANNELS as any).channels.map((c: any) => `<tr><td><b>${esc(c.label)} 유입</b></td><td>GA4 세션 출처 · utm</td><td><span class="pill gray">${c.measurable === 'yes' ? '가능' : c.measurable === 'partial' ? '일부만' : '불가'}</span></td><td class="sub">${esc(c.note)}</td></tr>`).join('')}
<tr><td><b>AI 크롤러 방문</b></td><td>서버 로그(UA · IP 대역)</td><td><span class="pill gray">조건부</span></td><td class="sub">서포터즈 사이트는 로그로 계측 가능. 병원 사이트는 로그 접근 권한이 있을 때만</td></tr>
<tr><td><b>전화 · 오프라인 방문</b></td><td>-</td><td><span class="pill gray">불가</span></td><td class="sub">사이트의 전화 탭 · 예약 클릭까지만 잼</td></tr>
</tbody></table>
<h3 style="margin-top:26px;font-size:18px">채점 방법</h3>
<p style="color:var(--slate);margin:0">기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.</p>
</div></section>
<footer>INFINITH · AI오투오 · 문의 o2oteam@o2o.kr · 이 리포트는 공개 페이지 실측에 근거하며, 오너 계정(GA4·GSC·서버 로그)이 필요한 항목은 미검증으로 두었습니다. 법률 자문이 아닙니다.</footer>
</body></html>`;
writeFileSync(out, html);
console.log(`${out} · AEO ${v2.aeo.score}${v2.aeo.grade} · GEO ${v2.geo.score}${v2.geo.grade} · v1 ${v1.score}${v1.grade} · 실측 ${verified}/${total}`);