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>
This commit is contained in:
Haewon Kam 2026-09-07 15:25:00 +09:00
parent c02de9ea51
commit 76ec63bc1e
32 changed files with 2524 additions and 89 deletions

View File

@ -1,5 +1,7 @@
# INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0 # INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0
> **2026-09-07 갱신:** 제품 점수는 v2.0(AEO 답변 준비도 100 · GEO 출처 준비도 100, `docs/AEO_GEO_RUBRIC_v2.md`)으로 바뀌었다. 이 문서의 36항목은 v2.0의 **실측 신호**로 그대로 쓰이며, v1.0 종합 100점은 리포트에 보조 표기로만 남는다.
> 코드 원본: `src/data/aeoGeoRubric.ts` · 타입: `src/types/discovery.ts` · 집계: `src/lib/discoveryScore.ts` > 코드 원본: `src/data/aeoGeoRubric.ts` · 타입: `src/types/discovery.ts` · 집계: `src/lib/discoveryScore.ts`
> 리포트 페이지: `/discovery/:id` (`src/pages/DiscoveryReportPage.tsx`) > 리포트 페이지: `/discovery/:id` (`src/pages/DiscoveryReportPage.tsx`)
> 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO KDD 2024 · C-SEO Bench NeurIPS 2025) > 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO KDD 2024 · C-SEO Bench NeurIPS 2025)

View File

@ -1,7 +1,7 @@
# INFINITH AI Discovery 채점 기준표 v2.0 (초안) · AEO / GEO 분리 지표 # INFINITH AI Discovery 채점 기준표 v2.0 · AEO / GEO 분리 지표
> 코드 원본: `src/data/aeoGeoRubricV2.ts` · 집계: `src/lib/discoveryScoreV2.ts` · 타입: `src/types/discovery.ts` (v2 블록) > 코드 원본: `src/data/aeoGeoRubricV2.ts` · 집계: `src/lib/discoveryScoreV2.ts` · 타입: `src/types/discovery.ts` (v2 블록)
> 상태: **draft** (2026-09-07). v1.0(36항목 종합 100점)은 유지하며 두 점수를 나란히 표시한다. 이 문서는 코드에서 생성한다(`npx tsx scripts/gen_rubric_v2_doc.ts`). > 상태: **active** (2026-09-07). v1.0(36항목 종합 100점)은 유지하며 두 점수를 나란히 표시한다. 이 문서는 코드에서 생성한다(`npx tsx scripts/gen_rubric_v2_doc.ts`).
## 설계 ## 설계

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,143 @@
"""GEO/AEO 최적화 웹사이트에 워드프레스를 쓰는 것의 장단점 — INFINITH 덱."""
import sys; sys.path.insert(0, "/Users/haewonkam/.claude/skills/infinith-deck/scripts")
from ifd import Deck
d = Deck()
# 01 표지
d.cover(eyebrow="GEO / AEO · PLATFORM DECISION",
title_lines=["WordPress for", "AI Search."],
subtitle="GEO·AEO 최적화 웹사이트에 워드프레스를 쓰는 것의 장단점 · 2026. 08. 28",
meta_right="INFINITH Strategy Memo · 2026")
# 02 요약
s = d.slide(eyebrow="Executive Summary",
title="콘텐츠를 계속 쌓는다면 ", title_accent="워드프레스",
lede=["GEO·AEO의 핵심은 AI 크롤러가 읽는 정적 HTML에 구조화된 사실을 꾸준히 쌓는 것입니다.",
"워드프레스는 이것을 기본으로 해주지만, 플러그인과 테마를 통제하지 못하면 장점이 전부 단점으로 뒤집힙니다."],
section="Summary")
a, b, c = s.cards3(y=s.band_y)
a.chip("결론", "good").title("병원 납품 사이트").text("비개발자가 FAQ 페이지를 직접 늘려야 합니다. 워드프레스 + 경량 테마 + 단일 스키마 플러그인 + 관리형 호스팅.").divider().foot("운영 편의 우선")
b.chip("결론", "info").title("자체 제품 랜딩").text("Astro 또는 프리렌더링된 React. 코드로 스키마와 헤더를 완전히 제어하고, 성능을 최고 수준으로 유지합니다.").divider().foot("제어·성능 우선")
c.chip("결론", "violet").title("확장 단계").text("워드프레스를 헤드리스 CMS로 두고 Astro가 빌드 시 가져와 정적 배포. 운영 편의와 제어를 둘 다 확보합니다.").divider().foot("프로덕트화 대비")
# 03 실측 (다크)
s = d.slide(eyebrow="Why It Matters · INFINITH 실측 2026. 08. 28",
title="JS만 있는 사이트는 ", title_accent="AI에게 안 보인다",
lede="INFINITH 랜딩을 GPTBot, ClaudeBot, PerplexityBot, Googlebot, Yeti로 직접 요청해 받은 본문 단어 수입니다.",
dark=True, section="Evidence")
s.stats3([("4 → 810", "프리렌더 전후 크롤러가 읽는 본문 단어 수. 전에는 title 4단어가 전부였습니다."),
("1.5 MB", "본문을 만들기 위해 실행해야 했던 JS 번들. GPTBot, ClaudeBot은 JS를 실행하지 않습니다."),
("0 → 3", "JSON-LD 스키마 수. Organization, SoftwareApplication, WebSite를 추가했습니다.")])
# 04 요구조건
s = d.slide(eyebrow="What GEO / AEO Requires",
title="플랫폼을 고르기 전에 ", title_accent="세 가지 조건",
lede="어떤 도구를 쓰든 이 세 조건을 통과해야 AI 답변에 인용됩니다. 플랫폼 비교는 이 기준으로만 합니다.",
section="Requirements")
a, b, c = s.cards3(y=s.band_y)
a.icon_square().title("크롤러 가독성").text("서버가 완성된 HTML을 내려줘야 합니다. 본문, 제목 구조, 링크가 JS 실행 없이 응답에 포함되어야 합니다.").divider().foot("SSR 또는 SSG")
b.icon_square().title("구조화 데이터").text("MedicalClinic, Physician, FAQPage, Article JSON-LD가 페이지마다 정확히 하나씩. 중복과 충돌이 없어야 합니다.").divider().foot("Schema.org 단일 소스")
c.icon_square().title("콘텐츠 생산량").text("질문과 답변 쌍을 수십에서 수백 페이지 쌓아야 합니다. 비개발자가 매주 추가할 수 있는 구조여야 합니다.").divider().foot("운영 지속성")
# 05 장점 1
s = d.slide(eyebrow="Advantages · 1 / 2",
title="크롤러 가독성은 ", title_accent="기본으로 통과",
lede="PHP가 완성된 HTML을 내려주므로 별도 작업 없이 본문이 크롤러에 노출됩니다.",
section="Advantages")
a, b, c = s.cards3(y=s.band_y)
a.chip("장점", "good").title("서버 렌더링 기본").text("GPTBot, ClaudeBot, PerplexityBot은 JS를 실행하지 않습니다. 워드프레스 응답에는 본문이 그대로 들어 있습니다. SPA는 빈 div만 보입니다.").divider().foot("추가 작업 0")
b.chip("장점", "good").title("스키마 생태계").text("Rank Math, Yoast, Schema Pro가 MedicalClinic, FAQPage, LocalBusiness JSON-LD를 UI로 생성합니다. robots.txt와 llms.txt도 파일 편집으로 즉시 반영됩니다.").divider().foot("비개발자 운영 가능")
c.chip("장점", "good").title("콘텐츠 생산 속도").text("코 재수술 비용, 눈매교정 회복 기간 같은 질문 페이지를 마케팅 담당자가 에디터에서 직접 추가합니다. ACF로 구조화 필드도 관리됩니다.").divider().foot("주 단위 발행")
# 06 장점 2
s = d.slide(eyebrow="Advantages · 2 / 2",
title="락인 없이 ", title_accent="오픈웹에 남는다",
lede="지금 워드프레스를 골라도 나중에 프론트를 바꿀 수 있고, 네이버와 달리 AI 크롤러가 접근할 수 있는 소유 자산이 됩니다.",
section="Advantages")
L, R = s.cards2(y=s.band_y)
L.chip("장점", "good").title("헤드리스 전환 경로").rows([
("REST API / WPGraphQL", "콘텐츠를 그대로 꺼내 Astro, Next.js 프론트에 연결할 수 있습니다."),
("빌드 시 정적화", "프론트를 바꾸면 성능과 스키마 제어를 코드 수준으로 가져옵니다."),
("이전 비용 낮음", "글, 이미지, 카테고리 구조가 DB에 남아 마이그레이션이 단순합니다."),
])
R.chip("장점", "good").title("오픈웹 소유 자산").rows([
("네이버 자산은 차단됨", "네이버 블로그, 플레이스, 카페는 robots.txt로 AI 크롤러를 전면 차단합니다."),
("자체 도메인은 열림", "워드프레스 사이트는 GPTBot, ClaudeBot이 읽고 인용할 수 있는 유일한 소유 표면입니다."),
("브랜드 신호 축적", "인용될수록 브랜드 언급이 쌓여 다음 답변에도 다시 인용됩니다."),
])
# 07 단점 1
s = d.slide(eyebrow="Disadvantages · 1 / 2",
title="통제 못하면 ", title_accent="성능과 HTML이 무너진다",
lede="테마, 페이지빌더, 플러그인이 쌓이면 크롤러가 읽는 HTML이 지저분해지고 로딩이 느려집니다.",
section="Disadvantages")
a, b, c = s.cards3(y=s.band_y)
a.chip("단점", "critical").title("성능 저하가 기본값").text("페이지빌더와 플러그인 20개면 LCP 4초를 넘기기 쉽습니다. AI Overview는 검색 순위 기반이라 간접 영향이 큽니다.").divider().foot("대책: 경량 테마 + 캐시")
b.chip("단점", "critical").title("HTML 노이즈").text("Elementor, Divi가 만드는 div 중첩 10단계와 인라인 스타일은 AI가 답변 단락을 뽑을 때 노이즈입니다. h2 질문, p 답변 구조가 깨집니다.").divider().foot("대책: Gutenberg만 사용")
c.chip("단점", "critical").title("스키마 중복 충돌").text("Yoast, 테마 내장, FAQ 플러그인이 각자 JSON-LD를 뿌려 Organization이 3개 나오는 경우가 흔합니다. Rich Results 검사에서 오류가 납니다.").divider().foot("대책: 스키마 소스 1개")
# 08 단점 2
s = d.slide(eyebrow="Disadvantages · 2 / 2",
title="보안, 제어, ", title_accent="한국 시장 커버리지",
lede="병원 사이트는 해킹 피해가 브랜드 오염으로 이어지고, 국내 AI 답변은 네이버 자산을 우선 인용합니다.",
section="Disadvantages")
a, b, c = s.cards3(y=s.band_y)
a.chip("단점", "warning").title("보안·유지보수 부담").text("플러그인 취약점으로 해킹당하면 스팸 페이지가 대량 생성되고, 이것이 AI 크롤러에 잡혀 브랜드가 오염됩니다.").divider().foot("대책: 관리형 호스팅")
b.chip("단점", "warning").title("세밀한 제어 한계").text("페이지별 robots 메타, 크롤러별 응답 분기, Edge 캐싱 정책은 커스텀 코드나 Cloudflare에서 처리해야 합니다.").divider().foot("Astro보다 손이 많이 감")
c.chip("단점", "warning").title("한국 시장 특수성").text("네이버 서치어드바이저 등록과 사이트맵 제출을 별도로 해야 하고, 네이버 AI 브리핑은 네이버 자산을 우선 인용합니다.").divider().foot("글로벌 AI에 강함")
# 09 대안 비교 (다크)
s = d.slide(eyebrow="Alternatives",
title="네 가지 선택지, ", title_accent="같은 기준으로",
lede="크롤러 가독성, 운영, 스키마 제어, 성능, 유지보수, 비용. INFINITH 현재 스택(Vite SPA)을 기준선으로 넣었습니다.",
dark=True, section="Alternatives")
w = s.card_wide(y=s.band_y)
w.grid(["기준", "워드프레스", "Astro / Next.js SSG", "Webflow / Framer", "Vite SPA (INFINITH 기존)"],
[["AI 크롤러 가독성", "좋음", "최고", "좋음", "나쁨"],
["비개발자 콘텐츠 운영", "최고", "나쁨", "좋음", "불가"],
["스키마 제어", "플러그인 의존", "코드로 완전 제어", "제한적", "코드로 완전 제어"],
["성능", "관리 필요", "최고", "좋음", "좋음"],
["유지보수", "부담 있음", "낮음", "낮음", "낮음"],
["비용", "낮음", "낮음", "월 구독", "낮음"]],
col_w=[3.5, 3.3, 3.3, 3.3, 3.3], row_h=0.66,
tones=[[None, "good", "good", "good", "critical"],
[None, "good", "critical", "good", "critical"],
[None, "warning", "good", "warning", "good"],
[None, "warning", "good", "good", "good"],
[None, "warning", "good", "good", "good"],
[None, "good", "good", "warning", "good"]])
# 10 추천 구성
s = d.slide(eyebrow="Recommendation",
title="용도에 따라 ", title_accent="세 가지 구성",
lede="한 가지 정답은 없습니다. 누가 콘텐츠를 추가하는지, 얼마나 제어가 필요한지로 정합니다.",
section="Recommendation")
a, b, c = s.cards3(y=s.band_y)
a.icon_square().title("병원 클라이언트 납품").text("워드프레스 + GeneratePress 또는 Kadence + Rank Math 단일 스키마 + WP Rocket 캐시 + 관리형 호스팅. 클라이언트가 FAQ를 직접 추가합니다.").divider().foot("운영 편의 우선")
b.icon_square().title("INFINITH 자체 랜딩").text("Astro 콘텐츠 + React 아일랜드. 현재 Vite SPA에는 프리렌더링을 붙여 크롤러 본문 4단어를 810단어로 이미 끌어올렸습니다.").divider().foot("제어·성능 우선")
c.icon_square().title("하이브리드").text("워드프레스를 헤드리스 CMS로 두고 Astro가 빌드 시 가져와 정적 배포. 클라이언트 수가 늘어나면 이 구성이 프로덕트화에 유리합니다.").divider().foot("확장 단계")
# 11 운영 규칙
s = d.slide(eyebrow="If WordPress · Operating Rules",
title="워드프레스를 쓴다면 ", title_accent="다섯 가지 규칙",
lede="이 규칙을 지키면 장점만 남고, 어기면 단점이 전부 살아납니다.",
section="Rules")
w = s.card_wide(y=s.band_y)
w.bullets([
"플러그인은 10개 이하. 하나 추가할 때마다 LCP와 HTML 크기를 다시 측정합니다.",
"테마는 GeneratePress, Kadence 같은 경량 테마만. Elementor, Divi 같은 페이지빌더는 쓰지 않습니다.",
"스키마 소스는 Rank Math 하나로 통일. 테마 내장 스키마와 별도 FAQ 플러그인의 JSON-LD 출력은 끕니다.",
"본문은 Gutenberg 블록으로 h2 질문, p 답변 구조를 유지합니다. AI는 이 단락 단위로 인용합니다.",
"호스팅은 관리형(WP Engine, Kinsta, Cafe24 매니지드)으로. 보안 패치와 백업을 운영자가 직접 하지 않습니다.",
], size=17.25, step=1.0)
# 12 클로징
d.closing(eyebrow="Next Step",
title_lines=["도구보다 조건이 먼저입니다."],
lede="크롤러 가독성, 단일 스키마, 콘텐츠 생산 구조. 이 세 가지를 통과하는 쪽을 고릅니다.",
cta_primary="병원용 워드프레스 표준 스택 문서화 →", cta_secondary="INFINITH 랜딩 FAQ 스키마 추가",
contact="infinith-demo.vercel.app · team.backstageai@gmail.com")
out = "docs/deck/INFINITH_WordPress_GEO_AEO_장단점.pptx"
d.save(out); print("saved", out)

View File

@ -0,0 +1,298 @@
# INFINITH AI Discovery PRD · 분석 → 진단 → 빌드 → 측정
- 버전: v0.2 (2026.08.28) · 상태: Draft for Engineering Review
- 작성: INFINITH Product · 대상: 프론트엔드 · 백엔드 · 인프라 · 콘텐츠 운영
- 목표 릴리스: v1.0 · 10주 · 2026.11.06
- 관련 코드: `AEO-GEO-Audit` 브랜치 (`src/data/aeoGeoRubric.ts`, `src/types/discovery.ts`, `src/lib/discoveryScore.ts`, `src/pages/DiscoveryReportPage.tsx`, `docs/AEO_GEO_RUBRIC.md`) · `Productize` 브랜치 (랜딩 프리렌더 커밋)
- 덱 버전: `INFINITH_AI-Discovery_PRD_20260828_v02.pptx`
---
## 1. 제품 흐름
병원 URL 하나를 넣으면 채널 분석과 AEO · GEO 진단이 나오고, 진단 항목이 그대로 빌드 요구사항과 측정 지표가 되는 제품.
| 단계 | 내용 | 상태 |
|---|---|---|
| 1 분석 | 채널 발견 · 6채널 데이터 수집 · 마케팅 리포트 | 구현 완료 · `discover-channels` → `collect-channel-data` → `generate-report` |
| 2 진단 | 36항목 AEO · GEO 스코어카드 · 등급 · 액션 플랜 | 프로토타입 동작 · 수동 채점 → 자동화 대상 |
| 3 빌드 | 진단 P0 · P1을 사이트 수정 또는 표준 사이트로 실행 | 신규 · 진단 항목이 곧 요구사항 |
| 4 측정 | 크롤러 로그 · GA4 AI 채널 · 재진단으로 점수 변화 증명 | 신규 · 루브릭 E 카테고리를 채운다 |
뷰성형외과는 1 · 2단계를 거쳐 P0 3건이 나왔다. 3 · 4단계가 없어 여기서 멈춘다. 그 공백이 이 PRD의 범위다.
## 2. 배경
AI 답변엔진은 JS를 실행하지 않는다. GPTBot, ClaudeBot, PerplexityBot은 서버가 내려준 HTML만 읽는다. 진단은 이 사실에서 출발한다.
- **실측 · 뷰성형외과: 읽히지만 라벨뿐.** AI 봇 12종 전부 200, JS 없이 본문 10,757자. 그런데 H1 5개가 전부 영문 라벨(VIEW SELFIE / VIEW EVENT …)이고 JSON-LD는 문법 오류(ItemList 블록 쉼표 누락)로 무효.
- **시장 · 네이버 자산은 AI에 차단됨.** 블로그 · 플레이스 · 카페는 robots.txt로 AI 크롤러를 차단한다. 인용되려면 오픈웹 소유 자산이 필요하다.
- **실측 · INFINITH 랜딩: 4단어.** 크롤러 UA 요청 결과 title 4단어 외 본문 0. 1.5 MB JS를 실행해야 본문이 나왔다. 프리렌더 후 810단어.
**문제 정의.** 진단은 나오는데 다음이 없다. 진단 결과를 받은 병원이 무엇을 고쳐야 하는지, 고친 뒤 점수가 올랐는지 증명할 수단이 제품 안에 없다.
## 3. 케이스 · 뷰성형외과 (2026.08.28)
카테고리 점수는 검증 항목 기준 정규화. 미검증(A6 · D6 · E1~E3)은 분모 제외.
| 카테고리 | 배점 | 획득 | 점수 | 핵심 발견 | → 빌드 · 측정 요구사항 |
|---|---|---|---|---|---|
| A 크롤러 접근성 | 20 | 15.7 / 17 | 92% | robots · SSR · HTTPS 정상. sitemap lastmod 15개월 전 | FR-B05 sitemap 자동 갱신 |
| B 엔티티 구조화 | 20 | 6.0 / 20 | 30% | JSON-LD 깨짐 · MedicalClinic 없음 · Physician 없음 | FR-B03 스키마 단일 소스 |
| C 인용 가능 콘텐츠 | 25 | 5.0 / 25 | 20% | 정의문 · FAQ · 서술형 H1 없음. 수치 부족 | FR-B02 질문 템플릿 · FR-B06 검사 |
| D 인용 표면 | 20 | 13.7 / 19 | 72% | 강남언니 19,316건 강함. GBP 전화 없음 · NAP 3종 | FR-B08 NAP 통일 체크리스트 |
| E 측정 인프라 | 10 | 0 / 2 | 0% | 로그 · GA4 · GSC 오너 접근 필요. 기준선 없음 | FR-M01 ~ M03 |
| F 기술 위생 | 5 | 1.7 / 5 | 33% | WordPress 5.5.3 · alt 누락 · 페이지 무게 | NFR-04 · FR-B09 CMS 업그레이드 |
종합 48점 · C등급. P0 3건: JSON-LD 수정 + MedicalClinic 삽입, GBP 전화 등록 + NAP 통일, 측정 인프라 설치.
> 점수 확인 필요: 현재 `discovery_viewclinic.ts` 레벨 데이터로 재계산한 값이 48점. 리포트 페이지 표시값과 대조할 것.
## 4. 목표와 지표
출시 10주 후 기준. 모든 지표는 제품 안에서 자동 측정된다.
| 단계 | 지표 | 현재 | v1.0 목표 | 측정 방법 |
|---|---|---|---|---|
| 진단 | 자동 채점 항목 비율 | 0% (36항목 수동) | 24 / 36 자동 (67%) | `discover-aeo-geo` 실행 로그 |
| 진단 | URL 입력 → 리포트 소요 시간 | 약 4시간 (수동) | 10분 이내 | `analysis_runs` 타임스탬프 |
| 진단 | 재진단 재현성 | 미검증 | 동일 URL 2회 점수 편차 3점 이내 | 회귀 테스트 |
| 빌드 | P0 액션 완료 → 재진단 점수 상승 | 측정 불가 | 뷰성형외과 48 → 65+ (B) | 재진단 비교 |
| 빌드 | 질문 페이지 발행 (표준 사이트) | 0 | 8주 내 40개 | CMS 발행 로그 |
| 측정 | AI 크롤러 · AI 유입 계측 병원 | 0 | 파일럿 100% | 로그 파서 · GA4 |
답변엔진 언급률은 지표에서 제외한다. 소비자 UI 자동 조회는 약관 위반이고 단발 조회는 무효다.
## 5. 범위
**In scope (v1.0)**
- 진단 Edge Function `discover-aeo-geo` (자동 채점 1 · 2 · 3단계)
- 수동 항목 입력 슬롯 (A6 · D1 · D2 · D4 · E1~E4)과 미검증 처리
- 진단 리포트 DB 저장 · `/discovery/:id` 실데이터 연결 · PDF
- 빌드 A: 기존 사이트 수정 패키지 (스키마 · NAP · CMS · FAQ 삽입)
- 빌드 B: 병원용 워드프레스 표준 사이트 (템플릿 4종 · 검사)
- 측정: 크롤러 로그 파서 · GA4 AI 채널 · 재진단 비교 뷰
- INFINITH 랜딩 파일럿: FAQ 섹션 · FAQPage 스키마
**Out of scope (v1.0)**
- 분석 파이프라인 변경 (`discover` · `collect` · `generate-report`)
- Q&A 콘텐츠 작성 (운영팀 · 병원 담당, 별도 가이드)
- 네이버 블로그 · 플레이스 운영 (AI 크롤러 차단 자산)
- 답변엔진 UI 자동 조회 · 언급률 리포트 (약관 위반)
- llms.txt 납품 (Google 효과 0 · 채택률 0.13%)
- 헤드리스(WordPress + Astro) 전환 (v2.0 후보)
빌드 A와 B를 나눈 이유: 뷰성형외과처럼 사이트가 있는 병원은 수정이고, 없는 병원은 표준 사이트다.
## 6. 사용자
| 구분 | 사용자 | 성공 기준 |
|---|---|---|
| Primary | 병원 마케팅 담당자 | URL을 넣고 10분 안에 스코어카드와 P0를 본다. 수정 후 재진단으로 점수 변화를 확인한다. 표준 사이트면 질문 페이지를 직접 추가한다 |
| Secondary | INFINITH 운영팀 | 수동 항목(GBP · 강남언니 · 로그)을 입력하고 여러 병원의 점수 · 크롤 수 · 오류를 한 화면에서 본다. 병원 온보딩 1일 |
| System | AI 크롤러 · 검색엔진 | GPTBot · ClaudeBot · PerplexityBot · Googlebot · Yeti. 진단은 이들의 UA로 실제 요청해 채점한다. 빌드 결과도 같은 방법으로 검증한다 |
설계 원칙: 확인 못 한 것은 감점하지 않는다(미검증은 분모 제외). 근거 없는 것은 팔지 않는다. 담당자의 실수는 시스템이 발행 전에 막는다.
## 7. 기능 요구사항
### 7.1 진단 (FR-D)
AEO-GEO-Audit 브랜치의 루브릭 · 타입 · 집계 로직을 그대로 쓰고, 채점 데이터만 수동에서 자동으로 바꾼다.
| ID | 요구사항 | 상세 | 루브릭 | 우선순위 |
|---|---|---|---|---|
| FR-D01 | 자동 채점 1단계 | robots 파싱 · 12개 AI 봇 UA GET · HTML 파서 (`crawler_audit.py` 이식) | A1~A5 · A7 · B1 · B4 · B6 · B7 · C2 · C3 · C5 · C6 · F1~F4 | P0 |
| FR-D02 | 자동 채점 2단계 | 본문 텍스트를 Perplexity 또는 Claude에 투입해 구조화 판정 | B2 · B3 · C1 · C4 · C7 · C8 | P0 |
| FR-D03 | 자동 채점 3단계 | 검색 그라운딩 도메인 집계 · Wikidata API | D3 · D6 | P1 |
| FR-D04 | 수동 입력 슬롯 | 운영팀이 근거 · 레벨 입력. 미입력은 `unverified`로 분모 제외 | A6 · D1 · D2 · D4 · E1~E4 | P0 |
| FR-D05 | 결과 저장 · 리포트 | `DiscoveryResult`를 DB 저장. `/discovery/:id`가 mock 대신 DB를 읽음 | 전체 | P0 |
| FR-D06 | 액션 플랜 생성 | level 0 · 1 항목에서 P0 · P1 · P2 액션 자동 생성. 빌드 FR과 연결 | 전체 | P1 |
| FR-D07 | 재진단 비교 | 같은 URL의 이전 결과와 항목별 diff · 점수 변화 표시 | 전체 | P1 |
1단계만으로 36항목 중 17개가 자동이다. 2단계까지 23개. 나머지는 오너 계정 접근이 필요해 수동이 맞다.
### 7.2 빌드 A · 기존 사이트 수정 (FR-B, P0)
뷰성형외과 액션 플랜에서 역산했다. 병원 개발사나 INFINITH가 붙여 넣을 수 있는 산출물이어야 한다.
| ID | 요구사항 | 상세 | 루브릭 | 우선순위 |
|---|---|---|---|---|
| FR-B01 | 서버 렌더링 검증 · 처방 | A3 실패 시 프리렌더 · SSR 처방 (INFINITH 랜딩 방식 문서) | A3 | P0 |
| FR-B03 | 스키마 패키지 생성 | 진단 데이터로 MedicalClinic · Physician JSON-LD 생성. 기존 스키마 차단 안내 | B1 · B2 · B3 · B5 | P0 |
| FR-B08 | NAP 통일 체크리스트 | 홈페이지 · 강남언니 · GBP · 플레이스 표기를 나열하고 정본 지정 | B4 · D2 · D4 | P0 |
| FR-B04 | 메타 패키지 | title · description · canonical · OG · hreflang 코드 생성 | B6 · B7 | P1 |
| FR-B05 | robots · sitemap 처방 | AI 봇 허용 robots.txt · lastmod 갱신 규칙 | A1 · A5 | P1 |
| FR-B07 | FAQ 삽입 패키지 | 시술별 질문 · 요약 답변 초안 + FAQPage 스키마 코드 | C1 · C2 · C4 | P1 |
| FR-B09 | CMS 위생 처방 | WordPress 버전 · alt 누락 목록 · 페이지 무게 리포트 | F1 · F2 · F3 | P1 |
빌드 A의 산출물은 코드 조각과 체크리스트다. 병원 사이트를 INFINITH가 직접 호스팅하지 않는다.
### 7.3 빌드 B · 표준 사이트 (FR-B, P1)
루브릭 A · B · C · F를 처음부터 통과하는 워드프레스 사이트. 담당자가 질문 페이지를 직접 늘린다.
| ID | 요구사항 | 상세 | 루브릭 | 우선순위 |
|---|---|---|---|---|
| FR-B10 | 표준 스택 프리셋 | 경량 테마 · Rank Math · WP Rocket · ACF · 관리형 호스팅. 1회 명령 복제 | A · F 전체 | P1 |
| FR-B02 | 질문 페이지 템플릿 | 질문(h1) · 요약 답변(p, 60자) · 상세 · 관련 시술 · 의료진. 필드 입력만으로 생성 | C1 · C2 · C3 · C8 | P1 |
| FR-B11 | 시술 · 의료진 템플릿 | MedicalProcedure · Physician 스키마 포함. 가격대 · 시간 · 회복 필드 | B3 · C4 · C7 | P1 |
| FR-B12 | 스키마 단일 소스 | 페이지 유형별 JSON-LD 1개 자동 출력. 테마 · 타 플러그인 출력 차단 | B1 · B2 · B3 | P1 |
| FR-B06 | 발행 전 검사 | 스키마 중복 · h1 2개 · alt 누락 · 요약 답변 누락 시 발행 차단 | B1 · C3 · F1 | P1 |
| FR-B13 | sitemap · robots 자동 | 발행 시 lastmod 갱신 · AI 봇 9종 허용 | A1 · A5 · C6 | P1 |
빌드 B는 P1이다. 뷰성형외과처럼 사이트가 있는 병원이 먼저이므로 빌드 A가 P0다.
### 7.4 측정 (FR-M)
인용 여부를 직접 볼 수 없으므로 소유한 신호를 계측한다. 재진단이 개선의 증거다.
| ID | 요구사항 | 상세 | 루브릭 | 우선순위 |
|---|---|---|---|---|
| FR-M01 | AI 크롤러 로그 파서 | 액세스 로그 또는 Cloudflare Logpush에서 GPTBot · ClaudeBot · PerplexityBot 히트를 IP 검증 후 일 집계 | E1 | P0 |
| FR-M02 | GA4 AI 채널 | chatgpt.com · perplexity.ai · gemini.google.com 리퍼러를 커스텀 채널로 분리 · 설정 가이드 | E2 | P0 |
| FR-M03 | 기준선 스팟체크 기록 | 분기 반복 측정 프로토콜과 입력 슬롯 (수동) | E4 | P1 |
| FR-M04 | 재진단 스케줄 | 월 1회 자동 재진단 · 점수 변화 알림 | 전체 | P1 |
| FR-M05 | 운영 대시보드 | 병원별 점수 · 크롤 수 · AI 세션 · 스키마 오류 · LCP 주간 표시 | E 전체 | P1 |
FR-M01 · M02는 뷰성형외과 P0 3번(측정 인프라 설치)이다. 빌드보다 먼저 깔아야 전후 비교가 된다.
## 8. 비기능 요구사항
| ID | 영역 | 요구사항 | 예산 · 기준 |
|---|---|---|---|
| NFR-01 | 진단 | 재현성 | 동일 URL 2회 실행 점수 편차 3점 이내 · LLM 판정 항목은 temperature 0 |
| NFR-02 | 진단 | 소요 시간 · 비용 | URL당 10분 이내 · API 비용 2,000원 이내 |
| NFR-03 | 진단 | 대상 사이트 부하 | UA 테스트 요청 30회 이내 · 1초 간격 · 429 시 중단 |
| NFR-04 | 빌드 B | 성능 | 모바일 LCP p75 2.5초 · CLS 0.1 · 플러그인 10개 이하 · HTML 80 KB 이하 |
| NFR-05 | 빌드 B | HTML 품질 · 보안 | h1 1개 · div 중첩 6단계 이하 · 관리형 호스팅 · 자동 업데이트 · 일 백업 |
| NFR-06 | 측정 | 로그 보존 · 정확도 | 90일 보존 · 봇 IP 역DNS 검증 (위장 UA 제외) |
| NFR-07 | 공통 | 정직성 규약 | 리포트에 "하지 않을 것" 섹션 고정 · 근거 등급(실측 · 학술 · 벤더 · 추정) 표기 |
Elementor · Divi 같은 페이지빌더는 NFR-04 · 05를 통과하지 못하므로 빌드 B에서 금지다.
## 9. 아키텍처
Supabase Edge Functions 체인. 분석 3개 함수는 그대로, 신규 4개를 추가한다.
| 단계 | 함수 · 컴포넌트 | 비고 |
|---|---|---|
| 1 분석 | `discover-channels` → `collect-channel-data` → `generate-report` | 기존 · `marketing_reports` · `clinic_registry` |
| 2 진단 | `discover-aeo-geo`: UA 테스트 · HTML 파서 · LLM 판정 · 집계 | 신규 · 루브릭 · 집계는 AEO-GEO-Audit 코드 이식 |
| 3 빌드 | `build-package`: 스키마 · 메타 · NAP · FAQ 코드 생성 / WP 커스텀 플러그인 | 신규 · 빌드 A는 함수, B는 플러그인 |
| 4 측정 | `log-ingest` (Logpush → Supabase) · GA4 커넥터 · 재진단 cron | 신규 · `discovery_results` 시계열 |
신규 테이블 2개: `discovery_results` (결과 · 버전) · `crawler_hits` (봇 · IP · 경로 · 일자).
## 10. 루브릭 → 빌드 매핑 (FR-D06 규칙표)
| 루브릭 항목 | level 0 · 1이면 | 빌드 산출물 | FR |
|---|---|---|---|
| A3 서버 렌더링 본문 | 본문이 JS에만 있음 | 프리렌더 · SSR 처방 문서 + 검증 스크립트 | FR-B01 |
| B1 · B2 · B3 스키마 | JSON-LD 깨짐 · 엔티티 없음 | MedicalClinic · Physician JSON-LD 코드 + 차단 안내 | FR-B03 · B12 |
| B4 · D2 · D4 NAP | 표면 간 표기 불일치 | 정본 NAP 체크리스트 · GBP 수정 항목 | FR-B08 |
| C1 · C2 · C4 콘텐츠 | 정의문 · FAQ · 수치 없음 | 시술별 질문 · 요약 답변 초안 + FAQPage 코드 | FR-B07 · B02 |
| C3 제목 계층 | H1 없음 · 이미지 텍스트 | H1 재설계안 · 이미지 정보 텍스트화 목록 | FR-B07 |
| C6 신선도 | dateModified 없음 | dateModified 활성화 · 갱신 주기 규칙 | FR-B13 |
| E1 · E2 측정 | 미설치 | 로그 파서 · GA4 채널 설정 | FR-M01 · M02 |
| F2 CMS | 구버전 | 업그레이드 절차 · 백업 체크리스트 | FR-B09 |
## 11. 스키마 스펙
빌드 A는 코드 조각으로, 빌드 B는 Rank Math 훅으로 같은 스펙을 출력한다. 테마 내장 스키마는 끈다.
| 페이지 | @type | 필수 속성 | 데이터 소스 |
|---|---|---|---|
| 홈 | MedicalClinic + WebSite | name · address · telephone · openingHours · sameAs | 분석 1단계 `clinics` · 병원 정보 ACF |
| 시술 | MedicalProcedure | name · procedureType · bodyLocation · howPerformed · preparation | 시술 ACF |
| 의료진 | Physician | name · medicalSpecialty · memberOf · image · alumniOf | 의료진 ACF · 강남언니 의료진 |
| 질문 | FAQPage (+ Article) | mainEntity[Question · acceptedAnswer] · author · dateModified | 질문 템플릿 필드 |
| 공통 | BreadcrumbList | itemListElement | 자동 |
수용 기준: Rich Results Test 오류 0. 응답에 ld+json 블록 정확히 1개 (Breadcrumb 포함 시 @graph 병합). 루브릭 B1 · B2 · B3 level 3.
## 12. 질문 페이지 템플릿
루브릭 C1 · C2 · C3 · C4 · C8을 동시에 통과하는 구조.
| 필드 | HTML 출력 | 제약 | 예시 | 루브릭 |
|---|---|---|---|---|
| 질문 | h1 | 물음표로 끝남 · 40자 이내 | 코 재수술은 언제 가능한가요? | C3 |
| 요약 답변 | p (h1 직후) | 60자 이내 · 숫자 · 기간 포함 권장 | 1차 수술 후 최소 6개월, 보통 1년 뒤 가능합니다. | C1 · C8 |
| 상세 답변 | h2 + p 반복 | h2는 하위 질문형 · 단락 3문장 이내 | 왜 6개월을 기다려야 하나요? | C3 · C8 |
| 가격 · 시간 · 회복 | 표 | 숫자 범위 필수 | 300~500만원 · 2시간 · 회복 2주 | C4 · C5 |
| 관련 시술 · 의료진 | 링크 + Physician | 각 1개 이상 | 코 재수술 · 김OO 원장 | C7 |
| 갱신일 | dateModified | 자동 | 2026-08-28 | C6 |
요약 답변 60자는 AI 답변엔진이 한 단락으로 인용하는 길이다. 이 필드가 비면 발행이 막힌다(FR-B06).
## 13. 수용 테스트
| ID | 단계 | 테스트 | 통과 기준 |
|---|---|---|---|
| AT-01 | 진단 | viewclinic.com 자동 진단 실행 | 수동 채점 대비 자동 항목 일치율 90% · 소요 10분 이내 |
| AT-02 | 진단 | infinith-demo.vercel.app 진단 | A3 level 3 · B1 level 3 · 재실행 편차 3점 이내 |
| AT-03 | 진단 | 미검증 처리 | 수동 슬롯 비운 채 실행 시 해당 항목 분모 제외 · 점수 계산 일치 |
| AT-04 | 빌드 A | 스키마 패키지 적용 후 Rich Results Test | 오류 0 · 블록 1개 |
| AT-05 | 빌드 B | `curl -A GPTBot` 5종 UA 본문 단어 수 | 300단어 이상 · 5개 UA 동일 · 429 · 403 0건 |
| AT-06 | 빌드 B | 발행 검사 | 요약 답변 비운 채 발행 시 차단 + 안내 |
| AT-07 | 측정 | 로그 파서 위장 UA 테스트 | 역DNS 실패 히트 제외 · 일 집계 정확 |
| AT-08 | 루프 | 빌드 후 재진단 | 변경 항목 level 상승이 diff에 표시 · 점수 상승 |
AT-02 · 05는 2026.08.28 INFINITH 랜딩 점검 스크립트를 그대로 쓴다.
## 14. 마일스톤 (10주)
2주 단위. 각 단계 끝에 수용 테스트를 통과해야 다음 단계로 간다.
| 주차 | 단계 | 내용 | 완료 기준 |
|---|---|---|---|
| W1-2 | 진단 1단계 | `discover-aeo-geo` · UA 테스트 · 파서 · DB · 리포트 연결 | FR-D01 · D04 · D05 · AT-01~03 |
| W3-4 | 측정 · 진단 2단계 | 로그 파서 · GA4 · LLM 판정 · 재진단 diff | FR-M01 · M02 · D02 · D07 · AT-07 |
| W5-6 | 빌드 A | 스키마 · NAP · FAQ · CMS 패키지 생성 | FR-B01 · B03 · B04 · B07 · B08 · AT-04 |
| W7-8 | 빌드 B | 표준 스택 · 템플릿 3종 · 검사 · 플러그인 | FR-B02 · B06 · B10~B13 · AT-05 · 06 |
| W9-10 | 파일럿 · 릴리스 | 뷰성형외과 빌드 A 적용 · 재진단 · 표준 사이트 1곳 | AT-08 · 48 → 65+ · v1.0 |
측정(W3-4)을 빌드보다 앞에 둔 이유: 전후 비교의 기준선이 빌드 전에 있어야 한다.
## 15. 리스크
| 리스크 | 영향 | 가능성 | 대응 |
|---|---|---|---|
| LLM 판정 항목(2단계)의 채점 불일치 | 높음 | 중간 | temperature 0 · 근거 인용 강제 · 수동 채점과 90% 일치 회귀 테스트 |
| 대상 사이트가 진단 요청을 차단 | 중간 | 중간 | 요청 30회 · 1초 간격 · 429 시 중단 후 unverified 처리 |
| 병원이 빌드 A 패키지를 적용하지 않음 | 높음 | 높음 | 적용 대행 옵션 · 재진단 알림 · P0만 먼저 |
| 담당자가 질문 페이지를 추가하지 않음 | 높음 | 높음 | 분석 리포트의 질문 목록을 초안으로 제공 · 주간 발행 목표 |
| AI 크롤러 UA · 정책 변경 | 중간 | 높음 | 봇 목록 중앙 관리 · 분기 점검 · 루브릭 버전 관리 |
| 스키마 플러그인 업데이트로 출력 변경 | 중간 | 중간 | AT-04 매 배포 실행 · 버전 고정 |
## 16. 오픈 이슈 (리뷰 미팅 결정)
| # | 질문 | 선택지 | 기본안 · 근거 |
|---|---|---|---|
| 1 | 2단계 LLM 판정 모델 | Perplexity · Claude · 둘 다 | Claude. 판정 일관성 · 근거 인용. Perplexity는 D3 그라운딩에만 |
| 2 | 로그 수집 방식 | 호스팅 로그 API · Cloudflare Logpush · WP 플러그인 | Cloudflare Logpush → Supabase. 호스팅 무관 |
| 3 | 빌드 A 적용 주체 | 병원 개발사 · INFINITH 대행 · 둘 다 | 둘 다. 패키지는 개발사용, 대행은 유료 옵션 |
| 4 | 빌드 B 호스팅 · 스키마 플러그인 | Kinsta · WP Engine · Cafe24 / Rank Math · Yoast | Kinsta + Rank Math. Cloudflare 통합 · 의료 타입 · 훅 |
| 5 | 헤드리스 전환 시점 | 병원 5곳 · 10곳 · 미정 | 병원 5곳 시 v2.0 검토. 콘텐츠는 WP DB에 남아 이전 비용 낮음 |
결정되지 않은 항목은 기본안으로 W1을 시작하고, 리뷰에서 뒤집히면 W2에 반영한다.
## 부록 · 실측 (2026.08.28)
| 대상 | 항목 | 결과 | 방법 |
|---|---|---|---|
| 뷰성형외과 | 종합 · 등급 | 48점 · C (검증 31 / 36항목) | `crawler_audit.py` + curl + HTML 파서 · 수동 채점 |
| 뷰성형외과 | 카테고리 | A 92 · B 30 · C 20 · D 72 · E 0 · F 33 | 루브릭 v1.0 · unverified 분모 제외 |
| 뷰성형외과 | P0 | JSON-LD 수정 + MedicalClinic · GBP 전화 + NAP 통일 · 측정 인프라 | 액션 플랜 |
| INFINITH 랜딩 | 크롤러 5종 본문 단어 | 4 → 810 | `vite build --ssr` + `renderToString` 프리렌더 |
| INFINITH 랜딩 | JSON-LD · lang · 메타 | 0 → 3 · en → ko · description · OG 추가 | `index.html` 정적 삽입 |
| INFINITH 랜딩 | robots · sitemap · llms.txt | HTML 반환 → text/plain · xml 정상 | `public/` 정적 파일 |
재현:
```bash
curl -sS -A "Mozilla/5.0 (compatible; GPTBot/1.0)" https://infinith-demo.vercel.app/ \
| sed -e 's/<script[^>]*>.*<\/script>//g' -e 's/<[^>]*>//g' | tr -s ' \n' | wc -w
# 뷰성형외과 리포트: /discovery/viewclinic (AEO-GEO-Audit 브랜치)
```

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,412 @@
"""GEO/AEO 최적화 병원 웹사이트 PRD — ADO2 AEO-GEO 경쟁분석 덱 포맷(13.333×7.5in, Pretendard, 333/666, 5D61D1/ADB0FF)."""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.oxml.ns import qn
from lxml import etree
INK, MUTED, DIM, WHITE, BLACK = "333333", "666666", "444444", "FFFFFF", "000000"
VIO, VIO_LT, VIO_BG, GRAY_BG, ZEBRA, LINE = "5D61D1", "ADB0FF", "F3F3FF", "F0F0F0", "F7F8FA", "CCCCCC"
FONT = "Pretendard"
DATE = "2026.08.28"
FOOT = f"INFINITH AI Discovery · 분석 → 진단 → 빌드 → 측정 PRD | {DATE}"
M, CW = 0.44, 12.46
prs = Presentation(); prs.slide_width = Inches(13.333); prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6]
page = 0
def rgb(h): return RGBColor.from_string(h)
def rect(s, x, y, w, h, fill=None, line=None):
sh = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
if fill: sh.fill.solid(); sh.fill.fore_color.rgb = rgb(fill)
else: sh.fill.background()
if line: sh.line.color.rgb = rgb(line); sh.line.width = Pt(0.75)
else: sh.line.fill.background()
sh.shadow.inherit = False
return sh
def tb(s, x, y, w, h, text, size, color=INK, bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, spc=None, ls=1.1):
t = s.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = t.text_frame; tf.word_wrap = True; tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
lines = text if isinstance(text, list) else [text]
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align; p.line_spacing = ls
r = p.add_run(); r.text = ln
r.font.name = FONT; r.font.size = Pt(size); r.font.bold = bold; r.font.color.rgb = rgb(color)
if spc: r.font._element.set("spc", str(spc))
return t
def chrome(s, eyebrow, title, lede=None):
global page; page += 1
tb(s, M, 0.31, 8, 0.19, eyebrow.upper(), 11.25, MUTED, True, spc=200)
tb(s, M, 0.60, 11.85, 0.60, title, 30, INK, True)
if lede: tb(s, M, 1.26, 11.85, 0.40, lede, 15.75, MUTED)
rect(s, M, 1.73, CW, 0.02, BLACK)
rect(s, M, 7.10, CW, 0.01, LINE)
tb(s, M, 7.20, 6, 0.16, FOOT, 10.5, MUTED)
tb(s, 12.4, 7.18, 0.5, 0.19, f"{page:02d}", 11.25, INK, True, PP_ALIGN.RIGHT)
def new(eyebrow, title, lede=None):
s = prs.slides.add_slide(BLANK); chrome(s, eyebrow, title, lede); return s
def card(s, x, y, w, h, tag, head, body, bar=VIO_LT):
rect(s, x, y, w, h, WHITE, LINE); rect(s, x, y, 0.07, h, bar)
tb(s, x + 0.23, y + 0.19, w - 0.4, 0.17, tag, 11.25, bar if bar != BLACK else DIM, True)
tb(s, x + 0.23, y + 0.45, w - 0.4, 0.42, head, 18, INK, True)
tb(s, x + 0.23, y + 0.95, w - 0.4, h - 1.1, body, 13.5, MUTED, ls=1.2)
def cards3(s, items, y=2.10, h=1.88):
w = 3.85; gap = (CW - 3 * w) / 2
bars = [VIO_LT, DIM, VIO]
for i, (tag, head, body) in enumerate(items):
card(s, M + i * (w + gap), y, w, h, tag, head, body, bars[i % 3])
def band(s, y, h, label, big, small=None):
rect(s, M, y, CW, h, BLACK)
tb(s, M + 0.29, y + 0.27, 11.56, 0.23, label, 12.75, VIO_LT, True)
tb(s, M + 0.29, y + 0.62, 11.56, 0.75, big, 18, WHITE, True, ls=1.15)
if small: tb(s, M + 0.29, y + h - 0.42, 11.56, 0.23, small, 12.75, LINE)
def table(s, headers, rows, col_w, y=2.14, row_h=0.52, size=12.5, first_bold=True):
n, m = len(rows) + 1, len(headers)
gs = s.shapes.add_table(n, m, Inches(M), Inches(y), Inches(CW), Inches(row_h * n))
t = gs.table
tblPr = t._tbl.tblPr
for a in ("bandRow", "firstRow"): tblPr.set(a, "0")
st = tblPr.find(qn("a:tableStyleId"))
if st is not None: tblPr.remove(st)
for i, w in enumerate(col_w): t.columns[i].width = Inches(w)
for r in range(n): t.rows[r].height = Inches(row_h)
for r in range(n):
for c in range(m):
cell = t.cell(r, c)
txt = headers[c] if r == 0 else rows[r - 1][c]
cell.margin_left, cell.margin_right = Inches(0.10), Inches(0.08)
cell.margin_top, cell.margin_bottom = Inches(0.05), Inches(0.05)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
cell.fill.solid()
cell.fill.fore_color.rgb = rgb(BLACK if r == 0 else (ZEBRA if r % 2 == 0 else WHITE))
tf = cell.text_frame; tf.word_wrap = True
p = tf.paragraphs[0]; p.text = ""
run = p.add_run(); run.text = txt
run.font.name = FONT; run.font.size = Pt(size)
run.font.bold = (r == 0) or (first_bold and c == 0)
run.font.color.rgb = rgb(WHITE if r == 0 else INK)
tcPr = cell._tc.get_or_add_tcPr()
for side in ("lnL", "lnR", "lnT", "lnB"):
ln = etree.SubElement(tcPr, qn(f"a:{side}"), w="6350")
sf = etree.SubElement(ln, qn("a:solidFill")); etree.SubElement(sf, qn("a:srgbClr"), val=LINE)
return t
def step_rows(s, items, y=2.10, h=0.81, gap=0.21):
"""번호 밴드 리스트(레퍼런스 S3). items: (num, label, mid, right). 위에서 아래로 진해짐 반대: 첫 줄 검정."""
fills = [BLACK, VIO_LT, VIO_LT, GRAY_BG, GRAY_BG, GRAY_BG]
for i, (num, label, mid, right) in enumerate(items):
f = fills[i]; fg = WHITE if f in (BLACK, VIO_LT) else INK
yy = y + i * (h + gap)
rect(s, M, yy, CW, h, f)
tb(s, M + 0.18, yy + 0.20, 1.1, 0.4, num, 22, fg, True)
tb(s, M + 1.35, yy + 0.25, 2.0, 0.3, label, 15, fg, True)
tb(s, M + 3.4, yy + 0.25, 4.3, 0.3, mid, 13.5, fg)
tb(s, M + 7.8, yy + 0.25, 4.5, 0.3, right, 13.5, fg, True)
def bullets(s, x, y, w, items, size=13.5, step=0.42, color=MUTED, dot=VIO):
for i, t in enumerate(items):
yy = y + i * step
d = s.shapes.add_shape(MSO_SHAPE.OVAL, Inches(x), Inches(yy + 0.08), Inches(0.09), Inches(0.09))
d.fill.solid(); d.fill.fore_color.rgb = rgb(dot); d.line.fill.background(); d.shadow.inherit = False
tb(s, x + 0.22, yy, w - 0.22, step, t, size, color, ls=1.15)
def takeaway(s, text, y=6.30):
tb(s, M, y, CW, 0.47, text, 16, VIO, True)
# ───────────────────────────── 01 표지
s = prs.slides.add_slide(BLANK); page += 1
tb(s, M, 0.42, 6, 0.19, "PRODUCT REQUIREMENTS DOCUMENT · v0.2", 11.25, MUTED, True, spc=200)
rect(s, M, 0.77, CW, 0.02, BLACK)
tb(s, M, 1.40, 7.5, 1.56, ["AI Discovery PRD", "분석 · 진단 · 빌드 · 측정"], 40.5, INK, True, ls=1.05)
tb(s, M, 3.46, 7.2, 0.73, ["병원 URL 하나를 넣으면 채널 분석과 AEO · GEO 진단이 나오고,", "진단 항목이 그대로 빌드 요구사항과 측정 지표가 되는 제품"], 18.75, MUTED, ls=1.2)
for x, w, bg, num, cap, col in [
(8.18, 2.14, VIO_BG, "36항목", "AEO · GEO 루브릭 v1.0", VIO_LT),
(10.52, 2.14, GRAY_BG, "C · 48점", "뷰성형외과 실측 2026.08.28", VIO),
]:
rect(s, x, 1.51, w, 1.38, bg); tb(s, x + 0.18, 1.70, w - 0.36, 0.54, num, 29.25, col, True); tb(s, x + 0.18, 2.36, w - 0.36, 0.36, cap, 13, INK, True)
rect(s, 8.18, 3.09, 4.48, 1.38, GRAY_BG); tb(s, 8.36, 3.28, 4.1, 0.54, "v1.0 · 10주", 29.25, DIM, True); tb(s, 8.36, 3.95, 4.1, 0.36, "목표 릴리스 · 2026.11.06", 13, INK, True)
tb(s, M, 6.75, 6.5, 0.21, f"작성 INFINITH Product | {DATE} | 상태 Draft for Engineering Review", 12, INK, True)
tb(s, 8.5, 6.75, 4.4, 0.21, "대상 · 프론트엔드 · 백엔드 · 인프라 · 콘텐츠 운영", 12, MUTED, align=PP_ALIGN.RIGHT)
# ───────────────────────────── 02 제품 흐름
s = new("Product Flow", "URL 하나로 분석, 진단, 빌드, 측정까지 한 흐름",
"1 · 2단계는 이미 동작한다. 이 PRD는 2단계를 자동화하고 3 · 4단계를 새로 만든다.")
step_rows(s, [
("4", "측정", "크롤러 로그 · GA4 AI 채널 · 재진단으로 점수 변화 증명", "신규 · 루브릭 E 카테고리를 채운다"),
("3", "빌드", "진단 P0 · P1을 사이트 수정 또는 표준 사이트로 실행", "신규 · 진단 항목이 곧 요구사항"),
("2", "진단", "36항목 AEO · GEO 스코어카드 · 등급 · 액션 플랜", "프로토타입 동작 · 수동 채점 → 자동화 대상"),
("1", "분석", "채널 발견 · 6채널 데이터 수집 · 마케팅 리포트", "구현 완료 · discover → collect → generate-report"),
], h=0.81, gap=0.21)
takeaway(s, "뷰성형외과는 1 · 2단계를 거쳐 P0 3건이 나왔다. 3 · 4단계가 없어 여기서 멈춘다. 그 공백이 이 PRD의 범위다.", y=6.35)
# ───────────────────────────── 03 배경
s = new("Background", "AI 답변엔진은 JS를 실행하지 않는다",
"GPTBot, ClaudeBot, PerplexityBot은 서버가 내려준 HTML만 읽는다. 진단은 이 사실에서 출발한다.")
cards3(s, [
("실측", "01 뷰성형외과: 읽히지만 라벨뿐", "AI 봇 12종 전부 200, JS 없이 본문 10,757자. 그런데 H1 5개가 전부 영문 라벨이고 JSON-LD는 문법 오류로 무효."),
("시장", "02 네이버 자산은 AI에 차단됨", "블로그 · 플레이스 · 카페는 robots.txt로 AI 크롤러를 차단한다. 인용되려면 오픈웹 소유 자산이 필요하다."),
("실측", "03 INFINITH 랜딩: 4단어", "크롤러 UA 요청 결과 title 4단어 외 본문 0. 1.5 MB JS를 실행해야 본문이 나왔다. 프리렌더 후 810단어."),
])
band(s, 4.31, 2.03, "문제 정의",
"진단은 나오는데 다음이 없다. 진단 결과를 받은 병원이 무엇을 고쳐야 하는지, 고친 뒤 점수가 올랐는지 증명할 수단이 제품 안에 없다.",
"이 PRD의 범위 · 진단 자동화 · 빌드(수정 · 표준 사이트) · 측정 인프라 · 재진단 루프")
# ───────────────────────────── 04 뷰성형외과 케이스
s = new("Case · 뷰성형외과 (2026.08.28)", "진단 결과가 빌드 요구사항으로 바로 이어진다",
"카테고리 점수는 검증 항목 기준 정규화. 미검증(A6 · D6 · E1~E3)은 분모 제외.")
table(s, ["카테고리", "배점", "획득", "점수", "핵심 발견", "→ 빌드 · 측정 요구사항"], [
["A 크롤러 접근성", "20", "15.7 / 17", "92%", "robots · SSR · HTTPS 정상. sitemap lastmod 15개월 전", "FR-B05 sitemap 자동 갱신"],
["B 엔티티 구조화", "20", "6.0 / 20", "30%", "JSON-LD 깨짐 · MedicalClinic 없음 · Physician 없음", "FR-B03 스키마 단일 소스"],
["C 인용 가능 콘텐츠", "25", "5.0 / 25", "20%", "정의문 · FAQ · 서술형 H1 없음. 수치 부족", "FR-B02 질문 템플릿 · FR-B06 검사"],
["D 인용 표면", "20", "13.7 / 19", "72%", "강남언니 19,316건 강함. GBP 전화 없음 · NAP 3종", "FR-B08 NAP 통일 체크리스트"],
["E 측정 인프라", "10", "0 / 2", "0%", "로그 · GA4 · GSC 오너 접근 필요. 기준선 없음", "FR-M01 ~ M03"],
["F 기술 위생", "5", "1.7 / 5", "33%", "WordPress 5.5.3 · alt 누락 · 페이지 무게", "NFR-04 · FR-B09 CMS 업그레이드"],
], [2.1, 0.7, 1.2, 0.8, 4.2, 3.46], row_h=0.5)
takeaway(s, "종합 48점 · C등급. P0 3건: JSON-LD 수정 + MedicalClinic 삽입, GBP 전화 등록 + NAP 통일, 측정 인프라 설치.", y=6.2)
# ───────────────────────────── 05 목표와 지표
s = new("Goals & Metrics", "목표는 진단 자동화와 점수 개선의 증명이다",
"출시 10주 후 기준. 모든 지표는 제품 안에서 자동 측정된다.")
table(s, ["단계", "지표", "현재", "v1.0 목표", "측정 방법"], [
["진단", "자동 채점 항목 비율", "0% (36항목 수동)", "24 / 36 자동 (67%)", "discover-aeo-geo 실행 로그"],
["진단", "URL 입력 → 리포트 소요 시간", "약 4시간 (수동)", "10분 이내", "analysis_runs 타임스탬프"],
["진단", "재진단 재현성", "미검증", "동일 URL 2회 점수 편차 3점 이내", "회귀 테스트"],
["빌드", "P0 액션 완료 → 재진단 점수 상승", "측정 불가", "뷰성형외과 48 → 65+ (B)", "재진단 비교"],
["빌드", "질문 페이지 발행 (표준 사이트)", "0", "8주 내 40개", "CMS 발행 로그"],
["측정", "AI 크롤러 · AI 유입 계측 병원", "0", "파일럿 100%", "로그 파서 · GA4"],
], [1.0, 3.0, 2.4, 3.0, 3.06], row_h=0.5)
takeaway(s, "답변엔진 언급률은 지표에서 제외한다. 소비자 UI 자동 조회는 약관 위반이고 단발 조회는 무효다.")
# ───────────────────────────── 06 범위
s = new("Scope", "v1.0은 진단 자동화부터 재진단 루프까지",
"분석(1단계)은 기존 구현을 그대로 쓴다. 콘텐츠 작성과 광고 · SNS 운영은 범위 밖이다.")
w = 6.08
rect(s, M, 2.10, w, 3.95, WHITE, LINE); rect(s, M, 2.10, 0.07, 3.95, VIO)
tb(s, M + 0.25, 2.28, 5.6, 0.3, "IN SCOPE", 12.75, VIO, True, spc=200)
bullets(s, M + 0.25, 2.70, 5.6, [
"진단 Edge Function discover-aeo-geo (자동 채점 1 · 2 · 3단계)",
"수동 항목 입력 슬롯 (A6 · D1 · D2 · D4 · E1~E4)과 미검증 처리",
"진단 리포트 DB 저장 · /discovery/:id 실데이터 연결 · PDF",
"빌드 A: 기존 사이트 수정 패키지 (스키마 · NAP · CMS · FAQ 삽입)",
"빌드 B: 병원용 워드프레스 표준 사이트 (템플릿 4종 · 검사)",
"측정: 크롤러 로그 파서 · GA4 AI 채널 · 재진단 비교 뷰",
"INFINITH 랜딩 파일럿: FAQ 섹션 · FAQPage 스키마",
], step=0.44)
x2 = M + w + 0.30
rect(s, x2, 2.10, w, 3.95, WHITE, LINE); rect(s, x2, 2.10, 0.07, 3.95, DIM)
tb(s, x2 + 0.25, 2.28, 5.6, 0.3, "OUT OF SCOPE (v1.0)", 12.75, DIM, True, spc=200)
bullets(s, x2 + 0.25, 2.70, 5.6, [
"분석 파이프라인 변경 (discover · collect · generate-report)",
"Q&A 콘텐츠 작성 (운영팀 · 병원 담당, 별도 가이드)",
"네이버 블로그 · 플레이스 운영 (AI 크롤러 차단 자산)",
"답변엔진 UI 자동 조회 · 언급률 리포트 (약관 위반)",
"llms.txt 납품 (Google 효과 0 · 채택률 0.13%)",
"헤드리스(WordPress + Astro) 전환 (v2.0 후보)",
], step=0.44, dot=DIM)
takeaway(s, "빌드 A와 B를 나눈 이유: 뷰성형외과처럼 사이트가 있는 병원은 수정이고, 없는 병원은 표준 사이트다.")
# ───────────────────────────── 07 사용자
s = new("Users", "세 사용자, 세 가지 다른 성공 기준",
"설계 기준은 병원 마케팅 담당자다. 진단을 읽고, 고치고, 다시 점수를 볼 수 있어야 한다.")
cards3(s, [
("PRIMARY", "병원 마케팅 담당자", "URL을 넣고 10분 안에 스코어카드와 P0를 본다. 수정 후 재진단으로 점수 변화를 확인한다. 표준 사이트면 질문 페이지를 직접 추가한다."),
("SECONDARY", "INFINITH 운영팀", "수동 항목(GBP · 강남언니 · 로그)을 입력하고 여러 병원의 점수 · 크롤 수 · 오류를 한 화면에서 본다. 성공 기준: 병원 온보딩 1일."),
("SYSTEM", "AI 크롤러 · 검색엔진", "GPTBot · ClaudeBot · PerplexityBot · Googlebot · Yeti. 진단은 이들의 UA로 실제 요청해 채점한다. 빌드 결과도 같은 방법으로 검증한다."),
], h=2.3)
band(s, 4.75, 1.55, "설계 원칙", "확인 못 한 것은 감점하지 않는다(미검증은 분모 제외). 근거 없는 것은 팔지 않는다. 담당자의 실수는 시스템이 발행 전에 막는다.")
# ───────────────────────────── 08 FR 진단
s = new("Functional Requirements · 진단", "FR-D01 ~ D07: 36항목을 제품 안에서 채점한다",
"AEO-GEO-Audit 브랜치의 루브릭 · 타입 · 집계 로직을 그대로 쓰고, 채점 데이터만 수동에서 자동으로 바꾼다.")
table(s, ["ID", "요구사항", "상세", "루브릭", "우선순위"], [
["FR-D01", "자동 채점 1단계", "robots 파싱 · 12개 AI 봇 UA GET · HTML 파서 (crawler_audit.py 이식)", "A1~A5 · A7 · B1 · B4 · B6 · B7 · C2 · C3 · C5 · C6 · F1~F4", "P0"],
["FR-D02", "자동 채점 2단계", "본문 텍스트를 Perplexity 또는 Claude에 투입해 구조화 판정", "B2 · B3 · C1 · C4 · C7 · C8", "P0"],
["FR-D03", "자동 채점 3단계", "검색 그라운딩 도메인 집계 · Wikidata API", "D3 · D6", "P1"],
["FR-D04", "수동 입력 슬롯", "운영팀이 근거 · 레벨 입력. 미입력은 unverified로 분모 제외", "A6 · D1 · D2 · D4 · E1~E4", "P0"],
["FR-D05", "결과 저장 · 리포트", "DiscoveryResult를 DB 저장. /discovery/:id가 mock 대신 DB를 읽음", "전체", "P0"],
["FR-D06", "액션 플랜 생성", "level 0 · 1 항목에서 P0 · P1 · P2 액션 자동 생성. 빌드 FR과 연결", "전체", "P1"],
["FR-D07", "재진단 비교", "같은 URL의 이전 결과와 항목별 diff · 점수 변화 표시", "전체", "P1"],
], [1.0, 1.8, 5.2, 3.5, 0.96], row_h=0.5, size=12)
takeaway(s, "1단계만으로 36항목 중 17개가 자동이다. 2단계까지 23개. 나머지는 오너 계정 접근이 필요해 수동이 맞다.", y=6.3)
# ───────────────────────────── 09 FR 빌드 A
s = new("Functional Requirements · 빌드 A (기존 사이트 수정)", "FR-B01 ~ B09: 진단 P0 · P1을 실행 가능한 패키지로",
"뷰성형외과 액션 플랜에서 역산했다. 병원 개발사나 INFINITH가 붙여 넣을 수 있는 산출물이어야 한다.")
table(s, ["ID", "요구사항", "상세", "루브릭", "우선순위"], [
["FR-B01", "서버 렌더링 검증 · 처방", "A3 실패 시 프리렌더 · SSR 처방 (INFINITH 랜딩 방식 문서)", "A3", "P0"],
["FR-B03", "스키마 패키지 생성", "진단 데이터로 MedicalClinic · Physician JSON-LD 생성. 기존 스키마 차단 안내", "B1 · B2 · B3 · B5", "P0"],
["FR-B08", "NAP 통일 체크리스트", "홈페이지 · 강남언니 · GBP · 플레이스 표기를 나열하고 정본 지정", "B4 · D2 · D4", "P0"],
["FR-B04", "메타 패키지", "title · description · canonical · OG · hreflang 코드 생성", "B6 · B7", "P1"],
["FR-B05", "robots · sitemap 처방", "AI 봇 허용 robots.txt · lastmod 갱신 규칙", "A1 · A5", "P1"],
["FR-B07", "FAQ 삽입 패키지", "시술별 질문 · 요약 답변 초안 + FAQPage 스키마 코드", "C1 · C2 · C4", "P1"],
["FR-B09", "CMS 위생 처방", "WordPress 버전 · alt 누락 목록 · 페이지 무게 리포트", "F1 · F2 · F3", "P1"],
], [1.0, 2.1, 5.4, 2.4, 1.56], row_h=0.5, size=12)
takeaway(s, "빌드 A의 산출물은 코드 조각과 체크리스트다. 병원 사이트를 INFINITH가 직접 호스팅하지 않는다.", y=6.3)
# ───────────────────────────── 10 FR 빌드 B
s = new("Functional Requirements · 빌드 B (표준 사이트)", "FR-B02 · B06 · B10 ~ B12: 사이트가 없는 병원용 표준 스택",
"루브릭 A · B · C · F를 처음부터 통과하는 워드프레스 사이트. 담당자가 질문 페이지를 직접 늘린다.")
table(s, ["ID", "요구사항", "상세", "루브릭", "우선순위"], [
["FR-B10", "표준 스택 프리셋", "경량 테마 · Rank Math · WP Rocket · ACF · 관리형 호스팅. 1회 명령 복제", "A · F 전체", "P1"],
["FR-B02", "질문 페이지 템플릿", "질문(h1) · 요약 답변(p, 60자) · 상세 · 관련 시술 · 의료진. 필드 입력만으로 생성", "C1 · C2 · C3 · C8", "P1"],
["FR-B11", "시술 · 의료진 템플릿", "MedicalProcedure · Physician 스키마 포함. 가격대 · 시간 · 회복 필드", "B3 · C4 · C7", "P1"],
["FR-B12", "스키마 단일 소스", "페이지 유형별 JSON-LD 1개 자동 출력. 테마 · 타 플러그인 출력 차단", "B1 · B2 · B3", "P1"],
["FR-B06", "발행 전 검사", "스키마 중복 · h1 2개 · alt 누락 · 요약 답변 누락 시 발행 차단", "B1 · C3 · F1", "P1"],
["FR-B13", "sitemap · robots 자동", "발행 시 lastmod 갱신 · AI 봇 9종 허용", "A1 · A5 · C6", "P1"],
], [1.0, 2.1, 5.4, 2.4, 1.56], row_h=0.5, size=12)
takeaway(s, "빌드 B는 P1이다. 뷰성형외과처럼 사이트가 있는 병원이 먼저이므로 빌드 A가 P0다.", y=6.0)
# ───────────────────────────── 11 FR 측정
s = new("Functional Requirements · 측정", "FR-M01 ~ M05: 루브릭 E를 채우고 개선을 증명한다",
"인용 여부를 직접 볼 수 없으므로 소유한 신호를 계측한다. 재진단이 개선의 증거다.")
table(s, ["ID", "요구사항", "상세", "루브릭", "우선순위"], [
["FR-M01", "AI 크롤러 로그 파서", "액세스 로그 또는 Cloudflare Logpush에서 GPTBot · ClaudeBot · PerplexityBot 히트를 IP 검증 후 일 집계", "E1", "P0"],
["FR-M02", "GA4 AI 채널", "chatgpt.com · perplexity.ai · gemini.google.com 리퍼러를 커스텀 채널로 분리 · 설정 가이드", "E2", "P0"],
["FR-M03", "기준선 스팟체크 기록", "분기 반복 측정 프로토콜과 입력 슬롯 (수동)", "E4", "P1"],
["FR-M04", "재진단 스케줄", "월 1회 자동 재진단 · 점수 변화 알림", "전체", "P1"],
["FR-M05", "운영 대시보드", "병원별 점수 · 크롤 수 · AI 세션 · 스키마 오류 · LCP 주간 표시", "E 전체", "P1"],
], [1.0, 2.1, 6.2, 1.6, 1.56], row_h=0.55, size=12)
takeaway(s, "FR-M01 · M02는 뷰성형외과 P0 3번(측정 인프라 설치)이다. 빌드보다 먼저 깔아야 전후 비교가 된다.", y=5.9)
# ───────────────────────────── 12 NFR
s = new("Non-Functional Requirements", "진단 재현성, 성능, 보안 예산",
"진단은 같은 입력에 같은 점수를 내야 한다. 빌드 B의 예산은 CI에서 강제한다.")
table(s, ["ID", "영역", "요구사항", "예산 · 기준"], [
["NFR-01", "진단", "재현성", "동일 URL 2회 실행 점수 편차 3점 이내 · LLM 판정 항목은 temperature 0"],
["NFR-02", "진단", "소요 시간 · 비용", "URL당 10분 이내 · API 비용 2,000원 이내"],
["NFR-03", "진단", "대상 사이트 부하", "UA 테스트 요청 30회 이내 · 1초 간격 · 429 시 중단"],
["NFR-04", "빌드 B", "성능", "모바일 LCP p75 2.5초 · CLS 0.1 · 플러그인 10개 이하 · HTML 80 KB 이하"],
["NFR-05", "빌드 B", "HTML 품질 · 보안", "h1 1개 · div 중첩 6단계 이하 · 관리형 호스팅 · 자동 업데이트 · 일 백업"],
["NFR-06", "측정", "로그 보존 · 정확도", "90일 보존 · 봇 IP 역DNS 검증 (위장 UA 제외)"],
["NFR-07", "공통", "정직성 규약", "리포트에 '하지 않을 것' 섹션 고정 · 근거 등급(실측 · 학술 · 벤더 · 추정) 표기"],
], [1.1, 1.1, 2.6, 7.66], row_h=0.5, size=12.5)
takeaway(s, "Elementor · Divi 같은 페이지빌더는 NFR-04 · 05를 통과하지 못하므로 빌드 B에서 금지다.", y=6.2)
# ───────────────────────────── 13 아키텍처
s = new("Architecture", "기존 파이프라인 뒤에 진단 · 빌드 · 측정 함수를 잇는다",
"Supabase Edge Functions 체인. 분석 3개 함수는 그대로, 신규 4개를 추가한다.")
step_rows(s, [
("4", "측정", "log-ingest · GA4 커넥터 · 재진단 cron", "신규 · discovery_results 시계열"),
("3", "빌드", "build-package: 스키마 · 메타 · NAP · FAQ 코드", "신규 · 빌드 A는 함수, B는 플러그인"),
("2", "진단", "discover-aeo-geo: UA 테스트 · 파서 · LLM 판정", "신규 · 루브릭 · 집계는 AEO-GEO-Audit 코드 이식"),
("1", "분석", "discover → collect → generate-report", "기존 · marketing_reports · clinic_registry"),
], h=0.81, gap=0.21)
takeaway(s, "신규 테이블 2개: discovery_results (결과 · 버전) · crawler_hits (봇 · IP · 경로 · 일자).", y=6.35)
# ───────────────────────────── 14 진단 → 빌드 매핑
s = new("Rubric → Build Mapping", "level 0 · 1 항목이 어떤 빌드 산출물로 이어지는지",
"FR-D06 액션 플랜 생성의 규칙표. 모든 항목이 빌드 또는 측정 FR 하나에 매핑된다.")
table(s, ["루브릭 항목", "level 0 · 1이면", "빌드 산출물", "FR"], [
["A3 서버 렌더링 본문", "본문이 JS에만 있음", "프리렌더 · SSR 처방 문서 + 검증 스크립트", "FR-B01"],
["B1 · B2 · B3 스키마", "JSON-LD 깨짐 · 엔티티 없음", "MedicalClinic · Physician JSON-LD 코드 + 차단 안내", "FR-B03 · B12"],
["B4 · D2 · D4 NAP", "표면 간 표기 불일치", "정본 NAP 체크리스트 · GBP 수정 항목", "FR-B08"],
["C1 · C2 · C4 콘텐츠", "정의문 · FAQ · 수치 없음", "시술별 질문 · 요약 답변 초안 + FAQPage 코드", "FR-B07 · B02"],
["C3 제목 계층", "H1 없음 · 이미지 텍스트", "H1 재설계안 · 이미지 정보 텍스트화 목록", "FR-B07"],
["C6 신선도", "dateModified 없음", "dateModified 활성화 · 갱신 주기 규칙", "FR-B13"],
["E1 · E2 측정", "미설치", "로그 파서 · GA4 채널 설정", "FR-M01 · M02"],
["F2 CMS", "구버전", "업그레이드 절차 · 백업 체크리스트", "FR-B09"],
], [2.6, 2.6, 5.3, 1.96], row_h=0.46, size=12)
# ───────────────────────────── 15 스키마 스펙
s = new("Schema Specification", "페이지 유형별 JSON-LD 1개, 단일 소스",
"빌드 A는 코드 조각으로, 빌드 B는 Rank Math 훅으로 같은 스펙을 출력한다. 테마 내장 스키마는 끈다.")
table(s, ["페이지", "@type", "필수 속성", "데이터 소스"], [
["홈", "MedicalClinic + WebSite", "name · address · telephone · openingHours · sameAs", "분석 1단계 clinics · 병원 정보 ACF"],
["시술", "MedicalProcedure", "name · procedureType · bodyLocation · howPerformed · preparation", "시술 ACF"],
["의료진", "Physician", "name · medicalSpecialty · memberOf · image · alumniOf", "의료진 ACF · 강남언니 의료진"],
["질문", "FAQPage (+ Article)", "mainEntity[Question · acceptedAnswer] · author · dateModified", "질문 템플릿 필드"],
["공통", "BreadcrumbList", "itemListElement", "자동"],
], [1.4, 2.6, 5.0, 3.46], row_h=0.55)
band(s, 5.75, 1.15, "수용 기준", "Rich Results Test 오류 0. 응답에 ld+json 블록 정확히 1개 (Breadcrumb 포함 시 @graph 병합). 루브릭 B1 · B2 · B3 level 3.")
# ───────────────────────────── 16 질문 페이지 템플릿
s = new("Q&A Page Template", "AI가 인용하는 단락 구조를 템플릿이 강제한다",
"루브릭 C1 · C2 · C3 · C4 · C8을 동시에 통과하는 구조. 담당자는 필드를 채우고, 템플릿이 HTML과 스키마를 만든다.")
table(s, ["필드", "HTML 출력", "제약", "예시", "루브릭"], [
["질문", "h1", "물음표로 끝남 · 40자 이내", "코 재수술은 언제 가능한가요?", "C3"],
["요약 답변", "p (h1 직후)", "60자 이내 · 숫자 · 기간 포함 권장", "1차 수술 후 최소 6개월, 보통 1년 뒤 가능합니다.", "C1 · C8"],
["상세 답변", "h2 + p 반복", "h2는 하위 질문형 · 단락 3문장 이내", "왜 6개월을 기다려야 하나요?", "C3 · C8"],
["가격 · 시간 · 회복", "표", "숫자 범위 필수", "300~500만원 · 2시간 · 회복 2주", "C4 · C5"],
["관련 시술 · 의료진", "링크 + Physician", "각 1개 이상", "코 재수술 · 김OO 원장", "C7"],
["갱신일", "dateModified", "자동", "2026-08-28", "C6"],
], [1.6, 1.6, 3.0, 4.3, 1.96], row_h=0.52)
takeaway(s, "요약 답변 60자는 AI 답변엔진이 한 단락으로 인용하는 길이다. 이 필드가 비면 발행이 막힌다(FR-B06).", y=6.0)
# ───────────────────────────── 17 수용 테스트
s = new("Acceptance Tests", "수용 기준은 전부 스크립트로 검증한다",
"진단은 뷰성형외과 · INFINITH 랜딩을 회귀 기준으로 삼는다. 빌드는 크롤러 UA 테스트를 CI로 옮긴다.")
table(s, ["ID", "단계", "테스트", "통과 기준"], [
["AT-01", "진단", "viewclinic.com 자동 진단 실행", "수동 채점 대비 자동 항목 일치율 90% · 소요 10분 이내"],
["AT-02", "진단", "infinith-demo.vercel.app 진단", "A3 level 3 · B1 level 3 · 재실행 편차 3점 이내"],
["AT-03", "진단", "미검증 처리", "수동 슬롯 비운 채 실행 시 해당 항목 분모 제외 · 점수 계산 일치"],
["AT-04", "빌드 A", "스키마 패키지 적용 후 Rich Results Test", "오류 0 · 블록 1개"],
["AT-05", "빌드 B", "curl -A GPTBot 5종 UA 본문 단어 수", "300단어 이상 · 5개 UA 동일 · 429 · 403 0건"],
["AT-06", "빌드 B", "발행 검사", "요약 답변 비운 채 발행 시 차단 + 안내"],
["AT-07", "측정", "로그 파서 위장 UA 테스트", "역DNS 실패 히트 제외 · 일 집계 정확"],
["AT-08", "루프", "빌드 후 재진단", "변경 항목 level 상승이 diff에 표시 · 점수 상승"],
], [1.0, 1.0, 4.5, 5.96], row_h=0.46)
takeaway(s, "AT-02 · 05는 2026.08.28 INFINITH 랜딩 점검 스크립트를 그대로 쓴다.", y=6.3)
# ───────────────────────────── 18 마일스톤
s = new("Milestones", "10주 · 5단계 · 뷰성형외과 파일럿 포함",
"2주 단위. 각 단계 끝에 수용 테스트를 통과해야 다음 단계로 간다.")
step_rows(s, [
("W9-10", "파일럿 · 릴리스", "뷰성형외과 빌드 A 적용 · 재진단 · 표준 사이트 1곳", "AT-08 · 48 → 65+ · v1.0"),
("W7-8", "빌드 B", "표준 스택 · 템플릿 3종 · 검사 · 플러그인", "FR-B02 · B06 · B10~B13 · AT-05 · 06"),
("W5-6", "빌드 A", "스키마 · NAP · FAQ · CMS 패키지 생성", "FR-B01 · B03 · B04 · B07 · B08 · AT-04"),
("W3-4", "측정 · 진단 2단계", "로그 파서 · GA4 · LLM 판정 · 재진단 diff", "FR-M01 · M02 · D02 · D07 · AT-07"),
("W1-2", "진단 1단계", "discover-aeo-geo · UA 테스트 · 파서 · DB · 리포트 연결", "FR-D01 · D04 · D05 · AT-01~03"),
], h=0.72, gap=0.16)
takeaway(s, "측정(W3-4)을 빌드보다 앞에 둔 이유: 전후 비교의 기준선이 빌드 전에 있어야 한다.", y=6.45)
# ───────────────────────────── 19 리스크
s = new("Risks & Mitigations", "가장 큰 리스크는 진단 신뢰도와 병원 측 실행이다",
"진단이 틀리면 빌드가 틀리고, 병원이 안 고치면 측정할 것이 없다.")
table(s, ["리스크", "영향", "가능성", "대응"], [
["LLM 판정 항목(2단계)의 채점 불일치", "높음", "중간", "temperature 0 · 근거 인용 강제 · 수동 채점과 90% 일치 회귀 테스트"],
["대상 사이트가 진단 요청을 차단", "중간", "중간", "요청 30회 · 1초 간격 · 429 시 중단 후 unverified 처리"],
["병원이 빌드 A 패키지를 적용하지 않음", "높음", "높음", "적용 대행 옵션 · 재진단 알림 · P0만 먼저"],
["담당자가 질문 페이지를 추가하지 않음", "높음", "높음", "분석 리포트의 질문 목록을 초안으로 제공 · 주간 발행 목표"],
["AI 크롤러 UA · 정책 변경", "중간", "높음", "봇 목록 중앙 관리 · 분기 점검 · 루브릭 버전 관리"],
["스키마 플러그인 업데이트로 출력 변경", "중간", "중간", "AT-04 매 배포 실행 · 버전 고정"],
], [3.8, 0.9, 0.9, 6.86], row_h=0.55)
# ───────────────────────────── 20 오픈 이슈
s = new("Open Questions", "개발 착수 전 결정이 필요한 다섯 가지",
"리뷰 미팅에서 결정한다. 기본안을 함께 적었다.")
table(s, ["#", "질문", "선택지", "기본안 · 근거"], [
["1", "2단계 LLM 판정 모델", "Perplexity · Claude · 둘 다", "Claude. 판정 일관성 · 근거 인용. Perplexity는 D3 그라운딩에만"],
["2", "로그 수집 방식", "호스팅 로그 API · Cloudflare Logpush · WP 플러그인", "Cloudflare Logpush → Supabase. 호스팅 무관"],
["3", "빌드 A 적용 주체", "병원 개발사 · INFINITH 대행 · 둘 다", "둘 다. 패키지는 개발사용, 대행은 유료 옵션"],
["4", "빌드 B 호스팅 · 스키마 플러그인", "Kinsta · WP Engine · Cafe24 / Rank Math · Yoast", "Kinsta + Rank Math. Cloudflare 통합 · 의료 타입 · 훅"],
["5", "헤드리스 전환 시점", "병원 5곳 · 10곳 · 미정", "병원 5곳 시 v2.0 검토. 콘텐츠는 WP DB에 남아 이전 비용 낮음"],
], [0.5, 2.6, 4.0, 5.36], row_h=0.62)
takeaway(s, "결정되지 않은 항목은 기본안으로 W1을 시작하고, 리뷰에서 뒤집히면 W2에 반영한다.", y=6.1)
# ───────────────────────────── 21 부록
s = new("Appendix · Evidence", "실측 두 건 (2026.08.28)",
"이 PRD의 수용 기준은 아래 두 실측에서 나왔다.")
table(s, ["대상", "항목", "결과", "방법"], [
["뷰성형외과", "종합 · 등급", "48점 · C (검증 31 / 36항목)", "crawler_audit.py + curl + HTML 파서 · 수동 채점"],
["뷰성형외과", "카테고리", "A 92 · B 30 · C 20 · D 72 · E 0 · F 33", "루브릭 v1.0 · unverified 분모 제외"],
["뷰성형외과", "P0", "JSON-LD 수정 + MedicalClinic · GBP 전화 + NAP 통일 · 측정 인프라", "액션 플랜"],
["INFINITH 랜딩", "크롤러 5종 본문 단어", "4 → 810", "vite build --ssr + renderToString 프리렌더"],
["INFINITH 랜딩", "JSON-LD · lang · 메타", "0 → 3 · en → ko · description · OG 추가", "index.html 정적 삽입"],
["INFINITH 랜딩", "robots · sitemap · llms.txt", "HTML 반환 → text/plain · xml 정상", "public/ 정적 파일"],
], [1.6, 2.2, 4.6, 4.06], row_h=0.5)
band(s, 5.95, 1.0, "재현", "curl -A GPTBot infinith-demo.vercel.app | sed 's/<[^>]*>//g' | wc -w · /discovery/viewclinic")
out = "docs/prd/INFINITH_AI-Discovery_PRD_20260828_v02.pptx"
prs.save(out); print("saved", out, "slides", len(prs.slides))

View File

@ -0,0 +1,317 @@
"""
Viewclinic AI Answer Readiness Report v2.0 덱 빌드 (INFINITH Deck 규격)
v1.1(2026-09-07 재검증 정정)의 실측을 그대로 두고, 점수를 기준표 v2.0(AEO 답변 준비도 100 · GEO 출처 준비도 100)으로
바꾼 판. v1.0·v1.1 파일은 haewon이 손본 정본이라 덮어쓰지 않고 v2.0 파일로 새로 저장한다.
바뀐 것: 표지 배지·부제, Executive Summary KPI(AEO 42 / GEO 56), 스코어카드 2장(AEO·GEO 항목별), 로드맵 제목,
Method(두 점수·게이트·36개 실측 신호). A~E 실측 슬라이드와 정정·요청·정직성 슬라이드는 v1.1과 같다.
점수 원본: src/data/aeoGeoRubricV2.ts + src/lib/discoveryScoreV2.ts (docs/AEO_GEO_RUBRIC_v2.md 재채점 표).
폰트: haewon 확정 기준(본문 20 / 리드 24 / 카드 제목 28). ifd 분량 검사 안에서 카피를 줄였다.
python3 docs/reports/viewclinic/01_readiness_report/build_viewclinic_deck_v2_0.py docs/reports/viewclinic/01_readiness_report/Viewclinic_AI_Answer_Readiness_Report_v2.0.pptx
"""
import sys
sys.path.insert(0, "/Users/haewonkam/.claude/skills/infinith-deck/scripts")
from ifd import Deck
OUT = sys.argv[1] if len(sys.argv) > 1 else "Viewclinic_AI_Answer_Readiness_Report_v2.0.pptx"
BODY, HEAD = 20.0, 28.0
d = Deck()
# 01 Cover
d.cover(
eyebrow="AI DISCOVERY · AEO / GEO AUDIT v2.0",
title_lines=["AI Answer", "Readiness Report."],
subtitle="뷰성형외과 · viewclinic.com · 실측 2026. 08. 28 · 재검증 09. 07 · 기준표 v2.0 채점 09. 07",
meta_right="AI Discovery Report · 2026",
)
# 02 Executive summary
s = d.slide(eyebrow="Executive Summary",
title="답은 부족하고, ", title_accent="출처로는 절반쯤 됩니다",
lede=["답변 준비도(AEO) 42점, 출처 준비도(GEO) 56점. 각각 100점의 독립 지표입니다.",
"36개 실측 항목 중 32개를 확인했고, 오너 계정이 필요한 4개는 분모에서 제외했습니다."],
section="Summary")
k1, k2, k3, k4 = s.cards4(y=s.band_y, h=3.4)
k1.kpi("42 / 100", "AEO · 답변 준비도", "등급 C · 정의문 0 · 라벨형 H1 · 표 0")
k2.kpi("56 / 100", "GEO · 출처 준비도", "등급 C · 근거 없는 주장 · 후기 광고 이력")
k3.kpi("2 / 2", "게이트 통과", "크롤러 접근 · 본문 렌더링")
k4.kpi("4 / 36", "미검증 실측 항목", "GA4 · 서버로그 · GSC · Bing")
# 03 What AI sees (dark)
s = d.slide(eyebrow="What AI Sees · 진단의 결론",
title="문제는 차단이 아니라 ", title_accent="내용에 있습니다", dark=True, section="Findings")
L, R = s.cards2(y=s.band_y)
L.chip("정상", "good").rows([
("크롤러 접근 정상", "robots.txt가 AI 검색봇 12종을 허용. Cloudflare 차단 없음."),
("서버 렌더링 본문", "WordPress가 본문을 서버에서 렌더. JS 없이 10,757자 추출."),
("표면이 넓습니다", "강남언니 후기 19,373건. 유튜브 1,215편. 블로그는 발행 중이고 AI 봇 허용."),
], t_size=HEAD, b_size=BODY)
R.chip("치명", "critical").rows([
("누구인지 확정 불가", "MedicalClinic 스키마 없음, sameAs 비어 있음. 시술 목록 JSON-LD 무효."),
("인용할 문장 부족", "H1 5개가 영문 라벨. HTML 표 0, 병원 정의문 0. FAQ는 프리저베뿐."),
("표면 간 NAP 불일치", "GBP 전화번호 없음. 웹사이트가 영문 도메인. 주소 표기 3종."),
], t_size=HEAD, b_size=BODY)
# 04 AEO scorecard
s = d.slide(eyebrow="Scorecard · AEO Answer Readiness · 42",
title="답변 준비도 ", title_accent="8개 항목",
lede="고객의 질문에 얼마나 명확하고 충분하게 답하는가. 항목 점수는 36개 실측 항목에서 파생하고, 실측이 없는 항목은 사람이 판정했습니다.",
section="AEO")
W, Rail = s.cards2(y=s.band_y, split=0.66)
W.grid(
headers=["항목", "배점", "점수", "근거", "판정"],
col_w=[3.5, 1.0, 1.2, 3.7, 1.5],
rows=[
["AEO1 질문 수요 충족", "20", "13.3", "FAQ 프리저베 1페이지뿐", "양호"],
["AEO2 답변의 직접성", "20", "0", "병원 정의문 없음", "치명"],
["AEO3 자기완결성", "15", "5.0", "단락에 상호·시술명 부족", "미흡"],
["AEO4 의사결정 정보", "15", "6.0", "수치 일부 · 표 0", "미흡"],
["AEO5 정보 구조·가독성", "10", "0", "H1 5개 영문 라벨", "치명"],
["AEO6 다음 행동", "10", "10", "전화·예약·방문 텍스트", "우수"],
["AEO7 텍스트 추출", "5", "3.7", "SSR 정상, alt 37% 누락", "양호"],
["AEO8 사이트 내 발견", "5", "4.3", "sitemap · canonical", "우수"],
],
tones=[[None, None, None, None, "good"], [None, None, None, None, "critical"],
[None, None, None, None, "warning"], [None, None, None, None, "warning"],
[None, None, None, None, "critical"], [None, None, None, None, "info"],
[None, None, None, None, "good"], [None, None, None, None, "info"]],
row_h=0.56, size=BODY - 2,
)
Rail.title("읽는 법", size=HEAD).bullets([
"8항목 합 100. 검증 8/8, 42.3점 = 42.",
"0점 두 항목(정의문·H1)이 40점을 비웁니다.",
"AEO6은 사람 판정. 나머지는 실측 파생.",
], size=BODY)
# 05 GEO scorecard
s = d.slide(eyebrow="Scorecard · GEO Source Readiness · 56",
title="출처 준비도 ", title_accent="8개 항목",
lede="그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 쓸 수 있는가. 정본 URL과 갱신 신호는 만점, 근거와 투명성이 깎입니다.",
section="GEO")
W, Rail = s.cards2(y=s.band_y, split=0.66)
W.grid(
headers=["항목", "배점", "점수", "근거", "판정"],
col_w=[3.5, 1.0, 1.2, 3.7, 1.5],
rows=[
["GEO1 주장·근거 일치", "20", "6.7", "수상·1위 주장 원문 없음", "미흡"],
["GEO2 출처 추적성", "15", "15", "http → 301 정본 수렴", "우수"],
["GEO3 전문성·책임", "15", "6.0", "약력 일부, 스키마 없음", "미흡"],
["GEO4 엔티티 정합성", "15", "7.0", "강남언니 강함, NAP 3종", "미흡"],
["GEO5 최신성·정정", "10", "10", "수정일 표기, sitemap 최신", "우수"],
["GEO6 원천 대비 가치", "10", "6.7", "주의사항·애프터케어 고유", "양호"],
["GEO7 발행 투명성", "10", "3.3", "후기 광고 시정명령 07-12", "미흡"],
["GEO8 인용 메타데이터", "5", "1.7", "JSON-LD 1블록 무효", "미흡"],
],
tones=[[None, None, None, None, "warning"], [None, None, None, None, "info"],
[None, None, None, None, "warning"], [None, None, None, None, "warning"],
[None, None, None, None, "info"], [None, None, None, None, "good"],
[None, None, None, None, "warning"], [None, None, None, None, "warning"]],
row_h=0.56, size=BODY - 2,
)
Rail.title("읽는 법", size=HEAD).bullets([
"8항목 합 100. 검증 8/8, 56.4점 = 56.",
"GEO1·GEO6·GEO7은 사람 판정(09-07 초안).",
"근거 원문 링크만 붙여도 20점이 열립니다.",
], size=BODY)
# 06 A 접근성
s = d.slide(eyebrow="A · Access & Crawlability · 실측 98%",
title="크롤러 접근은 ", title_accent="열려 있습니다", section="A · Access")
L, R = s.cards2(y=s.band_y)
L.chip("우수 · 3/3", "info").rows([
("A1 검색용 AI 봇 허용", "Disallow는 /wp-admin/ 뿐. 12종 UA로 GET, 전부 200."),
("A2 CDN 차단 없음", "nginx 직접 응답. Cloudflare 헤더와 AI UA 챌린지 없음."),
("A4 HTTPS 정합", "http → 301 → https://www.viewclinic.com/ 정본 수렴."),
], t_size=HEAD, b_size=BODY)
R.chip("보완·미검증", "warning").rows([
("A5 sitemap 우수", "page·portfolio lastmod 2026-08-31. 블로그는 별도 서브도메인."),
("A7 응답 속도·무게", "TTFB 0.135s 양호. 초기 HTML 838KB, 기준 300KB의 2.8배."),
("A6 Bing 색인 (미검증)", "ChatGPT 검색은 Bing 인덱스 의존. 계정 확인 필요."),
], t_size=HEAD, b_size=BODY)
# 07 B 엔티티 (dark)
s = d.slide(eyebrow="B · Entity & Structured Data · 실측 25%",
title="AI가 병원을 아직 ", title_accent="하나로 묶지 못합니다", dark=True, section="B · Entity")
L, R = s.cards2(y=s.band_y)
L.chip("치명", "critical").rows([
("B1 JSON-LD 문법 무효", "ItemList 블록 쉼표 누락. 09-07에도 그대로. 파서가 통째로 버림."),
("B2 MedicalClinic 없음", "Organization·WebSite·WebPage만. 주소·전화·진료시간 미구조화."),
("B3 Physician 없음", "의료진은 텍스트와 이미지뿐. 누가 수술하는지 기계가 못 읽음."),
], t_size=HEAD, b_size=BODY)
R.chip("미흡", "warning").rows([
("B5 sameAs 비어 있음", "푸터에 유튜브·인스타·블로그 링크가 있으나 스키마 미연결."),
("B4 NAP 표기 3종 혼재", "주소 표기 3종. 전화도 2회선과 휴대폰 혼재."),
("B7 영문 사이트 미연결", "viewplasticsurgery.com이 별도. hreflang 없음."),
], t_size=HEAD, b_size=BODY)
# 08 C 콘텐츠
s = d.slide(eyebrow="C · Answer-Ready Content · 실측 44%",
title="프리저베 페이지가 ", title_accent="인용 구조의 기준입니다", section="C · Content")
L, R = s.cards2(y=s.band_y)
L.chip("0점 항목", "critical").rows([
("C1 병원 정의문 없음", "메인은 슬라이더와 라벨. 가슴성형 페이지 첫 텍스트는 상담 폼."),
("C3 H1 5개, 전부 라벨", "가슴·코·오시는길은 H1 없음. 프리저베만 H1 1개 서술형."),
("C5 HTML 표 없음", "5개 페이지 모두 table 0개. 비교 정보는 이미지."),
], t_size=HEAD, b_size=BODY)
R.chip("부분 점수", "warning").rows([
("C2 FAQ는 프리저베에만", "FAQ 7문항과 유효한 FAQPage 스키마. 다른 시술 페이지는 없음."),
("C4 수치는 시술 상세에", "프리저베 20~40분·회복 1~3일, 코 1~2시간·실밥 7일. 가격대 없음."),
("C6 신선도 정상", "시술 페이지 2026-04-17, sitemap 08-31, 블로그 09-07 게시."),
], t_size=HEAD, b_size=BODY)
# 09 D 표면 / E 측정 (dark)
s = d.slide(eyebrow="D · Surfaces / E · Measurement",
title="강남언니는 강하고, ", title_accent="GBP 보완이 필요합니다", dark=True, section="D · Surfaces")
L, R = s.cards2(y=s.band_y)
L.chip("D · 표면 실측 68%", "good").rows([
("D1 강남언니 우수", "후기 19,373건, 의료진 26명, 이벤트 52건. 핵심 표면 확보."),
("D2 GBP 4.9점 (901)", "게시물 활성. 전화번호 미등록, 웹사이트 영문 도메인(08-28 실측)."),
("D3 오픈웹 표면", "유튜브 1,215편, 블로그 발행 중, 언론 1,137건. NAP 불일치로 3점 보류."),
], t_size=HEAD, b_size=BODY)
R.chip("E · 측정 (점수 밖 부가 지표)", "critical").rows([
("E1 크롤러 로그", "nginx 직접 호스팅. AI 봇 히트 추출 파이프라인 없음."),
("E2 GA4 AI 유입 채널", "chatgpt.com·perplexity.ai 채널그룹 없음. 계정 접근 필요."),
("E4 기준선 반복 측정", "120문항 API 실측 1회(09-01). 반복 측정은 미실시."),
], t_size=HEAD, b_size=BODY)
# 10 Action plan
s = d.slide(eyebrow="Action Plan",
title="지금 고칠 것과 ", title_accent="4주 안에 만들 것", section="Action Plan")
a, b, c = s.cards3(y=s.band_y)
a.chip("P0 · 1일~1주 · GEO", "critical").title("엔티티·근거·측정", size=HEAD).bullets([
"깨진 JSON-LD 수정, MedicalClinic 스키마.",
"수상·1위 주장에 원문 링크와 시점.",
"GBP 전화번호 등록, 4개 표면 NAP 통일.",
"GA4 AI 채널, 서버로그, GSC·Bing.",
], size=BODY)
b.chip("P1 · 4주 내 · AEO", "warning").title("콘텐츠 구조", size=HEAD).bullets([
"핵심 페이지 첫 단락에 병원 정의문.",
"프리저베 구조를 가슴·코·눈·윤곽으로 확산.",
"비교표를 HTML 표로. H1 재설계.",
"Physician 스키마, alt 111개 보완.",
], size=BODY)
c.chip("P2 · 지속", "good").title("연결·시딩", size=HEAD).bullets([
"블로그 글과 시술 페이지를 내부 링크로.",
"유튜브 영상을 블로그 글로 전사.",
"월 1회 갱신, 분기 1회 반복 측정.",
], size=BODY)
# 11 Roadmap (dark)
s = d.slide(eyebrow="Roadmap · 8 Weeks",
title="8주 안에 ", title_accent="AEO·GEO 모두 C에서 B로", dark=True, section="Roadmap")
s.stats3([
("W1–2", "스키마·NAP·근거 링크·측정 인프라.\n기준선 반복 측정 1회."),
("W3–6", "정의문·FAQ·H1 재설계·HTML 표.\nWordPress 업그레이드."),
("W7–8", "재진단. 미검증 4개 반영해\nAEO·GEO 재산정."),
], y=s.band_y + 0.6)
# 12 Validation
s = d.slide(eyebrow="Validation · 120-Question Live Test",
title="진단을 120문항 실측으로 ", title_accent="검증했습니다",
lede="잠재 환자 질문 120개를 OpenAI·Perplexity 정식 API로 질의해 예측력을 채점했습니다 (09-01).",
section="Validation")
L, R = s.cards2(y=s.band_y)
L.chip("예측 적중", "good").rows([
("논브랜드 질문에서 부재", "84문항 중 Perplexity 3, ChatGPT 10문항만 언급. 시술 정보 52문항은 0건."),
("원인은 답변 준비도(AEO)", "병원 정의문 0, HTML 표 0, FAQ는 한 페이지. 시술별 인용 단락 부재."),
("표면이 브랜드 방어", "병원명을 아는 36문항은 97~100% 답변. 근거는 강남언니·GBP·뉴스."),
], t_size=HEAD, b_size=BODY)
R.chip("진단 밖 발견", "info").rows([
("제3자 표면이 더 넓습니다", "뉴스·채용공고·바비톡·네이버 예약이 개원연도·주차·휴진을 답함."),
("발행 중인 블로그가 인용됨", "ChatGPT 추천 근거가 blog.viewclinic.com. 정정 반영."),
("엔진별 오답 편차", "주차 질문에 Perplexity는 정답, ChatGPT는 추측. 구조화 부재의 비용."),
], t_size=HEAD, b_size=BODY)
# 13 Corrections (dark)
s = d.slide(eyebrow="Corrections · 2026-09-07 재검증",
title="v1.0에서 틀린 것 ", title_accent="세 가지를 바로잡습니다", dark=True,
lede="홈·프리저베·가슴·코·오시는길 5개 URL의 원본 HTML을 다시 받아 파싱했습니다. 이 실측이 v2.0 점수의 신호입니다.",
section="Corrections")
L, R = s.cards2(y=s.band_y)
L.chip("정정", "warning").rows([
("블로그 정지 → 발행 중", "www post-sitemap만 본 오판. blog.viewclinic.com은 09-07까지 발행, AI 봇 14종 허용."),
("FAQ 0개 → 프리저베 7문항", "2개 URL 결과를 전체로 일반화. 프리저베에 FAQ 7문항과 FAQPage 스키마. C2 0→2."),
("수치 없음 → 시술 상세에 있음", "프리저베·코·윤곽 페이지에 수술시간·마취·회복 텍스트. 가격대는 없음. C4 1→2."),
], t_size=HEAD, b_size=BODY)
R.chip("유지 · 재확인", "info").rows([
("그대로 확인된 것", "H1 5개 라벨, ItemList 파싱 실패, sameAs 빈 값, 표 0개, 진료시간 이미지, WP 5.5.3."),
("점수 체계 변경", "v1.0 종합 54점(C)은 참고값. v2.0은 AEO 42 · GEO 56 두 점수로 봅니다."),
("재확인 필요", "GBP 전화번호·웹사이트는 08-28 실측값. 병원이 고쳤다면 GEO4 재채점."),
], t_size=HEAD, b_size=BODY)
# 14 Request to the clinic
s = d.slide(eyebrow="Request · 뷰 서포터즈 강화를 위해",
title="뷰성형외과에 ", title_accent="부탁드리는 것",
lede=["서포터즈 사이트 18편(검토 완료 10편)을 병원의 공식 답으로 완성하려면 병원만 아는 정보가 필요합니다.",
"아래 항목은 담당자 한 분이 1~2주 안에 처리하실 수 있는 범위로 추렸습니다."],
section="Request")
a, b, c = s.cards3(y=s.band_y)
a.chip("정보 · 병원만 아는 사실", "info").title("알려 주시면 반영합니다", size=HEAD).bullets([
"가슴성형 견적 기본 포함 항목과 결제 방식.",
"가슴성형 후 출근 기준과 걷기 시작 시점.",
"타원 수술 재수술 상담 가능 여부.",
"코·윤곽 재수술 대기 기간, 코 실밥 제거일.",
], size=BODY)
b.chip("확인 · 검토와 승인", "warning").title("확인해 주시면 켜집니다", size=HEAD).bullets([
"검토 대기 5편의 담당 원장 확정과 검토일.",
"검토 완료 10편의 기록을 병원 기록과 대조.",
"서포터즈 지원 관계 문구 확정.",
"작성자 실명·편집 책임자 표기 승인.",
], size=BODY)
c.chip("액션 · 홈페이지 쪽", "critical").title("병원이 직접 고쳐야 합니다", size=HEAD).bullets([
"ItemList JSON-LD 오류 수정, MedicalClinic 추가.",
"GBP 전화번호 등록, 웹사이트 교정.",
"프리저베 구조를 다른 시술 페이지로 확산.",
"서포터즈 정식 도메인 확정과 링크 연결.",
], size=BODY)
# 15 Integrity
s = d.slide(eyebrow="What We Don't Sell · 정직성 규약",
title="근거 없는 것은 ", title_accent="약속하지 않습니다", section="Integrity")
a, b, c = s.cards3(y=s.band_y)
a.icon_square().title("만들지 않는 것", size=HEAD).bullets([
"llms.txt. Google 공식 효과 0, 요청 로그 없음.",
"네이버 블로그 증량. AI 크롤링 금지 도메인.",
], size=BODY)
b.icon_square().title("약속하지 않는 것", size=HEAD).bullets([
"\"스키마 넣으면 인용됩니다\". 인과 증거 없음.",
"순위·노출 보장. 답변은 10~34% 흔들림.",
], size=BODY)
c.icon_square().title("측정 규율", size=HEAD).bullets([
"단발 조회 보고 금지. 반복 측정만.",
"답변엔진 UI 자동 조회 금지. 약관 위반.",
"틀린 판정은 정정 슬라이드로 남깁니다.",
], size=BODY)
# 16 Method
s = d.slide(eyebrow="Method · AEO / GEO Rubric v2.0",
title="두 점수, 각 100점, ", title_accent="36개 실측 신호", section="Method")
a, b, c = s.cards3(y=s.band_y)
a.icon_square().title("두 축의 질문", size=HEAD).bullets([
"AEO: 질문에 얼마나 명확히 답하는가.",
"GEO: 그 답을 출처로 믿을 수 있는가.",
"같은 항목을 두 점수에 겹쳐 넣지 않습니다.",
], size=BODY)
b.icon_square().title("채점 방식", size=HEAD).bullets([
"축당 8항목 × 배점. 미검증은 분모 제외.",
"실측 36항목의 0~3 레벨을 신호로 씁니다.",
"실측이 없는 4항목은 사람이 근거와 함께 판정.",
], size=BODY)
c.icon_square().title("게이트와 등급", size=HEAD).bullets([
"크롤러 차단·본문 미렌더는 점수 아닌 상한 40.",
"등급 A 81+ · B 61~80 · C 41~60 · D 40 이하.",
"제품용 기준이며 인용 확률이 아닙니다.",
], size=BODY)
# 17 Closing
d.closing(eyebrow="Next Step",
title_lines=["출처는 절반쯤 왔습니다.", "답을 먼저 쓰면 됩니다."],
lede="P0 세 가지: 스키마, 근거 링크, NAP 통일. 서포터즈 요청은 담당자 한 분이 처리할 수 있습니다.",
cta_primary="P0 착수하기 →", cta_secondary="서포터즈 사이트 보기",
contact="o2oteam@o2o.kr")
d.save(OUT)
print("saved", OUT)

View File

@ -0,0 +1,185 @@
{
"source": "Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx (2026-09-04 색인 허용 빌드 실측)",
"results": [
{
"criterionId": "A1",
"level": 3,
"evidence": "12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200"
},
{
"criterionId": "A2",
"level": 3,
"evidence": "Server: 미표기 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음"
},
{
"criterionId": "A3",
"level": 3,
"evidence": "서버 렌더링. JS 미실행 HTML에서 본문 텍스트 3,070자 추출"
},
{
"criterionId": "A4",
"level": 1,
"evidence": "변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://view-supporters-sample.vercel.app', 'https://www.view-supporters-sample.vercel.app']"
},
{
"criterionId": "A5",
"level": 3,
"evidence": "robots.txt에 sitemap 선언, 200 응답. URL 17개, lastmod 최신 2026-09-04"
},
{
"criterionId": "A6",
"level": "unverified",
"evidence": "[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)"
},
{
"criterionId": "A7",
"level": 2,
"evidence": "TTFB 0.536s / 초기 HTML 20KB. 시술 페이지 31KB"
},
{
"criterionId": "B1",
"level": 3,
"evidence": "JSON-LD 2블록 전부 유효 + @graph 연결"
},
{
"criterionId": "B2",
"level": 3,
"evidence": "@type MedicalClinic + 주소·전화·openingHours·전문분야/geo 구조화"
},
{
"criterionId": "B3",
"level": 3,
"evidence": "Physician/Attorney 스키마 1개 + 전문분야·소속/sameAs"
},
{
"criterionId": "B4",
"level": 3,
"evidence": "주소 단일 표기: \"서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩\". 전화 2종: 02-539-1177, 02-539-1132"
},
{
"criterionId": "B5",
"level": 3,
"evidence": "sameAs 5개 (SNS·버티컬·지도 연결)"
},
{
"criterionId": "B6",
"level": 2,
"evidence": "rel=canonical self-referencing 존재"
},
{
"criterionId": "B7",
"level": 1,
"evidence": "hreflang 없음, 다국어 페이지 흔적도 탐지 안 됨 (단일 언어)"
},
{
"criterionId": "C1",
"level": 3,
"evidence": "홈 히어로 첫 단락과 글 8편 전부 첫 단락에 '뷰성형외과는 2005년 개원한 서울 강남 신논현역 인근 성형외과' 정의문 + 세 줄 요약(직접 답변 블록). 시술 페이지 첫 텍스트가 상담폼이 아니라 답변"
},
{
"criterionId": "C2",
"level": 3,
"evidence": "FAQ 섹션(자주 묻는 질문) + FAQPage 스키마 존재"
},
{
"criterionId": "C3",
"level": 3,
"evidence": "H1 1개 서술형: \"뷰성형외과를 알아보는 사람들의질문에 먼저 답합니다.\". H2 7개 중복 없음"
},
{
"criterionId": "C4",
"level": 2,
"evidence": "시술 페이지(프리저베)에 절개 3~3.5cm, 수술 시간 약 30분, 200~300cc 등 수치가 본문·표·FAQ에 텍스트로 존재. 가격대·회복 일수 표는 없음(가격 노출 범위는 경영진 결정 대기)"
},
{
"criterionId": "C5",
"level": 2,
"evidence": "HTML 표 1개"
},
{
"criterionId": "C6",
"level": 3,
"evidence": "최신 갱신 신호 2026-09-04 (dateModified 2026-09-04, sitemap lastmod 2026-09-04) + dateModified 표기"
},
{
"criterionId": "C7",
"level": 3,
"evidence": "감수 원장 약력이 텍스트로 표기(서울대 의학박사, 고려대의료원 성형외과 전문의 등) + Physician 스키마 sameAs(강남언니 프로필) + 작성자 Person 스키마. 단 감수는 '예정' 상태"
},
{
"criterionId": "C8",
"level": 3,
"evidence": "세 줄 요약·FAQ 답변 10개 샘플 중 10개가 '뷰성형외과'와 시술명(프리저베·보형물 등)을 문장 안에 포함. 단독으로 떼어도 주어가 살아 있음"
},
{
"criterionId": "D1",
"level": 3,
"evidence": "병원 엔티티 항목. 공식 사이트와 같은 표면(강남언니 hospitals/189)이라 공식 실측값 준용: 강남언니 hospitals/189 등록. 후기 19,316건, 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10"
},
{
"criterionId": "D2",
"level": 2,
"evidence": "병원 엔티티 항목. 공식 실측값 준용: GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록(\"장소의 전화번호 추가\" 상태), 웹사이트가 국문 홈페이"
},
{
"criterionId": "D3",
"level": 2,
"evidence": "사이트 밖 언급 표면: 유튜브 1,215편·강남언니·언론 1,137건(뉴스룸 집계). 서포터즈 도메인 자체는 신규라 외부 언급 0. 검색 그라운딩 미실시"
},
{
"criterionId": "D4",
"level": 1,
"evidence": "병원 엔티티 항목. 공식 실측값 준용. 서포터즈는 팩트 시트에서 홈페이지 표기를 그대로 쓰므로 표면 간 불일치를 늘리지 않음"
},
{
"criterionId": "D5",
"level": 2,
"evidence": "병원 엔티티 항목. 공식 실측값 준용(네이버 플레이스 리뷰 776건). 서포터즈 병원 정보 페이지에 플레이스 링크 추가 예정"
},
{
"criterionId": "D6",
"level": 0,
"evidence": "Wikidata에서 \"뷰성형외과\" 검색 결과 없음"
},
{
"criterionId": "E1",
"level": "unverified",
"evidence": "[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)"
},
{
"criterionId": "E2",
"level": "unverified",
"evidence": "[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)"
},
{
"criterionId": "E3",
"level": "unverified",
"evidence": "[manual] GSC 접근 필요 (AI Overviews 노출)"
},
{
"criterionId": "E4",
"level": "unverified",
"evidence": "[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)"
},
{
"criterionId": "F1",
"level": 3,
"evidence": "메인 페이지 이미지 18개 중 0개(0%) alt 누락 또는 빈 값"
},
{
"criterionId": "F2",
"level": 3,
"evidence": "CMS 없음. Astro 정적 빌드(HTML 파일)라 보안 패치 대상 코어·플러그인이 없음. 채점기는 generator 메타가 없어 미검증 처리했으나 실질 위험 없음"
},
{
"criterionId": "F3",
"level": 3,
"evidence": "초기 HTML 20KB (300KB 미만)"
},
{
"criterionId": "F4",
"level": 2,
"evidence": "viewport meta 존재, 반응형. CWV 미측정"
}
]
}

View File

@ -0,0 +1,185 @@
{
"source": "Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx (2026-09-04 실측)",
"results": [
{
"criterionId": "A1",
"level": 0,
"evidence": "검색봇 6개 차단: OAI-SearchBot, Googlebot, bingbot, PerplexityBot, Claude-SearchBot, Applebot. 봇 GET: OAI-SearchBot 200·PerplexityBot 200·GPTBot 200"
},
{
"criterionId": "A2",
"level": 3,
"evidence": "Server: Vercel 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음"
},
{
"criterionId": "A3",
"level": 3,
"evidence": "서버 렌더링. JS 미실행 HTML에서 본문 텍스트 3,132자 추출"
},
{
"criterionId": "A4",
"level": 1,
"evidence": "변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://view-supporters-sample.vercel.app', 'https://www.view-supporters-sample.vercel.app']"
},
{
"criterionId": "A5",
"level": 1,
"evidence": "/sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 17개"
},
{
"criterionId": "A6",
"level": "unverified",
"evidence": "[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요 (또는 site: 검색 수동 확인)"
},
{
"criterionId": "A7",
"level": 2,
"evidence": "TTFB 0.536s / 초기 HTML 20KB. 시술 페이지 31KB"
},
{
"criterionId": "B1",
"level": 3,
"evidence": "JSON-LD 2블록 전부 유효 + @graph 연결"
},
{
"criterionId": "B2",
"level": 3,
"evidence": "@type MedicalClinic + 주소·전화·openingHours·전문분야/geo 구조화"
},
{
"criterionId": "B3",
"level": 3,
"evidence": "Physician/Attorney 스키마 1개 + 전문분야·소속/sameAs"
},
{
"criterionId": "B4",
"level": 3,
"evidence": "주소 단일 표기: \"서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩\". 전화 2종: 02-539-1177, 02-539-1132"
},
{
"criterionId": "B5",
"level": 3,
"evidence": "sameAs 5개 (SNS·버티컬·지도 연결)"
},
{
"criterionId": "B6",
"level": 2,
"evidence": "rel=canonical self-referencing 존재"
},
{
"criterionId": "B7",
"level": 1,
"evidence": "hreflang 없음, 다국어 페이지 흔적도 탐지 안 됨 (단일 언어)"
},
{
"criterionId": "C1",
"level": 3,
"evidence": "홈 히어로 첫 단락과 글 8편 전부 첫 단락에 '뷰성형외과는 2005년 개원한 서울 강남 신논현역 인근 성형외과' 정의문 + 세 줄 요약(직접 답변 블록). 시술 페이지 첫 텍스트가 상담폼이 아니라 답변"
},
{
"criterionId": "C2",
"level": 3,
"evidence": "FAQ 섹션(자주 묻는 질문) + FAQPage 스키마 존재"
},
{
"criterionId": "C3",
"level": 3,
"evidence": "H1 1개 서술형: \"뷰성형외과를 알아보는 사람들의질문에 먼저 답합니다.\". H2 7개 중복 없음"
},
{
"criterionId": "C4",
"level": 2,
"evidence": "시술 페이지(프리저베)에 절개 3~3.5cm, 수술 시간 약 30분, 200~300cc 등 수치가 본문·표·FAQ에 텍스트로 존재. 가격대·회복 일수 표는 없음(가격 노출 범위는 경영진 결정 대기)"
},
{
"criterionId": "C5",
"level": 2,
"evidence": "HTML 표 1개"
},
{
"criterionId": "C6",
"level": 3,
"evidence": "최신 갱신 신호 2026-09-04 (dateModified 2026-09-04, sitemap lastmod 2026-09-04) + dateModified 표기"
},
{
"criterionId": "C7",
"level": 3,
"evidence": "감수 원장 약력이 텍스트로 표기(서울대 의학박사, 고려대의료원 성형외과 전문의 등) + Physician 스키마 sameAs(강남언니 프로필) + 작성자 Person 스키마. 단 감수는 '예정' 상태"
},
{
"criterionId": "C8",
"level": 3,
"evidence": "세 줄 요약·FAQ 답변 10개 샘플 중 10개가 '뷰성형외과'와 시술명(프리저베·보형물 등)을 문장 안에 포함. 단독으로 떼어도 주어가 살아 있음"
},
{
"criterionId": "D1",
"level": 3,
"evidence": "병원 엔티티 항목. 공식 사이트와 같은 표면(강남언니 hospitals/189)이라 공식 실측값 준용: 강남언니 hospitals/189 등록. 후기 19,316건, 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10"
},
{
"criterionId": "D2",
"level": 2,
"evidence": "병원 엔티티 항목. 공식 실측값 준용: GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록(\"장소의 전화번호 추가\" 상태), 웹사이트가 국문 홈페이"
},
{
"criterionId": "D3",
"level": 2,
"evidence": "사이트 밖 언급 표면: 유튜브 1,215편·강남언니·언론 1,137건(뉴스룸 집계). 서포터즈 도메인 자체는 신규라 외부 언급 0. 검색 그라운딩 미실시"
},
{
"criterionId": "D4",
"level": 1,
"evidence": "병원 엔티티 항목. 공식 실측값 준용. 서포터즈는 팩트 시트에서 홈페이지 표기를 그대로 쓰므로 표면 간 불일치를 늘리지 않음"
},
{
"criterionId": "D5",
"level": 2,
"evidence": "병원 엔티티 항목. 공식 실측값 준용(네이버 플레이스 리뷰 776건). 서포터즈 병원 정보 페이지에 플레이스 링크 추가 예정"
},
{
"criterionId": "D6",
"level": 0,
"evidence": "Wikidata에서 \"뷰성형외과\" 검색 결과 없음"
},
{
"criterionId": "E1",
"level": "unverified",
"evidence": "[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)"
},
{
"criterionId": "E2",
"level": "unverified",
"evidence": "[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)"
},
{
"criterionId": "E3",
"level": "unverified",
"evidence": "[manual] GSC 접근 필요 (AI Overviews 노출)"
},
{
"criterionId": "E4",
"level": "unverified",
"evidence": "[manual] 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)"
},
{
"criterionId": "F1",
"level": 3,
"evidence": "메인 페이지 이미지 18개 중 0개(0%) alt 누락 또는 빈 값"
},
{
"criterionId": "F2",
"level": 3,
"evidence": "CMS 없음. Astro 정적 빌드(HTML 파일)라 보안 패치 대상 코어·플러그인이 없음. 채점기는 generator 메타가 없어 미검증 처리했으나 실질 위험 없음"
},
{
"criterionId": "F3",
"level": 3,
"evidence": "초기 HTML 20KB (300KB 미만)"
},
{
"criterionId": "F4",
"level": 2,
"evidence": "viewport meta 존재, 반응형. CWV 미측정"
}
]
}

View File

@ -0,0 +1,700 @@
{
"generatedAt": "2026-09-07",
"rubricVersion": "2.0",
"note": "점수는 INFINITH 제품용 진단 기준이며 검색엔진의 공식 점수나 인용 확률이 아니다. 서포터즈 v1.0 레벨은 2026-09-04 실측(기존 비교 엑셀), 공식은 2026-09-07 v1.1 재검증. 사람 판정 4항목은 2026-09-07 초안.",
"axes": [
{
"id": "aeo",
"code": "AEO",
"name": "답변 준비도",
"nameEn": "Answer Readiness",
"question": "고객의 질문에 얼마나 명확하고 충분하게 답하는가?",
"description": "질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가."
},
{
"id": "geo",
"code": "GEO",
"name": "출처 준비도",
"nameEn": "Source Readiness",
"question": "그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가?",
"description": "주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가."
}
],
"items": [
{
"id": "AEO1",
"axis": "aeo",
"name": "질문 수요 충족",
"weight": 20,
"method": "semi",
"question": "사람들이 실제로 묻는 질문에 답하는 페이지가 있는가",
"derivedFrom": "C2 FAQ 섹션(1)"
},
{
"id": "AEO2",
"axis": "aeo",
"name": "답변의 직접성",
"weight": 20,
"method": "semi",
"question": "페이지 첫 단락에 답이 먼저 나오는가",
"derivedFrom": "C1 정의문형 첫 문단(1)"
},
{
"id": "AEO3",
"axis": "aeo",
"name": "답변 단위의 자기완결성",
"weight": 15,
"method": "manual",
"question": "한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가",
"derivedFrom": "C8 패시지 자기완결성(1)"
},
{
"id": "AEO4",
"axis": "aeo",
"name": "의사결정에 필요한 정보",
"weight": 15,
"method": "semi",
"question": "가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가",
"derivedFrom": "C4 구체 수치 (가격·시간·회복)(0.6), C5 표·비교 구조(0.4)"
},
{
"id": "AEO5",
"axis": "aeo",
"name": "정보 구조와 가독성",
"weight": 10,
"method": "auto",
"question": "제목이 주제를 서술하고 계층이 논리적인가",
"derivedFrom": "C3 제목 계층 의미성(1)"
},
{
"id": "AEO6",
"axis": "aeo",
"name": "다음 행동의 명확성",
"weight": 10,
"method": "manual",
"question": "답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가",
"derivedFrom": ""
},
{
"id": "AEO7",
"axis": "aeo",
"name": "핵심 답변의 텍스트 추출",
"weight": 5,
"method": "auto",
"question": "답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가",
"derivedFrom": "A3 서버 렌더링 본문(0.6), F1 이미지 대체텍스트(0.4)"
},
{
"id": "AEO8",
"axis": "aeo",
"name": "사이트 내 답변 발견",
"weight": 5,
"method": "auto",
"question": "크롤러가 답이 있는 페이지를 찾아갈 수 있는가",
"derivedFrom": "A5 sitemap.xml 제공(0.6), B6 canonical·중복 제거(0.4)"
},
{
"id": "GEO1",
"axis": "geo",
"name": "주장과 근거의 일치",
"weight": 20,
"method": "manual",
"question": "수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가",
"derivedFrom": ""
},
{
"id": "GEO2",
"axis": "geo",
"name": "출처의 적합성과 추적성",
"weight": 15,
"method": "auto",
"question": "인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가",
"derivedFrom": "A4 HTTPS·리다이렉트 정합(1)"
},
{
"id": "GEO3",
"axis": "geo",
"name": "전문성과 책임 주체",
"weight": 15,
"method": "semi",
"question": "누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가",
"derivedFrom": "C7 전문가 E-E-A-T (의료진 / 변호사)(0.6), B3 전문가 스키마 (Physician / Attorney)(0.4)"
},
{
"id": "GEO4",
"axis": "geo",
"name": "병원 엔티티와 사실의 정합성",
"weight": 15,
"method": "semi",
"question": "이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가",
"derivedFrom": "B2 업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)(0.2), B4 NAP 사이트 내 일관성(0.15), B5 sameAs 연결(0.1), D1 업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)(0.2), D2 Google 비즈니스 프로필(0.1), D4 표면 간 NAP 완전 일치(0.15), D5 네이버 플레이스 (AI 브리핑 전용)(0.05), D6 위키데이터/위키백과 엔티티(0.05)"
},
{
"id": "GEO5",
"axis": "geo",
"name": "사실의 최신성과 정정",
"weight": 10,
"method": "auto",
"question": "언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가",
"derivedFrom": "C6 신선도 신호(1)"
},
{
"id": "GEO6",
"axis": "geo",
"name": "원천 대비 추가 가치",
"weight": 10,
"method": "manual",
"question": "다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가",
"derivedFrom": ""
},
{
"id": "GEO7",
"axis": "geo",
"name": "발행 관계와 투명성",
"weight": 10,
"method": "manual",
"question": "누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가",
"derivedFrom": ""
},
{
"id": "GEO8",
"axis": "geo",
"name": "인용 식별자와 메타데이터 정합성",
"weight": 5,
"method": "auto",
"question": "기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가",
"derivedFrom": "B1 JSON-LD 문법 유효성(1)"
}
],
"gates": [
{
"id": "G1",
"name": "AI 크롤러 접근",
"criterionIds": [
"A1",
"A2"
],
"failLevel": 1,
"cap": 40,
"axes": [
"aeo",
"geo"
],
"reason": "검색용 AI 봇이 차단되거나 CDN이 챌린지를 걸면 답도 출처도 될 수 없다. 점수 대신 상한 40으로 표시한다."
},
{
"id": "G2",
"name": "본문 렌더링",
"criterionIds": [
"A3"
],
"failLevel": 0,
"cap": 40,
"axes": [
"aeo",
"geo"
],
"reason": "JS 미실행 HTML에 본문이 없으면 크롤러에게 빈 페이지다."
}
],
"variants": [
{
"key": "official",
"label": "공식 홈페이지 (viewclinic.com)",
"v1": {
"score": 54,
"grade": "C"
},
"aeo": {
"score": 42,
"raw": 42,
"grade": "C",
"verified": 8,
"capped": []
},
"geo": {
"score": 56,
"raw": 56,
"grade": "C",
"verified": 8,
"capped": []
},
"gates": [
{
"id": "G1",
"passed": true,
"failedBy": []
},
{
"id": "G2",
"passed": true,
"failedBy": []
}
],
"items": [
{
"id": "AEO1",
"pct": 67,
"earned": 13.3,
"basis": "derived",
"signals": "C2=2",
"evidence": "C2: v1.1 정정: 프리저베 페이지(/preserve/)에 FAQ 7문항과 유효한 FAQPage 스키마 존재. 모바일 분기로 2문항이 중복. 메인·가슴성형(/breast/main/)·코성형·오시는길에는 FAQ 없음 → 시술별 확산 전이라 레벨 2"
},
{
"id": "AEO2",
"pct": 0,
"earned": 0,
"basis": "derived",
"signals": "C1=0",
"evidence": "C1: 메인 첫 화면은 슬라이더·\"VIEW SELFIE\" 등 라벨. \"뷰성형외과는 …에 있는 … 병원입니다\" 형태의 정의문 없음. 시술 페이지 첫 텍스트는 오시는길·상담폼"
},
{
"id": "AEO3",
"pct": 33,
"earned": 5,
"basis": "derived",
"signals": "C8=1",
"evidence": "C8: H2 \"철저한 정밀 검진 / ONE_STOP 시스템 / 1:1 전담 마취시스템\". 단독으로 떼면 어느 병원의 무엇인지 알 수 없음"
},
{
"id": "AEO4",
"pct": 40,
"earned": 6,
"basis": "derived",
"signals": "C4=2, C5=0",
"evidence": "C4: v1.1 정정: 프리저베 페이지에 수술시간 20~40분·마취(국소 또는 수면)·회복 1~3일, 코성형 페이지에 수술시간 1~2시간·실밥 7일·일상 5~7일, 안면윤곽 페이지에 1~2시간·전신마취·당일 또는 1일 입원이 텍스트로 있음. 가격대 표기는 없 / C5: HTML <table> 없음. 비교 정보는 이미지로 추정"
},
{
"id": "AEO5",
"pct": 0,
"earned": 0,
"basis": "derived",
"signals": "C3=0",
"evidence": "C3: H1 5개: \"VIEW SELFIE / VIEW EVENT / VIEW SHORTS / VIEW FILM / VIEW SPECIALITY\". 전부 영문 라벨. H2는 같은 문구가 2회씩 중복(모바일/PC 분기). 가슴·코·오시는길 페이지 H1 없음. "
},
{
"id": "AEO6",
"pct": 100,
"earned": 10,
"basis": "judged",
"signals": "",
"evidence": "모든 페이지 하단에 전화 02-539-1177·주소·온라인 상담 폼이 텍스트로 반복되고(수집기 반복 줄), /counsel/reservation/ 예약 페이지와 카카오톡 채널 링크가 있다. 전화·예약·방문 3경로가 텍스트로 연결됨"
},
{
"id": "AEO7",
"pct": 73,
"earned": 3.7,
"basis": "derived",
"signals": "A3=3, F1=1",
"evidence": "A3: WordPress 서버 렌더링. JS 미실행 HTML에서 본문 텍스트 10,757자 추출 / F1: 이미지 302개 중 111개(37%) alt 누락 또는 빈 값"
},
{
"id": "AEO8",
"pct": 87,
"earned": 4.3,
"basis": "derived",
"signals": "A5=3, B6=2",
"evidence": "A5: robots.txt에 sitemap 선언, 200 응답, AIOSEO 3.7.1 동적 생성, URL 3,636개. page/portfolio-sitemap lastmod 최신 2026-08-31. www의 post-sitemap은 2024-05-31에 / B6: rel=canonical self-referencing 존재"
},
{
"id": "GEO1",
"pct": 33,
"earned": 6.7,
"basis": "judged",
"signals": "",
"evidence": "\"모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)\"처럼 집계 주체·시점이 붙은 주장이 일부 있으나, 모티바 그랜드 어워드·KOL·렛미인 등 대부분의 권위 신호는 이미지·슬로건 안에 있고 원문 링크가 없다. \"마취과 전문의 상주 43% vs 23.1%\" 비교는 출처·기준 시점 없음"
},
{
"id": "GEO2",
"pct": 100,
"earned": 15,
"basis": "derived",
"signals": "A4=3",
"evidence": "A4: http://viewclinic.com → 301 → https://www.viewclinic.com/ 정본 수렴"
},
{
"id": "GEO3",
"pct": 40,
"earned": 6,
"basis": "derived",
"signals": "C7=2, B3=0",
"evidence": "C7: 원장·전문의·의료진 언급 17회, 모티바 글로벌 KOL·보건복지부장관표창·갈더마 방문 등 권위 신호 존재. 단 구조화 안 됨 / B3: Physician 스키마 없음. 의료진 정보는 HTML 텍스트·이미지로만 존재"
},
{
"id": "GEO4",
"pct": 47,
"earned": 7,
"basis": "derived",
"signals": "B2=1, B4=1, B5=0, D1=3, D2=2, D4=1, D5=2, D6=0",
"evidence": "B2: Organization + WebSite + WebPage만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음 / B4: 주소 3가지 표기 혼재: \"서울 강남구 봉은사로 107 뷰성형외과 빌딩\" / \"서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩\" / 시술 페이지 \"서울시 강남구 봉은사로 107, 뷰성형외과빌딩(논현동…\". 전화 02-539-1177·02-539-11 / B5: Organization.sameAs: [] (빈 배열). 푸터에 YouTube·Instagram·Facebook·네이버블로그 링크가 있으나 스키마에 미연결 / D1: 강남언니 hospitals/189 등록. 후기 19,373건(2026-09-04 재확인, 08-28 19,316건), 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10건(CCTV·마취과 상주), 진료시간 표기 / D2: GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록(\"장소의 전화번호 추가\" 상태), 웹사이트가 국문 홈페이지가 아닌 viewplasticsurgery.com / D4: 주소 표기 3종 혼재: 홈페이지 \"서울 강남구 봉은사로 107 뷰성형외과 빌딩\" / 강남언니 \"서울 강남구 봉은사로 107 (논현동)\" / GBP \"서울특별시 강남구 봉은사로 107\". GBP 전화 누락, 웹사이트 도메인 불일치 / D5: 네이버 플레이스 존재(리뷰 776건, 2026-04 INFINITH 리포트 기준). 홈페이지 링크 연결 여부 미확인 → 보수적으로 2 / D6: Wikidata에서 \"뷰성형외과\" 검색 결과 없음 (wbsearchentities ko, 2026-08-31)"
},
{
"id": "GEO5",
"pct": 100,
"earned": 10,
"basis": "derived",
"signals": "C6=3",
"evidence": "C6: 시술 페이지에 article:modified_time·dateModified 2026-04-17 존재, page/portfolio-sitemap lastmod 최신 2026-08-31. blog.viewclinic.com은 2026-09-07 게시까지"
},
{
"id": "GEO6",
"pct": 67,
"earned": 6.7,
"basis": "judged",
"signals": "",
"evidence": "수술 전·후 주의사항(부위별 8탭, 9,585자)·애프터케어 프로그램(4,572자)·검진센터 항목·안전수술시스템 절차는 병원 고유 정보다. 다만 표·체크리스트 형태가 없고(C5=0) 회복 일정은 문단에 흩어져 있다"
},
{
"id": "GEO7",
"pct": 33,
"earned": 3.3,
"basis": "judged",
"signals": "",
"evidence": "푸터에 대표·사업자등록번호·주소가 있어 운영 주체는 분명하다. 그러나 홈페이지에 게시한 후기 광고가 수술비 할인 대가를 표시하지 않아 2026-07-12 공정위 시정명령·공표명령을 받았고, 이벤트·전후·후기 섹션이 정보 페이지와 구분 없이 섞여 있다"
},
{
"id": "GEO8",
"pct": 33,
"earned": 1.7,
"basis": "derived",
"signals": "B1=1",
"evidence": "B1: JSON-LD 2블록 중 ItemList 블록이 문법 오류(쉼표 누락, line 11)로 파싱 실패 → 시술 목록 구조화 데이터 전체 무효"
}
]
},
{
"key": "supporters_noindex",
"label": "서포터즈 샘플 (현재 · noindex)",
"v1": {
"score": 78,
"grade": "B"
},
"aeo": {
"score": 40,
"raw": 92,
"grade": "D",
"verified": 8,
"capped": [
"G1"
]
},
"geo": {
"score": 40,
"raw": 77,
"grade": "D",
"verified": 8,
"capped": [
"G1"
]
},
"gates": [
{
"id": "G1",
"passed": false,
"failedBy": [
"A1"
]
},
{
"id": "G2",
"passed": true,
"failedBy": []
}
],
"items": [
{
"id": "AEO1",
"pct": 100,
"earned": 20,
"basis": "derived",
"signals": "C2=3",
"evidence": "C2: FAQ 섹션(자주 묻는 질문) + FAQPage 스키마 존재"
},
{
"id": "AEO2",
"pct": 100,
"earned": 20,
"basis": "derived",
"signals": "C1=3",
"evidence": "C1: 홈 히어로 첫 단락과 글 8편 전부 첫 단락에 '뷰성형외과는 2005년 개원한 서울 강남 신논현역 인근 성형외과' 정의문 + 세 줄 요약(직접 답변 블록). 시술 페이지 첫 텍스트가 상담폼이 아니라 답변"
},
{
"id": "AEO3",
"pct": 100,
"earned": 15,
"basis": "derived",
"signals": "C8=3",
"evidence": "C8: 세 줄 요약·FAQ 답변 10개 샘플 중 10개가 '뷰성형외과'와 시술명(프리저베·보형물 등)을 문장 안에 포함. 단독으로 떼어도 주어가 살아 있음"
},
{
"id": "AEO4",
"pct": 67,
"earned": 10,
"basis": "derived",
"signals": "C4=2, C5=2",
"evidence": "C4: 시술 페이지(프리저베)에 절개 3~3.5cm, 수술 시간 약 30분, 200~300cc 등 수치가 본문·표·FAQ에 텍스트로 존재. 가격대·회복 일수 표는 없음(가격 노출 범위는 경영진 결정 대기) / C5: HTML 표 1개"
},
{
"id": "AEO5",
"pct": 100,
"earned": 10,
"basis": "derived",
"signals": "C3=3",
"evidence": "C3: H1 1개 서술형: \"뷰성형외과를 알아보는 사람들의질문에 먼저 답합니다.\". H2 7개 중복 없음"
},
{
"id": "AEO6",
"pct": 100,
"earned": 10,
"basis": "judged",
"signals": "",
"evidence": "헤더 상담 예약 버튼, /visit 페이지에 전화·카카오톡·온라인 예약 3경로가 표로, FactBlock에 tel: 링크. 전부 텍스트 링크"
},
{
"id": "AEO7",
"pct": 100,
"earned": 5,
"basis": "derived",
"signals": "A3=3, F1=3",
"evidence": "A3: 서버 렌더링. JS 미실행 HTML에서 본문 텍스트 3,132자 추출 / F1: 메인 페이지 이미지 18개 중 0개(0%) alt 누락 또는 빈 값"
},
{
"id": "AEO8",
"pct": 47,
"earned": 2.3,
"basis": "derived",
"signals": "A5=1, B6=2",
"evidence": "A5: /sitemap.xml 200이나 robots.txt에 Sitemap 미선언. URL 17개 / B6: rel=canonical self-referencing 존재"
},
{
"id": "GEO1",
"pct": 67,
"earned": 13.3,
"basis": "judged",
"signals": "",
"evidence": "18편 전편의 참고 자료에 유형(병원 제공·원장 설명·제품·규제·플랫폼·2차)과 확인일이 붙고, FDA·ISO 주장은 원문 URL로 연결. 수치 문단 5곳에 개인차·사례 한정이 빠져 게이트 경고 → \"대부분 명시\""
},
{
"id": "GEO2",
"pct": 33,
"earned": 5,
"basis": "derived",
"signals": "A4=1",
"evidence": "A4: 변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://view-supporters-sample.vercel.app', 'https://www.view-supporters-sample.vercel.app']"
},
{
"id": "GEO3",
"pct": 100,
"earned": 15,
"basis": "derived",
"signals": "C7=3, B3=3",
"evidence": "C7: 감수 원장 약력이 텍스트로 표기(서울대 의학박사, 고려대의료원 성형외과 전문의 등) + Physician 스키마 sameAs(강남언니 프로필) + 작성자 Person 스키마. 단 감수는 '예정' 상태 / B3: Physician/Attorney 스키마 1개 + 전문분야·소속/sameAs"
},
{
"id": "GEO4",
"pct": 80,
"earned": 12,
"basis": "derived",
"signals": "B2=3, B4=3, B5=3, D1=3, D2=2, D4=1, D5=2, D6=0",
"evidence": "B2: @type MedicalClinic + 주소·전화·openingHours·전문분야/geo 구조화 / B4: 주소 단일 표기: \"서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩\". 전화 2종: 02-539-1177, 02-539-1132 / B5: sameAs 5개 (SNS·버티컬·지도 연결) / D1: 병원 엔티티 항목. 공식 사이트와 같은 표면(강남언니 hospitals/189)이라 공식 실측값 준용: 강남언니 hospitals/189 등록. 후기 19,316건, 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10 / D2: 병원 엔티티 항목. 공식 실측값 준용: GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록(\"장소의 전화번호 추가\" 상태), 웹사이트가 국문 홈페이 / D4: 병원 엔티티 항목. 공식 실측값 준용. 서포터즈는 팩트 시트에서 홈페이지 표기를 그대로 쓰므로 표면 간 불일치를 늘리지 않음 / D5: 병원 엔티티 항목. 공식 실측값 준용(네이버 플레이스 리뷰 776건). 서포터즈 병원 정보 페이지에 플레이스 링크 추가 예정 / D6: Wikidata에서 \"뷰성형외과\" 검색 결과 없음"
},
{
"id": "GEO5",
"pct": 100,
"earned": 10,
"basis": "derived",
"signals": "C6=3",
"evidence": "C6: 최신 갱신 신호 2026-09-04 (dateModified 2026-09-04, sitemap lastmod 2026-09-04) + dateModified 표기"
},
{
"id": "GEO6",
"pct": 100,
"earned": 10,
"basis": "judged",
"signals": "",
"evidence": "원천 콘텐츠 5유형 중 4유형 존재: 상담 질문 체크리스트, 회복 일정·마취 선택·검진 주기 조건 표(18편 전편에 표), 협진·동선 설명(이비인후과 협진·방문 안내), 원장 발언 인용(쇼츠 정리 답). 실측 기록(방문 취재)은 미착수"
},
{
"id": "GEO7",
"pct": 67,
"earned": 6.7,
"basis": "judged",
"signals": "",
"evidence": "운영 주체(AI오투오·편집 책임 안성민)·정정 이메일·병원 지원 관계를 모든 글 하단 Disclosure와 /about·푸터에 명시. 후기·이벤트 없음. 다만 고지가 글 첫 부분이 아니라 하단에 있어 심사지침의 \"첫 부분\" 기준에는 못 미침"
},
{
"id": "GEO8",
"pct": 100,
"earned": 5,
"basis": "derived",
"signals": "B1=3",
"evidence": "B1: JSON-LD 2블록 전부 유효 + @graph 연결"
}
]
},
{
"key": "supporters_indexable",
"label": "서포터즈 색인 허용 빌드",
"v1": {
"score": 86,
"grade": "A"
},
"aeo": {
"score": 94,
"raw": 94,
"grade": "A",
"verified": 8,
"capped": []
},
"geo": {
"score": 77,
"raw": 77,
"grade": "B",
"verified": 8,
"capped": []
},
"gates": [
{
"id": "G1",
"passed": true,
"failedBy": []
},
{
"id": "G2",
"passed": true,
"failedBy": []
}
],
"items": [
{
"id": "AEO1",
"pct": 100,
"earned": 20,
"basis": "derived",
"signals": "C2=3",
"evidence": "C2: FAQ 섹션(자주 묻는 질문) + FAQPage 스키마 존재"
},
{
"id": "AEO2",
"pct": 100,
"earned": 20,
"basis": "derived",
"signals": "C1=3",
"evidence": "C1: 홈 히어로 첫 단락과 글 8편 전부 첫 단락에 '뷰성형외과는 2005년 개원한 서울 강남 신논현역 인근 성형외과' 정의문 + 세 줄 요약(직접 답변 블록). 시술 페이지 첫 텍스트가 상담폼이 아니라 답변"
},
{
"id": "AEO3",
"pct": 100,
"earned": 15,
"basis": "derived",
"signals": "C8=3",
"evidence": "C8: 세 줄 요약·FAQ 답변 10개 샘플 중 10개가 '뷰성형외과'와 시술명(프리저베·보형물 등)을 문장 안에 포함. 단독으로 떼어도 주어가 살아 있음"
},
{
"id": "AEO4",
"pct": 67,
"earned": 10,
"basis": "derived",
"signals": "C4=2, C5=2",
"evidence": "C4: 시술 페이지(프리저베)에 절개 3~3.5cm, 수술 시간 약 30분, 200~300cc 등 수치가 본문·표·FAQ에 텍스트로 존재. 가격대·회복 일수 표는 없음(가격 노출 범위는 경영진 결정 대기) / C5: HTML 표 1개"
},
{
"id": "AEO5",
"pct": 100,
"earned": 10,
"basis": "derived",
"signals": "C3=3",
"evidence": "C3: H1 1개 서술형: \"뷰성형외과를 알아보는 사람들의질문에 먼저 답합니다.\". H2 7개 중복 없음"
},
{
"id": "AEO6",
"pct": 100,
"earned": 10,
"basis": "judged",
"signals": "",
"evidence": "헤더 상담 예약 버튼, /visit 페이지에 전화·카카오톡·온라인 예약 3경로가 표로, FactBlock에 tel: 링크. 전부 텍스트 링크"
},
{
"id": "AEO7",
"pct": 100,
"earned": 5,
"basis": "derived",
"signals": "A3=3, F1=3",
"evidence": "A3: 서버 렌더링. JS 미실행 HTML에서 본문 텍스트 3,070자 추출 / F1: 메인 페이지 이미지 18개 중 0개(0%) alt 누락 또는 빈 값"
},
{
"id": "AEO8",
"pct": 87,
"earned": 4.3,
"basis": "derived",
"signals": "A5=3, B6=2",
"evidence": "A5: robots.txt에 sitemap 선언, 200 응답. URL 17개, lastmod 최신 2026-09-04 / B6: rel=canonical self-referencing 존재"
},
{
"id": "GEO1",
"pct": 67,
"earned": 13.3,
"basis": "judged",
"signals": "",
"evidence": "18편 전편의 참고 자료에 유형(병원 제공·원장 설명·제품·규제·플랫폼·2차)과 확인일이 붙고, FDA·ISO 주장은 원문 URL로 연결. 수치 문단 5곳에 개인차·사례 한정이 빠져 게이트 경고 → \"대부분 명시\""
},
{
"id": "GEO2",
"pct": 33,
"earned": 5,
"basis": "derived",
"signals": "A4=1",
"evidence": "A4: 변형 간 최종 URL 불일치(중복 콘텐츠 위험): ['https://view-supporters-sample.vercel.app', 'https://www.view-supporters-sample.vercel.app']"
},
{
"id": "GEO3",
"pct": 100,
"earned": 15,
"basis": "derived",
"signals": "C7=3, B3=3",
"evidence": "C7: 감수 원장 약력이 텍스트로 표기(서울대 의학박사, 고려대의료원 성형외과 전문의 등) + Physician 스키마 sameAs(강남언니 프로필) + 작성자 Person 스키마. 단 감수는 '예정' 상태 / B3: Physician/Attorney 스키마 1개 + 전문분야·소속/sameAs"
},
{
"id": "GEO4",
"pct": 80,
"earned": 12,
"basis": "derived",
"signals": "B2=3, B4=3, B5=3, D1=3, D2=2, D4=1, D5=2, D6=0",
"evidence": "B2: @type MedicalClinic + 주소·전화·openingHours·전문분야/geo 구조화 / B4: 주소 단일 표기: \"서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩\". 전화 2종: 02-539-1177, 02-539-1132 / B5: sameAs 5개 (SNS·버티컬·지도 연결) / D1: 병원 엔티티 항목. 공식 사이트와 같은 표면(강남언니 hospitals/189)이라 공식 실측값 준용: 강남언니 hospitals/189 등록. 후기 19,316건, 이벤트 52건, 의료진 26명(정재현 9.6·후기 3,236 등), 병원정보 10 / D2: 병원 엔티티 항목. 공식 실측값 준용: GBP 존재. 평점 4.9(리뷰 901), 소유자 제공 게시물 2일 전(소유·활성). 단 전화번호 미등록(\"장소의 전화번호 추가\" 상태), 웹사이트가 국문 홈페이 / D4: 병원 엔티티 항목. 공식 실측값 준용. 서포터즈는 팩트 시트에서 홈페이지 표기를 그대로 쓰므로 표면 간 불일치를 늘리지 않음 / D5: 병원 엔티티 항목. 공식 실측값 준용(네이버 플레이스 리뷰 776건). 서포터즈 병원 정보 페이지에 플레이스 링크 추가 예정 / D6: Wikidata에서 \"뷰성형외과\" 검색 결과 없음"
},
{
"id": "GEO5",
"pct": 100,
"earned": 10,
"basis": "derived",
"signals": "C6=3",
"evidence": "C6: 최신 갱신 신호 2026-09-04 (dateModified 2026-09-04, sitemap lastmod 2026-09-04) + dateModified 표기"
},
{
"id": "GEO6",
"pct": 100,
"earned": 10,
"basis": "judged",
"signals": "",
"evidence": "원천 콘텐츠 5유형 중 4유형 존재: 상담 질문 체크리스트, 회복 일정·마취 선택·검진 주기 조건 표(18편 전편에 표), 협진·동선 설명(이비인후과 협진·방문 안내), 원장 발언 인용(쇼츠 정리 답). 실측 기록(방문 취재)은 미착수"
},
{
"id": "GEO7",
"pct": 67,
"earned": 6.7,
"basis": "judged",
"signals": "",
"evidence": "운영 주체(AI오투오·편집 책임 안성민)·정정 이메일·병원 지원 관계를 모든 글 하단 Disclosure와 /about·푸터에 명시. 후기·이벤트 없음. 다만 고지가 글 첫 부분이 아니라 하단에 있어 심사지침의 \"첫 부분\" 기준에는 못 미침"
},
{
"id": "GEO8",
"pct": 100,
"earned": 5,
"basis": "derived",
"signals": "B1=3",
"evidence": "B1: JSON-LD 2블록 전부 유효 + @graph 연결"
}
]
}
]
}

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""공식 홈페이지 vs 서포터즈 사이트 · v2.0 AEO/GEO 비교 엑셀.
사용: python3 scripts/build_supporters_audit_v2_xlsx.py <v2_compare.json> <out.xlsx>
입력 JSON은 scripts/build_supporters_v2_compare.ts 가 만든다 (점수 계산은 TS 한 곳에서만)."""
import json, sys
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
src, out = sys.argv[1], sys.argv[2]
D = json.load(open(src, encoding="utf-8"))
V = D["variants"]; ITEMS = D["items"]; AX = {a["id"]: a for a in D["axes"]}
NAVY = "0A1128"; VIOLET = "4F1DA1"; LIGHT = "F4F6FB"; GOOD = "F3F0FF"; WARN = "FFF6ED"; CRIT = "FFF0F0"
hf = Font(name="Pretendard", bold=True, color="FFFFFF", size=14); bf = Font(name="Pretendard", size=14); bb = Font(name="Pretendard", size=14, bold=True)
tf = Font(name="Pretendard", size=18, bold=True, color=NAVY)
fill = lambda h: PatternFill("solid", fgColor=h); thin = Side(style="thin", color="E2E8F0"); bd = Border(left=thin, right=thin, top=thin, bottom=thin)
wrap = Alignment(wrap_text=True, vertical="top"); center = Alignment(horizontal="center", vertical="center", wrap_text=True)
def header(ws, row, cols):
for i, c in enumerate(cols, 1):
cell = ws.cell(row=row, column=i, value=c); cell.font = hf; cell.fill = fill(VIOLET); cell.alignment = center; cell.border = bd
def put(ws, row, col, val, bold=False, bg=None, align=wrap):
cell = ws.cell(row=row, column=col, value=val); cell.font = bb if bold else bf; cell.alignment = align; cell.border = bd
if bg: cell.fill = fill(bg)
return cell
def pct_bg(p):
if p is None: return LIGHT
return CRIT if p <= 40 else WARN if p <= 60 else GOOD
def widths(ws, ws_widths):
for i, w in enumerate(ws_widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
wb = Workbook()
# ── 1. 요약
ws = wb.active; ws.title = "요약"
ws["A1"] = "뷰성형외과 공식 홈페이지 vs 뷰 서포터즈 사이트 · AEO / GEO v2.0"; ws["A1"].font = tf
ws["A2"] = f"생성 {D['generatedAt']} · 기준표 v{D['rubricVersion']} · {D['note']}"; ws["A2"].font = bf; ws["A2"].alignment = wrap; ws.merge_cells("A2:E2"); ws.row_dimensions[2].height = 60
header(ws, 4, ["구분"] + [v["label"] for v in V] + ["비고"])
rows = [
("AEO 답변 준비도 (100)", [f"{v['aeo']['score']} / {v['aeo']['grade']}" + (f" (상한 {','.join(v['aeo']['capped'])}, 원점수 {v['aeo']['raw']})" if v['aeo']['capped'] else "") for v in V], AX["aeo"]["question"]),
("GEO 출처 준비도 (100)", [f"{v['geo']['score']} / {v['geo']['grade']}" + (f" (상한 {','.join(v['geo']['capped'])}, 원점수 {v['geo']['raw']})" if v['geo']['capped'] else "") for v in V], AX["geo"]["question"]),
("검증 항목 (AEO / GEO, 각 8)", [f"{v['aeo']['verified']} / {v['geo']['verified']}" for v in V], "미검증 항목은 분모 제외"),
("게이트", [" · ".join(f"{g['id']} {'통과' if g['passed'] else '실패(' + ','.join(g['failedBy']) + ')'}" for g in v["gates"]) for v in V], "실패 시 두 축 상한 40. 점수가 아니라 상한"),
("v1.0 종합 (참고)", [f"{v['v1']['score']} / {v['v1']['grade']}" for v in V], "36항목 종합. v2.0과 비교하지 않는다"),
]
for r, (k, vals, note) in enumerate(rows, 5):
put(ws, r, 1, k, bold=True, bg=LIGHT)
for i, val in enumerate(vals, 2): put(ws, r, i, val, bold=(r <= 6), align=center)
put(ws, r, len(vals) + 2, note)
r = 5 + len(rows) + 1
put(ws, r, 1, "게이트 정의", bold=True, bg=LIGHT); ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=5)
for g in D["gates"]:
r += 1; put(ws, r, 1, f"{g['id']} {g['name']}", bold=True); put(ws, r, 2, f"v1.0 {', '.join(g['criterionIds'])} 중 하나라도 level ≤ {g['failLevel']} → 상한 {g['cap']}"); ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3); put(ws, r, 4, g["reason"]); ws.merge_cells(start_row=r, start_column=4, end_row=r, end_column=5)
widths(ws, [30, 34, 34, 34, 50])
# ── 2. 항목별 비교
ws = wb.create_sheet("항목별 비교 (16항목)")
cols = ["ID", "축", "항목", "배점", "채점", "v1.0 파생 신호(비중)"]
for v in V: cols += [f"{v['label']} %", "점수", "근거"]
header(ws, 1, cols)
for r, it in enumerate(ITEMS, 2):
put(ws, r, 1, it["id"], bold=True); put(ws, r, 2, AX[it["axis"]]["code"] + " " + AX[it["axis"]]["name"]); put(ws, r, 3, it["name"] + "\n" + it["question"]); put(ws, r, 4, it["weight"], align=center)
put(ws, r, 5, {"auto": "자동", "semi": "반자동", "manual": "사람"}[it["method"]], align=center); put(ws, r, 6, it["derivedFrom"] or "없음 (사람 판정)")
c = 7
for v in V:
row = next(x for x in v["items"] if x["id"] == it["id"])
put(ws, r, c, "미검증" if row["pct"] is None else row["pct"], bg=pct_bg(row["pct"]), align=center, bold=True)
put(ws, r, c + 1, f"{row['earned']} / {it['weight']}" if row["pct"] is not None else "—", align=center)
put(ws, r, c + 2, ("[사람 판정] " if row["basis"] == "judged" else "") + (row["evidence"] or row["signals"]))
c += 3
ws.row_dimensions[r].height = 120
widths(ws, [8, 16, 34, 7, 8, 34] + [10, 10, 60] * len(V)); ws.freeze_panes = "D2"
# ── 3. 축별 해석
ws = wb.create_sheet("해석")
header(ws, 1, ["축", "공식 홈페이지가 낮은 이유", "서포터즈가 높은 이유", "서포터즈 남은 과제"])
def low(v, axis, n=3):
xs = [x for x in v["items"] if x["id"].startswith(axis.upper()) and x["pct"] is not None]; xs.sort(key=lambda x: x["pct"]); return xs[:n]
def hi(v, axis, n=3):
xs = [x for x in v["items"] if x["id"].startswith(axis.upper()) and x["pct"] is not None]; xs.sort(key=lambda x: -x["pct"]); return xs[:n]
off, sup = V[0], V[-1]; name = {i["id"]: i["name"] for i in ITEMS}
for r, ax in enumerate(["aeo", "geo"], 2):
put(ws, r, 1, f"{AX[ax]['code']} {AX[ax]['name']}\n공식 {off[ax]['score']} → 서포터즈 {sup[ax]['score']}", bold=True, bg=LIGHT)
put(ws, r, 2, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in low(off, ax)))
put(ws, r, 3, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in hi(sup, ax)))
put(ws, r, 4, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in low(sup, ax)))
ws.row_dimensions[r].height = 110
widths(ws, [26, 44, 44, 44])
wb.save(out); print("saved", out)

View File

@ -0,0 +1,67 @@
/**
* 공식 홈페이지 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);

View File

@ -6,6 +6,18 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { OverallScore } from '../../types/discovery'; import type { OverallScore } from '../../types/discovery';
/** 랜딩이 쓰는 축 요약. v2.0 AEO·GEO 계산값에서만 만든다 (하드코딩 금지) */
export interface AxisSummary {
code: string;
name: string;
nameEn: string;
score: number;
grade: OverallScore['grade'];
/** 다음 등급 목표. A등급이면 null */
target: { score: number; grade: OverallScore['grade'] } | null;
items: { id: string; name: string; pct: number | null }[];
}
const NODES = [ const NODES = [
{ letter: 'A', label: 'Audit', pos: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2' }, { letter: 'A', label: 'Audit', pos: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2' },
{ letter: 'B', label: 'Build', pos: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2' }, { letter: 'B', label: 'Build', pos: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2' },
@ -14,16 +26,12 @@ const NODES = [
]; ];
interface AbmrLoopProps { interface AbmrLoopProps {
overall: OverallScore; axes: AxisSummary[];
/** 다음 등급 목표 (A등급이면 null) */
target: { score: number; grade: OverallScore['grade'] } | null;
} }
export default function AbmrLoop({ overall, target }: AbmrLoopProps) { export default function AbmrLoop({ axes }: AbmrLoopProps) {
const chips = [ const chips = [
...(target ...axes.map((ax) => (ax.target ? `${ax.code} ${ax.score}/${ax.grade} → ${ax.target.score}+/${ax.target.grade}` : `${ax.code} ${ax.score}/${ax.grade} 유지 검증`)),
? [`점수 ${overall.score} → ${target.score}+`, `등급 ${overall.grade} → ${target.grade}`]
: [`점수 ${overall.score} 유지 검증`, `등급 ${overall.grade} 유지 검증`]),
'언급률 상승 추적', '언급률 상승 추적',
]; ];

View File

@ -6,13 +6,12 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import type { OverallScore } from '../../types/discovery';
import AbmrLoop from './AbmrLoop'; import AbmrLoop from './AbmrLoop';
import type { AxisSummary } from './AbmrLoop';
import AiAnswerMock from './AiAnswerMock'; import AiAnswerMock from './AiAnswerMock';
interface DiscoveryHeroProps { interface DiscoveryHeroProps {
overall: OverallScore; axes: AxisSummary[];
target: { score: number; grade: OverallScore['grade'] } | null;
clinicName: string; clinicName: string;
sampleReportPath: string; sampleReportPath: string;
criteriaCount: number; criteriaCount: number;
@ -21,8 +20,7 @@ interface DiscoveryHeroProps {
} }
export default function DiscoveryHero({ export default function DiscoveryHero({
overall, axes,
target,
clinicName, clinicName,
sampleReportPath, sampleReportPath,
criteriaCount, criteriaCount,
@ -72,8 +70,8 @@ export default function DiscoveryHero({
className="text-lg md:text-xl text-slate-600 leading-relaxed max-w-3xl mb-10 break-keep" className="text-lg md:text-xl text-slate-600 leading-relaxed max-w-3xl mb-10 break-keep"
> >
고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다. 고객은 이제 ChatGPT와 Perplexity에게 병원을 묻습니다.
<br className="hidden md:block" /> 답변에 인용될 근거를 {criteriaCount}개 항목으로 실측 <br className="hidden md:block" /> 답변에 인용될 근거를 {criteriaCount}개 항목으로 실측해
진단하고, 8주 안에 구축합니다. 답변 준비도와 출처 준비도로 진단하고, 8주 안에 구축합니다.
</motion.p> </motion.p>
{/* CTAs */} {/* CTAs */}
@ -101,7 +99,7 @@ export default function DiscoveryHero({
{/* ABMR loop + AI answer mock */} {/* ABMR loop + AI answer mock */}
<div className="w-full max-w-7xl grid md:grid-cols-2 gap-6"> <div className="w-full max-w-7xl grid md:grid-cols-2 gap-6">
<AbmrLoop overall={overall} target={target} /> <AbmrLoop axes={axes} />
<AiAnswerMock clinicName={clinicName} criteriaCount={criteriaCount} /> <AiAnswerMock clinicName={clinicName} criteriaCount={criteriaCount} />
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@ export default function HowItWorks({ criteriaCount, categoryCount }: HowItWorksP
{ {
step: 'STEP 01', step: 'STEP 01',
title: 'Audit', title: 'Audit',
detail: `AEO/GEO 기준표 v1.0으로 ${criteriaCount}개 항목을 실측합니다. 크롤러 접근성부터 인용 가능 콘텐츠, 측정 인프라까지 ${categoryCount}개 카테고리 100점 척도. 항목마다 근거 등급을 명시합니다.`, detail: `기준표 v2.0으로 ${criteriaCount}개 항목을 실측해 두 점수로 집계합니다. AEO 답변 준비도(질문에 얼마나 명확하게 답하는가)와 GEO 출처 준비도(그 답을 얼마나 믿을 수 있는 출처로 쓸 수 있는가), 각 100점. ${categoryCount}개 실측 카테고리, 항목마다 근거 등급을 명시합니다.`,
icon: ( icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="#6C5CE7" strokeWidth="1.6" /> <circle cx="11" cy="11" r="6.5" stroke="#6C5CE7" strokeWidth="1.6" />

View File

@ -2,16 +2,19 @@
* 8주 로드맵 타임라인. 타이틀의 등급 전환(C → B)은 계산값에서 파생. * 8주 로드맵 타임라인. 타이틀의 등급 전환(C → B)은 계산값에서 파생.
*/ */
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { OverallScore } from '../../types/discovery'; import type { AxisSummary } from './AbmrLoop';
interface RoadmapProps { interface RoadmapProps {
grade: OverallScore['grade']; axes: AxisSummary[];
targetGrade: OverallScore['grade'] | null;
criteriaCount: number; criteriaCount: number;
unverifiedCount: number; unverifiedCount: number;
} }
export default function Roadmap({ grade, targetGrade, criteriaCount, unverifiedCount }: RoadmapProps) { export default function Roadmap({ axes, criteriaCount, unverifiedCount }: RoadmapProps) {
const rising = axes.filter((ax) => ax.target);
const title = rising.length
? `8주 안에, ${rising.map((ax) => `${ax.name} ${ax.grade}에서 ${ax.target!.grade}로`).join(', ')} 올립니다.`
: `8주마다, ${axes.map((ax) => `${ax.name} ${ax.grade}`).join(' · ')} 등급을 재검증합니다.`;
const phases = [ const phases = [
{ {
week: 'W1–2', week: 'W1–2',
@ -26,7 +29,7 @@ export default function Roadmap({ grade, targetGrade, criteriaCount, unverifiedC
{ {
week: 'W7–8', week: 'W7–8',
title: '재진단·보고', title: '재진단·보고',
detail: `${criteriaCount}개 항목을 재측정하고 미검증 ${unverifiedCount}개를 반영해 총점을 다시 산정합니다.`, detail: `${criteriaCount}개 항목을 재측정하고 미검증 ${unverifiedCount}개를 반영해 답변 준비도와 출처 준비도를 다시 산정합니다.`,
}, },
]; ];
@ -42,9 +45,7 @@ export default function Roadmap({ grade, targetGrade, criteriaCount, unverifiedC
> >
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">8-WEEK ROADMAP</div> <div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">8-WEEK ROADMAP</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 break-keep"> <h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 break-keep">
{targetGrade {title}
? `8주 안에, ${grade}에서 ${targetGrade}로 올립니다.`
: `8주마다, ${grade} 등급을 재검증합니다.`}
</h2> </h2>
</motion.div> </motion.div>

View File

@ -1,12 +1,13 @@
/** /**
* 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷. * 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷.
* 점수·등급·카테고리 %·항목 수는 전부 discoveryScore 계산값에서 렌더 (하드코딩 금지). * 점수·등급·항목 %·항목 수는 전부 discoveryScore(v1 실측)·discoveryScoreV2(AEO/GEO) 계산값에서 렌더 (하드코딩 금지).
* 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다. * 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다.
*/ */
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { DiscoveryResult, OverallScore } from '../../types/discovery'; import type { DiscoveryResult, OverallScore } from '../../types/discovery';
import { scorePctColor } from '../../lib/discoveryScore'; import { scorePctColor } from '../../lib/discoveryScore';
import { ScoreRing } from '../report/ui/ScoreRing'; import { ScoreRing } from '../report/ui/ScoreRing';
import type { AxisSummary } from './AbmrLoop';
const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = { const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = {
A: 'bg-[#EFF0FF] border-[#C5CBF5] text-[#3A3F7C]', A: 'bg-[#EFF0FF] border-[#C5CBF5] text-[#3A3F7C]',
@ -15,7 +16,7 @@ const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = {
D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]', D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
}; };
/** keyFindings 압축본 (시안 확정 카피). 수치는 2026-08-28 실측 스냅샷과 일치한다. */ /** keyFindings 압축본 (시안 확정 카피). 수치는 2026-09-07 재검증(v1.1) 스냅샷과 일치한다. */
const FINDING_CHIPS = [ const FINDING_CHIPS = [
{ {
label: '치명', label: '치명',
@ -29,23 +30,24 @@ const FINDING_CHIPS = [
chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]', chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
dotColor: '#D4A872', dotColor: '#D4A872',
title: '인용할 문장이 없음', title: '인용할 문장이 없음',
detail: '정의문 0개, FAQ 0개, HTML 표 0개. 답변엔진이 잘라 갈 단락이 없습니다.', detail: '정의문 0개, HTML 표 0개, FAQ는 프리저베 한 페이지뿐. 답변엔진이 잘라 갈 단락이 없습니다.',
}, },
{ {
label: '강점', label: '강점',
chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]', chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
dotColor: '#9B8AD4', dotColor: '#9B8AD4',
title: '표면 자산은 이미 강함', title: '표면 자산은 이미 강함',
detail: '강남언니 후기 19,316건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.', detail: '강남언니 후기 19,373건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.',
}, },
]; ];
interface SampleReportProps { interface SampleReportProps {
overall: OverallScore; overall: OverallScore;
axes: AxisSummary[];
result: DiscoveryResult; result: DiscoveryResult;
} }
export default function SampleReport({ overall, result }: SampleReportProps) { export default function SampleReport({ overall, axes, result }: SampleReportProps) {
const totalCriteria = overall.categories.reduce((sum, c) => sum + c.totalCount, 0); const totalCriteria = overall.categories.reduce((sum, c) => sum + c.totalCount, 0);
const verifiedCriteria = overall.categories.reduce((sum, c) => sum + c.verifiedCount, 0); const verifiedCriteria = overall.categories.reduce((sum, c) => sum + c.verifiedCount, 0);
const unverifiedCriteria = totalCriteria - verifiedCriteria; const unverifiedCriteria = totalCriteria - verifiedCriteria;
@ -66,8 +68,8 @@ export default function SampleReport({ overall, result }: SampleReportProps) {
읽을 수는 있지만, 인용할 근거가 없습니다. 읽을 수는 있지만, 인용할 근거가 없습니다.
</h2> </h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep"> <p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep">
실제 1차 진단 결과입니다. 어디가 강하고 어디가 비어 있는지, 점수는 카테고리 단위로 실제 1차 진단 결과입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고,
드러납니다. 어디가 강하고 어디가 비어 있는지 항목 단위로 드러냅니다.
</p> </p>
</motion.div> </motion.div>
@ -80,17 +82,21 @@ export default function SampleReport({ overall, result }: SampleReportProps) {
transition={{ duration: 0.6, delay: 0.1 }} transition={{ duration: 0.6, delay: 0.1 }}
className="flex flex-col items-center justify-center gap-4 bg-white border border-slate-200 rounded-2xl px-8 py-10 shadow-[0_8px_32px_rgba(31,38,135,0.07)]" className="flex flex-col items-center justify-center gap-4 bg-white border border-slate-200 rounded-2xl px-8 py-10 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
> >
<ScoreRing score={overall.score} size={160} valueClassName="text-[40px]" subLabel="/ 100" /> <div className="flex items-start gap-6">
<div className="flex items-center gap-2.5"> {axes.map((ax) => (
<div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[overall.grade]}`}> <div key={ax.code} className="flex flex-col items-center gap-2.5">
<span className="text-sm font-bold">등급 {overall.grade}</span> <ScoreRing score={ax.score} size={120} valueClassName="text-[32px]" subLabel="/ 100" />
</div> <div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[ax.grade]}`}>
<span className="text-sm text-slate-500">AI Answer Readiness</span> <span className="text-sm font-bold">{ax.code} 등급 {ax.grade}</span>
</div>
<span className="text-sm text-slate-500">{ax.name}</span>
</div>
))}
</div> </div>
<p className="text-[13.5px] leading-relaxed text-slate-400 text-center break-keep"> <p className="text-[13.5px] leading-relaxed text-slate-400 text-center break-keep">
{totalCriteria}개 항목 중 {verifiedCriteria}개 실측. {totalCriteria}개 실측 항목 중 {verifiedCriteria}개 확인.
<br /> <br />
미검증 {unverifiedCriteria}개는 분모에서 제외했습니다. 미검증 {unverifiedCriteria}개는 분모에서 제외했습니다. v1.0 종합 {overall.score}점.
</p> </p>
</motion.div> </motion.div>
@ -102,31 +108,38 @@ export default function SampleReport({ overall, result }: SampleReportProps) {
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.2 }}
className="flex flex-col justify-center gap-[18px] bg-white border border-slate-200 rounded-2xl px-6 md:px-10 py-9 shadow-[0_8px_32px_rgba(31,38,135,0.07)]" className="flex flex-col justify-center gap-[18px] bg-white border border-slate-200 rounded-2xl px-6 md:px-10 py-9 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
> >
{overall.categories.map((cat) => { <div className="grid md:grid-cols-2 gap-x-10 gap-y-[18px]">
const color = scorePctColor(cat.pct); {axes.map((ax) => (
return ( <div key={ax.code} className="flex flex-col gap-[14px]">
<div key={cat.category.id} className="flex flex-col gap-[7px]"> <div className="flex justify-between items-baseline gap-3 border-b border-slate-100 pb-2">
<div className="flex justify-between items-baseline gap-3"> <span className="text-[15px] font-bold text-primary-900 break-keep">{ax.code} · {ax.name}</span>
<span className="text-[15px] font-semibold text-primary-900 break-keep"> <span className="text-[15px] font-bold" style={{ color: scorePctColor(ax.score) }}>{ax.score}</span>
{cat.category.code} · {cat.category.name}
</span>
<span className="text-[15px] font-bold" style={{ color }}>
{cat.pct}%
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className="h-2 rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
whileInView={{ width: `${Math.max(cat.pct, 2)}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div> </div>
{ax.items.map((it) => {
const pct = it.pct ?? 0;
const color = it.pct === null ? '#CBD5E1' : scorePctColor(pct);
return (
<div key={it.id} className="flex flex-col gap-[6px]">
<div className="flex justify-between items-baseline gap-3">
<span className="text-[14px] font-medium text-primary-900 break-keep">{it.name}</span>
<span className="text-[14px] font-bold" style={{ color }}>{it.pct === null ? '미검증' : `${it.pct}%`}</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className="h-2 rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
whileInView={{ width: `${Math.max(pct, 2)}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div>
</div>
);
})}
</div> </div>
); ))}
})} </div>
</motion.div> </motion.div>
</div> </div>

View File

@ -1,5 +1,5 @@
/** /**
* INFINITH AI Discovery 채점 기준표 v2.0 (초안) · AEO / GEO 분리 지표 * INFINITH AI Discovery 채점 기준표 v2.0 · AEO / GEO 분리 지표 (2026-09-07 정식 채택. v1.0 36항목은 실측 신호로 유지)
* *
* - AEO 답변 준비도(100): 고객의 질문에 얼마나 명확하고 충분하게 답하는가 * - AEO 답변 준비도(100): 고객의 질문에 얼마나 명확하고 충분하게 답하는가
* - GEO 출처 준비도(100): 그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가 * - GEO 출처 준비도(100): 그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가
@ -22,9 +22,9 @@ const L = (l0: string, l1: string, l2: string, l3: string) => [
]; ];
export const AEO_GEO_RUBRIC_V2: RubricV2 = { export const AEO_GEO_RUBRIC_V2: RubricV2 = {
version: '2.0-draft', version: '2.0',
updatedAt: '2026-09-07', updatedAt: '2026-09-07',
status: 'draft', status: 'active',
axes: [ axes: [
{ {
id: 'aeo', id: 'aeo',

View File

@ -12,7 +12,7 @@ export const DISCOVERY_TAEHA: DiscoveryResult = {
url: 'https://taehadrug.com', url: 'https://taehadrug.com',
industry: '로펌 · 마약 형사 (강남 테헤란로, 6개 분사무소)', industry: '로펌 · 마약 형사 (강남 테헤란로, 6개 분사무소)',
auditedAt: '2026-08-28', auditedAt: '2026-08-28',
rubricVersion: '1.0', rubricVersion: '2.0',
conditions: [ conditions: [
'선정: 네이버 "마약 변호사" 검색 파워링크 1위 광고주. 광고 랜딩은 마약 전용 도메인 taehadrug.com', '선정: 네이버 "마약 변호사" 검색 파워링크 1위 광고주. 광고 랜딩은 마약 전용 도메인 taehadrug.com',
'실측 대상: taehadrug.com 메인 + /success/success_story/, www.taehalaw.com 메인 + /member/page. JS 미실행 HTML 기준', '실측 대상: taehadrug.com 메인 + /success/success_story/, www.taehalaw.com 메인 + /member/page. JS 미실행 HTML 기준',

View File

@ -15,7 +15,7 @@ export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
url: 'https://www.viewclinic.com', url: 'https://www.viewclinic.com',
industry: '성형외과 (강남 신논현)', industry: '성형외과 (강남 신논현)',
auditedAt: '2026-09-07', auditedAt: '2026-09-07',
rubricVersion: '1.0', rubricVersion: '2.0',
conditions: [ conditions: [
'실측 대상: 메인 페이지 + /breast/main/ (가슴성형) 2개 URL, JS 미실행 HTML 기준', '실측 대상: 메인 페이지 + /breast/main/ (가슴성형) 2개 URL, JS 미실행 HTML 기준',
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청', '크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',
@ -158,7 +158,7 @@ export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' }, { item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' }, { item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' },
], ],
// v2.0(초안) 사람 판정. v1.0에 대응 항목이 없는 4개만. 근거는 2026-09-07 수집기(evidence/viewclinic) 원문. 병원 확인 전 초안이다. // v2.0 사람 판정. v1.0에 대응 항목이 없는 4개만. 근거는 2026-09-07 수집기(evidence/viewclinic) 원문. 병원 확인 전 초안이다.
v2Results: [ v2Results: [
{ criterionId: 'AEO6', level: 3, evidence: '모든 페이지 하단에 전화 02-539-1177·주소·온라인 상담 폼이 텍스트로 반복되고(수집기 반복 줄), /counsel/reservation/ 예약 페이지와 카카오톡 채널 링크가 있다. 전화·예약·방문 3경로가 텍스트로 연결됨', source: 'evidence/viewclinic/index.json (reservation 5, direction 2)' }, { criterionId: 'AEO6', level: 3, evidence: '모든 페이지 하단에 전화 02-539-1177·주소·온라인 상담 폼이 텍스트로 반복되고(수집기 반복 줄), /counsel/reservation/ 예약 페이지와 카카오톡 채널 링크가 있다. 전화·예약·방문 3경로가 텍스트로 연결됨', source: 'evidence/viewclinic/index.json (reservation 5, direction 2)' },
{ criterionId: 'GEO1', level: 1, evidence: '"모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)"처럼 집계 주체·시점이 붙은 주장이 일부 있으나, 모티바 그랜드 어워드·KOL·렛미인 등 대부분의 권위 신호는 이미지·슬로건 안에 있고 원문 링크가 없다. "마취과 전문의 상주 43% vs 23.1%" 비교는 출처·기준 시점 없음', source: 'https://www.viewclinic.com/breast/breast-augmentation/motiva/ · /system/safe-surgery/ (2026-09-07)' }, { criterionId: 'GEO1', level: 1, evidence: '"모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)"처럼 집계 주체·시점이 붙은 주장이 일부 있으나, 모티바 그랜드 어워드·KOL·렛미인 등 대부분의 권위 신호는 이미지·슬로건 안에 있고 원문 링크가 없다. "마취과 전문의 상주 43% vs 23.1%" 비교는 출처·기준 시점 없음', source: 'https://www.viewclinic.com/breast/breast-augmentation/motiva/ · /system/safe-surgery/ (2026-09-07)' },

View File

@ -2,14 +2,17 @@
* /discovery — AI Discovery(AEO/GEO) 서비스 랜딩. * /discovery — AI Discovery(AEO/GEO) 서비스 랜딩.
* 시안: docs/reports/landing/ai_discovery_landing.html (2026-08-31 확정) * 시안: docs/reports/landing/ai_discovery_landing.html (2026-08-31 확정)
* *
* 데이터 원칙: 점수·등급·카테고리 %·항목 수는 전부 * 데이터 원칙: 점수·등급·항목 %·항목 수는 전부
* scoreDiscovery(AEO_GEO_RUBRIC, DISCOVERY_VIEWCLINIC) 계산값에서 파생한다. * scoreDiscovery(v1.0 실측) / scoreDiscoveryV2(v2.0 AEO·GEO) 계산값에서 파생한다.
* 재진단으로 데이터가 바뀌면 랜딩 수치도 함께 바뀐다 (JSX 하드코딩 금지). * 재진단으로 데이터가 바뀌면 랜딩 수치도 함께 바뀐다 (JSX 하드코딩 금지).
*/ */
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric'; import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { DISCOVERY_VIEWCLINIC } from '../data/discovery_viewclinic'; import { DISCOVERY_VIEWCLINIC } from '../data/discovery_viewclinic';
import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore'; import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import type { AxisSummary } from '../components/discovery/AbmrLoop';
import DiscoveryHero from '../components/discovery/DiscoveryHero'; import DiscoveryHero from '../components/discovery/DiscoveryHero';
import WhyNow from '../components/discovery/WhyNow'; import WhyNow from '../components/discovery/WhyNow';
import HowItWorks from '../components/discovery/HowItWorks'; import HowItWorks from '../components/discovery/HowItWorks';
@ -23,7 +26,16 @@ export default function DiscoveryLandingPage() {
const [leadOpen, setLeadOpen] = useState(false); const [leadOpen, setLeadOpen] = useState(false);
const result = DISCOVERY_VIEWCLINIC; const result = DISCOVERY_VIEWCLINIC;
const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]); const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]);
const target = nextGradeTarget(overall.score); const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]);
const axes: AxisSummary[] = [overallV2.aeo, overallV2.geo].map((ax) => ({
code: ax.axis.code,
name: ax.axis.name,
nameEn: ax.axis.nameEn,
score: ax.score,
grade: ax.grade,
target: nextGradeTarget(ax.score),
items: ax.items.map((it) => ({ id: it.item.id, name: it.item.name, pct: it.pct === null ? null : Math.round(it.pct * 100) })),
}));
const criteriaCount = AEO_GEO_RUBRIC.criteria.length; const criteriaCount = AEO_GEO_RUBRIC.criteria.length;
const categoryCount = AEO_GEO_RUBRIC.categories.length; const categoryCount = AEO_GEO_RUBRIC.categories.length;
@ -33,8 +45,7 @@ export default function DiscoveryLandingPage() {
return ( return (
<div className="[word-break:keep-all]"> <div className="[word-break:keep-all]">
<DiscoveryHero <DiscoveryHero
overall={overall} axes={axes}
target={target}
clinicName={result.clinicName} clinicName={result.clinicName}
sampleReportPath={`/discovery/${result.id}`} sampleReportPath={`/discovery/${result.id}`}
criteriaCount={criteriaCount} criteriaCount={criteriaCount}
@ -42,10 +53,9 @@ export default function DiscoveryLandingPage() {
/> />
<WhyNow /> <WhyNow />
<HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} /> <HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} />
<SampleReport overall={overall} result={result} /> <SampleReport overall={overall} axes={axes} result={result} />
<Roadmap <Roadmap
grade={overall.grade} axes={axes}
targetGrade={target?.grade ?? null}
criteriaCount={criteriaCount} criteriaCount={criteriaCount}
unverifiedCount={unverifiedCount} unverifiedCount={unverifiedCount}
/> />

View File

@ -126,19 +126,26 @@ export default function DiscoveryReportPage() {
{result.url} {result.url}
</a> </a>
<p className="text-white/60 text-sm mt-4"> <p className="text-white/60 text-sm mt-4">
실측일 {result.auditedAt} · 검증 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} / 실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
{' '}{AEO_GEO_RUBRIC.criteria.length} · 미검증 항목은 분모 제외 {' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외
</p> </p>
</div> </div>
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-3 min-w-[260px]"> <div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
<ScoreRing score={overall.score} size={160} label="AI Answer Readiness" /> <div className="flex items-start gap-8">
<div className="flex items-baseline gap-2"> {overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
<span className="font-serif text-4xl font-black text-[#0A1128]">{overall.grade}</span> <div key={ax.axis.id} className="flex flex-col items-center gap-2">
<span className="text-sm text-slate-500"> <ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
{overall.earned} / {overall.possible}점 <div className="flex items-baseline gap-2">
</span> <span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
<span className="text-sm text-slate-500">{ax.axis.code} {ax.axis.name}</span>
</div>
</div>
))}
</div> </div>
<p className="text-sm text-slate-500">
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
</p>
</div> </div>
</div> </div>
@ -183,7 +190,7 @@ export default function DiscoveryReportPage() {
<SectionWrapper <SectionWrapper
id="breakdown" id="breakdown"
title="Scorecard by Category" title="Scorecard by Category"
subtitle="36개 항목 · 각 항목의 실측 근거와 근거 등급" subtitle="v1.0 실측 항목 36개 · AEO/GEO 점수의 파생 신호. 각 항목의 실측 근거와 근거 등급"
dark dark
> >
<div className="space-y-6"> <div className="space-y-6">
@ -351,7 +358,7 @@ export default function DiscoveryReportPage() {
<SectionWrapper <SectionWrapper
id="rubric" id="rubric"
title="Scoring Rubric" title="Scoring Rubric"
subtitle={`AEO/GEO 채점 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목 · 100점`} subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
> >
<div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white"> <div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
<table className="w-full text-base min-w-[860px]"> <table className="w-full text-base min-w-[860px]">