diff --git a/custom-skills/95-ourdigital-presales-seo/SKILL.md b/custom-skills/95-ourdigital-presales-seo/SKILL.md new file mode 100644 index 0000000..70bec71 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/SKILL.md @@ -0,0 +1,89 @@ +--- +name: ourdigital-presales-seo +description: Standardized pre-sales SEO + Knowledge Graph diagnostic for a prospect domain that produces a technical/on-page scan, KG/entity analysis, consolidated brief, a rate-card-based cost estimate (견적), and an editable PPTX sales deck. Use for pre-sales prospecting, sales briefing prep, "pre-sales SEO audit", "프리세일즈 진단", "견적 + 제안 슬라이드", or when preparing for a prospect meeting. Public-data only (no client GSC/GA4 access required). Korean-first output. +--- + +# OurDigital Pre-sales SEO Workflow + +Runs the full pre-sales diagnostic for **any prospect domain** as **step-by-step +confirmed stages**, ending in a cost estimate and a sales-briefing deck. Public-data +only — designed for prospects where you don't yet have GSC/GA4/GTM access. + +Origin: standardizes the Sono Hotels & Resorts pre-sales diagnostic. + +## Execution model — STEP BY STEP + +Run one stage at a time. After each stage, **present the result and WAIT for the +user's go-ahead** before the next. Create a TodoWrite/Task item per stage. Stages 5 +(estimate) and 6 (deliverables) are explicit review gates — never send without sign-off. + +## Stage 0 — Scope & preflight +Collect (ask only for what you can't infer): +- `domain` (required); `brand_name` + `aliases` (앤/& / EN variants — infer from ``/og first) +- `sub_brands`, `properties` (auto-extract from crawl URL patterns in Stage 1 if not given) +- `competitors` (else pick a set from `references/competitor_sets.md` by vertical) +- `market`/`language` (default South Korea / ko), `vertical` +- `output_dir` — default routing: if `~/Workspaces/<slug>-workspace/` exists → + `…/audits/<YYYY-MM-DD>-presales/`, else `~/Workspaces/seo-workspace/prospecting/<YYYY-MM-DD>-<prospect>/` + +Preflight tool check (report missing + fallback): Firecrawl, DataForSEO, `GOOGLE_KG_API_KEY`, +headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.json` (see `findings.schema.json`). + +## Stage 1 — Discovery & crawl +- `WebFetch` robots.txt + `/sitemap.xml` + `/sitemap_index.xml` (note status; 500/404 = finding). +- `firecrawl_map` (limit high, includeSubdomains) → URL inventory; record `discoverable_urls`, + derive URL architecture, `estimated_pages`, and hygiene flags (`/test`, params, duplicate path schemes). +- Populate `findings.json.discovery`. → write scan §1. + +## Stage 2 — Technical / on-page +- `firecrawl_scrape` (json) homepage + 1-2 property pages: JSON-LD `@type`s, Organization completeness + (sameAs/alternateName/address), meta/title/H1, hreflang set, robots/googlebot meta. +- `mcp__dfs-mcp__on_page_lighthouse` homepage (+ a property) → CWV. +- Populate `findings.json.technical`. → write `01_technical-onpage-scan.md`. +- **Honesty rule:** never claim "noindexed" if the page ranks — flag conflicting directives as "verify". + +## Stage 3 — Knowledge Graph / entity +- `python scripts/kg_query.py --brand … --aliases … --parent … --legacy … --subbrands … --properties … --competitors … --out-dir <out>/data` + (needs `GOOGLE_KG_API_KEY`; run with sandbox disabled — read-only API GET). +- Live SERP panel verification: `mcp__dfs-mcp__serp_organic_live_advanced` (ko, South Korea) for brand, + a sub-brand, a property, and one competitor → record actual panel type/title, local packs, reseller leakage. +- Populate `findings.json.entity` (panel, name_split, legacy_contamination, sub-brand/property entity counts, + competitor_benchmark[], wikipedia). → write `02_knowledge-graph-entity.md` + `data/serp_panels_findings.md`. + +## Stage 4 — Consolidated brief +- Synthesize, rank by severity, build the competitive-gap table. Populate `findings.json.findings[]`. + → write `03_presales-opportunity-brief.md`. + +## Stage 5 — Estimate (견적) — REVIEW GATE +- `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --out-dir <out> --seq <N>` +- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the ranged 견적; get sign-off. +- Rules in `references/findings_to_service.md`; rates in `references/rate_card.yaml` (edit both together). + +## Stage 6 — Deliverables — REVIEW GATE before send +- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS), + then `bash scripts/render_pdf.sh <brief>.html` → PDF. Verify Korean renders (Read the PDF). +- **Sales deck**: `python scripts/build_deck.py --findings <out>/data/findings.json --estimate <out>/data/estimate.json --out <out>/sales-deck.pptx` +- Sanitize the client-facing pieces: no internal pricing strategy beyond the 견적; tasteful competitor benchmark only. + +## Stage 7 — Archive (standard) +Push the consolidated report to the OurDigital SEO Audit DB: +``` +python <notion-writer path>/notion_writer.py \ + --database 2c8581e5-8a1e-8035-880b-e38cefc2f3ef \ + --title "<프로스펙트> SEO 사전진단 (Pre-sales) — <YYYY-MM-DD>" \ + --properties '{"Target URL": "<domain>", "Audit Date": "<YYYY-MM-DD>", "Account Code": "<CODE>"}' \ + --file <out>/03_presales-opportunity-brief.md +``` +Property names are exact: `Target URL`, `Audit Date`, `Account Code`. **Do NOT set `Audit ID`** +(read-only formula). notion-writer path: `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/notion_writer.py`. + +## Tool gotchas (learned) +- OurSEO `crawl_website` **caps ~60 pages** regardless of `max_pages` — use Firecrawl `map` for inventory; report the discoverable count as a finding. +- Firecrawl `scrape` cache may return empty — re-scrape. +- KG API + headless Chrome + Notion push need network → run those Bash calls with the sandbox disabled. +- Korean PDF: headless Chrome uses system fonts (AppleSDGothicNeo on macOS). On Windows set deck font to Malgun Gothic in `build_deck.py`. + +## Conventions +- Korean-first for all client-facing output; keep technical terms in English (SEO, CWV, Schema, hreflang). +- File names: `01_/02_/03_/05_` prefixes as above; raw artifacts in `data/`. +- Outputs go to the workspace (Stage 0 routing), **never** into this skills repo. diff --git a/custom-skills/95-ourdigital-presales-seo/findings.schema.json b/custom-skills/95-ourdigital-presales-seo/findings.schema.json new file mode 100644 index 0000000..5aefc1f --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/findings.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ourdigital-presales-seo findings contract", + "description": "Shared artifact populated by analysis stages 1-4 and consumed by estimate.py (5) and build_deck.py (6). Generators must never re-crawl; they read this file.", + "type": "object", + "required": ["prospect", "discovery", "technical", "entity", "findings"], + "properties": { + "prospect": { + "type": "object", + "required": ["name", "domain"], + "properties": { + "name": {"type": "string"}, + "domain": {"type": "string"}, + "aliases": {"type": "array", "items": {"type": "string"}}, + "vertical": {"type": "string"}, + "audit_date": {"type": "string", "description": "YYYY-MM-DD"}, + "account_code": {"type": "string"} + } + }, + "discovery": { + "type": "object", + "properties": { + "sitemap_status": {"type": "integer"}, + "robots_sitemap_declared": {"type": "boolean"}, + "discoverable_urls": {"type": "integer"}, + "estimated_pages": {"type": "string"}, + "url_hygiene": {"type": "array", "items": {"type": "string"}} + } + }, + "technical": { + "type": "object", + "properties": { + "cwv": { + "type": "object", + "properties": { + "lcp_ms": {"type": "number"}, "cls": {"type": "number"}, + "ttfb_ms": {"type": "number"}, "perf": {"type": "number"} + } + }, + "schema": { + "type": "object", + "properties": { + "org": {"type": "string", "enum": ["bare", "complete", "none"]}, + "hotel_on_property": {"type": "boolean"} + } + }, + "meta_dupe": {"type": "boolean"}, + "title_i18n_mismatch": {"type": "boolean"}, + "hreflang": {"type": "string", "enum": ["complete", "incomplete", "none"]} + } + }, + "entity": { + "type": "object", + "properties": { + "panel": {"type": "string", "enum": ["company", "hotel", "none"]}, + "name_split": {"type": "boolean"}, + "legacy_contamination": {"type": "boolean"}, + "subbrands_with_entity": {"type": "integer"}, + "subbrands_total": {"type": "integer"}, + "properties_with_entity": {"type": "integer"}, + "properties_total": {"type": "integer"}, + "wikipedia": {"type": "boolean"}, + "competitor_benchmark": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, "score": {"type": "number"}, + "type": {"type": "string"}, "wikipedia": {"type": "boolean"} + } + } + } + } + }, + "measurement": { + "type": "object", + "properties": { + "gsc_access": {"type": "boolean"}, + "ga4_access": {"type": "boolean"}, + "tag_gaps": {"type": "boolean"} + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "class", "severity"], + "properties": { + "id": {"type": "string"}, + "class": {"type": "string", "description": "crawlability|cwv|schema_entity|subbrand_entity|local|measurement|onpage"}, + "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, + "title_ko": {"type": "string"}, + "evidence": {"type": "string"}, + "recommended_services": {"type": "array", "items": {"type": "string"}} + } + } + } + } +} diff --git a/custom-skills/95-ourdigital-presales-seo/references/competitor_sets.md b/custom-skills/95-ourdigital-presales-seo/references/competitor_sets.md new file mode 100644 index 0000000..ef85e8b --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/references/competitor_sets.md @@ -0,0 +1,23 @@ +# Default competitor benchmark sets (Korean market) + +Used by Stage 3 (KG/entity) when the user doesn't supply competitors. Pick the set +matching the prospect's vertical; pass as `--competitors` to `kg_query.py`. + +## hotel_resort (호텔·리조트) +- 롯데호텔 (Lotte Hotel) — strong KG, LodgingBusiness type, Korean Wikipedia +- 신라호텔 / 호텔신라 (Shilla) +- 조선호텔앤리조트 (Josun) +- 한화리조트 / 한화호텔앤드리조트 (Hanwha) +- 켄싱턴리조트 (Kensington) + +## city_hotel (시티 호텔) +- 롯데호텔, 신라호텔, 조선호텔, 글래드호텔, 나인트리 + +## condo_membership (콘도·회원권) +- 한화리조트, 대명(소노), 한솔오크밸리, 금호리조트 + +## benchmark_signals +For each competitor record in `findings.json.entity.competitor_benchmark`: +`{name, score (KG result_score), type (@type), wikipedia (bool)}`. +The benchmark table contrasts the prospect's entity strength/type/Wikipedia +presence against these — the core competitive-gap visual in the deck. diff --git a/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md new file mode 100644 index 0000000..d11523e --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md @@ -0,0 +1,26 @@ +# Findings → service rubric + +How `estimate.py` maps detected findings (from `findings.json`) to `rate_card.yaml` +service lines. The script reads the structured signals below; this file is the +human-readable source of truth for the rules. + +| Trigger (signal in findings.json) | Service line(s) | Scope driver | +|---|---|---| +| `discovery.sitemap_status != 200` OR `discovery.robots_sitemap_declared == false` OR `discovery.discoverable_urls` low vs `estimated_pages` | `technical_audit` + `technical_remediation` | site size / # templates | +| `technical.cwv.perf < 0.5` OR `cls > 0.1` OR `lcp_ms > 2500` OR `ttfb_ms > 600` | `technical_audit` (if not already) + `technical_remediation` | # templates | +| `technical.schema.org == "bare"/"none"` OR `entity.panel != "hotel"` OR `entity.name_split` OR `entity.legacy_contamination` OR `entity.subbrands_with_entity == 0` | `schema_build` (one-time) + `onpage_entity` (retainer) | # sub-brands + # properties | +| `entity.properties_with_entity == 0` OR `url_hygiene` contains GBP/local mismatch | `local_seo` | # properties | +| `findings[].class` includes measurement gap / no GSC-GA4 | `ga4_impl` and/or `dashboard` (+ `gtm_setup` if tag gaps) | — | +| `technical.meta_dupe` OR `technical.title_i18n_mismatch` OR `technical.hreflang == "incomplete"` | `onpage_entity` | # templates | + +**Severity → priority** (for the brief/deck ordering, not pricing): +- `critical`: crawl/index blocking, CWV failing, entity mistyped +- `high`: entity/sub-brand gaps, duplicate URLs, meta dupes +- `medium`: hreflang, H1, hygiene + +**Quantity rules** +- `monthly` line items use `rate_card.defaults.retainer_months` (default 6). +- `local_seo` scope note scales with property count (`entity` / discovery counts). +- One-time items counted once even if triggered by multiple findings. + +Edit this file and `rate_card.yaml` together when rates or rules change. diff --git a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml new file mode 100644 index 0000000..08b6be6 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml @@ -0,0 +1,51 @@ +# OurDigital service rate card — single source for estimate.py +# Mirrors the ourdigital-backoffice quote ranges. Values are KRW, treated as +# pre-sales estimate RANGES (finalize after a precise diagnostic with access). +quote_prefix: OD # quote number format: OD-YYYY-NNN +currency: KRW + +services: + technical_audit: + label_ko: "Technical Audit / 기술 SEO 진단" + unit: one_time # one_time | monthly | project + min: 3000000 + max: 5000000 + technical_remediation: + label_ko: "기술 개선 실행 (sitemap/CWV/SSR)" + unit: project + min: 3000000 + max: 8000000 + onpage_entity: + label_ko: "On-Page / Entity Optimization (월 운영)" + unit: monthly + min: 1500000 + max: 3000000 + schema_build: + label_ko: "구조화 데이터(Schema) 구축 (1회)" + unit: one_time + min: 2000000 + max: 4000000 + local_seo: + label_ko: "Local SEO (프로퍼티 로컬 최적화)" + unit: monthly + min: 1000000 + max: 2000000 + gtm_setup: + label_ko: "GTM Setup / 태그 관리 구축" + unit: project + min: 2000000 + max: 4000000 + ga4_impl: + label_ko: "GA4 Implementation / 분석 환경 구축" + unit: project + min: 1500000 + max: 3000000 + dashboard: + label_ko: "Dashboard Development / 대시보드 개발" + unit: project + min: 3000000 + max: 6000000 + +defaults: + retainer_months: 6 # default contract length for monthly line items + disclaimer_ko: "본 견적은 공개 데이터 기반 사전 추정 범위이며, Search Console/Analytics 권한 확보 후 정밀 진단을 통해 확정됩니다." diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py new file mode 100755 index 0000000..cfc3a10 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py @@ -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() diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py new file mode 100755 index 0000000..3b99572 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py @@ -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() diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/kg_query.py b/custom-skills/95-ourdigital-presales-seo/scripts/kg_query.py new file mode 100755 index 0000000..6612e67 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/scripts/kg_query.py @@ -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() diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/render_pdf.sh b/custom-skills/95-ourdigital-presales-seo/scripts/render_pdf.sh new file mode 100755 index 0000000..16f43ca --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/scripts/render_pdf.sh @@ -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" diff --git a/custom-skills/95-ourdigital-presales-seo/templates/client_brief.html b/custom-skills/95-ourdigital-presales-seo/templates/client_brief.html new file mode 100644 index 0000000..dc3d27a --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/templates/client_brief.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<!-- + Client-facing pre-sales brief template (Korean). Stage 6. + HOW TO USE: copy this file into the engagement output folder, then fill the + {{TOKENS}} (and the four finding blocks) with the engagement's findings — keep + the CSS as-is. Render to PDF with: bash scripts/render_pdf.sh <thisfile>.html + Keep it ~1 page. Sanitize: no internal pricing strategy; tasteful competitor note only. +--> +<html lang="ko"> +<head> +<meta charset="UTF-8"> +<title>{{PROSPECT}} 검색 가시성 사전 진단 + + + +
+
SEO Pre-sales Brief · OurDigital
+

{{PROSPECT}} 검색 가시성 사전 진단

+
작성: OurDigital · {{DATE}} · 대상: {{DOMAIN}}
+
* 본 자료는 공개 데이터만으로 수행한 사전 스냅샷이며, Search Console 등 권한 확보 후 정밀 진단으로 보완됩니다.
+
+ +
{{ONE_LINER — 자산은 최상급이나 검색 가시성이 규모에 미치지 못한다는 핵심 메시지}}
+ +

핵심 발견

+ +
+ 1

{{FINDING_TITLE}}

+ +
왜 중요한가 — {{WHY_IT_MATTERS}}
+
+ +
+

기대 효과

{{EXPECTED_IMPACT}}
+

다음 단계 제안

+
  1. 30분 미팅
  2. 정밀 진단 (권한 확보 후)
  3. 단기 파일럿
+
+
+ + + +