Files
our-claude-skills/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py
Andrew Yim ba88247496 Implement ourdigital-presales-seo skill
SKILL.md orchestration (8 gated stages), references (rate_card.yaml,
findings_to_service rubric, competitor sets), findings.schema.json contract,
and scripts: kg_query.py (generalized KG examination), estimate.py
(findings→rate-card 견적 md/xlsx/json), build_deck.py (9-slide branded PPTX),
render_pdf.sh (Korean PDF via headless Chrome), plus client_brief.html template.

Validated on Sono Hotels & Resorts findings: estimate OD-2026-001
(23-47M KRW) and a 9-slide deck generated cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:10:53 +09:00

316 lines
13 KiB
Python
Executable File

#!/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
s = blank(prs)
header(s, "ESTIMATE", "예상 견적 (사전 추정 범위)")
if EST:
items = EST.get("line_items", [])
rows = min(len(items) + 1, 9)
tbl = s.shapes.add_table(rows, 3, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.45 * rows)).table
tbl.columns[0].width = Inches(6.0)
tbl.columns[1].width = Inches(2.0)
tbl.columns[2].width = Inches(3.6)
for j, htxt in enumerate(["항목", "단위", "금액(범위)"]):
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)
unit_ko = {"one_time": "1회", "project": "프로젝트", "monthly": ""}
for i, it in enumerate(items[:rows - 1], 1):
amt = f"{it['amount_min']:,}~{it['amount_max']:,}"
for j, v in enumerate([it["label"], unit_ko.get(it["unit"], it["unit"]), amt]):
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)
tot = EST.get("totals", {})
textbox(s, 0.85, 2.0 + 0.45 * rows + 0.2, 11.6, 1.2, [
[("총계(범위): ", 14, NAVY, True),
(f"{tot.get('grand_min', 0):,} ~ {tot.get('grand_max', 0):,}", 14, RED, True)],
[(EST.get("disclaimer", ""), 10, 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()