195 lines
21 KiB
TypeScript
195 lines
21 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 { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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">기준표 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}`);
|