feat(seo-schema-generator): merge site-extraction + source-to-schema into one skill

Unify the two schema-generation scenarios into a single slot-17 skill, both
feeding one claims register -> build -> validate(16) pipeline:

- Mode 1 (existing site): NEW scripts/extract_site_claims.py turns URLs / local
  HTML / a directory into a claims register. Existing JSON-LD -> CONFIRMED;
  title/OpenGraph -> PENDING (never auto-shipped). + site-extraction-methodology.md
  and bundled fixtures/site/ demo pages.
- Mode 2 (not-yet-published site): land the source-to-schema engine
  (build_schema_drafts.py, type_templates.json, claims/source registers, 3 refs,
  sample_claims.csv) from the Desktop builder.
- Rewrite SKILL.md (v2.0) around the two-mode framing; the claims register is the
  shared pivot. Only CONFIRMED, non-conflicting claims become schema; unfilled
  template slots are pruned, never emitted as placeholders.
- Retire the old template-fill generator (code/ + desktop/); update root CLAUDE.md.

Self-tested both chains end-to-end: Mode 2 sample -> build -> validate PASS (P0=0);
Mode 1 fixtures -> extract -> build -> validate PASS (P0=0), JSON-LD round-trips with
nested address intact. Fixed two adapter bugs (nested node promotion; relative-path URI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:38:40 +09:00
parent 3a8edebfef
commit 1706a820fe
39 changed files with 1536 additions and 1599 deletions

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
build_schema_drafts.py — Source-to-Schema draft generator (skill 17, pre-launch)
WHAT IT DOES
Turns a *claims register* (reconciled, provenance-tracked facts) into JSON-LD
drafts, then writes a dataset CSV that feeds straight into
16-seo-schema-validator/scripts/validate_schema.py.
WHY A CLAIMS REGISTER FIRST (the core idea)
Authoring schema for a site that does not exist yet is error-prone because the
facts live in many conflicting sources (DART, Wikipedia, brochures...). If you
pour raw, unreconciled facts straight into JSON-LD you reproduce every conflict
and gap as a schema defect. So we reconcile facts FIRST (one confirmed value per
property, with sources recorded), and only CONFIRMED, non-conflicting claims are
allowed to become schema. Everything else is reported, not shipped.
THE PRUNING RULE (placeholder leakage is the #1 pre-launch defect)
A template slot that has no confirmed value is DELETED — never emitted as
"{{...}}" or "TODO". An empty nested object (only @type left) is dropped too.
This guarantees drafts contain only backed facts.
INPUT (claims register): .csv or .xlsx with columns (case-insensitive, KR/EN aliases ok)
entity_id e.g. org:shilla, hotel:theshilla-seoul (groups rows into one node)
entity_type Organization | Hotel | Person | JobPosting | VideoObject | WebSite | FAQPage
property schema.org property, dotted for nesting: address.streetAddress
append nothing for scalars; arrays are handled automatically
value the confirmed value (pipe-separate multiple values: a|b|c)
lang optional (ko/en/ja/zh) -> produces one draft per language
url optional target URL (provisional ok) -> carried to validator
source_ids optional pipe-separated refs into the source register (provenance)
authority optional 1..n (1 = most authoritative) — for your audit trail
confidence optional high|med|low
conflict optional — any truthy value (Y/1/true/충돌) EXCLUDES the claim
status CONFIRMED (default if blank) | PENDING | REJECTED — only CONFIRMED ships
note optional
USAGE
python scripts/build_schema_drafts.py path/to/claims_register.csv \
--templates scripts/type_templates.json --out drafts_out
# then hand off to the validator:
python ../16-seo-schema-validator/scripts/validate_schema.py \
drafts_out/schema_drafts_dataset.csv --out qa_out
"""
import argparse, csv, json, os, sys, copy, re
from collections import defaultdict, OrderedDict
# ----------------------------------------------------------------------------- #
# Column aliasing — accept Korean/English header variants #
# ----------------------------------------------------------------------------- #
COL_ALIASES = {
"entity_id": ["entity_id", "entity", "엔티티", "엔티티id", "id"],
"entity_type": ["entity_type", "type", "타입", "유형", "스키마타입"],
"property": ["property", "prop", "속성", "프로퍼티", "path"],
"value": ["value", "", "내용"],
"lang": ["lang", "language", "언어", "언어코드"],
"url": ["url", "메뉴 url", "메뉴url", "주소"],
"source_ids": ["source_ids", "source", "sources", "출처", "출처id"],
"authority": ["authority", "권위", "권위순위"],
"confidence": ["confidence", "신뢰도"],
"conflict": ["conflict", "충돌", "conflict_flag"],
"status": ["status", "상태"],
"note": ["note", "notes", "비고", "메모"],
}
TRUTHY = {"y", "yes", "1", "true", "t", "충돌", "conflict", "o"}
PLACEHOLDER_RE = re.compile(r"^\{\{(.+?)\}\}$") # matches an entire-string slot
UNFILLED = object() # sentinel: slot had no value
def _norm(s):
return (s or "").strip().lower().replace(" ", "")
def map_columns(headers):
"""Return {canonical_name: actual_header} using the alias table."""
lookup = {_norm(h): h for h in headers}
out = {}
for canon, aliases in COL_ALIASES.items():
for a in aliases:
if _norm(a) in lookup:
out[canon] = lookup[_norm(a)]
break
return out
# ----------------------------------------------------------------------------- #
# Loading the claims register #
# ----------------------------------------------------------------------------- #
def load_rows(path):
"""Yield dict rows from .csv or .xlsx. Keeps original header names."""
ext = os.path.splitext(path)[1].lower()
if ext in (".csv", ".tsv"):
delim = "\t" if ext == ".tsv" else ","
with open(path, encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f, delimiter=delim):
yield row
elif ext in (".xlsx", ".xlsm"):
try:
from openpyxl import load_workbook
except ImportError:
sys.exit("openpyxl required for .xlsx — pip install openpyxl")
wb = load_workbook(path, read_only=True, data_only=True)
for ws in wb.worksheets:
rows = ws.iter_rows(values_only=True)
try:
headers = [str(h) if h is not None else "" for h in next(rows)]
except StopIteration:
continue
if not map_columns(headers).get("entity_id"):
continue # sheet without our schema -> skip
for r in rows:
yield {headers[i]: ("" if v is None else str(v))
for i, v in enumerate(r) if i < len(headers)}
else:
sys.exit(f"Unsupported claims register format: {ext}")
# ----------------------------------------------------------------------------- #
# Distil rows -> per-(entity, lang) confirmed claim maps + exclusion log #
# ----------------------------------------------------------------------------- #
def collect_claims(path):
rows = list(load_rows(path))
if not rows:
sys.exit("Claims register is empty.")
cmap = map_columns(rows[0].keys())
for req in ("entity_id", "entity_type", "property", "value"):
if req not in cmap:
sys.exit(f"Missing required column '{req}'. Found: {list(rows[0].keys())}")
def g(row, key):
col = cmap.get(key)
return (row.get(col, "") if col else "").strip()
# claims[(entity_id, lang)] -> {"type":..., "url":..., "props": {path: [values]}}
claims = OrderedDict()
excluded = [] # (entity, prop, reason, detail)
for row in rows:
eid = g(row, "entity_id")
if not eid:
continue
etype = g(row, "entity_type")
prop = g(row, "property")
val = g(row, "value")
lang = g(row, "lang") or ""
status = (g(row, "status") or "CONFIRMED").upper()
conflict = _norm(g(row, "conflict")) in TRUTHY
if conflict:
excluded.append((eid, prop, "CONFLICT", f"sources disagree -> resolve first"))
continue
if status == "REJECTED":
excluded.append((eid, prop, "REJECTED", g(row, "note")))
continue
if status == "PENDING":
excluded.append((eid, prop, "PENDING", "not yet confirmed by an authoritative source"))
continue
if not val:
excluded.append((eid, prop, "EMPTY", "confirmed row but value is blank"))
continue
key = (eid, lang)
node = claims.setdefault(key, {"type": etype, "url": g(row, "url"),
"props": defaultdict(list)})
if etype and not node["type"]:
node["type"] = etype
if g(row, "url") and not node["url"]:
node["url"] = g(row, "url")
# pipe-separated value -> multiple values (array support)
for v in (val.split("|") if "|" in val else [val]):
v = v.strip()
if v:
node["props"][prop].append(v)
return claims, excluded
# ----------------------------------------------------------------------------- #
# Fill a template, pruning every unfilled slot #
# ----------------------------------------------------------------------------- #
def fill(node_template, props):
"""Recursively fill {{slots}}; return UNFILLED when a branch has no real data."""
if isinstance(node_template, str):
m = PLACEHOLDER_RE.match(node_template.strip())
if not m:
return node_template # literal (e.g. "@type":"Hotel")
path = m.group(1)
is_array = path.endswith("[]")
if is_array:
path = path[:-2]
vals = props.get(path, [])
if not vals:
return UNFILLED
if is_array:
return list(vals)
if len(vals) > 1:
print(f" ! multiple values for scalar '{path}' — using first ({len(vals)} given)")
return vals[0]
if isinstance(node_template, dict):
out = OrderedDict()
for k, v in node_template.items():
filled = fill(v, props)
if filled is UNFILLED:
continue
out[k] = filled
# an object that only carries @type/@context (no real data, no @id ref) is empty
meaningful = [k for k in out if k not in ("@type", "@context")]
if not meaningful:
return UNFILLED
return out
if isinstance(node_template, list):
out = [x for x in (fill(i, props) for i in node_template) if x is not UNFILLED]
return out if out else UNFILLED
return node_template
# ----------------------------------------------------------------------------- #
# Main #
# ----------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser(description="Build JSON-LD drafts from a claims register.")
ap.add_argument("claims", help="claims register .csv/.xlsx")
ap.add_argument("--templates", default=os.path.join(os.path.dirname(__file__), "type_templates.json"))
ap.add_argument("--out", default="drafts_out")
args = ap.parse_args()
templates = json.load(open(args.templates, encoding="utf-8"))["templates"]
claims, excluded = collect_claims(args.claims)
os.makedirs(os.path.join(args.out, "drafts"), exist_ok=True)
dataset_rows = []
built, skipped_type = 0, []
for (eid, lang), node in claims.items():
etype = node["type"]
if etype not in templates:
skipped_type.append((eid, etype))
continue
filled = fill(copy.deepcopy(templates[etype]["tpl"]), node["props"])
if filled is UNFILLED or not filled:
skipped_type.append((eid, f"{etype} (no usable claims)"))
continue
jsonld = json.dumps(filled, ensure_ascii=False, indent=2)
safe = re.sub(r"[^A-Za-z0-9]+", "_", eid).strip("_")
fname = f"{safe}__{lang}.jsonld" if lang else f"{safe}.jsonld"
with open(os.path.join(args.out, "drafts", fname), "w", encoding="utf-8") as f:
f.write(jsonld)
dataset_rows.append(OrderedDict([
("entity_id", eid), ("entity_type", etype),
("url", node["url"]), ("lang", lang),
("jsonld", json.dumps(filled, ensure_ascii=False)),
]))
built += 1
# dataset CSV — directly consumable by validate_schema.py (auto-detects 'jsonld')
ds_path = os.path.join(args.out, "schema_drafts_dataset.csv")
with open(ds_path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["entity_id", "entity_type", "url", "lang", "jsonld"])
w.writeheader()
w.writerows(dataset_rows)
# build report
rep = [
"# Schema Draft Build Report",
"",
f"- Entities built: **{built}**",
f"- Claims excluded (not shipped): **{len(excluded)}**",
f"- Entities skipped (no template / no usable claims): **{len(skipped_type)}**",
"",
"## Excluded claims (resolve before they can become schema)",
]
if excluded:
rep.append("| entity | property | reason | detail |")
rep.append("|--------|----------|--------|--------|")
for eid, prop, reason, detail in excluded:
rep.append(f"| {eid} | {prop} | **{reason}** | {detail} |")
else:
rep.append("_None._")
if skipped_type:
rep += ["", "## Skipped entities", ""]
for eid, why in skipped_type:
rep.append(f"- {eid}{why}")
rep += [
"", "## Next step",
"1. Resolve every excluded claim (confirm an authoritative value, clear conflicts).",
"2. Re-run this builder.",
"3. Validate the output (the QA gate):",
" ```bash",
f" python ../16-seo-schema-validator/scripts/validate_schema.py {ds_path} --out qa_out",
" ```",
"4. Fix all P0 from the validator, then proceed to client review.",
]
rep_path = os.path.join(args.out, "build_report.md")
open(rep_path, "w", encoding="utf-8").write("\n".join(rep))
print(f"Built {built} drafts | excluded {len(excluded)} claims | skipped {len(skipped_type)} entities")
print(f"Wrote: {ds_path}")
print(f" {rep_path}")
print(f" {os.path.join(args.out, 'drafts')}/*.jsonld")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
extract_site_claims.py — Scenario 1 adapter: an EXISTING website → claims register.
THE MERGE, IN ONE SENTENCE
Schema generation has two scenarios that differ only in WHERE facts come from:
1) from a given website — the live pages ARE the source of truth (this script)
2) from collected sources — a not-yet-published site, facts scattered & conflicting
Both emit the SAME claims_register.csv, which build_schema_drafts.py then turns into
drafts and 16-seo-schema-validator gates. The claims register is the shared pivot
that lets one skill cover both scenarios.
WHAT THIS DOES
Reads pages of an existing site and seeds a claims register from them:
- existing JSON-LD (<script type="application/ld+json">) -> CONFIRMED (authority 1):
the site already published these facts about itself.
- <title> / meta description / OpenGraph / <html lang> / canonical -> PENDING:
inferred, not authoritative. The builder EXCLUDES PENDING claims until a human
confirms them — so inference never silently ships (the skill's core principle).
You then review/edit claims_register.csv, run build_schema_drafts.py, and validate.
INPUT (any mix): URLs (needs `requests`), local .html files, or a directory of .html
OUTPUT (in --out): claims_register.csv + extraction_report.md
USAGE
python extract_site_claims.py https://example.com/ https://example.com/about --out site_claims
python extract_site_claims.py ./snapshot/ --out site_claims # offline, local HTML
# then:
python build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
python ../16-seo-schema-validator/scripts/validate_schema.py drafts_out/schema_drafts_dataset.csv --out qa_out
"""
import argparse
import csv
import json
import os
import re
import sys
from collections import OrderedDict
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urlparse
JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.IGNORECASE | re.DOTALL,
)
# entity_id prefix per schema type — keeps ids readable and groupable.
TYPE_PREFIX = {
"Organization": "org", "Corporation": "org", "LocalBusiness": "biz",
"WebSite": "site", "WebPage": "page",
"Hotel": "hotel", "LodgingBusiness": "hotel", "Resort": "hotel",
"Restaurant": "dining", "FoodEstablishment": "dining", "BarOrPub": "dining",
"Person": "person", "Product": "product", "Article": "article",
"NewsArticle": "article", "BlogPosting": "article", "Event": "event",
"FAQPage": "faq", "BreadcrumbList": "crumb",
}
# OpenGraph og:type -> our seed @type for the meta-only fallback.
OG_TYPE_MAP = {"website": "WebSite", "article": "Article", "product": "Product",
"business.business": "LocalBusiness", "profile": "Person"}
CLAIM_FIELDS = ["entity_id", "entity_type", "property", "value", "lang", "url",
"source_ids", "authority", "confidence", "conflict", "status", "note"]
# --------------------------------------------------------------------------- #
# Lightweight HTML meta extraction (stdlib only)
# --------------------------------------------------------------------------- #
class MetaParser(HTMLParser):
"""Pull <title>, <html lang>, <link rel=canonical>, and <meta> name/property."""
def __init__(self):
super().__init__()
self.title_parts = []
self._in_title = False
self.lang = ""
self.canonical = ""
self.meta = {} # name/property (lowercased) -> content
def handle_starttag(self, tag, attrs):
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "html" and a.get("lang"):
self.lang = a["lang"].strip()
elif tag == "title":
self._in_title = True
elif tag == "link" and a.get("rel", "").lower() == "canonical" and a.get("href"):
self.canonical = a["href"].strip()
elif tag == "meta":
key = (a.get("property") or a.get("name") or "").lower().strip()
if key and "content" in a:
self.meta.setdefault(key, a["content"].strip())
def handle_endtag(self, tag):
if tag == "title":
self._in_title = False
def handle_data(self, data):
if self._in_title:
self.title_parts.append(data)
@property
def title(self):
return re.sub(r"\s+", " ", "".join(self.title_parts)).strip()
# --------------------------------------------------------------------------- #
# Page acquisition
# --------------------------------------------------------------------------- #
def gather_pages(inputs):
"""Yield (url, html). Accepts http(s) URLs, .html files, and directories."""
for item in inputs:
if re.match(r"^https?://", item, re.IGNORECASE):
try:
import requests
except ImportError:
sys.exit("Fetching URLs needs requests: pip install requests "
"(or pass local .html files / a directory instead).")
try:
r = requests.get(item, timeout=20,
headers={"User-Agent": "Mozilla/5.0 (SchemaGen/1.0)"})
r.raise_for_status()
yield item, r.text
except Exception as exc: # noqa: BLE001 — best-effort fetch
print(f" ! could not fetch {item}: {exc}", file=sys.stderr)
else:
p = Path(item)
if p.is_dir():
for hp in sorted(p.rglob("*.html")):
hp = hp.resolve()
yield hp.as_uri(), hp.read_text(encoding="utf-8", errors="replace")
elif p.is_file():
p = p.resolve()
yield p.as_uri(), p.read_text(encoding="utf-8", errors="replace")
else:
print(f" ! not a URL/file/dir, skipped: {item}", file=sys.stderr)
# --------------------------------------------------------------------------- #
# JSON-LD node -> flat (dotted-path, value) claims
# --------------------------------------------------------------------------- #
def primary_type(node):
t = node.get("@type")
if isinstance(t, list):
return t[0] if t else ""
return t or ""
def top_level_nodes(parsed):
"""Return the ENTITY nodes only — @graph members, array items, or a single object.
Deliberately NOT recursive: nested objects (PostalAddress, GeoCoordinates, …) belong
to their parent and are captured by flatten() as dotted paths. Recursing here would
wrongly promote a nested address into its own entity.
"""
if isinstance(parsed, dict) and "@graph" in parsed:
graph = parsed["@graph"]
return [n for n in graph if isinstance(n, dict) and "@type" in n]
if isinstance(parsed, list):
return [n for n in parsed if isinstance(n, dict) and "@type" in n]
if isinstance(parsed, dict) and "@type" in parsed:
return [parsed]
return []
def flatten(node, prefix=""):
"""Yield (property_path, value) pairs matching the template {{dotted.path}} slots.
- scalars -> ("name", "X")
- scalar arrays -> ("sameAs", "a|b|c") (pipe-joined; builder splits on '|')
- nested objects -> recurse with dotted prefix ("address.streetAddress", ...)
- @type inside a nested object is structural (templates hard-code it) -> skipped
- a bare {"@id": "..."} reference -> ("parentOrganization.@id", "...")
"""
for key, val in node.items():
if key == "@type":
continue
path = f"{prefix}{key}"
if isinstance(val, (str, int, float, bool)):
yield path, str(val)
elif isinstance(val, list):
scalars = [str(v) for v in val if isinstance(v, (str, int, float, bool))]
if scalars:
yield path, "|".join(scalars)
for v in val: # objects inside arrays -> recurse (best-effort)
if isinstance(v, dict):
yield from flatten(v, prefix=f"{path}.")
elif isinstance(val, dict):
yield from flatten(val, prefix=f"{path}.")
def slugify(text, maxlen=40):
s = re.sub(r"^https?://", "", text or "")
s = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()
return s[:maxlen] or "node"
def entity_id_for(node, url, idx):
"""Readable, groupable id like `hotel:westin-hotel` from an @id or URL."""
etype = primary_type(node)
prefix = TYPE_PREFIX.get(etype, "node")
nid = node.get("@id")
if nid:
pr = urlparse(str(nid))
tail = [s for s in pr.path.split("/") if s]
parts = (tail[-1:] if tail else []) + ([pr.fragment] if pr.fragment else [])
base = "-".join(parts) or pr.netloc or str(nid)
else:
base = url or f"n{idx}"
return f"{prefix}:{slugify(base)}"
# --------------------------------------------------------------------------- #
# Build claims rows from one page
# --------------------------------------------------------------------------- #
def claims_from_page(url, html, default_lang, rows, seen_props):
"""Append claim rows for one page. seen_props tracks (entity_id, property)
already taken from authoritative JSON-LD, so meta only fills genuine gaps."""
page_lang = ""
found_jsonld = False
# --- 1) existing JSON-LD -> CONFIRMED (authority 1) ---
for block in JSONLD_RE.findall(html):
try:
parsed = json.loads(block.strip())
except json.JSONDecodeError:
continue
for i, node in enumerate(top_level_nodes(parsed)):
etype = primary_type(node)
if not etype:
continue
found_jsonld = True
eid = entity_id_for(node, url, i)
node_lang = node.get("inLanguage") if isinstance(node.get("inLanguage"), str) else ""
lang = node_lang or default_lang
for prop, value in flatten(node):
rows.append(_row(eid, etype, prop, value, lang, url,
"S-SITE", 1, "high", "CONFIRMED",
"extracted from existing JSON-LD"))
seen_props.add((eid, prop))
# --- 2) meta / OpenGraph -> PENDING (inferred, needs confirmation) ---
mp = MetaParser()
try:
mp.feed(html)
except Exception: # noqa: BLE001 — tolerate malformed HTML
pass
page_lang = mp.lang or default_lang
og_type = mp.meta.get("og:type", "").lower()
etype = OG_TYPE_MAP.get(og_type, "WebPage")
eid = f"{TYPE_PREFIX.get(etype, 'page')}:{slugify(mp.canonical or url)}"
inferred = {
"name": mp.meta.get("og:title") or mp.title,
"url": mp.meta.get("og:url") or mp.canonical or (url if url.startswith("http") else ""),
"description": mp.meta.get("og:description") or mp.meta.get("description"),
"image": mp.meta.get("og:image"),
"inLanguage": page_lang,
}
# Only emit meta claims when this page contributed NO JSON-LD (else JSON-LD wins).
if not found_jsonld:
for prop, value in inferred.items():
if value and (eid, prop) not in seen_props:
rows.append(_row(eid, etype, prop, value, page_lang, url,
"S-SITE-META", 2, "med", "PENDING",
"inferred from <title>/OpenGraph — confirm before shipping"))
def _row(eid, etype, prop, value, lang, url, src, authority, conf, status, note):
return OrderedDict([
("entity_id", eid), ("entity_type", etype), ("property", prop),
("value", value), ("lang", lang or ""), ("url", url if url.startswith("http") else ""),
("source_ids", src), ("authority", authority), ("confidence", conf),
("conflict", ""), ("status", status), ("note", note),
])
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
def main(argv=None):
ap = argparse.ArgumentParser(
description="Scenario-1 adapter: existing website -> claims register.")
ap.add_argument("inputs", nargs="+", help="URLs, .html files, or a directory")
ap.add_argument("--out", default="site_claims", help="output directory")
ap.add_argument("--default-lang", default="", help="fallback language code (e.g. ko)")
args = ap.parse_args(argv)
rows, seen = [], set()
pages = 0
for url, html in gather_pages(args.inputs):
pages += 1
before = len(rows)
claims_from_page(url, html, args.default_lang, rows, seen)
print(f" · {url}{len(rows) - before} claims")
if not rows:
print("No claims extracted (no JSON-LD or usable meta on the given pages).",
file=sys.stderr)
return 1
outdir = Path(args.out)
outdir.mkdir(parents=True, exist_ok=True)
reg = outdir / "claims_register.csv"
with open(reg, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=CLAIM_FIELDS)
w.writeheader()
w.writerows(rows)
confirmed = sum(1 for r in rows if r["status"] == "CONFIRMED")
pending = sum(1 for r in rows if r["status"] == "PENDING")
entities = sorted({r["entity_id"] for r in rows})
rep = [
"# Site Extraction Report", "",
f"- Pages read: **{pages}**",
f"- Claims extracted: **{len(rows)}** "
f"(CONFIRMED from JSON-LD: {confirmed} · PENDING from meta: {pending})",
f"- Entities seeded: **{len(entities)}**", "",
"## Entities", "",
]
rep += [f"- `{e}`" for e in entities]
rep += [
"", "## Review before building",
"1. **CONFIRMED** rows came from the site's own JSON-LD — spot-check accuracy.",
"2. **PENDING** rows were inferred from `<title>`/OpenGraph and will NOT ship until "
"you set `status=CONFIRMED` (and clear any `conflict`).",
"3. Add anything the pages didn't expose (telephone, address, geo, sameAs).",
"", "## Next step",
"```bash",
f"python build_schema_drafts.py {reg} --out drafts_out",
"python ../16-seo-schema-validator/scripts/validate_schema.py "
"drafts_out/schema_drafts_dataset.csv --out qa_out",
"```",
]
(outdir / "extraction_report.md").write_text("\n".join(rep), encoding="utf-8")
print(f"\nWrote {len(rows)} claims ({confirmed} CONFIRMED, {pending} PENDING) "
f"for {len(entities)} entities → {reg}")
print(f" {outdir / 'extraction_report.md'}")
print("Review the register (confirm PENDING rows), then run build_schema_drafts.py.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
make_sample.py — generate fixtures/sample_claims.csv
A small, realistic claims register for the Shilla context. It exercises:
- 5 entity types: Organization, WebSite, Hotel, Person, JobPosting, VideoObject
- dotted nested paths (address.*, geo.*) and an array (sameAs[])
- @id cross-references between entities (Hotel.parentOrganization -> org:shilla)
- the EXCLUSION gate: one PENDING claim, one CONFLICT claim, one EMPTY value
Run, then: python scripts/build_schema_drafts.py fixtures/sample_claims.csv
"""
import csv, os
ROWS = [
# entity_id, entity_type, property, value, lang, url, source_ids, authority, confidence, conflict, status, note
# ---- Organization (DART + official + Wikidata) ----
("org:shilla", "Organization", "@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "name", "The Shilla Hotels & Resorts", "", "", "S-OFF|S-WIKI", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "legalName", "주식회사 호텔신라", "", "", "S-DART", "1", "high", "", "CONFIRMED", "DART 법인명"),
("org:shilla", "Organization", "url", "https://www.shillahotels.com/", "", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "foundingDate", "1973-05-09", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "sameAs", "https://www.wikidata.org/wiki/Q494845|https://en.wikipedia.org/wiki/The_Shilla", "", "", "S-WIKI|S-WD", "2", "high", "", "CONFIRMED", "array via pipe"),
("org:shilla", "Organization", "address.streetAddress", "동호로 249", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "address.addressLocality", "중구", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "address.addressRegion", "서울", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("org:shilla", "Organization", "address.addressCountry", "KR", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
# ---- WebSite (per-language) ----
("site:ko", "WebSite", "@id", "https://www.shillahotels.com/ko#website", "ko", "https://www.shillahotels.com/ko/", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("site:ko", "WebSite", "name", "신라호텔", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("site:ko", "WebSite", "url", "https://www.shillahotels.com/ko/", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("site:ko", "WebSite", "inLanguage", "ko", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("site:ko", "WebSite", "publisher.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", "ref to org"),
# ---- Hotel (property; @id ref back to org) ----
("hotel:theshilla-seoul", "Hotel", "@id", "https://www.shillahotels.com/ko/theshilla/seoul#hotel", "ko", "https://www.shillahotels.com/ko/theshilla/seoul/index.do", "S-OFF|S-BROCH", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "name", "The Shilla Seoul", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "telephone", "+82-2-2233-3131", "ko", "", "S-OFF|S-GBP", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "priceRange", "$$$$", "ko", "", "S-OFF", "2", "med", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "brand.name", "The Shilla", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "parentOrganization.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", "entity graph link"),
("hotel:theshilla-seoul", "Hotel", "address.streetAddress", "동호로 249", "ko", "", "S-OFF|S-GBP", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "address.addressLocality", "서울", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "address.addressCountry", "KR", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "geo.latitude", "37.5564", "ko", "", "S-GBP", "1", "high", "", "CONFIRMED", ""),
("hotel:theshilla-seoul", "Hotel", "geo.longitude", "127.0058", "ko", "", "S-GBP", "1", "high", "", "CONFIRMED", ""),
# ---- Person (executive bio) ----
("person:ceo", "Person", "@id", "https://www.shillahotels.com/#ceo", "", "", "S-DART|S-NEWS", "1", "high", "", "CONFIRMED", ""),
("person:ceo", "Person", "name", "이부진", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("person:ceo", "Person", "jobTitle", "대표이사 사장", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
("person:ceo", "Person", "worksFor.@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
# ---- JobPosting (recruitment site) ----
("job:fo-manager", "JobPosting", "title", "프런트오피스 매니저", "ko", "https://recruit.shilla.net/job/1234", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "description", "더 신라 서울 프런트오피스 운영 총괄 및 VIP 응대.", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "datePosted", "2026-05-01", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "employmentType", "FULL_TIME", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "hiringOrganization.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "jobLocation.addressLocality", "서울", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
("job:fo-manager", "JobPosting", "jobLocation.addressCountry", "KR", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
# ---- VideoObject (official YouTube) ----
("video:brand-film", "VideoObject", "name", "The Shilla — Authentic Indulgence", "", "https://www.youtube.com/watch?v=XXXX", "S-YT", "1", "high", "", "CONFIRMED", ""),
("video:brand-film", "VideoObject", "description", "더 신라 브랜드 필름.", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
("video:brand-film", "VideoObject", "thumbnailUrl", "https://i.ytimg.com/vi/XXXX/maxresdefault.jpg", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
("video:brand-film", "VideoObject", "uploadDate", "2025-11-20", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
("video:brand-film", "VideoObject", "duration", "PT1M45S", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
("video:brand-film", "VideoObject", "publisher.@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
# ---- EXCLUSION GATE demonstrations (these must NOT appear in drafts) ----
("hotel:theshilla-seoul", "Hotel", "image", "https://example.com/seoul.jpg", "ko", "", "S-OFF", "3", "low", "", "PENDING", "이미지 최종본 미확정"),
("org:shilla", "Organization", "telephone", "+82-2-2233-3131", "", "", "S-OFF", "2", "med", "Y", "CONFIRMED", "대표번호 vs IR번호 출처 충돌"),
("person:ceo", "Person", "image", "", "", "", "", "", "", "", "CONFIRMED", "값 공란 -> 제외"),
]
HEADERS = ["entity_id", "entity_type", "property", "value", "lang", "url",
"source_ids", "authority", "confidence", "conflict", "status", "note"]
def main():
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = os.path.join(here, "fixtures", "sample_claims.csv")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(HEADERS)
w.writerows(ROWS)
print("Wrote", out, f"({len(ROWS)} claim rows)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,6 @@
# build_schema_drafts.py and extract_site_claims.py run on the Python standard
# library alone for CSV input and local-HTML extraction (the offline default).
#
# Optional extras, installed only when you need them:
openpyxl>=3.1 # read .xlsx claims registers
requests>=2.31 # fetch live URLs in extract_site_claims.py (Mode 1 over the network)

View File

@@ -0,0 +1,136 @@
{
"_meta": {
"purpose": "JSON-LD draft templates for source-to-schema authoring (pre-launch).",
"placeholder_syntax": "{{property.path}} — dotted paths map to claims-register 'property' column. Lines whose value stays unfilled are dropped (never shipped as placeholder).",
"aligned_with": "16-seo-schema-validator/scripts/schema_rules.json (required props match Google rich-result requirements)"
},
"templates": {
"Organization": {
"_source_hint": "DART, official site footer/about, sustainability report, Wikidata, newsroom",
"tpl": {
"@context": "https://schema.org",
"@type": "Organization",
"@id": "{{@id}}",
"name": "{{name}}",
"legalName": "{{legalName}}",
"url": "{{url}}",
"logo": "{{logo}}",
"sameAs": "{{sameAs[]}}",
"foundingDate": "{{foundingDate}}",
"address": {
"@type": "PostalAddress",
"streetAddress": "{{address.streetAddress}}",
"addressLocality": "{{address.addressLocality}}",
"addressRegion": "{{address.addressRegion}}",
"postalCode": "{{address.postalCode}}",
"addressCountry": "{{address.addressCountry}}"
},
"contactPoint": {
"@type": "ContactPoint",
"telephone": "{{contactPoint.telephone}}",
"contactType": "{{contactPoint.contactType}}"
}
}
},
"WebSite": {
"_source_hint": "official homepage; one per language site",
"tpl": {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "{{@id}}",
"name": "{{name}}",
"url": "{{url}}",
"inLanguage": "{{inLanguage}}",
"publisher": { "@id": "{{publisher.@id}}" }
}
},
"Hotel": {
"_source_hint": "property pages, brochure PDF, GBP, booking data; one per property per language",
"tpl": {
"@context": "https://schema.org",
"@type": "Hotel",
"@id": "{{@id}}",
"name": "{{name}}",
"url": "{{url}}",
"telephone": "{{telephone}}",
"priceRange": "{{priceRange}}",
"image": "{{image[]}}",
"brand": { "@type": "Brand", "name": "{{brand.name}}" },
"parentOrganization": { "@id": "{{parentOrganization.@id}}" },
"address": {
"@type": "PostalAddress",
"streetAddress": "{{address.streetAddress}}",
"addressLocality": "{{address.addressLocality}}",
"addressRegion": "{{address.addressRegion}}",
"postalCode": "{{address.postalCode}}",
"addressCountry": "{{address.addressCountry}}"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "{{geo.latitude}}",
"longitude": "{{geo.longitude}}"
}
}
},
"Person": {
"_source_hint": "executive bios, people-info sites, Wikipedia, press kit; one per person",
"tpl": {
"@context": "https://schema.org",
"@type": "Person",
"@id": "{{@id}}",
"name": "{{name}}",
"jobTitle": "{{jobTitle}}",
"worksFor": { "@id": "{{worksFor.@id}}" },
"url": "{{url}}",
"image": "{{image}}",
"sameAs": "{{sameAs[]}}"
}
},
"JobPosting": {
"_source_hint": "recruitment sites (채용공고); one per open role",
"tpl": {
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "{{title}}",
"description": "{{description}}",
"datePosted": "{{datePosted}}",
"validThrough": "{{validThrough}}",
"employmentType": "{{employmentType}}",
"hiringOrganization": { "@id": "{{hiringOrganization.@id}}" },
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{jobLocation.addressLocality}}",
"addressCountry": "{{jobLocation.addressCountry}}"
}
}
}
},
"VideoObject": {
"_source_hint": "official YouTube channel; one per featured video",
"tpl": {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "{{name}}",
"description": "{{description}}",
"thumbnailUrl": "{{thumbnailUrl[]}}",
"uploadDate": "{{uploadDate}}",
"duration": "{{duration}}",
"contentUrl": "{{contentUrl}}",
"embedUrl": "{{embedUrl}}",
"publisher": { "@id": "{{publisher.@id}}" }
}
},
"FAQPage": {
"_source_hint": "press kit FAQ, newsroom, distilled common questions; one per FAQ page",
"tpl": {
"@context": "https://schema.org",
"@type": "FAQPage",
"url": "{{url}}",
"inLanguage": "{{inLanguage}}",
"mainEntity": "{{mainEntity[]}}"
}
}
}
}