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

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

413 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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))