Real-world feedback: list-rate calc overshot SMB-acceptable levels. - scaling: driver properties_total -> subbrands_total (chains share templates), cap x6.5 -> x2.0, applied to On-page only (Technical now fixed site-wide) - add 'smb' entry tier (lean hours @ 0.55 billing); 3-tier auto-select (smb/basic/treatment) by portfolio size; per-tier billing; --baseline smb enabled - docs (findings_to_service.md, SKILL.md) synced to 3-tier model Effect: SHR 25-property chain 71.5M -> 29.5M; SMB single hotel ~3.0M; basic/treatment still reproduce real 10.5M/25.0M at 1 property. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
13 KiB
Python
Executable File
325 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 (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:
|
||
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()
|