Files
our-claude-skills/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py
Andrew Yim c9bdbb57f7 Extract ourdigital-estimate-engine; presales-seo now calls it
New skill 96-ourdigital-estimate-engine: method-aware quoting engine
(effort / coaching / procurement) with universal rate_card + per-service
catalog. Real catalogs: seo (effort), education (coaching); stubs:
digital_ads, digital_branding. Validated to reproduce real quotes —
SEO basic ₩10.5M / treatment ₩25.0M, SHR chain ₩29.5M, L'Escape basic
₩10.5M, GA4/GTM coaching ₩1,570,000, procurement +15%.

Refactor 95-ourdigital-presales-seo: remove rate_card.yaml, sow_templates.yaml,
estimate.py (migrated to engine); add findings_to_scope.py; Stage 5 now maps
findings→scope.json and calls the engine CLI. build_deck/kg_query unchanged;
end-to-end validated on SHR (29.5M) + deck renders engine estimate.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:54:11 +09:00

325 lines
13 KiB
Python
Executable File
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.
#!/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('label') or 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:
dl = "브랜드/템플릿" if sc.get("driver") == "subbrands_total" else "프로퍼티"
note += f" · {dl} {sc.get('driver_count')}×{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()