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

@@ -39,7 +39,7 @@ This is a Claude Skills collection repository containing:
| 14 | seo-core-web-vitals | LCP, CLS, FID, INP metrics | "Core Web Vitals", "page speed" | | 14 | seo-core-web-vitals | LCP, CLS, FID, INP metrics | "Core Web Vitals", "page speed" |
| 15 | seo-search-console | GSC data analysis | "Search Console", "rankings" | | 15 | seo-search-console | GSC data analysis | "Search Console", "rankings" |
| 16 | seo-schema-validator | Structured data validation | "validate schema", "JSON-LD" | | 16 | seo-schema-validator | Structured data validation | "validate schema", "JSON-LD" |
| 17 | seo-schema-generator | Schema markup creation | "generate schema", "create JSON-LD" | | 17 | seo-schema-generator | JSON-LD generation — Mode 1 from existing site, Mode 2 from collected sources → claims register → drafts → validate (16) | "generate schema", "create JSON-LD", "source-to-schema", "schema from site" |
| 18 | seo-local-audit | NAP, GBP, citations | "local SEO", "Google Business Profile" | | 18 | seo-local-audit | NAP, GBP, citations | "local SEO", "Google Business Profile" |
| 19 | seo-keyword-strategy | Keyword expansion, intent, clustering, gaps | "keyword research", "keyword strategy" | | 19 | seo-keyword-strategy | Keyword expansion, intent, clustering, gaps | "keyword research", "keyword strategy" |
| 20 | seo-serp-analysis | Google/Naver SERP features, competitor positions | "SERP analysis", "SERP features" | | 20 | seo-serp-analysis | Google/Naver SERP features, competitor positions | "SERP analysis", "SERP features" |

View File

@@ -0,0 +1,126 @@
---
name: 17-seo-schema-generator
description: |
Generates validation-ready JSON-LD structured data for a site, covering BOTH
scenarios: (1) from an existing website — extract facts from live pages; and
(2) from collected sources for a not-yet-published site — reconcile conflicting
facts into a provenance-tracked claims register. Both modes emit the same claims
register, build pruned drafts from type templates (no placeholders shipped), and
hand off to 16-seo-schema-validator (generate -> validate, gate = zero P0).
Triggers: generate schema, create JSON-LD, schema markup, structured data generator,
source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작,
구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출.
version: "2.0"
author: OurDigital / D.intelligence
environment: Code
---
# SEO Schema Generator (17)
Author JSON-LD for a site — whether the pages already exist or the site is not yet
published. Both cases are error-prone for the same reason: facts must be turned into
schema without leaking conflicts, gaps, or placeholders. This skill makes that reliable
by routing **both scenarios through one pivot — a claims register** — then generating
pruned drafts that hand off cleanly to the `16-seo-schema-validator` gate.
**Generate (17) → Validate (16).** This skill produces drafts; 16 is the QA gate.
## Two modes, one pipeline
The only thing that differs between the scenarios is **where facts come from**.
Everything after the claims register is identical.
| | **Mode 1 — from an existing site** | **Mode 2 — from collected sources** |
|---|---|---|
| When | Site has pages but lacks (or needs better) schema | Site not published yet (no DOM) |
| Source of truth | the live pages | scattered, conflicting sources (DART, Wikidata, brochures) |
| Seed the register with | `scripts/extract_site_claims.py` | manual research → `templates/claims-register.csv` |
| Hard part | extraction & mapping | authority hierarchy + entity reconciliation |
| Conflicts | rare (one source) | frequent → resolve before shipping |
```
Mode 1 (extract_site_claims.py)─┐
├─▶ claims_register.csv ─▶ build_schema_drafts.py ─▶ drafts/*.jsonld
Mode 2 (research + register)────┘ (the pivot) └─▶ schema_drafts_dataset.csv
16-seo-schema-validator ▼ (gate: zero P0)
```
## The claims register — the core idea
A **claims register** is a provenance-tracked, conflict-resolved fact table. Columns:
`entity_id, entity_type, property, value, lang, url, source_ids, authority, confidence,
conflict, status, note`. Dotted `property` paths nest (`address.streetAddress`);
pipe-separate array values (`a|b|c`).
**Only `CONFIRMED`, non-conflicting claims become schema.** Everything else (PENDING,
CONFLICT, REJECTED, EMPTY) is excluded and reported — never shipped. An unfilled
template slot is **deleted**, never emitted as `{{…}}` or `TODO` (placeholder leakage
is the #1 pre-launch P0).
## How to run
```bash
# Try the bundled sample first (Mode 2)
python scripts/make_sample.py
python scripts/build_schema_drafts.py fixtures/sample_claims.csv --out drafts_out
# MODE 1 — existing site → register (URLs, or local .html / a directory offline)
python scripts/extract_site_claims.py https://example.com/ https://example.com/about \
--out site_claims
# review site_claims/claims_register.csv (confirm PENDING rows), then build:
python scripts/build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
# MODE 2 — collected sources → register (fill templates/claims-register.csv by hand)
python scripts/build_schema_drafts.py path/to/claims_register.csv --out drafts_out
# HAND OFF TO THE GATE (must reach zero P0)
python ../16-seo-schema-validator/scripts/validate_schema.py \
drafts_out/schema_drafts_dataset.csv --out qa_out
```
## Outputs
- `drafts/*.jsonld` — one pruned draft per entity (× language).
- `schema_drafts_dataset.csv` — directly consumable by `16-seo-schema-validator`.
- `build_report.md` — entities built + **excluded claims** (PENDING / CONFLICT / EMPTY) with reasons.
- (Mode 1 also) `claims_register.csv` + `extraction_report.md`.
## Stage gates (설계→개발→테스트→안정화→런칭 후)
- **G1 설계** — Lock the entity→type map (`references/entity-and-type-map.md`). Mode 2: source
register complete (≥2 sources/entity). *DoD:* every entity has an assigned type + required list.
- **G2 개발** — Seed the register (Mode 1 extract / Mode 2 research), reconcile to `CONFIRMED`,
conflicts = 0, run the builder → drafts have **zero placeholders**.
- **G3 테스트** — Validate (16): **zero P0**; triage P1; fact-accuracy sign-off via `templates/review-guide.md` (report-based, not raw JSON).
- **G4 안정화** — Google Rich Results Test green on a sample; re-run shows no regression.
- **G5 런칭 후** — live schema == drafts; GSC "Rich results" no new errors.
## References & templates
- Mode 1 SOP: `references/site-extraction-methodology.md`.
- Mode 2 SOP (9 steps): `references/source-to-schema-methodology.md`.
- Source authority ranking: `references/source-authority-hierarchy.md`.
- Entity→type scoping: `references/entity-and-type-map.md`.
- Registers + review guide: `templates/claims-register.csv`, `templates/source-register.csv`, `templates/review-guide.md`.
## Templates included
`scripts/type_templates.json` covers Organization, WebSite, Hotel, Person, JobPosting,
VideoObject, FAQPage. Required props are aligned with the validator's rule set, so a
fully confirmed entity passes the gate. **Add a type = add a template block (edit JSON only).**
## Limits & honesty
- Quality of drafts == quality of the register. Garbage-in still produces gaps — but
reported, never as placeholders.
- Mode 1 inference (title/OpenGraph) is seeded as `PENDING` and will NOT ship until a
human confirms it; existing JSON-LD is seeded `CONFIRMED`. If a site already has good
JSON-LD, prefer auditing it directly with `16` Mode B.
- Authoritative rich-result eligibility still needs Google's online test on a sample at G4.
## Integration
- **→ 16-seo-schema-validator**: the dataset CSV is the handoff; the gate is `zero P0`.
- **→ seo-comprehensive-audit**: post-launch (G5) uses the validator's Mode B as audit stage 4.
- This skill is a one-time-per-site authoring workflow, **not** an audit-pipeline stage.

View File

@@ -1,156 +0,0 @@
# CLAUDE.md
## Overview
Schema markup generator: create JSON-LD structured data from templates for various content types.
## Quick Start
```bash
pip install -r scripts/requirements.txt
# Generate Organization schema
python scripts/schema_generator.py --type organization --url https://example.com
# Generate from template
python scripts/schema_generator.py --template templates/article.json --data article_data.json
```
## Scripts
| Script | Purpose |
|--------|---------|
| `schema_generator.py` | Generate schema markup |
| `base_client.py` | Shared utilities |
## Supported Schema Types
| Type | Template | Use Case |
|------|----------|----------|
| Organization | `organization.json` | Company/brand info |
| LocalBusiness | `local_business.json` | Physical locations |
| Article | `article.json` | Blog posts, news |
| Product | `product.json` | E-commerce items |
| FAQPage | `faq.json` | FAQ sections |
| BreadcrumbList | `breadcrumb.json` | Navigation path |
| WebSite | `website.json` | Site-level info |
## Usage Examples
### Organization
```bash
python scripts/schema_generator.py --type organization \
--name "Company Name" \
--url "https://example.com" \
--logo "https://example.com/logo.png"
```
### LocalBusiness
```bash
python scripts/schema_generator.py --type localbusiness \
--name "Restaurant Name" \
--address "123 Main St, City, State 12345" \
--phone "+1-555-123-4567" \
--hours "Mo-Fr 09:00-17:00"
```
### Article
```bash
python scripts/schema_generator.py --type article \
--headline "Article Title" \
--author "Author Name" \
--published "2024-01-15" \
--image "https://example.com/image.jpg"
```
### FAQPage
```bash
python scripts/schema_generator.py --type faq \
--questions questions.json
```
## Output
Generated JSON-LD ready for insertion:
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Company Name",
"url": "https://example.com",
"logo": "https://example.com/logo.png"
}
</script>
```
## Template Customization
Templates in `templates/` can be modified. Required fields are marked:
```json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "{{REQUIRED}}",
"author": {
"@type": "Person",
"name": "{{REQUIRED}}"
},
"datePublished": "{{REQUIRED}}",
"image": "{{RECOMMENDED}}"
}
```
## Validation
Generated schemas are validated before output:
- Syntax correctness
- Required properties present
- Schema.org vocabulary compliance
Use skill 13 (schema-validator) for additional validation.
## Dependencies
```
jsonschema>=4.21.0
requests>=2.31.0
python-dotenv>=1.0.0
```
## Notion Output (Required)
**IMPORTANT**: All audit reports MUST be saved to the OurDigital SEO Audit Log database.
### Database Configuration
| Field | Value |
|-------|-------|
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
| URL | https://www.notion.so/dintelligence/2c8581e58a1e8035880be38cefc2f3ef |
### Required Properties
| Property | Type | Description |
|----------|------|-------------|
| Issue | Title | Report title (Korean + date) |
| Site | URL | Audited website URL |
| Category | Select | Technical SEO, On-page SEO, Performance, Schema/Structured Data, Sitemap, Robots.txt, Content, Local SEO |
| Priority | Select | Critical, High, Medium, Low |
| Found Date | Date | Audit date (YYYY-MM-DD) |
| Audit ID | Rich Text | Format: [TYPE]-YYYYMMDD-NNN |
### Language Guidelines
- Report content in Korean (한국어)
- Keep technical English terms as-is (e.g., SEO Audit, Core Web Vitals, Schema Markup)
- URLs and code remain unchanged
### Example MCP Call
```bash
mcp-cli call notion/API-post-page '{"parent": {"database_id": "2c8581e5-8a1e-8035-880b-e38cefc2f3ef"}, "properties": {...}}'
```

View File

@@ -1,207 +0,0 @@
"""
Base Client - Shared async client utilities
===========================================
Purpose: Rate-limited async operations for API clients
Python: 3.10+
"""
import asyncio
import logging
import os
from asyncio import Semaphore
from datetime import datetime
from typing import Any, Callable, TypeVar
from dotenv import load_dotenv
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
# Load environment variables
load_dotenv()
# Logging setup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
T = TypeVar("T")
class RateLimiter:
"""Rate limiter using token bucket algorithm."""
def __init__(self, rate: float, per: float = 1.0):
"""
Initialize rate limiter.
Args:
rate: Number of requests allowed
per: Time period in seconds (default: 1 second)
"""
self.rate = rate
self.per = per
self.tokens = rate
self.last_update = datetime.now()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
"""Acquire a token, waiting if necessary."""
async with self._lock:
now = datetime.now()
elapsed = (now - self.last_update).total_seconds()
self.tokens = min(self.rate, self.tokens + elapsed * (self.rate / self.per))
self.last_update = now
if self.tokens < 1:
wait_time = (1 - self.tokens) * (self.per / self.rate)
await asyncio.sleep(wait_time)
self.tokens = 0
else:
self.tokens -= 1
class BaseAsyncClient:
"""Base class for async API clients with rate limiting."""
def __init__(
self,
max_concurrent: int = 5,
requests_per_second: float = 3.0,
logger: logging.Logger | None = None,
):
"""
Initialize base client.
Args:
max_concurrent: Maximum concurrent requests
requests_per_second: Rate limit
logger: Logger instance
"""
self.semaphore = Semaphore(max_concurrent)
self.rate_limiter = RateLimiter(requests_per_second)
self.logger = logger or logging.getLogger(self.__class__.__name__)
self.stats = {
"requests": 0,
"success": 0,
"errors": 0,
"retries": 0,
}
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(Exception),
)
async def _rate_limited_request(
self,
coro: Callable[[], Any],
) -> Any:
"""Execute a request with rate limiting and retry."""
async with self.semaphore:
await self.rate_limiter.acquire()
self.stats["requests"] += 1
try:
result = await coro()
self.stats["success"] += 1
return result
except Exception as e:
self.stats["errors"] += 1
self.logger.error(f"Request failed: {e}")
raise
async def batch_requests(
self,
requests: list[Callable[[], Any]],
desc: str = "Processing",
) -> list[Any]:
"""Execute multiple requests concurrently."""
try:
from tqdm.asyncio import tqdm
has_tqdm = True
except ImportError:
has_tqdm = False
async def execute(req: Callable) -> Any:
try:
return await self._rate_limited_request(req)
except Exception as e:
return {"error": str(e)}
tasks = [execute(req) for req in requests]
if has_tqdm:
results = []
for coro in tqdm.as_completed(tasks, total=len(tasks), desc=desc):
result = await coro
results.append(result)
return results
else:
return await asyncio.gather(*tasks, return_exceptions=True)
def print_stats(self) -> None:
"""Print request statistics."""
self.logger.info("=" * 40)
self.logger.info("Request Statistics:")
self.logger.info(f" Total Requests: {self.stats['requests']}")
self.logger.info(f" Successful: {self.stats['success']}")
self.logger.info(f" Errors: {self.stats['errors']}")
self.logger.info("=" * 40)
class ConfigManager:
"""Manage API configuration and credentials."""
def __init__(self):
load_dotenv()
@property
def google_credentials_path(self) -> str | None:
"""Get Google service account credentials path."""
# Prefer SEO-specific credentials, fallback to general credentials
seo_creds = os.path.expanduser("~/.credential/ourdigital-seo-agent.json")
if os.path.exists(seo_creds):
return seo_creds
return os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
@property
def pagespeed_api_key(self) -> str | None:
"""Get PageSpeed Insights API key."""
return os.getenv("PAGESPEED_API_KEY")
@property
def custom_search_api_key(self) -> str | None:
"""Get Custom Search API key."""
return os.getenv("CUSTOM_SEARCH_API_KEY")
@property
def custom_search_engine_id(self) -> str | None:
"""Get Custom Search Engine ID."""
return os.getenv("CUSTOM_SEARCH_ENGINE_ID")
@property
def notion_token(self) -> str | None:
"""Get Notion API token."""
return os.getenv("NOTION_TOKEN") or os.getenv("NOTION_API_KEY")
def validate_google_credentials(self) -> bool:
"""Validate Google credentials are configured."""
creds_path = self.google_credentials_path
if not creds_path:
return False
return os.path.exists(creds_path)
def get_required(self, key: str) -> str:
"""Get required environment variable or raise error."""
value = os.getenv(key)
if not value:
raise ValueError(f"Missing required environment variable: {key}")
return value
# Singleton config instance
config = ConfigManager()

View File

@@ -1,6 +0,0 @@
# 14-seo-schema-generator dependencies
jsonschema>=4.21.0
requests>=2.31.0
python-dotenv>=1.0.0
rich>=13.7.0
typer>=0.9.0

View File

@@ -1,490 +0,0 @@
"""
Schema Generator - Generate JSON-LD structured data markup
==========================================================
Purpose: Generate schema.org structured data in JSON-LD format
Python: 3.10+
Usage:
python schema_generator.py --type organization --name "Company Name" --url "https://example.com"
"""
import argparse
import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Template directory relative to this script
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "schema_templates"
class SchemaGenerator:
"""Generate JSON-LD schema markup from templates."""
SCHEMA_TYPES = {
"organization": "organization.json",
"local_business": "local_business.json",
"product": "product.json",
"article": "article.json",
"faq": "faq.json",
"breadcrumb": "breadcrumb.json",
"website": "website.json",
}
# Business type mappings for LocalBusiness
BUSINESS_TYPES = {
"restaurant": "Restaurant",
"cafe": "CafeOrCoffeeShop",
"bar": "BarOrPub",
"hotel": "Hotel",
"store": "Store",
"medical": "MedicalBusiness",
"dental": "Dentist",
"legal": "LegalService",
"real_estate": "RealEstateAgent",
"auto": "AutoRepair",
"beauty": "BeautySalon",
"gym": "HealthClub",
"spa": "DaySpa",
}
# Article type mappings
ARTICLE_TYPES = {
"article": "Article",
"blog": "BlogPosting",
"news": "NewsArticle",
"tech": "TechArticle",
"scholarly": "ScholarlyArticle",
}
def __init__(self, template_dir: Path = TEMPLATE_DIR):
self.template_dir = template_dir
def load_template(self, schema_type: str) -> dict:
"""Load a schema template file."""
if schema_type not in self.SCHEMA_TYPES:
raise ValueError(f"Unknown schema type: {schema_type}. "
f"Available: {list(self.SCHEMA_TYPES.keys())}")
template_file = self.template_dir / self.SCHEMA_TYPES[schema_type]
if not template_file.exists():
raise FileNotFoundError(f"Template not found: {template_file}")
with open(template_file, "r", encoding="utf-8") as f:
return json.load(f)
def fill_template(self, template: dict, data: dict[str, Any]) -> dict:
"""Fill template placeholders with actual data."""
template_str = json.dumps(template, ensure_ascii=False)
# Replace placeholders {{key}} with values
for key, value in data.items():
placeholder = f"{{{{{key}}}}}"
if value is not None:
template_str = template_str.replace(placeholder, str(value))
# Remove unfilled placeholders and their parent objects if empty
result = json.loads(template_str)
return self._clean_empty_values(result)
def _clean_empty_values(self, obj: Any) -> Any:
"""Remove empty values and unfilled placeholders."""
if isinstance(obj, dict):
cleaned = {}
for key, value in obj.items():
cleaned_value = self._clean_empty_values(value)
# Skip if value is empty, None, or unfilled placeholder
if cleaned_value is None:
continue
if isinstance(cleaned_value, str) and cleaned_value.startswith("{{"):
continue
if isinstance(cleaned_value, (list, dict)) and not cleaned_value:
continue
cleaned[key] = cleaned_value
return cleaned if cleaned else None
elif isinstance(obj, list):
cleaned = []
for item in obj:
cleaned_item = self._clean_empty_values(item)
if cleaned_item is not None:
if isinstance(cleaned_item, str) and cleaned_item.startswith("{{"):
continue
cleaned.append(cleaned_item)
return cleaned if cleaned else None
elif isinstance(obj, str):
if obj.startswith("{{") and obj.endswith("}}"):
return None
return obj
return obj
def generate_organization(
self,
name: str,
url: str,
logo_url: str | None = None,
description: str | None = None,
founding_date: str | None = None,
phone: str | None = None,
address: dict | None = None,
social_links: list[str] | None = None,
) -> dict:
"""Generate Organization schema."""
template = self.load_template("organization")
data = {
"name": name,
"url": url,
"logo_url": logo_url,
"description": description,
"founding_date": founding_date,
"phone": phone,
}
if address:
data.update({
"street_address": address.get("street"),
"city": address.get("city"),
"region": address.get("region"),
"postal_code": address.get("postal_code"),
"country": address.get("country", "KR"),
})
if social_links:
# Handle social links specially
pass
return self.fill_template(template, data)
def generate_local_business(
self,
name: str,
business_type: str,
address: dict,
phone: str | None = None,
url: str | None = None,
description: str | None = None,
hours: dict | None = None,
geo: dict | None = None,
price_range: str | None = None,
rating: float | None = None,
review_count: int | None = None,
) -> dict:
"""Generate LocalBusiness schema."""
template = self.load_template("local_business")
schema_business_type = self.BUSINESS_TYPES.get(
business_type.lower(), "LocalBusiness"
)
data = {
"business_type": schema_business_type,
"name": name,
"url": url,
"description": description,
"phone": phone,
"price_range": price_range,
"street_address": address.get("street"),
"city": address.get("city"),
"region": address.get("region"),
"postal_code": address.get("postal_code"),
"country": address.get("country", "KR"),
}
if geo:
data["latitude"] = geo.get("lat")
data["longitude"] = geo.get("lng")
if hours:
data.update({
"weekday_opens": hours.get("weekday_opens", "09:00"),
"weekday_closes": hours.get("weekday_closes", "18:00"),
"weekend_opens": hours.get("weekend_opens"),
"weekend_closes": hours.get("weekend_closes"),
})
if rating is not None:
data["rating"] = str(rating)
data["review_count"] = str(review_count or 0)
return self.fill_template(template, data)
def generate_product(
self,
name: str,
description: str,
price: float,
currency: str = "KRW",
brand: str | None = None,
sku: str | None = None,
images: list[str] | None = None,
availability: str = "InStock",
condition: str = "NewCondition",
rating: float | None = None,
review_count: int | None = None,
url: str | None = None,
seller: str | None = None,
) -> dict:
"""Generate Product schema."""
template = self.load_template("product")
data = {
"name": name,
"description": description,
"price": str(int(price)),
"currency": currency,
"brand_name": brand,
"sku": sku,
"product_url": url,
"availability": availability,
"condition": condition,
"seller_name": seller,
}
if images:
for i, img in enumerate(images[:3], 1):
data[f"image_url_{i}"] = img
if rating is not None:
data["rating"] = str(rating)
data["review_count"] = str(review_count or 0)
return self.fill_template(template, data)
def generate_article(
self,
headline: str,
description: str,
author_name: str,
date_published: str,
publisher_name: str,
article_type: str = "article",
date_modified: str | None = None,
images: list[str] | None = None,
page_url: str | None = None,
publisher_logo: str | None = None,
author_url: str | None = None,
section: str | None = None,
word_count: int | None = None,
keywords: str | None = None,
) -> dict:
"""Generate Article schema."""
template = self.load_template("article")
schema_article_type = self.ARTICLE_TYPES.get(
article_type.lower(), "Article"
)
data = {
"article_type": schema_article_type,
"headline": headline,
"description": description,
"author_name": author_name,
"author_url": author_url,
"date_published": date_published,
"date_modified": date_modified or date_published,
"publisher_name": publisher_name,
"publisher_logo_url": publisher_logo,
"page_url": page_url,
"section": section,
"word_count": str(word_count) if word_count else None,
"keywords": keywords,
}
if images:
for i, img in enumerate(images[:2], 1):
data[f"image_url_{i}"] = img
return self.fill_template(template, data)
def generate_faq(self, questions: list[dict[str, str]]) -> dict:
"""Generate FAQPage schema."""
schema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [],
}
for qa in questions:
schema["mainEntity"].append({
"@type": "Question",
"name": qa["question"],
"acceptedAnswer": {
"@type": "Answer",
"text": qa["answer"],
},
})
return schema
def generate_breadcrumb(self, items: list[dict[str, str]]) -> dict:
"""Generate BreadcrumbList schema."""
schema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [],
}
for i, item in enumerate(items, 1):
schema["itemListElement"].append({
"@type": "ListItem",
"position": i,
"name": item["name"],
"item": item["url"],
})
return schema
def generate_website(
self,
name: str,
url: str,
search_url_template: str | None = None,
description: str | None = None,
language: str = "ko-KR",
publisher_name: str | None = None,
logo_url: str | None = None,
alternate_name: str | None = None,
) -> dict:
"""Generate WebSite schema."""
template = self.load_template("website")
data = {
"site_name": name,
"url": url,
"description": description,
"language": language,
"search_url_template": search_url_template,
"publisher_name": publisher_name or name,
"logo_url": logo_url,
"alternate_name": alternate_name,
}
return self.fill_template(template, data)
def to_json_ld(self, schema: dict, pretty: bool = True) -> str:
"""Convert schema dict to JSON-LD string."""
indent = 2 if pretty else None
return json.dumps(schema, ensure_ascii=False, indent=indent)
def to_html_script(self, schema: dict) -> str:
"""Wrap schema in HTML script tag."""
json_ld = self.to_json_ld(schema)
return f'<script type="application/ld+json">\n{json_ld}\n</script>'
def main():
"""Main entry point for CLI usage."""
parser = argparse.ArgumentParser(
description="Generate JSON-LD schema markup",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate Organization schema
python schema_generator.py --type organization --name "My Company" --url "https://example.com"
# Generate Product schema
python schema_generator.py --type product --name "Widget" --price 29900 --currency KRW
# Generate Article schema
python schema_generator.py --type article --headline "Article Title" --author "John Doe"
""",
)
parser.add_argument(
"--type", "-t",
required=True,
choices=SchemaGenerator.SCHEMA_TYPES.keys(),
help="Schema type to generate",
)
parser.add_argument("--name", help="Name/title")
parser.add_argument("--url", help="URL")
parser.add_argument("--description", help="Description")
parser.add_argument("--price", type=float, help="Price (for product)")
parser.add_argument("--currency", default="KRW", help="Currency code")
parser.add_argument("--headline", help="Headline (for article)")
parser.add_argument("--author", help="Author name")
parser.add_argument("--output", "-o", help="Output file path")
parser.add_argument("--html", action="store_true", help="Output as HTML script tag")
args = parser.parse_args()
generator = SchemaGenerator()
try:
if args.type == "organization":
schema = generator.generate_organization(
name=args.name or "Organization Name",
url=args.url or "https://example.com",
description=args.description,
)
elif args.type == "product":
schema = generator.generate_product(
name=args.name or "Product Name",
description=args.description or "Product description",
price=args.price or 0,
currency=args.currency,
)
elif args.type == "article":
schema = generator.generate_article(
headline=args.headline or args.name or "Article Title",
description=args.description or "Article description",
author_name=args.author or "Author",
date_published=datetime.now().strftime("%Y-%m-%d"),
publisher_name="Publisher",
)
elif args.type == "website":
schema = generator.generate_website(
name=args.name or "Website Name",
url=args.url or "https://example.com",
description=args.description,
)
elif args.type == "faq":
# Example FAQ
schema = generator.generate_faq([
{"question": "Question 1?", "answer": "Answer 1"},
{"question": "Question 2?", "answer": "Answer 2"},
])
elif args.type == "breadcrumb":
# Example breadcrumb
schema = generator.generate_breadcrumb([
{"name": "Home", "url": "https://example.com/"},
{"name": "Category", "url": "https://example.com/category/"},
])
elif args.type == "local_business":
schema = generator.generate_local_business(
name=args.name or "Business Name",
business_type="store",
address={"street": "123 Main St", "city": "Seoul", "country": "KR"},
url=args.url,
description=args.description,
)
else:
raise ValueError(f"Unsupported type: {args.type}")
if args.html:
output = generator.to_html_script(schema)
else:
output = generator.to_json_ld(schema)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(output)
logger.info(f"Schema written to {args.output}")
else:
print(output)
except Exception as e:
logger.error(f"Error generating schema: {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
{
"@context": "https://schema.org",
"@type": "{{article_type}}",
"headline": "{{headline}}",
"description": "{{description}}",
"image": [
"{{image_url_1}}",
"{{image_url_2}}"
],
"datePublished": "{{date_published}}",
"dateModified": "{{date_modified}}",
"author": {
"@type": "Person",
"name": "{{author_name}}",
"url": "{{author_url}}"
},
"publisher": {
"@type": "Organization",
"name": "{{publisher_name}}",
"logo": {
"@type": "ImageObject",
"url": "{{publisher_logo_url}}"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{page_url}}"
},
"articleSection": "{{section}}",
"wordCount": "{{word_count}}",
"keywords": "{{keywords}}"
}

View File

@@ -1,24 +0,0 @@
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "{{level_1_name}}",
"item": "{{level_1_url}}"
},
{
"@type": "ListItem",
"position": 2,
"name": "{{level_2_name}}",
"item": "{{level_2_url}}"
},
{
"@type": "ListItem",
"position": 3,
"name": "{{level_3_name}}",
"item": "{{level_3_url}}"
}
]
}

View File

@@ -1,30 +0,0 @@
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "{{question_1}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_1}}"
}
},
{
"@type": "Question",
"name": "{{question_2}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_2}}"
}
},
{
"@type": "Question",
"name": "{{question_3}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_3}}"
}
}
]
}

View File

@@ -1,47 +0,0 @@
{
"@context": "https://schema.org",
"@type": "{{business_type}}",
"name": "{{name}}",
"description": "{{description}}",
"url": "{{url}}",
"telephone": "{{phone}}",
"email": "{{email}}",
"image": "{{image_url}}",
"priceRange": "{{price_range}}",
"address": {
"@type": "PostalAddress",
"streetAddress": "{{street_address}}",
"addressLocality": "{{city}}",
"addressRegion": "{{region}}",
"postalCode": "{{postal_code}}",
"addressCountry": "{{country}}"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "{{latitude}}",
"longitude": "{{longitude}}"
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "{{weekday_opens}}",
"closes": "{{weekday_closes}}"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday", "Sunday"],
"opens": "{{weekend_opens}}",
"closes": "{{weekend_closes}}"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{rating}}",
"reviewCount": "{{review_count}}"
},
"sameAs": [
"{{facebook_url}}",
"{{instagram_url}}"
]
}

View File

@@ -1,37 +0,0 @@
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "{{name}}",
"url": "{{url}}",
"logo": "{{logo_url}}",
"description": "{{description}}",
"foundingDate": "{{founding_date}}",
"founders": [
{
"@type": "Person",
"name": "{{founder_name}}"
}
],
"address": {
"@type": "PostalAddress",
"streetAddress": "{{street_address}}",
"addressLocality": "{{city}}",
"addressRegion": "{{region}}",
"postalCode": "{{postal_code}}",
"addressCountry": "{{country}}"
},
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "{{phone}}",
"contactType": "customer service",
"availableLanguage": ["Korean", "English"]
}
],
"sameAs": [
"{{facebook_url}}",
"{{twitter_url}}",
"{{linkedin_url}}",
"{{instagram_url}}"
]
}

View File

@@ -1,76 +0,0 @@
{
"@context": "https://schema.org",
"@type": "Product",
"name": "{{name}}",
"description": "{{description}}",
"image": [
"{{image_url_1}}",
"{{image_url_2}}",
"{{image_url_3}}"
],
"sku": "{{sku}}",
"mpn": "{{mpn}}",
"gtin13": "{{gtin13}}",
"brand": {
"@type": "Brand",
"name": "{{brand_name}}"
},
"offers": {
"@type": "Offer",
"url": "{{product_url}}",
"price": "{{price}}",
"priceCurrency": "{{currency}}",
"priceValidUntil": "{{price_valid_until}}",
"availability": "https://schema.org/{{availability}}",
"itemCondition": "https://schema.org/{{condition}}",
"seller": {
"@type": "Organization",
"name": "{{seller_name}}"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "{{shipping_cost}}",
"currency": "{{currency}}"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": "{{handling_min_days}}",
"maxValue": "{{handling_max_days}}",
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": "{{transit_min_days}}",
"maxValue": "{{transit_max_days}}",
"unitCode": "DAY"
}
}
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{rating}}",
"reviewCount": "{{review_count}}",
"bestRating": "5",
"worstRating": "1"
},
"review": [
{
"@type": "Review",
"reviewRating": {
"@type": "Rating",
"ratingValue": "{{review_rating}}",
"bestRating": "5"
},
"author": {
"@type": "Person",
"name": "{{reviewer_name}}"
},
"reviewBody": "{{review_text}}"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{{site_name}}",
"alternateName": "{{alternate_name}}",
"url": "{{url}}",
"description": "{{description}}",
"inLanguage": "{{language}}",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "{{search_url_template}}"
},
"query-input": "required name=search_term_string"
},
"publisher": {
"@type": "Organization",
"name": "{{publisher_name}}",
"logo": {
"@type": "ImageObject",
"url": "{{logo_url}}"
}
}
}

View File

@@ -1,155 +0,0 @@
---
name: seo-schema-generator
description: |
JSON-LD structured data generator from templates for various content types.
Triggers: generate schema, create JSON-LD, schema markup, structured data generator.
---
# SEO Schema Generator
## Purpose
Generate JSON-LD structured data markup for various content types using templates.
## Core Capabilities
1. **Organization** - Company/brand information
2. **LocalBusiness** - Physical location businesses
3. **Article** - Blog posts and news articles
4. **Product** - E-commerce products
5. **FAQPage** - FAQ sections
6. **BreadcrumbList** - Navigation breadcrumbs
7. **WebSite** - Site-level with search action
## Workflow
1. Identify content type
2. Gather required information
3. Generate JSON-LD from template
4. Validate output
5. Provide implementation instructions
## Schema Templates
### Organization
```json
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "[Company Name]",
"url": "[Website URL]",
"logo": "[Logo URL]",
"sameAs": [
"[Social Media URLs]"
]
}
```
### LocalBusiness
```json
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "[Business Name]",
"address": {
"@type": "PostalAddress",
"streetAddress": "[Street]",
"addressLocality": "[City]",
"addressRegion": "[State]",
"postalCode": "[ZIP]",
"addressCountry": "[Country]"
},
"telephone": "[Phone]",
"openingHours": ["Mo-Fr 09:00-17:00"]
}
```
### Article
```json
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "[Title]",
"author": {
"@type": "Person",
"name": "[Author Name]"
},
"datePublished": "[YYYY-MM-DD]",
"dateModified": "[YYYY-MM-DD]",
"image": "[Image URL]",
"publisher": {
"@type": "Organization",
"name": "[Publisher]",
"logo": "[Logo URL]"
}
}
```
### FAQPage
```json
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "[Question]",
"acceptedAnswer": {
"@type": "Answer",
"text": "[Answer]"
}
}
]
}
```
### Product
```json
{
"@context": "https://schema.org",
"@type": "Product",
"name": "[Product Name]",
"image": "[Image URL]",
"description": "[Description]",
"offers": {
"@type": "Offer",
"price": "[Price]",
"priceCurrency": "[Currency]",
"availability": "https://schema.org/InStock"
}
}
```
## Implementation
Place generated JSON-LD in `<head>` section:
```html
<head>
<script type="application/ld+json">
[Generated Schema Here]
</script>
</head>
```
## Validation
After generating:
1. Use schema validator skill (13) to verify
2. Test with Google Rich Results Test
3. Monitor in Search Console
## Limitations
- Templates cover common types only
- Complex nested schemas may need manual adjustment
- Some Rich Results require additional properties
## Notion Output (Required)
All audit reports MUST be saved to OurDigital SEO Audit Log:
- **Database ID**: `2c8581e5-8a1e-8035-880b-e38cefc2f3ef`
- **Properties**: Issue (title), Site (url), Category, Priority, Found Date, Audit ID
- **Language**: Korean with English technical terms
- **Audit ID Format**: [TYPE]-YYYYMMDD-NNN

View File

@@ -1,12 +0,0 @@
# Skill metadata (extracted from SKILL.md frontmatter)
name: seo-schema-generator
description: |
Schema markup generator for JSON-LD structured data. Triggers: generate schema, create JSON-LD, add structured data, schema markup.
# Optional fields
allowed-tools:
- mcp__firecrawl__*
- mcp__perplexity__*
# triggers: [] # TODO: Extract from description

View File

@@ -1,32 +0,0 @@
{
"@context": "https://schema.org",
"@type": "{{article_type}}",
"headline": "{{headline}}",
"description": "{{description}}",
"image": [
"{{image_url_1}}",
"{{image_url_2}}"
],
"datePublished": "{{date_published}}",
"dateModified": "{{date_modified}}",
"author": {
"@type": "Person",
"name": "{{author_name}}",
"url": "{{author_url}}"
},
"publisher": {
"@type": "Organization",
"name": "{{publisher_name}}",
"logo": {
"@type": "ImageObject",
"url": "{{publisher_logo_url}}"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{page_url}}"
},
"articleSection": "{{section}}",
"wordCount": "{{word_count}}",
"keywords": "{{keywords}}"
}

View File

@@ -1,24 +0,0 @@
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "{{level_1_name}}",
"item": "{{level_1_url}}"
},
{
"@type": "ListItem",
"position": 2,
"name": "{{level_2_name}}",
"item": "{{level_2_url}}"
},
{
"@type": "ListItem",
"position": 3,
"name": "{{level_3_name}}",
"item": "{{level_3_url}}"
}
]
}

View File

@@ -1,30 +0,0 @@
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "{{question_1}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_1}}"
}
},
{
"@type": "Question",
"name": "{{question_2}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_2}}"
}
},
{
"@type": "Question",
"name": "{{question_3}}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{answer_3}}"
}
}
]
}

View File

@@ -1,47 +0,0 @@
{
"@context": "https://schema.org",
"@type": "{{business_type}}",
"name": "{{name}}",
"description": "{{description}}",
"url": "{{url}}",
"telephone": "{{phone}}",
"email": "{{email}}",
"image": "{{image_url}}",
"priceRange": "{{price_range}}",
"address": {
"@type": "PostalAddress",
"streetAddress": "{{street_address}}",
"addressLocality": "{{city}}",
"addressRegion": "{{region}}",
"postalCode": "{{postal_code}}",
"addressCountry": "{{country}}"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "{{latitude}}",
"longitude": "{{longitude}}"
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "{{weekday_opens}}",
"closes": "{{weekday_closes}}"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday", "Sunday"],
"opens": "{{weekend_opens}}",
"closes": "{{weekend_closes}}"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{rating}}",
"reviewCount": "{{review_count}}"
},
"sameAs": [
"{{facebook_url}}",
"{{instagram_url}}"
]
}

View File

@@ -1,37 +0,0 @@
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "{{name}}",
"url": "{{url}}",
"logo": "{{logo_url}}",
"description": "{{description}}",
"foundingDate": "{{founding_date}}",
"founders": [
{
"@type": "Person",
"name": "{{founder_name}}"
}
],
"address": {
"@type": "PostalAddress",
"streetAddress": "{{street_address}}",
"addressLocality": "{{city}}",
"addressRegion": "{{region}}",
"postalCode": "{{postal_code}}",
"addressCountry": "{{country}}"
},
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "{{phone}}",
"contactType": "customer service",
"availableLanguage": ["Korean", "English"]
}
],
"sameAs": [
"{{facebook_url}}",
"{{twitter_url}}",
"{{linkedin_url}}",
"{{instagram_url}}"
]
}

View File

@@ -1,76 +0,0 @@
{
"@context": "https://schema.org",
"@type": "Product",
"name": "{{name}}",
"description": "{{description}}",
"image": [
"{{image_url_1}}",
"{{image_url_2}}",
"{{image_url_3}}"
],
"sku": "{{sku}}",
"mpn": "{{mpn}}",
"gtin13": "{{gtin13}}",
"brand": {
"@type": "Brand",
"name": "{{brand_name}}"
},
"offers": {
"@type": "Offer",
"url": "{{product_url}}",
"price": "{{price}}",
"priceCurrency": "{{currency}}",
"priceValidUntil": "{{price_valid_until}}",
"availability": "https://schema.org/{{availability}}",
"itemCondition": "https://schema.org/{{condition}}",
"seller": {
"@type": "Organization",
"name": "{{seller_name}}"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "{{shipping_cost}}",
"currency": "{{currency}}"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": "{{handling_min_days}}",
"maxValue": "{{handling_max_days}}",
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": "{{transit_min_days}}",
"maxValue": "{{transit_max_days}}",
"unitCode": "DAY"
}
}
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{rating}}",
"reviewCount": "{{review_count}}",
"bestRating": "5",
"worstRating": "1"
},
"review": [
{
"@type": "Review",
"reviewRating": {
"@type": "Rating",
"ratingValue": "{{review_rating}}",
"bestRating": "5"
},
"author": {
"@type": "Person",
"name": "{{reviewer_name}}"
},
"reviewBody": "{{review_text}}"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{{site_name}}",
"alternateName": "{{alternate_name}}",
"url": "{{url}}",
"description": "{{description}}",
"inLanguage": "{{language}}",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "{{search_url_template}}"
},
"query-input": "required name=search_term_string"
},
"publisher": {
"@type": "Organization",
"name": "{{publisher_name}}",
"logo": {
"@type": "ImageObject",
"url": "{{logo_url}}"
}
}
}

View File

@@ -1,15 +0,0 @@
# Firecrawl
> TODO: Document tool usage for this skill
## Available Commands
- [ ] List commands
## Configuration
- [ ] Add configuration details
## Examples
- [ ] Add usage examples

View File

@@ -1,15 +0,0 @@
# Perplexity
> TODO: Document tool usage for this skill
## Available Commands
- [ ] List commands
## Configuration
- [ ] Add configuration details
## Examples
- [ ] Add usage examples

View File

@@ -0,0 +1,47 @@
entity_id,entity_type,property,value,lang,url,source_ids,authority,confidence,conflict,status,note
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,
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: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: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,
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,
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,
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,값 공란 -> 제외
1 entity_id entity_type property value lang url source_ids authority confidence conflict status note
2 org:shilla Organization @id https://www.shillahotels.com/#org 1 high CONFIRMED
3 org:shilla Organization name The Shilla Hotels & Resorts S-OFF|S-WIKI 1 high CONFIRMED
4 org:shilla Organization legalName 주식회사 호텔신라 S-DART 1 high CONFIRMED DART 법인명
5 org:shilla Organization url https://www.shillahotels.com/ S-OFF 1 high CONFIRMED
6 org:shilla Organization foundingDate 1973-05-09 S-DART 1 high CONFIRMED
7 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
8 org:shilla Organization address.streetAddress 동호로 249 S-DART 1 high CONFIRMED
9 org:shilla Organization address.addressLocality 중구 S-DART 1 high CONFIRMED
10 org:shilla Organization address.addressRegion 서울 S-DART 1 high CONFIRMED
11 org:shilla Organization address.addressCountry KR S-DART 1 high CONFIRMED
12 site:ko WebSite @id https://www.shillahotels.com/ko#website ko https://www.shillahotels.com/ko/ S-OFF 1 high CONFIRMED
13 site:ko WebSite name 신라호텔 ko S-OFF 1 high CONFIRMED
14 site:ko WebSite url https://www.shillahotels.com/ko/ ko S-OFF 1 high CONFIRMED
15 site:ko WebSite inLanguage ko ko S-OFF 1 high CONFIRMED
16 site:ko WebSite publisher.@id https://www.shillahotels.com/#org ko 1 high CONFIRMED ref to org
17 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
18 hotel:theshilla-seoul Hotel name The Shilla Seoul ko S-OFF 1 high CONFIRMED
19 hotel:theshilla-seoul Hotel telephone +82-2-2233-3131 ko S-OFF|S-GBP 1 high CONFIRMED
20 hotel:theshilla-seoul Hotel priceRange $$$$ ko S-OFF 2 med CONFIRMED
21 hotel:theshilla-seoul Hotel brand.name The Shilla ko S-OFF 1 high CONFIRMED
22 hotel:theshilla-seoul Hotel parentOrganization.@id https://www.shillahotels.com/#org ko 1 high CONFIRMED entity graph link
23 hotel:theshilla-seoul Hotel address.streetAddress 동호로 249 ko S-OFF|S-GBP 1 high CONFIRMED
24 hotel:theshilla-seoul Hotel address.addressLocality 서울 ko S-OFF 1 high CONFIRMED
25 hotel:theshilla-seoul Hotel address.addressCountry KR ko S-OFF 1 high CONFIRMED
26 hotel:theshilla-seoul Hotel geo.latitude 37.5564 ko S-GBP 1 high CONFIRMED
27 hotel:theshilla-seoul Hotel geo.longitude 127.0058 ko S-GBP 1 high CONFIRMED
28 person:ceo Person @id https://www.shillahotels.com/#ceo S-DART|S-NEWS 1 high CONFIRMED
29 person:ceo Person name 이부진 S-DART 1 high CONFIRMED
30 person:ceo Person jobTitle 대표이사 사장 S-DART 1 high CONFIRMED
31 person:ceo Person worksFor.@id https://www.shillahotels.com/#org 1 high CONFIRMED
32 job:fo-manager JobPosting title 프런트오피스 매니저 ko https://recruit.shilla.net/job/1234 S-RECRUIT 1 high CONFIRMED
33 job:fo-manager JobPosting description 더 신라 서울 프런트오피스 운영 총괄 및 VIP 응대. ko S-RECRUIT 1 high CONFIRMED
34 job:fo-manager JobPosting datePosted 2026-05-01 ko S-RECRUIT 1 high CONFIRMED
35 job:fo-manager JobPosting employmentType FULL_TIME ko S-RECRUIT 1 high CONFIRMED
36 job:fo-manager JobPosting hiringOrganization.@id https://www.shillahotels.com/#org ko 1 high CONFIRMED
37 job:fo-manager JobPosting jobLocation.addressLocality 서울 ko S-RECRUIT 1 high CONFIRMED
38 job:fo-manager JobPosting jobLocation.addressCountry KR ko S-RECRUIT 1 high CONFIRMED
39 video:brand-film VideoObject name The Shilla — Authentic Indulgence https://www.youtube.com/watch?v=XXXX S-YT 1 high CONFIRMED
40 video:brand-film VideoObject description 더 신라 브랜드 필름. S-YT 1 high CONFIRMED
41 video:brand-film VideoObject thumbnailUrl https://i.ytimg.com/vi/XXXX/maxresdefault.jpg S-YT 1 high CONFIRMED
42 video:brand-film VideoObject uploadDate 2025-11-20 S-YT 1 high CONFIRMED
43 video:brand-film VideoObject duration PT1M45S S-YT 1 high CONFIRMED
44 video:brand-film VideoObject publisher.@id https://www.shillahotels.com/#org 1 high CONFIRMED
45 hotel:theshilla-seoul Hotel image https://example.com/seoul.jpg ko S-OFF 3 low PENDING 이미지 최종본 미확정
46 org:shilla Organization telephone +82-2-2233-3131 S-OFF 2 med Y CONFIRMED 대표번호 vs IR번호 출처 충돌
47 person:ceo Person image CONFIRMED 값 공란 -> 제외

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<!-- No JSON-LD here → only meta/OG, seeded as PENDING (must be confirmed) -->
<title>그랜드 조선 부산 — 객실 및 예약</title>
<meta name="description" content="해운대 해변에 위치한 럭셔리 호텔, 그랜드 조선 부산.">
<meta property="og:type" content="business.business">
<meta property="og:title" content="그랜드 조선 부산">
<meta property="og:url" content="https://www.josunhotel.com/grand-busan">
<meta property="og:image" content="https://www.josunhotel.com/grand-busan.jpg">
<link rel="canonical" href="https://www.josunhotel.com/grand-busan">
</head>
<body><h1>그랜드 조선 부산</h1></body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<title>조선호텔앤리조트 — 공식 홈페이지</title>
<meta property="og:site_name" content="조선호텔앤리조트">
<meta property="og:type" content="website">
<meta property="og:url" content="https://www.josunhotel.com/">
<link rel="canonical" href="https://www.josunhotel.com/">
<!-- Existing JSON-LD on the live page → extracted as CONFIRMED claims -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://www.josunhotel.com/#org",
"name": "조선호텔앤리조트",
"url": "https://www.josunhotel.com/",
"logo": "https://www.josunhotel.com/logo.png",
"sameAs": [
"https://www.instagram.com/josunhotelsandresorts/",
"https://www.wikidata.org/wiki/Q567458"
]
},
{
"@type": "Hotel",
"@id": "https://www.josunhotel.com/westin#hotel",
"name": "웨스틴 조선 서울",
"url": "https://www.josunhotel.com/westin",
"telephone": "+82-2-771-0500",
"priceRange": "$$$$",
"address": {
"@type": "PostalAddress",
"streetAddress": "소공로 106",
"addressLocality": "서울",
"addressRegion": "중구",
"addressCountry": "KR"
}
}
]
}
</script>
</head>
<body><h1>조선호텔앤리조트</h1></body>
</html>

View File

@@ -0,0 +1,59 @@
# Entity → Schema Type Map (source-to-schema, pre-launch)
Maps the source/entity kinds collected in steps 12 to schema.org types, and lists the
Google rich-result requirements each type must satisfy. Required/recommended columns are
kept in sync with `16-seo-schema-validator/scripts/schema_rules.json` so drafts pass the gate.
## Type map
| Source / entity | Type | Google required | Key recommended | Cardinality |
|-----------------|------|-----------------|-----------------|-------------|
| Corporate/legal (DART, official About) | `Organization` | `name`, `url` | `logo`, `sameAs`, `address`, `contactPoint` | 1 per legal entity |
| Per-language site | `WebSite` | `name`, `url` | `inLanguage`, `publisher` | 1 per language |
| Hotel property | `Hotel` | `name`, `address` | `telephone`, `priceRange`, `geo`, `image`, `brand` | 1 per property × language |
| Executive / person | `Person` | `name` | `jobTitle`, `worksFor`, `sameAs`, `url` | 1 per person |
| Recruitment posting | `JobPosting` | `title`, `description`, `datePosted`, `hiringOrganization`, `jobLocation` | `validThrough`, `employmentType`, `baseSalary` | 1 per open role |
| Official video | `VideoObject` | `name`, `description`, `thumbnailUrl`, `uploadDate` | `duration`, `contentUrl`, `embedUrl` | 1 per featured video |
| FAQ / press-kit Q&A | `FAQPage` | `mainEntity` (Question→Answer) | `inLanguage`, `url` | 1 per FAQ page |
| Site navigation | `BreadcrumbList` | `itemListElement` | — | 1 per page |
Note: `JobPosting` and `VideoObject` were added to the validator rule set for this workflow,
since recruitment sites and the official YouTube channel are listed source types.
## Address requirement nuance (avoid false P0)
`PostalAddress` requires only `addressLocality` + `addressCountry` as the context-safe minimum.
`streetAddress` is **recommended** (P2), because it is expected for `Hotel`/`LocalBusiness` NAP
but NOT required by Google for `JobPosting.jobLocation` (city/region/country suffices). For
hotels, the L4 NAP-consistency check still enforces a complete, identical street address across
all pages of a property — so the street address signal is not lost where it matters.
## Brand-tier rule (Shilla)
- The Shilla, Shilla Monogram → `Hotel`
- Shilla Stay → `Hotel` (preferred; it is lodging, not a generic LocalBusiness)
- Always set `brand.name` (e.g. "The Shilla", "Shilla Monogram", "Shilla Stay") and link
`parentOrganization.@id` back to the single `Organization` node.
## @id entity graph (critical for pre-launch coherence)
Author stable `@id` URIs and cross-reference them so search engines read the portfolio as one
connected entity graph rather than disconnected snippets:
```
Organization @id = https://www.shillahotels.com/#org
▲ parentOrganization ▲ publisher ▲ hiringOrganization ▲ worksFor / publisher
│ │ │ │
Hotel (per property) WebSite (per lang) JobPosting Person / VideoObject
```
Rules
- One `Organization` node, one `@id`, referenced by every other node via `@id`.
- `@id` must be an absolute, stable URI (the provisional production URL + a `#fragment`).
- Every `@id` referenced must also be defined somewhere in the dataset (the validator's L4
checks for dangling `@id` references).
## When NOT to author schema
- Authenticated/transactional pages (mypage, login, booking cart) → no schema.
- Thin or duplicate pages → no schema until content exists.
- Any entity whose facts are still `conflict`/`PENDING` → resolve first (builder excludes them).

View File

@@ -0,0 +1,76 @@
# Site-Extraction Methodology (Mode 1 — from an existing website)
How to turn an existing site into a claims register, and why this mode is *easier*
than Mode 2 (collected sources) but still must not blindly trust what it scrapes.
Pair skill: extraction is `scripts/extract_site_claims.py`; the build engine and the
QA gate are shared with Mode 2. See `source-to-schema-methodology.md` for Mode 2.
## Why a website is the easy case (and where it still bites)
A published site has a **single source of truth** — the pages themselves — so there is
little to reconcile. The risks are different from Mode 2:
| Risk on an existing site | What it causes | Countermeasure |
|---|---|---|
| Trusting inferred meta as fact | wrong/old values shipped as schema | meta/OG seeded **PENDING**, never auto-shipped |
| Existing JSON-LD is partial or stale | gaps, outdated facts | extracted as CONFIRMED but **spot-checked** at review |
| Many near-identical pages | duplicate descriptions, bloated register | one entity per real thing; let Layer 4 catch dupes |
| JS-rendered schema not in raw HTML | "nothing extracted" | use a rendered snapshot / live fetch, or fall to Mode 2 |
## The 5 steps
### 1. Choose the pages
Pick the canonical page per entity (home, about/company, each property/location, key
product/FAQ pages). One representative page per entity is enough to seed it; you don't
need the whole crawl.
### 2. Extract
Run the adapter on URLs, local `.html` files, or a directory (offline):
```bash
python scripts/extract_site_claims.py https://site/ https://site/about --out site_claims
python scripts/extract_site_claims.py ./snapshot/ --out site_claims # offline
```
It produces two tiers of claims:
- **Existing JSON-LD → `CONFIRMED` (authority 1).** The site already published these
facts about itself; flattened to dotted-path claims.
- **`<title>` / meta description / OpenGraph / `<html lang>` / canonical → `PENDING`
(authority 2).** Inferred, not authoritative. These will **not** ship until confirmed.
### 3. Review the register (the critical human step)
Open `site_claims/claims_register.csv`:
- **Spot-check CONFIRMED rows** — extraction is faithful, but the site's own JSON-LD can
be wrong/stale. Correct values; clear nothing silently.
- **Confirm or drop PENDING rows** — set `status=CONFIRMED` only for facts you've verified;
delete the rest. PENDING rows are excluded by the builder by design.
- **Add what the page didn't expose** — telephone, full address, `geo`, `sameAs`,
`priceRange`. The richest schema usually needs facts no single page renders.
- Set `conflict=Y` on any value you're unsure about to keep it out until resolved.
### 4. Build
```bash
python scripts/build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
```
Unfilled slots are pruned; only CONFIRMED, non-conflicting claims become schema. Read
`drafts_out/build_report.md` for everything excluded and why.
### 5. Validate (the gate)
```bash
python ../16-seo-schema-validator/scripts/validate_schema.py \
drafts_out/schema_drafts_dataset.csv --out qa_out
```
Gate = **zero P0**. Fix P0, re-build, re-validate, then open client review against the
report (not raw JSON).
## When NOT to use Mode 1
If the existing site **already has good, complete JSON-LD**, you don't need to regenerate
it — **audit it in place** with `16-seo-schema-validator` Mode B
(`validate_schema.py --live <URL>`). Mode 1 is for sites whose pages carry the *facts* but
not yet the *structured data*, or whose schema needs a rebuild.
## entity_id convention
The adapter assigns `prefix:slug` ids (`org:`, `site:`, `hotel:`, `dining:`, `page:`, …)
derived from each node's `@id` fragment or page URL. Rename them to stable, human ids
during review (e.g. `hotel:theshilla-seoul`) so re-runs and Mode 2 additions line up.

View File

@@ -0,0 +1,62 @@
# 출처 권위 위계 · 출처추적(Provenance) · 충돌 해소
미발행 사이트 스키마 저작의 성패는 "사실을 스키마로 굳히기 전에 단일 확정값을 만들 수 있는가"에 달려 있다. 그 판단 규칙을 명문화한다.
## 1. 출처 권위 위계 (authority rank)
값이 충돌할 때 **상위 권위 출처가 이긴다.** 클레임 레지스터의 `authority` 열에 1~5로 기록한다.
| 순위 | 출처 유형 | 신뢰 대상(어떤 사실에 권위) |
|:---:|-----------|------------------------------|
| **1** | 기업공시(DART), 사업자등록 정보 | 법인명·설립일·본사주소·대표자 (법적 사실) |
| **1** | 공식 홈페이지/IR, 프레스킷 | 브랜드 표기·연락처·URL·로고 (공식 표기) |
| **2** | 지속가능경영보고서, 사보, 공식 발간물 | 서사·정책·시설 스펙 |
| **2** | Wikidata, 위키백과 | 엔티티 식별자(Q-ID)·sameAs·국제 표기 |
| **3** | 주요 미디어 기사 | 사건·인용·맥락 (교차검증용) |
| **4** | 인물정보/집계 사이트, 소셜 | 보조·후보값 (단독 근거 불가) |
규칙
- **법적 사실**(법인명/설립일/주소)은 순위 1(공시) 우선.
- **공식 표기**(브랜드명/전화/URL)는 순위 1(공식 채널) 우선.
- **국제 식별/연결**(sameAs, 외국어 표기)은 Wikidata 우선.
- 순위 4 단독으로는 CONFIRMED 불가 → 상위 출처로 교차검증 필수.
## 2. 출처추적(provenance)을 남기는 이유
발행된 페이지 기반 작업은 "그 페이지가 근거"라는 자명한 출처가 있다. 미발행 작업은 그렇지 않으므로 **모든 클레임에 출처를 명시**해야 한다.
- `source_ids` — 소스 레지스터의 출처 ID(파이프로 복수). 예: `S-DART|S-OFF`
- 효용:
1. 충돌 시 권위 비교의 근거가 된다.
2. 럭셔리 브랜드 특성상 **사실 오류는 PR/법적 리스크** — 근거 추적이 방어선.
3. 런칭 후 사실 변경 시 어느 클레임을 갱신할지 즉시 특정 가능.
## 3. 충돌 해소 절차
```
값 충돌 발견
├─ 권위 순위가 다른가? ──예──▶ 상위 출처 채택, 하위는 note에 기록, status=CONFIRMED
└─ 동순위 충돌인가?
├─ 최신성(retrieved_date) 우선 적용 가능? ──예──▶ 최신 채택
└─ 판단 불가 ──▶ conflict=Y 유지, status=PENDING
→ 빌더가 자동 제외하고 build_report에 보고
→ 고객/이해관계자 질의로 확정 (최소 단위 질문)
```
원칙: **충돌이 미해소면 스키마에 넣지 않는다.** 모순된 사실로 만든 스키마는 NAP 불일치·KG 혼선으로 직결된다. 비우는 편이 틀리는 것보다 낫다.
## 4. 엔티티 정합(reconciliation) — 동명 함정
- 모든 핵심 엔티티는 **Wikidata Q-ID**로 못박는다(예: 호텔 법인 vs 동명 역사·지명).
- `sameAs`에는 검증된 식별 URL만: Wikidata, 위키백과, 공식 소셜.
- 미디어 기사 URL은 sameAs가 아님(엔티티 식별자가 아니라 언급).
- Knowledge Panel이 이미 있으면 그 표기를 공식 표기와 대조해 일치시킨다.
## 5. CONFIRMED 승격 체크리스트
- [ ] 값이 정규화됨(전화 E.164 / 날짜 ISO 8601 / 언어 BCP-47 / 국가 ISO alpha-2)
- [ ] `source_ids` 1개 이상, 핵심 사실은 권위 순위 1~2
- [ ] 동일 속성 충돌 없음(`conflict` 비어 있음)
- [ ] 엔티티는 `sameAs`/Q-ID로 식별 정합 완료
- 위 충족 시에만 `status=CONFIRMED` → 빌더가 스키마로 채택.

View File

@@ -0,0 +1,167 @@
# Source-to-Schema 표준 프로세스 (미발행 사이트용 Schema 저작)
> 대상: 아직 발행되지 않은 웹사이트의 구조화 데이터(JSON-LD)를 **텍스트 소스로부터 저작**하는 작업.
> 짝 스킬: 생성/저작은 `17-seo-schema-generator`(본 문서 = Mode 2 소스 기반), 검증은 `16-seo-schema-validator`.
---
## 0. 왜 이 작업이 "발행된 페이지 기반"보다 어려운가
발행된 페이지 기반 스키마는 **DOM이라는 단일 진실원본(single source of truth)** 이 이미 있고, 거기서 *추출*만 하면 된다. 미발행 사이트는 그 진실원본이 존재하지 않기 때문에 다음 네 가지 난점이 동시에 발생한다.
| # | 난점 | 결과적으로 생기는 결함 | 대응 원칙 |
|---|------|----------------------|-----------|
| 1 | 사실이 여러 출처에 흩어져 있고 서로 **충돌** (DART vs 위키 vs 브로셔) | NAP 불일치, 값 모순 | **출처 권위 위계**로 단일 값 확정 |
| 2 | 붙일 **URL이 아직 없음** | placeholder/TODO 누출 (최다 P0) | 확정값 없으면 **키 자체를 생성하지 않음** |
| 3 | 엔티티 식별을 **사람이 수동**으로 (어느 "신라"인가) | 잘못된 sameAs, 엔티티 혼선 | **Wikidata/Wikipedia 정합(reconciliation)** |
| 4 | 무엇을 만들지 **범위 자체가 미정** | 누락/과잉 엔티티 | **엔티티-타입 맵**으로 범위 선확정 |
**결론적 설계 원칙**: 스키마로 굳히기 *전에* "출처 → 클레임(claim) 확정"을 먼저 끝낸다. 정제되지 않은 사실을 곧장 JSON-LD에 부으면 모든 충돌·공백이 그대로 스키마 결함이 된다. 그래서 본 프로세스의 중심축은 **클레임 레지스터(claims register)** — 출처가 추적되고 충돌이 해소된 사실 대장 — 이며, **CONFIRMED 클레임만 스키마가 된다.**
---
## 9단계 표준 프로세스
각 단계는 목적 / 입력 / 절차 / 산출 / 완료기준(AC) / 리스크로 명세한다.
### 1단계. 온라인 정통 소스(authentic source) 수집
- **목적**: 권위 있는 1차 출처를 폭넓게 확보한다.
- **입력**: 대상 기업/브랜드명, 법인명, 도메인.
- **절차**: 다음을 수집·기록 → `templates/source-register.csv`
- 기업공시(**DART**) — 법인명/설립일/주소/대표자 (법적 사실의 최상위 권위)
- **공식 홈페이지**(About/푸터/IR), 지속가능경영보고서, 뉴스룸/미디어, 뉴스레터
- **Wikidata / 위키백과** — 엔티티 식별자(Q-ID)와 sameAs 후보
- 채용 사이트 — JobPosting 원천
- 공식 **YouTube 채널** — VideoObject 원천
- 공식 소셜/디지털 채널 — sameAs 후보
- 주요 미디어 기사, 인물정보 사이트 — 보조/교차검증용
- **산출**: 소스 레지스터(출처별 1행, 권위순위·언어·커버 엔티티 기록).
- **AC**: 각 핵심 엔티티에 대해 **최소 2개 독립 출처** 확보(교차검증 가능).
- **리스크**: 비공식·오래된 출처를 1차로 오인 → 권위순위를 반드시 명시.
### 2단계. 오프라인 콘텐츠 수집
- **목적**: 온라인에 없는 1차 사실(브랜드 서사, 시설 스펙, 공식 표기) 확보.
- **입력**: 브로셔 PDF, 사보/경영보고서, 프레스 킷, 보도자료 모음, 발간물.
- **절차**: 파일을 소스 레지스터에 등록(파일 경로·발행일·언어). PDF는 텍스트 추출(스캔본은 OCR) 후 출처 표기 유지.
- **산출**: 오프라인 출처도 동일 레지스터에 통합.
- **AC**: 모든 오프라인 소스에 `retrieved_date``authority` 기재.
- **리스크**: PDF 추출 시 인코딩 깨짐/표 붕괴 → 추출 직후 육안 점검.
### 3단계. 정규화 & 정제(distill)
- **목적**: 수집물을 **클레임 단위**로 분해하고 노이즈·중복·빈값을 제거한다.
- **입력**: 1·2단계 소스.
- **절차**:
1. 각 소스에서 사실을 (엔티티, 속성, 값, 출처) 단위로 추출 → `templates/claims-register.csv`
2. 동일 (엔티티, 속성)의 **중복 통합**, 빈값/노이즈 제거
3. 출처 충돌 시 `conflict=Y`로 표시(해소 전까지 스키마 진입 차단)
4. 표기 정규화: 전화(E.164), 날짜(ISO 8601), 언어코드(BCP-47), 국가(ISO 3166-1 alpha-2)
- **산출**: 1차 클레임 레지스터(아직 미확정 포함).
- **AC**: 모든 핵심 속성이 단일 정규화 값 후보를 가짐(또는 conflict/pending로 명시).
- **리스크**: 정규화 누락이 다운스트림 VAL 결함으로 직결 → 3단계에서 포맷 확정.
### 4단계. 텍스트 분석 & Knowledge Graph 교차검증
- **목적**: 클레임을 외부 KG와 대조해 **이중 점검**하고, 동시에 현황·개선과제를 도출한다.
- **입력**: 1차 클레임 레지스터.
- **절차**:
1. **엔티티 정합**: Wikidata Q-ID, 위키백과, Google Knowledge Panel과 대조 → `sameAs` 확정
2. 핵심 사실(설립일, 법인명, 대표자)을 KG 값과 비교 → 불일치는 `conflict`
3. KG에 **존재하지 않거나 빈약한 엔티티**를 식별 → *개선과제 자료*로 별도 기록(런칭 후 KG 강화 목표)
4. 충돌·미확정을 권위 위계로 해소 → `status=CONFIRMED` 승격
- **산출**: 확정 클레임 레지스터 + **KG 현황/개선과제 메모**(별도 컨설팅 산출물로 활용).
- **AC**: 모든 핵심 엔티티에 검증된 `sameAs` 1개 이상; conflict 0건.
- **리스크**: 동명 엔티티 오정합(예: "신라" 왕조 vs 호텔) → Q-ID로 못박기.
- 참고: `references/source-authority-hierarchy.md`
### 5단계. 유형별 활용 스키마 타입 분류
- **목적**: 어떤 엔티티에 어떤 schema.org 타입을 쓸지 범위를 확정한다.
- **입력**: 확정 클레임 + 페이지/엔티티 목록.
- **절차**: 엔티티·소스 유형을 타입에 매핑(아래는 본 스킬 기본 매핑).
| 소스/엔티티 | schema.org 타입 |
|-------------|-----------------|
| 법인·브랜드(공시/공식) | `Organization` |
| 언어별 사이트 | `WebSite` |
| 호텔 프로퍼티 | `Hotel` (= LocalBusiness 계열) |
| 임원/인물 | `Person` |
| 채용공고 | `JobPosting` |
| 공식 영상 | `VideoObject` |
| FAQ/프레스킷 Q&A | `FAQPage` |
| 사이트 내비게이션 | `BreadcrumbList` |
- **산출**: 엔티티-타입 맵(엔티티별 타입 + 필수 속성 목록).
- **AC**: 모든 대상 엔티티에 타입과 Google 필수 속성 목록이 배정됨.
- **리스크**: 과잉 타입 부여(불필요한 타입은 검증 부담만 가중) → "리치결과 가치 있는 타입" 우선.
- 참고: `references/entity-and-type-map.md`
### 6단계. 타입별 템플릿 설정 & 초안 추출 (자동화)
- **목적**: 확정 클레임 + 타입 템플릿으로 JSON-LD 초안을 **자동 생성**한다.
- **입력**: 확정 클레임 레지스터(`status=CONFIRMED`), `scripts/type_templates.json`.
- **절차**:
```bash
python scripts/build_schema_drafts.py path/to/claims_register.csv \
--templates scripts/type_templates.json --out drafts_out
```
- CONFIRMED·비충돌 클레임만 스키마가 됨. PENDING/REJECTED/conflict/공란은 **제외 후 보고**.
- 미충족 슬롯은 **키 자체를 삭제**(placeholder 누출 원천 차단).
- 엔티티 간 `@id` 참조로 엔티티 그래프 형성(Hotel→parentOrganization 등).
- **산출**: `drafts/*.jsonld`, 검증기 입력용 `schema_drafts_dataset.csv`, `build_report.md`(제외 클레임 목록).
- **AC**: 초안에 placeholder/빈 객체 0건; 모든 제외 클레임이 보고서에 사유와 함께 기재.
- **리스크**: 템플릿 누락 타입은 건너뜀(보고됨) → 5단계 맵과 템플릿 동기화.
### 7단계. 리뷰·검토·수정 + 리뷰 가이드
- **목적**: 초안을 사람·고객이 검토하되, **원본 JSON이 아니라 결함 리포트**로 검토하게 한다.
- **입력**: 6단계 초안 + 검증기 결과.
- **절차**:
1. 초안을 **즉시 검증기에 통과**(아래 8단계)시켜 P0=0 게이트부터 확보
2. 남은 항목을 `templates/review-guide.md` 기준으로 검토(사실 정확성·표기·번역)
3. 고객 검토는 P0가 0인 깨끗한 초안에 대해서만, 결함 리포트 기준으로 진행
- **산출**: 수정 반영 초안 + 리뷰 가이드 체크 결과.
- **AC**: 모든 P0 해소; 사실 정확성 검토 서명(저작자·검수자).
- **리스크**: 사람이 원본 JSON을 직접 보면 "오류 과다" 문제 재발 → 반드시 리포트 기반 검토.
- 참고: `templates/review-guide.md`
### 8단계. 수정 초안의 rich result 적격성 점검
- **목적**: 리치결과 적격성을 (1) 오프라인 게이트 + (2) Google 온라인 테스트로 이중 확인.
- **입력**: 수정 초안 데이터셋.
- **절차**:
```bash
# 오프라인 게이트 (반드시 zero P0)
python ../16-seo-schema-validator/scripts/validate_schema.py drafts_out/schema_drafts_dataset.csv --out qa_out
```
- 게이트 PASS 후, **표본 엔트리**를 Google Rich Results Test에 통과(이 런타임은 오프라인이라 온라인 테스트는 사용자가 수행)시켜 캡처.
- **산출**: 검증기 리포트(Gate PASS) + Rich Results Test 표본 통과 캡처.
- **AC**: P0=0, P1 트리아지 완료, 온라인 테스트 표본 green.
- **리스크**: 오프라인 규칙은 호텔 도메인 큐레이션 부분집합 → 온라인 표본 검사로 보완.
### 9단계. 발행 후 유효성 검증 & KG 변화 측정
- **목적**: 배포된 스키마가 저작 초안과 일치하는지 확인하고, 4단계의 KG 개선과제 달성도를 측정한다.
- **입력**: 라이브 URL.
- **절차**:
1. 검증기 **Mode B**(라이브 URL)로 렌더링된 스키마 재검증 → `seo-comprehensive-audit` 4단계 연계
2. GSC "리치 결과" 리포트 모니터링(신규 오류 0 유지)
3. 4단계 KG 메모 대비 Knowledge Panel/Wikidata 노출·정확도 변화 측정
- **산출**: 발행 후 검증 리포트 + KG 변화 측정(전/후 비교).
- **AC**: 라이브 스키마 = 저작 초안; GSC 신규 구조화데이터 오류 0; KG 개선과제 진척 기록.
- **리스크**: 렌더링 단계에서 JS로 스키마 누락/변형 → Mode B로 실측.
---
## 스테이지 게이트 (설계→개발→테스트→안정화→런칭 후)
| 게이트 | 단계 | DoD(완료 정의) |
|--------|------|----------------|
| **G1 설계** | 1·2·5 | 소스 레지스터 완료(엔티티당 ≥2출처), 엔티티-타입 맵 확정(타입+필수속성) |
| **G2 개발** | 3·4·6 | 클레임 레지스터 CONFIRMED·conflict 0, 빌더 실행 → 초안 placeholder 0 |
| **G3 테스트** | 7·8 | 검증기 **zero P0**, P1 트리아지(`decision-log`), 사실정확성 검수 서명 |
| **G4 안정화** | 8 | Google Rich Results Test 표본 green, 재실행 무회귀 |
| **G5 런칭 후** | 9 | 라이브=초안 일치, GSC 신규오류 0, KG 변화 측정 기록 |
---
## 산출물 일람
- `templates/source-register.csv` — 1·2단계 출처 대장
- `templates/claims-register.csv` — 3·4단계 사실 대장(스키마의 원천)
- `templates/review-guide.md` — 7단계 검토 기준
- `scripts/type_templates.json` — 6단계 타입별 JSON-LD 템플릿
- `scripts/build_schema_drafts.py` — 6단계 초안 자동 생성
- (검증) `16-seo-schema-validator` — 7·8·9단계 게이트

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[]}}"
}
}
}
}

View File

@@ -0,0 +1,8 @@
entity_id,entity_type,property,value,lang,url,source_ids,authority,confidence,conflict,status,note
org:example,Organization,@id,https://www.example.com/#org,,,S-OFF,1,high,,CONFIRMED,
org:example,Organization,name,Example Corp,,,S-OFF|S-DART,1,high,,CONFIRMED,
org:example,Organization,url,https://www.example.com/,,,S-OFF,1,high,,CONFIRMED,
org:example,Organization,address.addressLocality,Seoul,,,S-DART,1,high,,CONFIRMED,
org:example,Organization,address.addressCountry,KR,,,S-DART,1,high,,CONFIRMED,
org:example,Organization,sameAs,https://www.wikidata.org/wiki/Q000|https://en.wikipedia.org/wiki/Example,,,S-WD|S-WIKI,2,high,,CONFIRMED,array via pipe
org:example,Organization,foundingDate,1998-01-01,,,S-DART,1,high,Y,PENDING,두 출처 연도 충돌 -> 해소 필요
1 entity_id entity_type property value lang url source_ids authority confidence conflict status note
2 org:example Organization @id https://www.example.com/#org S-OFF 1 high CONFIRMED
3 org:example Organization name Example Corp S-OFF|S-DART 1 high CONFIRMED
4 org:example Organization url https://www.example.com/ S-OFF 1 high CONFIRMED
5 org:example Organization address.addressLocality Seoul S-DART 1 high CONFIRMED
6 org:example Organization address.addressCountry KR S-DART 1 high CONFIRMED
7 org:example Organization sameAs https://www.wikidata.org/wiki/Q000|https://en.wikipedia.org/wiki/Example S-WD|S-WIKI 2 high CONFIRMED array via pipe
8 org:example Organization foundingDate 1998-01-01 S-DART 1 high Y PENDING 두 출처 연도 충돌 -> 해소 필요

View File

@@ -0,0 +1,40 @@
# Schema 초안 리뷰 가이드 (7단계)
> 원칙: **사람은 원본 JSON을 직접 보지 않는다.** 기계가 잡을 수 있는 결함은 검증기 게이트(8단계)가
> 먼저 0건으로 만들고, 사람은 "기계가 못 잡는 것"만 본다. 그래야 "오류 과다" 문제가 재발하지 않는다.
## 검토 순서
1. **먼저 검증기 통과**`zero P0` 확보 (P0가 남은 초안은 검토 대상 아님)
2. 아래 항목은 사람만 판단 가능 → 검토
3. 고객 검토는 P0=0인 깨끗한 초안 + 결함 리포트로만 진행
## 사람이 검토할 항목 (기계가 못 잡는 것)
### A. 사실 정확성 (출처 대조)
- [ ] `name` / `legalName` 이 공식 표기와 정확히 일치하는가 (공백·영문병기 포함)
- [ ] 주소·전화가 **현재 유효한** 값인가 (출처가 오래되지 않았는가)
- [ ] `foundingDate` 등 날짜가 공시값과 일치하는가
- [ ] 인물의 `jobTitle`**현직** 기준인가
### B. 엔티티 정합
- [ ] `sameAs` 가 **정확히 그 엔티티**를 가리키는가 (동명 오정합 없는가)
- [ ] `@id` 참조가 의도한 엔티티로 연결되는가 (Hotel→올바른 Organization)
- [ ] 브랜드 티어(`brand.name`)가 프로퍼티와 일치하는가 (The Shilla/Monogram/Stay)
### C. 언어·번역
- [ ] 언어별 초안의 `inLanguage` 와 실제 값 언어가 일치하는가
- [ ] 번역값이 공식 다국어 표기와 일치하는가 (임의 번역 아님)
### D. 범위 적절성
- [ ] 스키마를 붙이면 안 되는 페이지(mypage/login/booking)에 초안이 없는가
- [ ] 누락된 핵심 엔티티가 없는가 (엔티티-타입 맵 대조)
## 검토 결과 처리
- 수정 필요 → 클레임 레지스터에서 값 수정 후 **빌더 재실행**(JSON 직접 수정 금지: 원천은 항상 클레임)
- 충돌 발견 → `conflict=Y`, `status=PENDING` → 출처 권위로 해소
- 검토 완료 → 저작자·검수자 서명, P1은 `decision-log`에 기록
## 서명
- 저작(빌드): ______ / 일자: 2026-__-__
- 검수(사실확인): ______ / 일자: 2026-__-__
- 게이트(검증기 PASS) 확인: ______ / 일자: 2026-__-__

View File

@@ -0,0 +1,11 @@
source_id,source_type,title_or_name,url_or_filepath,retrieved_date,authority,language,entities_covered,note
S-DART,corporate_disclosure,DART 사업보고서,https://dart.fss.or.kr/...,2026-05-__,1,ko,org:shilla,법인명/설립일/주소/대표자
S-OFF,official_site,공식 홈페이지 About/푸터,https://www.shillahotels.com/,2026-05-__,1,ko,org:shilla|hotel:*|site:ko,공식 표기/연락처/URL
S-SUSTAIN,sustainability_report,지속가능경영보고서 2025,/path/to/esg.pdf,2026-05-__,2,ko,org:shilla,서사/정책
S-WD,wikidata,Wikidata 항목,https://www.wikidata.org/wiki/Q______,2026-05-__,2,en,org:shilla,Q-ID/sameAs
S-WIKI,wikipedia,위키백과,https://en.wikipedia.org/wiki/______,2026-05-__,2,en,org:shilla,sameAs/국제표기
S-RECRUIT,recruitment,채용 사이트 공고,https://recruit._____,2026-05-__,1,ko,job:*,JobPosting 원천
S-YT,youtube,공식 YouTube 채널,https://www.youtube.com/@______,2026-05-__,1,ko,video:*,VideoObject 원천
S-GBP,google_business_profile,Google Business Profile,,2026-05-__,1,ko,hotel:*,NAP/geo
S-BROCH,brochure_pdf,프로퍼티 브로셔,/path/to/brochure.pdf,2026-05-__,2,ko,hotel:*,시설 스펙
S-NEWS,media_article,주요 미디어 기사,https://_____,2026-05-__,3,ko,person:ceo,교차검증
1 source_id source_type title_or_name url_or_filepath retrieved_date authority language entities_covered note
2 S-DART corporate_disclosure DART 사업보고서 https://dart.fss.or.kr/... 2026-05-__ 1 ko org:shilla 법인명/설립일/주소/대표자
3 S-OFF official_site 공식 홈페이지 About/푸터 https://www.shillahotels.com/ 2026-05-__ 1 ko org:shilla|hotel:*|site:ko 공식 표기/연락처/URL
4 S-SUSTAIN sustainability_report 지속가능경영보고서 2025 /path/to/esg.pdf 2026-05-__ 2 ko org:shilla 서사/정책
5 S-WD wikidata Wikidata 항목 https://www.wikidata.org/wiki/Q______ 2026-05-__ 2 en org:shilla Q-ID/sameAs
6 S-WIKI wikipedia 위키백과 https://en.wikipedia.org/wiki/______ 2026-05-__ 2 en org:shilla sameAs/국제표기
7 S-RECRUIT recruitment 채용 사이트 공고 https://recruit._____ 2026-05-__ 1 ko job:* JobPosting 원천
8 S-YT youtube 공식 YouTube 채널 https://www.youtube.com/@______ 2026-05-__ 1 ko video:* VideoObject 원천
9 S-GBP google_business_profile Google Business Profile 2026-05-__ 1 ko hotel:* NAP/geo
10 S-BROCH brochure_pdf 프로퍼티 브로셔 /path/to/brochure.pdf 2026-05-__ 2 ko hotel:* 시설 스펙
11 S-NEWS media_article 주요 미디어 기사 https://_____ 2026-05-__ 3 ko person:ceo 교차검증