/** * 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=/report.mjs * node /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 필요'); 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, '"'); 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 = { 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 = { critical: '#D4889A', warning: '#D4A872', good: '#9B8AD4' }; const sevLabel: Record = { 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 `
${score}/ 100${label} 등급 ${grade}
`; }; 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; // 서포터즈 자동 빌드 섹션 데이터 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) => ` ${ax.items.map((it) => ``).join('')}
항목무엇을 보는가점수근거파생된 실측 항목
${esc(it.item.id)}${esc(it.item.name)}
${esc(it.item.question)}
${it.pct === null ? '미검증' : `${Math.round(it.pct * 100)} / 100`}${it.basis === 'derived' ? '실측 파생' : it.basis === 'judged' ? '사람 판정' : '미검증'}${it.signals.map((s) => `${s.criterionId} ${s.level === 'unverified' ? '미검증' : 'L' + s.level}`).join(' · ')}
${ax.cappedBy?.length ? `

게이트 상한 적용: ${ax.cappedBy.map((g) => esc(g.gate.name)).join(', ')}. 상한 전 점수 ${ax.rawScore}.

` : ''}`; const html = ` ${esc(result.clinicName)} AI Answer Readiness Report v${esc(result.rubricVersion)}
INFINITH AI Discovery · AEO / GEO Audit v${esc(result.rubricVersion)}

AI Answer Readiness

${esc(result.clinicName)}
${esc(result.industry)} · ${esc(result.url)}

AI 답변엔진(ChatGPT·Perplexity·Gemini·네이버 AI 브리핑)이 이 병원을 얼마나 읽고, 믿고, 인용할 수 있는지를 실측 신호로 채점한 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 확인하지 못한 항목은 점수에서 뺍니다.

${ring(v2.aeo.score, 'AEO', v2.aeo.grade)}${ring(v2.geo.score, 'GEO', v2.geo.grade)}
${verified}실측 항목 / ${total}
${v1.score}v1.0 종합 (${v1.grade})
${result.keyFindings.length}핵심 발견
${result.actions.length}실행 항목

실측일 ${esc(result.auditedAt)} · 리포트 작성 ${today} · 미검증 ${total - verified}개 항목은 분모에서 제외했습니다. 조건은 마지막 장에 적었습니다.

Findings

AI가 지금 ${esc(result.clinicName)}를 어떻게 읽는지 정리했습니다

${result.keyFindings.map((f) => `
${sevLabel[f.severity]}

${esc(f.title)}

${esc(f.detail)}

`).join('')}
AEO · Answer Readiness

답변 준비도 ${v2.aeo.items.length}개 항목

${esc(v2.aeo.axis.description)}

${axisTable(v2.aeo)}
GEO · Source Readiness

출처 준비도 ${v2.geo.items.length}개 항목

${esc(v2.geo.axis.description)}

${axisTable(v2.geo)}
Category Detail

카테고리별 실측 ${total}개 항목

${v1.categories.map((c) => `
${esc(c.category.code)} · ${esc(c.category.nameEn)}

${esc(c.category.name)}

${c.pct} / 100 · 실측 ${c.verifiedCount}/${c.totalCount}
`).join('')}
${AEO_GEO_RUBRIC.categories.map((cat) => { const rows = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id); return `

${esc(cat.code)} · ${esc(cat.name)} ${esc(cat.description)}

${rows.map((c) => { const r = resultById.get(c.id); const lv = r?.level ?? 'unverified'; const cls = lv === 'unverified' ? 'lvu' : `lv${lv}`; return ``; }).join('')}
ID항목판정실측 근거
${c.id}${esc(c.name)}
배점 ${c.weight}
${lv === 'unverified' ? '미검증' : 'L' + lv}
${esc(levelLabel(c.id, lv))}
${esc(r?.evidence ?? '')}
`; }).join('')}
Action Plan

지금 고칠 것과 4주 안에 만들 것을 나눴습니다

${(['P0', 'P1', 'P2'] as const).map((p) => { const list = result.actions.filter((a) => a.priority === p); if (!list.length) return ''; return `

${p} · ${P[p]}

${list.map((a) => `
${esc(a.effort)}
${p}

${esc(a.title)}

${esc(a.detail)}

연결 항목 ${a.criterionIds.map((c) => `${c}`).join(' ')}
`).join('')}
`; }).join('')}
${status ? `
Supporters Preview

홈페이지 자료만으로 서포터즈 사이트 초안을 만들었습니다

병원 URL 하나로 근거 수집, 글 생성, 발행 게이트, 미리보기 배포까지 자동으로 돌렸습니다. 모든 글은 의학 검토 대기 상태이고 색인은 막혀 있습니다. 병원이 확인 항목을 입력하고 검토·승인하면 코드 수정 없이 발행됩니다.

${posts.length}글 (게이트 통과)
${videos?.channel?.videos ?? '-'}유튜브 영상 집계
${imgCounts ? imgCounts.doctors + imgCounts.clinic + imgCounts.procedure + imgCounts.equipment : '-'}사용 가능 이미지 (전후·사례 제외)
${physicians}의료진 프로필

미리보기 ${status.previewUrl ? `${esc(status.previewUrl)}` : '준비 중'} · 병원 확인 항목 입력 infinith-demo.vercel.app/supporters/${esc(id)}

자동 생성한 글 ${posts.length}편

${posts.map((p: any) => { const b = briefById.get(p.id); return ``; }).join('')}
분류제목근거 영상병원 확인 대기상태
${esc(p.category)}${esc(p.title)}${status.previewUrl ? `` : ''}${(b?.videos ?? []).length}편${(p.pending ?? []).length}건의학 검토 대기
${status.report?.channels ? `

발견한 채널과 확인 결과

홈페이지 역링크, 프로필 제목·설명, 등록부, 검색 결과를 근거로 병원 것인지 판정했습니다. "확인 필요"는 병원이 맞다고 알려 주시면 연결합니다.

${(['youtube', 'instagram', 'facebook', 'naverBlog', 'tiktok', 'kakao', 'gangnamunni', 'naverPlace', 'babitalk'] as const).map((p) => { const L: Record = { youtube: '유튜브', instagram: '인스타그램', facebook: '페이스북', naverBlog: '네이버 블로그', tiktok: '틱톡', kakao: '카카오톡 채널', gangnamunni: '강남언니', naverPlace: '네이버 플레이스', babitalk: '바비톡' }; const ok = status.report.channels.confirmed[p] ?? []; const cd = status.report.channels.candidates[p] ?? []; return ``; }).join('')}
플랫폼확인됨확인 필요
${L[p]}${ok.length ? ok.map((c: any) => `${esc(c.title || c.key)}${c.subscribers ? ` (구독 ${Number(c.subscribers).toLocaleString('ko-KR')})` : ''}`).join('
') : '찾지 못함'}
${cd.map((c: any) => `${esc(c.title || c.key)}`).join('
') || ''}
` : ''}

병원이 확인해 주실 것 ${INPUTS.length}가지

홈페이지 공개 자료에 없어 병원만 답할 수 있는 값입니다. 입력 전까지 사이트에는 "확인 대기"로 표시됩니다.

${INPUTS.map((t, i) => `
${i + 1} ${esc(t)}
`).join('')}

싣지 않는 것: 전후 사진, 환자 경험담, 다른 병원과의 비교, 효과 보장, 가격 금액. 이미지는 의료법 기준 규칙에 따라 전후·사례·모델·수술 장면·인증 마크를 자동 제외했습니다.

` : ''}
Integrity

근거 없는 것은 약속하지 않습니다

${result.notDoing.map((n) => ``).join('')}
하지 않는 것이유
${esc(n.item)}${esc(n.reason)}

측정 조건

    ${result.conditions.map((c) => `
  • ${esc(c)}
  • `).join('')}

채점 방법

기준표 v1.0 ${total}개 실측 항목(A 접근성 · B 엔티티 · C 콘텐츠 · D 표면 · E 측정 · F 위생)을 0~3 레벨로 판정하고, v2.0은 그 항목을 답변 준비도(AEO) ${v2.aeo.items.length}개와 출처 준비도(GEO) ${v2.geo.items.length}개로 묶어 각 100점으로 정규화합니다. 미검증 항목은 분모에서 빼고, 치명 게이트(크롤러 차단 등)에 걸리면 상한을 둡니다. 답변엔진 언급률은 단발 조회로는 재지 않습니다.

`; 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}`);