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:
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()
|
||||
Reference in New Issue
Block a user