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>
142 lines
6.1 KiB
Python
Executable File
142 lines
6.1 KiB
Python
Executable File
#!/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()
|