#!/usr/bin/env python3 """Build an editable OurDigital-branded sales-briefing deck (PPTX) for pre-sales SEO. Part of the ourdigital-presales-seo skill (Stage 6). Reads findings.json (+ optional estimate.json) and writes a 9-slide .pptx. Content is populated from data; text stays editable in PowerPoint/Keynote. Usage: python build_deck.py --findings findings.json --estimate data/estimate.json \ --out sales-deck.pptx """ import argparse import json from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import PP_ALIGN from pptx.util import Inches, Pt NAVY = RGBColor(0x11, 0x24, 0x3D) ACCENT = RGBColor(0x1B, 0x6F, 0xB3) LIGHT = RGBColor(0xF3, 0xF7, 0xFB) GREY = RGBColor(0x6B, 0x77, 0x87) WHITE = RGBColor(0xFF, 0xFF, 0xFF) RED = RGBColor(0xC0, 0x39, 0x2B) FONT = "Apple SD Gothic Neo" # macOS Korean; edit in deck if on Windows (Malgun Gothic) EMU_W, EMU_H = Inches(13.333), Inches(7.5) def _style(run, size, color=NAVY, bold=False): run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color run.font.name = FONT def textbox(slide, l, t, w, h, lines, align=PP_ALIGN.LEFT): """lines: list of (text, size, color, bold) or list of such lists (paragraphs).""" tb = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h)) tf = tb.text_frame tf.word_wrap = True if lines and not isinstance(lines[0], list): lines = [lines] for idx, para in enumerate(lines): p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph() p.alignment = align p.space_after = Pt(4) # para is a list of runs: each (text, size, color, bold) if para and isinstance(para[0], str): para = [tuple(para)] for (text, size, color, bold) in para: r = p.add_run() r.text = text _style(r, size, color, bold) return tb def bar(slide, l, t, w, h, color=ACCENT): shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(l), Inches(t), Inches(w), Inches(h)) shp.fill.solid() shp.fill.fore_color.rgb = color shp.line.fill.background() shp.shadow.inherit = False return shp def blank(prs): return prs.slides.add_slide(prs.slide_layouts[6]) def fill_bg(slide, color): slide.background.fill.solid() slide.background.fill.fore_color.rgb = color def header(slide, kicker, title): bar(slide, 0.6, 0.55, 0.12, 0.9) textbox(slide, 0.85, 0.5, 11.8, 1.1, [ [(kicker, 11, ACCENT, True)], [(title, 24, NAVY, True)], ]) def card(slide, l, t, w, h, fill=LIGHT): shp = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(l), Inches(t), Inches(w), Inches(h)) shp.fill.solid() shp.fill.fore_color.rgb = fill shp.line.color.rgb = RGBColor(0xDC, 0xE7, 0xF1) shp.line.width = Pt(0.75) shp.shadow.inherit = False return shp def finding_slide(prs, kicker, title, headline, bullets, metric=None): s = blank(prs) header(s, kicker, title) if metric: card(s, 0.85, 1.95, 3.5, 4.6, NAVY) textbox(s, 1.0, 2.5, 3.2, 3.5, [ [(metric[0], 40, WHITE, True)], [(metric[1], 13, RGBColor(0xCF, 0xDD, 0xEE), False)], ]) bx = 4.7 else: bx = 0.85 rows = [[(headline, 15, NAVY, True)]] for b in bullets: rows.append([("• ", 12, ACCENT, True), (b, 12, RGBColor(0x33, 0x3A, 0x45), False)]) textbox(s, bx, 2.1, 13.0 - bx - 0.6, 4.4, rows) return s def main(): ap = argparse.ArgumentParser(description="Build pre-sales SEO sales deck (PPTX)") ap.add_argument("--findings", required=True) ap.add_argument("--estimate", default=None) ap.add_argument("--out", default="sales-deck.pptx") args = ap.parse_args() with open(args.findings, encoding="utf-8") as fh: F = json.load(fh) EST = None if args.estimate: try: with open(args.estimate, encoding="utf-8") as fh: EST = json.load(fh) except FileNotFoundError: EST = None p = F.get("prospect", {}) name = p.get("name", "(프로스펙트)") date = p.get("audit_date", "") d = F.get("discovery", {}) t = F.get("technical", {}) e = F.get("entity", {}) prs = Presentation() prs.slide_width, prs.slide_height = EMU_W, EMU_H # 1) Title s = blank(prs) fill_bg(s, NAVY) bar(s, 0.9, 2.7, 1.6, 0.14, ACCENT) textbox(s, 0.9, 2.9, 11.5, 3.0, [ [("SEO PRE-SALES BRIEF", 13, RGBColor(0x7F, 0xA8, 0xCF), True)], [(f"{name}", 40, WHITE, True)], [("검색 가시성 사전 진단", 26, RGBColor(0xCF, 0xDD, 0xEE), True)], [(f"OurDigital · {date} · 공개 데이터 기준 사전 스냅샷", 12, RGBColor(0x9F, 0xB4, 0xCC), False)], ]) # 2) Overview s = blank(prs) header(s, "OVERVIEW", "한눈에 보기") card(s, 0.85, 1.95, 11.6, 1.7) textbox(s, 1.1, 2.15, 11.1, 1.4, [ [(f"{name}는 국내 최대급 호텔·리조트 자산을 보유하지만, ", 14, NAVY, False), ("검색에서의 디지털 가시성은 그 규모에 미치지 못합니다.", 14, ACCENT, True)], ]) textbox(s, 0.95, 3.95, 11.6, 2.6, [ [("핵심 병목 두 가지", 14, NAVY, True)], [("① 기술 — ", 13, RED, True), ('"보이지 않는 사이트": 색인·CWV 문제로 발견 가능 페이지가 극소수', 13, RGBColor(0x33, 0x3A, 0x45), False)], [("② 엔티티 — ", 13, RED, True), ('"잘못 인식된 브랜드": 회사 타입·표기 분열·레거시 잔존, 서브브랜드 부재', 13, RGBColor(0x33, 0x3A, 0x45), False)], ]) # 3) Finding 1 — crawl/index finding_slide( prs, "FINDING 01", "크롤링 / 색인 — 사이트가 검색엔진에 보이지 않습니다", '사이트맵 오류와 크롤성 한계로 대부분의 페이지가 발견·색인되지 못합니다.', [ f"sitemap 상태: HTTP {d.get('sitemap_status', 'N/A')}" + (" (정상)" if d.get('sitemap_status') == 200 else " — 오류/미동작"), "robots.txt 사이트맵 선언: " + ("있음" if d.get("robots_sitemap_declared") else "없음"), f"추정 전체 페이지: {d.get('estimated_pages', 'N/A')}", "위생 이슈: " + (", ".join(d.get("url_hygiene", [])) or "없음"), ], metric=(str(d.get("discoverable_urls", "—")), "외부 발견 가능 URL (개)"), ) # 4) Finding 2 — CWV cwv = t.get("cwv", {}) finding_slide( prs, "FINDING 02", "Core Web Vitals — 속도·안정성 취약", "구글 순위 요소이자 예약 전환·모바일 경험에 직접 영향을 줍니다.", [ f"LCP {cwv.get('lcp_ms', 0)/1000:.1f}초 (기준 <2.5초)", f"TTFB {cwv.get('ttfb_ms', 0)/1000:.1f}초 (기준 <0.6초)", f"Performance score {cwv.get('perf', 0):.2f} (기준 ≥0.9)", ], metric=(f"{cwv.get('cls', 0):.3f}", "CLS (화면 밀림) · 기준 <0.1"), ) # 5) Finding 3 — entity recognition panel_ko = {"company": '"회사"로만 인식 (호텔 아님)', "hotel": "호텔로 인식", "none": "지식패널 없음"} finding_slide( prs, "FINDING 03", "엔티티 인식 — 구글이 브랜드를 호텔로 보지 않습니다", "호텔 전용 검색 노출에 불리하고 리브랜딩 효과가 검색에 반영되지 못합니다.", [ "지식패널 분류: " + panel_ko.get(e.get("panel"), "확인 필요"), "브랜드 표기 분열(앤 vs &): " + ("있음" if e.get("name_split") else "없음"), "레거시(구 브랜드명) 잔존: " + ("있음" if e.get("legacy_contamination") else "없음"), "Korean Wikipedia 등재: " + ("있음" if e.get("wikipedia") else "없음"), ], ) # 6) Finding 4 — subbrand/competitor s = finding_slide( prs, "FINDING 04", "서브브랜드·프로퍼티 엔티티 + 경쟁 벤치마크", "다(多)브랜드 체계가 검색 자산으로 축적되지 못하고 있습니다.", [ f"서브브랜드 엔티티: {e.get('subbrands_with_entity', 0)} / {e.get('subbrands_total', 0)}", f"프로퍼티 엔티티: {e.get('properties_with_entity', 0)} / {e.get('properties_total', 0)}", ], ) bench = e.get("competitor_benchmark", []) if bench: rows = min(len(bench) + 1, 7) tbl = s.shapes.add_table(rows, 4, Inches(7.0), Inches(2.4), Inches(5.5), Inches(0.4 * rows)).table for j, htxt in enumerate(["브랜드", "KG 강도", "타입", "위키"]): c = tbl.cell(0, j) c.text = htxt c.fill.solid() c.fill.fore_color.rgb = NAVY for para in c.text_frame.paragraphs: for r in para.runs: _style(r, 11, WHITE, True) for i, b in enumerate(bench[:rows - 1], 1): vals = [b.get("name", ""), str(int(b.get("score", 0))), b.get("type", ""), "○" if b.get("wikipedia") else "—"] for j, v in enumerate(vals): c = tbl.cell(i, j) c.text = v for para in c.text_frame.paragraphs: for r in para.runs: _style(r, 10, NAVY, False) # 7) Roadmap s = blank(prs) header(s, "ROADMAP", "개선 로드맵") phases = [ ("Phase 0 · 긴급 기술 복구", "사이트맵 복구 · CWV(CLS/LCP/TTFB) · 중복 URL canonical", ACCENT), ("Phase 1 · 엔티티 정합", "Organization/Hotel schema · 표기 통일 · 서브브랜드/프로퍼티 엔티티 · Wikipedia", NAVY), ("Phase 2 · 콘텐츠·로컬·확장", "프로퍼티 로컬 SEO · 브랜드 체계 콘텐츠 · Naver · AI 검색 가시성", GREY), ] for i, (h, body, col) in enumerate(phases): top = 2.1 + i * 1.55 card(s, 0.85, top, 11.6, 1.35) bar(s, 0.85, top, 0.14, 1.35, col) textbox(s, 1.15, top + 0.18, 11.0, 1.1, [ [(h, 15, col, True)], [(body, 12, RGBColor(0x33, 0x3A, 0x45), False)], ]) # 8) Estimate (effort-based: module subtotals + 제안가) s = blank(prs) header(s, "ESTIMATE", "예상 견적 (사전 추정)") mods = (EST or {}).get("modules") if EST else None if mods: rows = len(mods) + 2 # header + modules + proposal tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table tbl.columns[0].width = Inches(8.4) tbl.columns[1].width = Inches(3.2) for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]): c = tbl.cell(0, j) c.text = htxt c.fill.solid() c.fill.fore_color.rgb = NAVY for para in c.text_frame.paragraphs: for r in para.runs: _style(r, 12, WHITE, True) for i, m in enumerate(mods, 1): for j, v in enumerate([m["name"], f"{int(round(m['subtotal'])):,}원"]): c = tbl.cell(i, j) c.text = v for para in c.text_frame.paragraphs: for r in para.runs: _style(r, 11, NAVY, False) pr = len(mods) + 1 for j, v in enumerate(["제안가 (절사 적용 · 부가세 별도)", f"{EST.get('proposal', 0):,}원"]): c = tbl.cell(pr, j) c.text = v c.fill.solid() c.fill.fore_color.rgb = LIGHT for para in c.text_frame.paragraphs: for r in para.runs: _style(r, 13 if j else 12, RED if j else NAVY, True) sc = EST.get("scope", {}) note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반" if sc.get("hours_multiplier", 1.0) != 1.0: note += f" · 프로퍼티 {sc.get('properties_total')}개 ×{sc['hours_multiplier']:g}" textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [ [(note, 10, GREY, False)], [(EST.get("disclaimer", ""), 9, GREY, False)], ]) else: textbox(s, 0.85, 2.2, 11.6, 1.0, [[("견적 데이터(estimate.json) 미연결 — estimate.py 실행 후 재생성", 13, GREY, False)]]) # 9) Next steps s = blank(prs) fill_bg(s, NAVY) bar(s, 0.9, 1.0, 1.6, 0.14, ACCENT) textbox(s, 0.9, 1.2, 11.5, 1.2, [ [("NEXT STEPS", 13, RGBColor(0x7F, 0xA8, 0xCF), True)], [("다음 단계", 30, WHITE, True)], ]) steps = [ ("1. 30분 미팅", "진단 결과 공유 및 우선순위 논의"), ("2. 정밀 진단", "Search Console·Analytics 권한 확보 후 색인·트래픽·키워드 정량 분석"), ("3. 단기 파일럿", "긴급 기술 복구(사이트맵·CWV)부터 빠른 가시 성과"), ] for i, (h, body) in enumerate(steps): top = 2.8 + i * 1.2 textbox(s, 1.1, top, 11.0, 1.1, [ [(h, 17, WHITE, True)], [(body, 12, RGBColor(0xCF, 0xDD, 0xEE), False)], ]) textbox(s, 1.1, 6.7, 11.0, 0.5, [[("OurDigital · andrew.yim@ourdigital.org", 11, RGBColor(0x9F, 0xB4, 0xCC), False)]]) prs.save(args.out) print(f"Wrote {args.out} ({len(prs.slides)} slides)") if __name__ == "__main__": main()