- 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>
413 lines
34 KiB
Python
413 lines
34 KiB
Python
"""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))
|