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>
This commit is contained in:
315
custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py
Executable file
315
custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py
Executable file
@@ -0,0 +1,315 @@
|
||||
#!/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()
|
||||
203
custom-skills/95-ourdigital-presales-seo/scripts/estimate.py
Executable file
203
custom-skills/95-ourdigital-presales-seo/scripts/estimate.py
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a ranged 견적 (estimate) from findings.json using the OurDigital rate card.
|
||||
|
||||
Part of the ourdigital-presales-seo skill (Stage 5). Maps detected findings to
|
||||
rate-card service lines (see references/findings_to_service.md) and emits:
|
||||
- 05_estimate_ko.md (Korean line-item quote)
|
||||
- data/estimate.json (consumed by build_deck.py)
|
||||
- 05_estimate.xlsx (spreadsheet quote)
|
||||
|
||||
Usage:
|
||||
python estimate.py --findings findings.json --rate-card ../references/rate_card.yaml \
|
||||
--out-dir ./audits/2026-05-27-presales --seq 1
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
import yaml
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
|
||||
def won(n):
|
||||
return f"{n:,}원"
|
||||
|
||||
|
||||
def select_services(f):
|
||||
"""Return {service_key: [reasons]} based on findings signals."""
|
||||
chosen = {}
|
||||
d = f.get("discovery", {})
|
||||
t = f.get("technical", {})
|
||||
e = f.get("entity", {})
|
||||
m = f.get("measurement", {})
|
||||
|
||||
def need(key, reason):
|
||||
chosen.setdefault(key, [])
|
||||
if reason not in chosen[key]:
|
||||
chosen[key].append(reason)
|
||||
|
||||
# Crawlability / indexation
|
||||
if d.get("sitemap_status", 200) != 200 or d.get("robots_sitemap_declared", True) is False:
|
||||
need("technical_audit", "sitemap/robots 색인 이슈")
|
||||
need("technical_remediation", "sitemap 복구·크롤성 개선")
|
||||
# Core Web Vitals
|
||||
cwv = t.get("cwv", {})
|
||||
if (cwv.get("perf", 1) < 0.5 or cwv.get("cls", 0) > 0.1
|
||||
or cwv.get("lcp_ms", 0) > 2500 or cwv.get("ttfb_ms", 0) > 600):
|
||||
need("technical_audit", "Core Web Vitals 취약")
|
||||
need("technical_remediation", "CWV(CLS/LCP/TTFB) 개선")
|
||||
# Schema / entity
|
||||
if (t.get("schema", {}).get("org") in ("bare", "none") or e.get("panel") != "hotel"
|
||||
or e.get("name_split") or e.get("legacy_contamination")
|
||||
or (e.get("subbrands_with_entity", 0) == 0 and e.get("subbrands_total", 0) > 0)):
|
||||
need("schema_build", "Organization/Hotel schema·브랜드 표기 정합")
|
||||
need("onpage_entity", "엔티티·서브브랜드 최적화")
|
||||
# Local
|
||||
hygiene = " ".join(d.get("url_hygiene", [])).lower()
|
||||
if ((e.get("properties_with_entity", 0) == 0 and e.get("properties_total", 0) > 0)
|
||||
or "local" in hygiene or "gbp" in hygiene or "dup_path" in hygiene):
|
||||
need("local_seo", "프로퍼티 로컬·GBP 정합")
|
||||
# Measurement
|
||||
if m.get("gsc_access") is False or m.get("ga4_access") is False:
|
||||
need("ga4_impl", "측정 환경(GA4) 구축")
|
||||
need("dashboard", "리포팅 대시보드 구축")
|
||||
if m.get("tag_gaps"):
|
||||
need("gtm_setup", "태그 관리(GTM) 구축")
|
||||
# On-page hygiene
|
||||
if t.get("meta_dupe") or t.get("title_i18n_mismatch") or t.get("hreflang") == "incomplete":
|
||||
need("onpage_entity", "Meta/Title/hreflang 정리")
|
||||
return chosen
|
||||
|
||||
|
||||
def build_line_items(chosen, rate):
|
||||
months = rate["defaults"]["retainer_months"]
|
||||
order = {"one_time": 0, "project": 1, "monthly": 2}
|
||||
items = []
|
||||
for key, reasons in chosen.items():
|
||||
svc = rate["services"][key]
|
||||
unit = svc["unit"]
|
||||
qty = months if unit == "monthly" else 1
|
||||
items.append({
|
||||
"key": key, "label": svc["label_ko"], "unit": unit, "qty": qty,
|
||||
"unit_min": svc["min"], "unit_max": svc["max"],
|
||||
"amount_min": svc["min"] * qty, "amount_max": svc["max"] * qty,
|
||||
"reason": "; ".join(reasons),
|
||||
})
|
||||
items.sort(key=lambda x: order.get(x["unit"], 9))
|
||||
return items, months
|
||||
|
||||
|
||||
def totals(items):
|
||||
one = [i for i in items if i["unit"] != "monthly"]
|
||||
mon = [i for i in items if i["unit"] == "monthly"]
|
||||
return {
|
||||
"one_time_min": sum(i["amount_min"] for i in one),
|
||||
"one_time_max": sum(i["amount_max"] for i in one),
|
||||
"monthly_min": sum(i["amount_min"] for i in mon),
|
||||
"monthly_max": sum(i["amount_max"] for i in mon),
|
||||
"grand_min": sum(i["amount_min"] for i in items),
|
||||
"grand_max": sum(i["amount_max"] for i in items),
|
||||
}
|
||||
|
||||
|
||||
UNIT_KO = {"one_time": "1회", "project": "프로젝트", "monthly": "월"}
|
||||
|
||||
|
||||
def write_md(path, quote_no, date, prospect, items, tot, months, disclaimer):
|
||||
L = [f"# 견적서 (Pre-sales 추정) — {prospect}",
|
||||
"", f"- **견적번호**: {quote_no}", f"- **작성일**: {date}",
|
||||
f"- **대상**: {prospect}", f"- **공급자**: OurDigital (andrew.yim@ourdigital.org)",
|
||||
"", "## 견적 내역", "",
|
||||
"| 항목 | 근거 | 단위 | 수량 | 단가(범위) | 금액(범위) |",
|
||||
"|---|---|---|---:|---|---|"]
|
||||
for i in items:
|
||||
L.append(f"| {i['label']} | {i['reason']} | {UNIT_KO.get(i['unit'], i['unit'])} | {i['qty']} | "
|
||||
f"{won(i['unit_min'])}~{won(i['unit_max'])} | {won(i['amount_min'])}~{won(i['amount_max'])} |")
|
||||
L += ["", "## 합계 (범위)", "",
|
||||
f"- 일회성/프로젝트: **{won(tot['one_time_min'])} ~ {won(tot['one_time_max'])}**",
|
||||
f"- 월 운영({months}개월 기준): **{won(tot['monthly_min'])} ~ {won(tot['monthly_max'])}**",
|
||||
f"- 총계: **{won(tot['grand_min'])} ~ {won(tot['grand_max'])}**",
|
||||
"", f"> {disclaimer}"]
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(L) + "\n")
|
||||
|
||||
|
||||
def write_xlsx(path, quote_no, date, prospect, items, tot, months, disclaimer):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "견적"
|
||||
hdr = PatternFill("solid", fgColor="11243D")
|
||||
hf = Font(color="FFFFFF", bold=True)
|
||||
ws.append([f"견적서 (Pre-sales 추정) — {prospect}"])
|
||||
ws.append([f"견적번호 {quote_no}", f"작성일 {date}", "공급자 OurDigital"])
|
||||
ws.append([])
|
||||
cols = ["항목", "근거", "단위", "수량", "단가 min", "단가 max", "금액 min", "금액 max"]
|
||||
ws.append(cols)
|
||||
for c in range(1, len(cols) + 1):
|
||||
cell = ws.cell(row=ws.max_row, column=c)
|
||||
cell.fill = hdr
|
||||
cell.font = hf
|
||||
for i in items:
|
||||
ws.append([i["label"], i["reason"], UNIT_KO.get(i["unit"], i["unit"]), i["qty"],
|
||||
i["unit_min"], i["unit_max"], i["amount_min"], i["amount_max"]])
|
||||
ws.append([])
|
||||
ws.append(["일회성/프로젝트 합계", "", "", "", "", "", tot["one_time_min"], tot["one_time_max"]])
|
||||
ws.append([f"월 운영 합계 ({months}개월)", "", "", "", "", "", tot["monthly_min"], tot["monthly_max"]])
|
||||
ws.append(["총계", "", "", "", "", "", tot["grand_min"], tot["grand_max"]])
|
||||
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
||||
ws.append([])
|
||||
ws.append([disclaimer])
|
||||
widths = [34, 30, 8, 6, 12, 12, 14, 14]
|
||||
for idx, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[chr(64 + idx)].width = w
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Generate ranged 견적 from findings.json")
|
||||
ap.add_argument("--findings", required=True)
|
||||
ap.add_argument("--rate-card", required=True)
|
||||
ap.add_argument("--out-dir", default=".")
|
||||
ap.add_argument("--seq", type=int, default=1, help="quote sequence number (NNN)")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.findings, encoding="utf-8") as fh:
|
||||
f = json.load(fh)
|
||||
with open(args.rate_card, encoding="utf-8") as fh:
|
||||
rate = yaml.safe_load(fh)
|
||||
|
||||
prospect = f.get("prospect", {}).get("name", "(prospect)")
|
||||
date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat()
|
||||
year = date[:4]
|
||||
quote_no = f"{rate.get('quote_prefix', 'OD')}-{year}-{args.seq:03d}"
|
||||
disclaimer = rate["defaults"]["disclaimer_ko"]
|
||||
|
||||
chosen = select_services(f)
|
||||
items, months = build_line_items(chosen, rate)
|
||||
tot = totals(items)
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
data_dir = os.path.join(args.out_dir, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
md_path = os.path.join(args.out_dir, "05_estimate_ko.md")
|
||||
xlsx_path = os.path.join(args.out_dir, "05_estimate.xlsx")
|
||||
json_path = os.path.join(data_dir, "estimate.json")
|
||||
|
||||
write_md(md_path, quote_no, date, prospect, items, tot, months, disclaimer)
|
||||
write_xlsx(xlsx_path, quote_no, date, prospect, items, tot, months, disclaimer)
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"quote_no": quote_no, "date": date, "prospect": prospect,
|
||||
"line_items": items, "totals": tot, "retainer_months": months,
|
||||
"disclaimer": disclaimer}, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"견적 {quote_no}: {len(items)} line items | "
|
||||
f"one-time {won(tot['one_time_min'])}~{won(tot['one_time_max'])} | "
|
||||
f"monthly {won(tot['monthly_min'])}~{won(tot['monthly_max'])}/{months}mo")
|
||||
print(f"Wrote: {md_path}\n {xlsx_path}\n {json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
141
custom-skills/95-ourdigital-presales-seo/scripts/kg_query.py
Executable file
141
custom-skills/95-ourdigital-presales-seo/scripts/kg_query.py
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Query Google Knowledge Graph (Korean) for a prospect's brand ecosystem + competitors.
|
||||
|
||||
Part of the ourdigital-presales-seo skill (Stage 3). Generalized from the
|
||||
Sono Hotels & Resorts pre-sales script: entities are built from CLI args, not
|
||||
hardcoded. Read-only GET to kgsearch.googleapis.com.
|
||||
|
||||
Key resolution: env GOOGLE_KG_API_KEY -> GOOGLE_API_KEY.
|
||||
|
||||
Example:
|
||||
python kg_query.py --brand "소노호텔앤리조트" \
|
||||
--aliases "소노호텔&리조트,SONO Hotels & Resorts" \
|
||||
--parent "소노인터내셔널" --legacy "대명소노그룹,대명리조트" \
|
||||
--subbrands "소노벨,소노캄,소노펠리체,쏠비치,소노문" \
|
||||
--properties "소노벨 비발디파크,소노캄 제주,쏠비치 양양" \
|
||||
--competitors "롯데호텔,신라호텔,조선호텔앤리조트,한화리조트,켄싱턴리조트" \
|
||||
--out-dir ./data
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
API_KEY = os.environ.get("GOOGLE_KG_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
||||
ENDPOINT = "https://kgsearch.googleapis.com/v1/entities:search"
|
||||
LODGING = {"Hotel", "LodgingBusiness", "Resort", "Place", "TouristAttraction"}
|
||||
|
||||
|
||||
def query_kg(q, lang, limit):
|
||||
params = {"query": q, "key": API_KEY, "languages": lang, "limit": limit, "indent": "true"}
|
||||
url = ENDPOINT + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "OurDigital-SEO-Audit/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def flatten(label, group, data):
|
||||
rows = []
|
||||
for item in data.get("itemListElement", []):
|
||||
r = item.get("result", {})
|
||||
detail = r.get("detailedDescription", {}) or {}
|
||||
rows.append({
|
||||
"group": group, "input_label": label, "kg_id": r.get("@id"),
|
||||
"name": r.get("name"), "types": r.get("@type"), "description": r.get("description"),
|
||||
"result_score": item.get("resultScore"), "has_detailed_desc": bool(detail.get("articleBody")),
|
||||
"detailed_source": detail.get("url"), "image": (r.get("image") or {}).get("contentUrl"),
|
||||
"url": r.get("url"),
|
||||
})
|
||||
if not rows:
|
||||
rows.append({"group": group, "input_label": label, "kg_id": None, "name": None,
|
||||
"types": None, "description": "NO KG ENTITY FOUND", "result_score": 0,
|
||||
"has_detailed_desc": False, "detailed_source": None, "image": None, "url": None})
|
||||
return rows
|
||||
|
||||
|
||||
def build_entities(args):
|
||||
ents = []
|
||||
|
||||
def add(group, raw):
|
||||
for it in (raw or "").split(","):
|
||||
it = it.strip()
|
||||
if it:
|
||||
ents.append((group, it, it))
|
||||
|
||||
add("01_master", args.brand)
|
||||
add("01_master", args.aliases)
|
||||
add("02_corporate", args.parent)
|
||||
add("03_legacy", args.legacy)
|
||||
add("04_membership", args.membership)
|
||||
add("05_subbrand", args.subbrands)
|
||||
add("06_property", args.properties)
|
||||
add("09_competitor", args.competitors)
|
||||
return ents
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="KG entity examination for pre-sales SEO")
|
||||
ap.add_argument("--brand", required=True, help="Master brand (comma-separated allowed)")
|
||||
ap.add_argument("--aliases", default="", help="Brand name variants (& vs 앤, EN)")
|
||||
ap.add_argument("--parent", default="", help="Parent / corporate entity")
|
||||
ap.add_argument("--legacy", default="", help="Former / legacy names")
|
||||
ap.add_argument("--membership", default="", help="Membership / sales-rep units")
|
||||
ap.add_argument("--subbrands", default="", help="Sub-brands")
|
||||
ap.add_argument("--properties", default="", help="Flagship properties")
|
||||
ap.add_argument("--competitors", default="", help="Competitor benchmarks")
|
||||
ap.add_argument("--lang", default="ko")
|
||||
ap.add_argument("--limit", type=int, default=30)
|
||||
ap.add_argument("--out-dir", default=".")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not API_KEY:
|
||||
sys.exit("ERROR: set GOOGLE_KG_API_KEY or GOOGLE_API_KEY in the environment.")
|
||||
|
||||
ents = build_entities(args)
|
||||
raw, flat = {}, []
|
||||
for group, label, q in ents:
|
||||
try:
|
||||
data = query_kg(q, args.lang, args.limit)
|
||||
raw[label] = data
|
||||
flat.extend(flatten(label, group, data))
|
||||
except Exception as e: # network/quota — record, continue
|
||||
raw[label] = {"error": str(e)}
|
||||
flat.append({"group": group, "input_label": label, "kg_id": None, "name": None,
|
||||
"types": None, "description": f"ERROR: {e}", "result_score": 0,
|
||||
"has_detailed_desc": False, "detailed_source": None, "image": None, "url": None})
|
||||
time.sleep(0.3)
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
with open(os.path.join(args.out_dir, "kg_korean_raw.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(raw, f, ensure_ascii=False, indent=2)
|
||||
with open(os.path.join(args.out_dir, "kg_korean_flat.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(flat, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Console summary: top match per input label + lodging-type flag
|
||||
by_label = {}
|
||||
for r in flat:
|
||||
e = by_label.setdefault(r["input_label"], {"group": r["group"], "n": 0, "lodging": False, "top": r})
|
||||
e["group"] = r["group"]
|
||||
if r.get("kg_id"):
|
||||
e["n"] += 1
|
||||
if set(r.get("types") or []) & LODGING:
|
||||
e["lodging"] = True
|
||||
if (r.get("result_score") or 0) > (e["top"].get("result_score") or 0):
|
||||
e["top"] = r
|
||||
|
||||
print(f"{'GROUP':<14}{'SCORE':>8} {'#':>3} {'LODG':<5} {'TOP TYPE':<22} {'DESC':<5} INPUT -> TOP KG NAME")
|
||||
print("-" * 120)
|
||||
for lbl, e in sorted(by_label.items(), key=lambda kv: kv[1]["group"]):
|
||||
top = e["top"]
|
||||
ttype = ",".join((top.get("types") or [])[:2]) or "-"
|
||||
print(f"{e['group']:<14}{top.get('result_score') or 0:>8.0f} {e['n']:>3} "
|
||||
f"{('YES' if e['lodging'] else '-'):<5} {ttype[:22]:<22} "
|
||||
f"{('yes' if top.get('has_detailed_desc') else 'no'):<5} {lbl[:40]} -> {top.get('name') or '(NONE)'}")
|
||||
print(f"\nWrote kg_korean_raw.json + kg_korean_flat.json to {args.out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
custom-skills/95-ourdigital-presales-seo/scripts/render_pdf.sh
Executable file
18
custom-skills/95-ourdigital-presales-seo/scripts/render_pdf.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render an HTML brief to PDF via headless Chrome (uses system fonts → Korean OK).
|
||||
# Part of ourdigital-presales-seo (Stage 6).
|
||||
# Usage: render_pdf.sh <input.html> [output.pdf]
|
||||
set -euo pipefail
|
||||
HTML="${1:?usage: render_pdf.sh <input.html> [output.pdf]}"
|
||||
OUT="${2:-${HTML%.html}.pdf}"
|
||||
|
||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
if [ ! -x "$CHROME" ]; then CHROME="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; fi
|
||||
if [ ! -x "$CHROME" ]; then CHROME="/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; fi
|
||||
if [ ! -x "$CHROME" ]; then echo "ERROR: no Chromium-based browser found for PDF rendering" >&2; exit 1; fi
|
||||
|
||||
DIR="$(cd "$(dirname "$HTML")" && pwd)"
|
||||
BASE="$(basename "$HTML")"
|
||||
"$CHROME" --headless --disable-gpu --no-pdf-header-footer \
|
||||
--print-to-pdf="$OUT" "file://$DIR/$BASE" 2>/dev/null
|
||||
echo "Wrote $OUT"
|
||||
Reference in New Issue
Block a user