diff --git a/CLAUDE.md b/CLAUDE.md index 5c75cce..4430f2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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" | | 15 | seo-search-console | GSC data analysis | "Search Console", "rankings" | | 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" | | 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" | diff --git a/custom-skills/17-seo-schema-generator/SKILL.md b/custom-skills/17-seo-schema-generator/SKILL.md new file mode 100644 index 0000000..8c8299d --- /dev/null +++ b/custom-skills/17-seo-schema-generator/SKILL.md @@ -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. diff --git a/custom-skills/17-seo-schema-generator/code/CLAUDE.md b/custom-skills/17-seo-schema-generator/code/CLAUDE.md deleted file mode 100644 index de70c31..0000000 --- a/custom-skills/17-seo-schema-generator/code/CLAUDE.md +++ /dev/null @@ -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 - -``` - -## 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": {...}}' -``` - diff --git a/custom-skills/17-seo-schema-generator/code/scripts/base_client.py b/custom-skills/17-seo-schema-generator/code/scripts/base_client.py deleted file mode 100644 index ac5715b..0000000 --- a/custom-skills/17-seo-schema-generator/code/scripts/base_client.py +++ /dev/null @@ -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() diff --git a/custom-skills/17-seo-schema-generator/code/scripts/requirements.txt b/custom-skills/17-seo-schema-generator/code/scripts/requirements.txt deleted file mode 100644 index 48c7180..0000000 --- a/custom-skills/17-seo-schema-generator/code/scripts/requirements.txt +++ /dev/null @@ -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 diff --git a/custom-skills/17-seo-schema-generator/code/scripts/schema_generator.py b/custom-skills/17-seo-schema-generator/code/scripts/schema_generator.py deleted file mode 100644 index 8afa504..0000000 --- a/custom-skills/17-seo-schema-generator/code/scripts/schema_generator.py +++ /dev/null @@ -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'' - - -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() diff --git a/custom-skills/17-seo-schema-generator/code/templates/article.json b/custom-skills/17-seo-schema-generator/code/templates/article.json deleted file mode 100644 index 8d0daab..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/article.json +++ /dev/null @@ -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}}" -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/breadcrumb.json b/custom-skills/17-seo-schema-generator/code/templates/breadcrumb.json deleted file mode 100644 index 1e8f9e8..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/breadcrumb.json +++ /dev/null @@ -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}}" - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/faq.json b/custom-skills/17-seo-schema-generator/code/templates/faq.json deleted file mode 100644 index f90b98c..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/faq.json +++ /dev/null @@ -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}}" - } - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/local_business.json b/custom-skills/17-seo-schema-generator/code/templates/local_business.json deleted file mode 100644 index 8e6dba4..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/local_business.json +++ /dev/null @@ -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}}" - ] -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/organization.json b/custom-skills/17-seo-schema-generator/code/templates/organization.json deleted file mode 100644 index 76c4734..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/organization.json +++ /dev/null @@ -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}}" - ] -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/product.json b/custom-skills/17-seo-schema-generator/code/templates/product.json deleted file mode 100644 index d24af66..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/product.json +++ /dev/null @@ -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}}" - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/code/templates/website.json b/custom-skills/17-seo-schema-generator/code/templates/website.json deleted file mode 100644 index 65ec61a..0000000 --- a/custom-skills/17-seo-schema-generator/code/templates/website.json +++ /dev/null @@ -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}}" - } - } -} diff --git a/custom-skills/17-seo-schema-generator/desktop/SKILL.md b/custom-skills/17-seo-schema-generator/desktop/SKILL.md deleted file mode 100644 index 20b4619..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/SKILL.md +++ /dev/null @@ -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 `` section: - -```html - - - -``` - -## 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 - diff --git a/custom-skills/17-seo-schema-generator/desktop/skill.yaml b/custom-skills/17-seo-schema-generator/desktop/skill.yaml deleted file mode 100644 index fa3ca0a..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/skill.yaml +++ /dev/null @@ -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 diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/article.json b/custom-skills/17-seo-schema-generator/desktop/templates/article.json deleted file mode 100644 index 8d0daab..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/article.json +++ /dev/null @@ -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}}" -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/breadcrumb.json b/custom-skills/17-seo-schema-generator/desktop/templates/breadcrumb.json deleted file mode 100644 index 1e8f9e8..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/breadcrumb.json +++ /dev/null @@ -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}}" - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/faq.json b/custom-skills/17-seo-schema-generator/desktop/templates/faq.json deleted file mode 100644 index f90b98c..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/faq.json +++ /dev/null @@ -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}}" - } - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/local_business.json b/custom-skills/17-seo-schema-generator/desktop/templates/local_business.json deleted file mode 100644 index 8e6dba4..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/local_business.json +++ /dev/null @@ -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}}" - ] -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/organization.json b/custom-skills/17-seo-schema-generator/desktop/templates/organization.json deleted file mode 100644 index 76c4734..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/organization.json +++ /dev/null @@ -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}}" - ] -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/product.json b/custom-skills/17-seo-schema-generator/desktop/templates/product.json deleted file mode 100644 index d24af66..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/product.json +++ /dev/null @@ -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}}" - } - ] -} diff --git a/custom-skills/17-seo-schema-generator/desktop/templates/website.json b/custom-skills/17-seo-schema-generator/desktop/templates/website.json deleted file mode 100644 index 65ec61a..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/templates/website.json +++ /dev/null @@ -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}}" - } - } -} diff --git a/custom-skills/17-seo-schema-generator/desktop/tools/firecrawl.md b/custom-skills/17-seo-schema-generator/desktop/tools/firecrawl.md deleted file mode 100644 index c9c1465..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/tools/firecrawl.md +++ /dev/null @@ -1,15 +0,0 @@ -# Firecrawl - -> TODO: Document tool usage for this skill - -## Available Commands - -- [ ] List commands - -## Configuration - -- [ ] Add configuration details - -## Examples - -- [ ] Add usage examples diff --git a/custom-skills/17-seo-schema-generator/desktop/tools/perplexity.md b/custom-skills/17-seo-schema-generator/desktop/tools/perplexity.md deleted file mode 100644 index 2eb28d1..0000000 --- a/custom-skills/17-seo-schema-generator/desktop/tools/perplexity.md +++ /dev/null @@ -1,15 +0,0 @@ -# Perplexity - -> TODO: Document tool usage for this skill - -## Available Commands - -- [ ] List commands - -## Configuration - -- [ ] Add configuration details - -## Examples - -- [ ] Add usage examples diff --git a/custom-skills/17-seo-schema-generator/fixtures/sample_claims.csv b/custom-skills/17-seo-schema-generator/fixtures/sample_claims.csv new file mode 100644 index 0000000..3d7a4ca --- /dev/null +++ b/custom-skills/17-seo-schema-generator/fixtures/sample_claims.csv @@ -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,값 공란 -> 제외 diff --git a/custom-skills/17-seo-schema-generator/fixtures/site/grand.html b/custom-skills/17-seo-schema-generator/fixtures/site/grand.html new file mode 100644 index 0000000..42accd8 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/fixtures/site/grand.html @@ -0,0 +1,14 @@ + + + + + 그랜드 조선 부산 — 객실 및 예약 + + + + + + + +

그랜드 조선 부산

+ diff --git a/custom-skills/17-seo-schema-generator/fixtures/site/index.html b/custom-skills/17-seo-schema-generator/fixtures/site/index.html new file mode 100644 index 0000000..58b87d1 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/fixtures/site/index.html @@ -0,0 +1,45 @@ + + + + 조선호텔앤리조트 — 공식 홈페이지 + + + + + + + +

조선호텔앤리조트

+ diff --git a/custom-skills/17-seo-schema-generator/references/entity-and-type-map.md b/custom-skills/17-seo-schema-generator/references/entity-and-type-map.md new file mode 100644 index 0000000..4d9df6b --- /dev/null +++ b/custom-skills/17-seo-schema-generator/references/entity-and-type-map.md @@ -0,0 +1,59 @@ +# Entity → Schema Type Map (source-to-schema, pre-launch) + +Maps the source/entity kinds collected in steps 1–2 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). diff --git a/custom-skills/17-seo-schema-generator/references/site-extraction-methodology.md b/custom-skills/17-seo-schema-generator/references/site-extraction-methodology.md new file mode 100644 index 0000000..beca391 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/references/site-extraction-methodology.md @@ -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. +- **`` / 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. diff --git a/custom-skills/17-seo-schema-generator/references/source-authority-hierarchy.md b/custom-skills/17-seo-schema-generator/references/source-authority-hierarchy.md new file mode 100644 index 0000000..7724779 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/references/source-authority-hierarchy.md @@ -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` → 빌더가 스키마로 채택. diff --git a/custom-skills/17-seo-schema-generator/references/source-to-schema-methodology.md b/custom-skills/17-seo-schema-generator/references/source-to-schema-methodology.md new file mode 100644 index 0000000..ee6b0bc --- /dev/null +++ b/custom-skills/17-seo-schema-generator/references/source-to-schema-methodology.md @@ -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단계 게이트 diff --git a/custom-skills/17-seo-schema-generator/scripts/build_schema_drafts.py b/custom-skills/17-seo-schema-generator/scripts/build_schema_drafts.py new file mode 100644 index 0000000..871c464 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/scripts/build_schema_drafts.py @@ -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() diff --git a/custom-skills/17-seo-schema-generator/scripts/extract_site_claims.py b/custom-skills/17-seo-schema-generator/scripts/extract_site_claims.py new file mode 100644 index 0000000..9e0df84 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/scripts/extract_site_claims.py @@ -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()) diff --git a/custom-skills/17-seo-schema-generator/scripts/make_sample.py b/custom-skills/17-seo-schema-generator/scripts/make_sample.py new file mode 100644 index 0000000..3fc97b8 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/scripts/make_sample.py @@ -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() diff --git a/custom-skills/17-seo-schema-generator/scripts/requirements.txt b/custom-skills/17-seo-schema-generator/scripts/requirements.txt new file mode 100644 index 0000000..6fcce20 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/scripts/requirements.txt @@ -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) diff --git a/custom-skills/17-seo-schema-generator/scripts/type_templates.json b/custom-skills/17-seo-schema-generator/scripts/type_templates.json new file mode 100644 index 0000000..e795721 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/scripts/type_templates.json @@ -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[]}}" + } + } + } +} diff --git a/custom-skills/17-seo-schema-generator/templates/claims-register.csv b/custom-skills/17-seo-schema-generator/templates/claims-register.csv new file mode 100644 index 0000000..1b4eacb --- /dev/null +++ b/custom-skills/17-seo-schema-generator/templates/claims-register.csv @@ -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,두 출처 연도 충돌 -> 해소 필요 diff --git a/custom-skills/17-seo-schema-generator/templates/review-guide.md b/custom-skills/17-seo-schema-generator/templates/review-guide.md new file mode 100644 index 0000000..2cea4da --- /dev/null +++ b/custom-skills/17-seo-schema-generator/templates/review-guide.md @@ -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-__-__ diff --git a/custom-skills/17-seo-schema-generator/templates/source-register.csv b/custom-skills/17-seo-schema-generator/templates/source-register.csv new file mode 100644 index 0000000..8cc6dd8 --- /dev/null +++ b/custom-skills/17-seo-schema-generator/templates/source-register.csv @@ -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,교차검증