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:
@@ -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()
|
||||
@@ -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())
|
||||
87
custom-skills/17-seo-schema-generator/scripts/make_sample.py
Normal file
87
custom-skills/17-seo-schema-generator/scripts/make_sample.py
Normal 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()
|
||||
@@ -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)
|
||||
@@ -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[]}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user