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