Restructure skill numbering: SEO 11-30, GTM 60-69, reserve 19-28 for future skills
Renumber 12 existing skills to new ranges: - SEO: 11→13, 12→18, 13→16, 14→17, 15→14, 16→15, 17→29, 18→30, 19→12 - GTM: 20→60, 21→61, 22→62 Update cross-references in gateway architect/builder skills, GTM guardian README, CLAUDE.md (skill tables + directory layout), and AGENTS.md (domain routing ranges). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
156
custom-skills/17-seo-schema-generator/code/CLAUDE.md
Normal file
156
custom-skills/17-seo-schema-generator/code/CLAUDE.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Overview
|
||||
|
||||
Schema markup generator: create JSON-LD structured data from templates for various content types.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements.txt
|
||||
|
||||
# Generate Organization schema
|
||||
python scripts/schema_generator.py --type organization --url https://example.com
|
||||
|
||||
# Generate from template
|
||||
python scripts/schema_generator.py --template templates/article.json --data article_data.json
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `schema_generator.py` | Generate schema markup |
|
||||
| `base_client.py` | Shared utilities |
|
||||
|
||||
## Supported Schema Types
|
||||
|
||||
| Type | Template | Use Case |
|
||||
|------|----------|----------|
|
||||
| Organization | `organization.json` | Company/brand info |
|
||||
| LocalBusiness | `local_business.json` | Physical locations |
|
||||
| Article | `article.json` | Blog posts, news |
|
||||
| Product | `product.json` | E-commerce items |
|
||||
| FAQPage | `faq.json` | FAQ sections |
|
||||
| BreadcrumbList | `breadcrumb.json` | Navigation path |
|
||||
| WebSite | `website.json` | Site-level info |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Organization
|
||||
```bash
|
||||
python scripts/schema_generator.py --type organization \
|
||||
--name "Company Name" \
|
||||
--url "https://example.com" \
|
||||
--logo "https://example.com/logo.png"
|
||||
```
|
||||
|
||||
### LocalBusiness
|
||||
```bash
|
||||
python scripts/schema_generator.py --type localbusiness \
|
||||
--name "Restaurant Name" \
|
||||
--address "123 Main St, City, State 12345" \
|
||||
--phone "+1-555-123-4567" \
|
||||
--hours "Mo-Fr 09:00-17:00"
|
||||
```
|
||||
|
||||
### Article
|
||||
```bash
|
||||
python scripts/schema_generator.py --type article \
|
||||
--headline "Article Title" \
|
||||
--author "Author Name" \
|
||||
--published "2024-01-15" \
|
||||
--image "https://example.com/image.jpg"
|
||||
```
|
||||
|
||||
### FAQPage
|
||||
```bash
|
||||
python scripts/schema_generator.py --type faq \
|
||||
--questions questions.json
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Generated JSON-LD ready for insertion:
|
||||
|
||||
```html
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Company Name",
|
||||
"url": "https://example.com",
|
||||
"logo": "https://example.com/logo.png"
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Template Customization
|
||||
|
||||
Templates in `templates/` can be modified. Required fields are marked:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "{{REQUIRED}}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{REQUIRED}}"
|
||||
},
|
||||
"datePublished": "{{REQUIRED}}",
|
||||
"image": "{{RECOMMENDED}}"
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Generated schemas are validated before output:
|
||||
- Syntax correctness
|
||||
- Required properties present
|
||||
- Schema.org vocabulary compliance
|
||||
|
||||
Use skill 13 (schema-validator) for additional validation.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
jsonschema>=4.21.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
```
|
||||
|
||||
## Notion Output (Required)
|
||||
|
||||
**IMPORTANT**: All audit reports MUST be saved to the OurDigital SEO Audit Log database.
|
||||
|
||||
### Database Configuration
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
|
||||
| URL | https://www.notion.so/dintelligence/2c8581e58a1e8035880be38cefc2f3ef |
|
||||
|
||||
### Required Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| Issue | Title | Report title (Korean + date) |
|
||||
| Site | URL | Audited website URL |
|
||||
| Category | Select | Technical SEO, On-page SEO, Performance, Schema/Structured Data, Sitemap, Robots.txt, Content, Local SEO |
|
||||
| Priority | Select | Critical, High, Medium, Low |
|
||||
| Found Date | Date | Audit date (YYYY-MM-DD) |
|
||||
| Audit ID | Rich Text | Format: [TYPE]-YYYYMMDD-NNN |
|
||||
|
||||
### Language Guidelines
|
||||
|
||||
- Report content in Korean (한국어)
|
||||
- Keep technical English terms as-is (e.g., SEO Audit, Core Web Vitals, Schema Markup)
|
||||
- URLs and code remain unchanged
|
||||
|
||||
### Example MCP Call
|
||||
|
||||
```bash
|
||||
mcp-cli call notion/API-post-page '{"parent": {"database_id": "2c8581e5-8a1e-8035-880b-e38cefc2f3ef"}, "properties": {...}}'
|
||||
```
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Schema Generator - Generate JSON-LD structured data markup
|
||||
==========================================================
|
||||
Purpose: Generate schema.org structured data in JSON-LD format
|
||||
Python: 3.10+
|
||||
Usage:
|
||||
python schema_generator.py --type organization --name "Company Name" --url "https://example.com"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory relative to this script
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "schema_templates"
|
||||
|
||||
|
||||
class SchemaGenerator:
|
||||
"""Generate JSON-LD schema markup from templates."""
|
||||
|
||||
SCHEMA_TYPES = {
|
||||
"organization": "organization.json",
|
||||
"local_business": "local_business.json",
|
||||
"product": "product.json",
|
||||
"article": "article.json",
|
||||
"faq": "faq.json",
|
||||
"breadcrumb": "breadcrumb.json",
|
||||
"website": "website.json",
|
||||
}
|
||||
|
||||
# Business type mappings for LocalBusiness
|
||||
BUSINESS_TYPES = {
|
||||
"restaurant": "Restaurant",
|
||||
"cafe": "CafeOrCoffeeShop",
|
||||
"bar": "BarOrPub",
|
||||
"hotel": "Hotel",
|
||||
"store": "Store",
|
||||
"medical": "MedicalBusiness",
|
||||
"dental": "Dentist",
|
||||
"legal": "LegalService",
|
||||
"real_estate": "RealEstateAgent",
|
||||
"auto": "AutoRepair",
|
||||
"beauty": "BeautySalon",
|
||||
"gym": "HealthClub",
|
||||
"spa": "DaySpa",
|
||||
}
|
||||
|
||||
# Article type mappings
|
||||
ARTICLE_TYPES = {
|
||||
"article": "Article",
|
||||
"blog": "BlogPosting",
|
||||
"news": "NewsArticle",
|
||||
"tech": "TechArticle",
|
||||
"scholarly": "ScholarlyArticle",
|
||||
}
|
||||
|
||||
def __init__(self, template_dir: Path = TEMPLATE_DIR):
|
||||
self.template_dir = template_dir
|
||||
|
||||
def load_template(self, schema_type: str) -> dict:
|
||||
"""Load a schema template file."""
|
||||
if schema_type not in self.SCHEMA_TYPES:
|
||||
raise ValueError(f"Unknown schema type: {schema_type}. "
|
||||
f"Available: {list(self.SCHEMA_TYPES.keys())}")
|
||||
|
||||
template_file = self.template_dir / self.SCHEMA_TYPES[schema_type]
|
||||
if not template_file.exists():
|
||||
raise FileNotFoundError(f"Template not found: {template_file}")
|
||||
|
||||
with open(template_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def fill_template(self, template: dict, data: dict[str, Any]) -> dict:
|
||||
"""Fill template placeholders with actual data."""
|
||||
template_str = json.dumps(template, ensure_ascii=False)
|
||||
|
||||
# Replace placeholders {{key}} with values
|
||||
for key, value in data.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
if value is not None:
|
||||
template_str = template_str.replace(placeholder, str(value))
|
||||
|
||||
# Remove unfilled placeholders and their parent objects if empty
|
||||
result = json.loads(template_str)
|
||||
return self._clean_empty_values(result)
|
||||
|
||||
def _clean_empty_values(self, obj: Any) -> Any:
|
||||
"""Remove empty values and unfilled placeholders."""
|
||||
if isinstance(obj, dict):
|
||||
cleaned = {}
|
||||
for key, value in obj.items():
|
||||
cleaned_value = self._clean_empty_values(value)
|
||||
# Skip if value is empty, None, or unfilled placeholder
|
||||
if cleaned_value is None:
|
||||
continue
|
||||
if isinstance(cleaned_value, str) and cleaned_value.startswith("{{"):
|
||||
continue
|
||||
if isinstance(cleaned_value, (list, dict)) and not cleaned_value:
|
||||
continue
|
||||
cleaned[key] = cleaned_value
|
||||
return cleaned if cleaned else None
|
||||
elif isinstance(obj, list):
|
||||
cleaned = []
|
||||
for item in obj:
|
||||
cleaned_item = self._clean_empty_values(item)
|
||||
if cleaned_item is not None:
|
||||
if isinstance(cleaned_item, str) and cleaned_item.startswith("{{"):
|
||||
continue
|
||||
cleaned.append(cleaned_item)
|
||||
return cleaned if cleaned else None
|
||||
elif isinstance(obj, str):
|
||||
if obj.startswith("{{") and obj.endswith("}}"):
|
||||
return None
|
||||
return obj
|
||||
return obj
|
||||
|
||||
def generate_organization(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
logo_url: str | None = None,
|
||||
description: str | None = None,
|
||||
founding_date: str | None = None,
|
||||
phone: str | None = None,
|
||||
address: dict | None = None,
|
||||
social_links: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Generate Organization schema."""
|
||||
template = self.load_template("organization")
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"logo_url": logo_url,
|
||||
"description": description,
|
||||
"founding_date": founding_date,
|
||||
"phone": phone,
|
||||
}
|
||||
|
||||
if address:
|
||||
data.update({
|
||||
"street_address": address.get("street"),
|
||||
"city": address.get("city"),
|
||||
"region": address.get("region"),
|
||||
"postal_code": address.get("postal_code"),
|
||||
"country": address.get("country", "KR"),
|
||||
})
|
||||
|
||||
if social_links:
|
||||
# Handle social links specially
|
||||
pass
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_local_business(
|
||||
self,
|
||||
name: str,
|
||||
business_type: str,
|
||||
address: dict,
|
||||
phone: str | None = None,
|
||||
url: str | None = None,
|
||||
description: str | None = None,
|
||||
hours: dict | None = None,
|
||||
geo: dict | None = None,
|
||||
price_range: str | None = None,
|
||||
rating: float | None = None,
|
||||
review_count: int | None = None,
|
||||
) -> dict:
|
||||
"""Generate LocalBusiness schema."""
|
||||
template = self.load_template("local_business")
|
||||
|
||||
schema_business_type = self.BUSINESS_TYPES.get(
|
||||
business_type.lower(), "LocalBusiness"
|
||||
)
|
||||
|
||||
data = {
|
||||
"business_type": schema_business_type,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"description": description,
|
||||
"phone": phone,
|
||||
"price_range": price_range,
|
||||
"street_address": address.get("street"),
|
||||
"city": address.get("city"),
|
||||
"region": address.get("region"),
|
||||
"postal_code": address.get("postal_code"),
|
||||
"country": address.get("country", "KR"),
|
||||
}
|
||||
|
||||
if geo:
|
||||
data["latitude"] = geo.get("lat")
|
||||
data["longitude"] = geo.get("lng")
|
||||
|
||||
if hours:
|
||||
data.update({
|
||||
"weekday_opens": hours.get("weekday_opens", "09:00"),
|
||||
"weekday_closes": hours.get("weekday_closes", "18:00"),
|
||||
"weekend_opens": hours.get("weekend_opens"),
|
||||
"weekend_closes": hours.get("weekend_closes"),
|
||||
})
|
||||
|
||||
if rating is not None:
|
||||
data["rating"] = str(rating)
|
||||
data["review_count"] = str(review_count or 0)
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_product(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
price: float,
|
||||
currency: str = "KRW",
|
||||
brand: str | None = None,
|
||||
sku: str | None = None,
|
||||
images: list[str] | None = None,
|
||||
availability: str = "InStock",
|
||||
condition: str = "NewCondition",
|
||||
rating: float | None = None,
|
||||
review_count: int | None = None,
|
||||
url: str | None = None,
|
||||
seller: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate Product schema."""
|
||||
template = self.load_template("product")
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"price": str(int(price)),
|
||||
"currency": currency,
|
||||
"brand_name": brand,
|
||||
"sku": sku,
|
||||
"product_url": url,
|
||||
"availability": availability,
|
||||
"condition": condition,
|
||||
"seller_name": seller,
|
||||
}
|
||||
|
||||
if images:
|
||||
for i, img in enumerate(images[:3], 1):
|
||||
data[f"image_url_{i}"] = img
|
||||
|
||||
if rating is not None:
|
||||
data["rating"] = str(rating)
|
||||
data["review_count"] = str(review_count or 0)
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_article(
|
||||
self,
|
||||
headline: str,
|
||||
description: str,
|
||||
author_name: str,
|
||||
date_published: str,
|
||||
publisher_name: str,
|
||||
article_type: str = "article",
|
||||
date_modified: str | None = None,
|
||||
images: list[str] | None = None,
|
||||
page_url: str | None = None,
|
||||
publisher_logo: str | None = None,
|
||||
author_url: str | None = None,
|
||||
section: str | None = None,
|
||||
word_count: int | None = None,
|
||||
keywords: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate Article schema."""
|
||||
template = self.load_template("article")
|
||||
|
||||
schema_article_type = self.ARTICLE_TYPES.get(
|
||||
article_type.lower(), "Article"
|
||||
)
|
||||
|
||||
data = {
|
||||
"article_type": schema_article_type,
|
||||
"headline": headline,
|
||||
"description": description,
|
||||
"author_name": author_name,
|
||||
"author_url": author_url,
|
||||
"date_published": date_published,
|
||||
"date_modified": date_modified or date_published,
|
||||
"publisher_name": publisher_name,
|
||||
"publisher_logo_url": publisher_logo,
|
||||
"page_url": page_url,
|
||||
"section": section,
|
||||
"word_count": str(word_count) if word_count else None,
|
||||
"keywords": keywords,
|
||||
}
|
||||
|
||||
if images:
|
||||
for i, img in enumerate(images[:2], 1):
|
||||
data[f"image_url_{i}"] = img
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_faq(self, questions: list[dict[str, str]]) -> dict:
|
||||
"""Generate FAQPage schema."""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [],
|
||||
}
|
||||
|
||||
for qa in questions:
|
||||
schema["mainEntity"].append({
|
||||
"@type": "Question",
|
||||
"name": qa["question"],
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": qa["answer"],
|
||||
},
|
||||
})
|
||||
|
||||
return schema
|
||||
|
||||
def generate_breadcrumb(self, items: list[dict[str, str]]) -> dict:
|
||||
"""Generate BreadcrumbList schema."""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [],
|
||||
}
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
schema["itemListElement"].append({
|
||||
"@type": "ListItem",
|
||||
"position": i,
|
||||
"name": item["name"],
|
||||
"item": item["url"],
|
||||
})
|
||||
|
||||
return schema
|
||||
|
||||
def generate_website(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
search_url_template: str | None = None,
|
||||
description: str | None = None,
|
||||
language: str = "ko-KR",
|
||||
publisher_name: str | None = None,
|
||||
logo_url: str | None = None,
|
||||
alternate_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate WebSite schema."""
|
||||
template = self.load_template("website")
|
||||
|
||||
data = {
|
||||
"site_name": name,
|
||||
"url": url,
|
||||
"description": description,
|
||||
"language": language,
|
||||
"search_url_template": search_url_template,
|
||||
"publisher_name": publisher_name or name,
|
||||
"logo_url": logo_url,
|
||||
"alternate_name": alternate_name,
|
||||
}
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def to_json_ld(self, schema: dict, pretty: bool = True) -> str:
|
||||
"""Convert schema dict to JSON-LD string."""
|
||||
indent = 2 if pretty else None
|
||||
return json.dumps(schema, ensure_ascii=False, indent=indent)
|
||||
|
||||
def to_html_script(self, schema: dict) -> str:
|
||||
"""Wrap schema in HTML script tag."""
|
||||
json_ld = self.to_json_ld(schema)
|
||||
return f'<script type="application/ld+json">\n{json_ld}\n</script>'
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for CLI usage."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate JSON-LD schema markup",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Generate Organization schema
|
||||
python schema_generator.py --type organization --name "My Company" --url "https://example.com"
|
||||
|
||||
# Generate Product schema
|
||||
python schema_generator.py --type product --name "Widget" --price 29900 --currency KRW
|
||||
|
||||
# Generate Article schema
|
||||
python schema_generator.py --type article --headline "Article Title" --author "John Doe"
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--type", "-t",
|
||||
required=True,
|
||||
choices=SchemaGenerator.SCHEMA_TYPES.keys(),
|
||||
help="Schema type to generate",
|
||||
)
|
||||
parser.add_argument("--name", help="Name/title")
|
||||
parser.add_argument("--url", help="URL")
|
||||
parser.add_argument("--description", help="Description")
|
||||
parser.add_argument("--price", type=float, help="Price (for product)")
|
||||
parser.add_argument("--currency", default="KRW", help="Currency code")
|
||||
parser.add_argument("--headline", help="Headline (for article)")
|
||||
parser.add_argument("--author", help="Author name")
|
||||
parser.add_argument("--output", "-o", help="Output file path")
|
||||
parser.add_argument("--html", action="store_true", help="Output as HTML script tag")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generator = SchemaGenerator()
|
||||
|
||||
try:
|
||||
if args.type == "organization":
|
||||
schema = generator.generate_organization(
|
||||
name=args.name or "Organization Name",
|
||||
url=args.url or "https://example.com",
|
||||
description=args.description,
|
||||
)
|
||||
elif args.type == "product":
|
||||
schema = generator.generate_product(
|
||||
name=args.name or "Product Name",
|
||||
description=args.description or "Product description",
|
||||
price=args.price or 0,
|
||||
currency=args.currency,
|
||||
)
|
||||
elif args.type == "article":
|
||||
schema = generator.generate_article(
|
||||
headline=args.headline or args.name or "Article Title",
|
||||
description=args.description or "Article description",
|
||||
author_name=args.author or "Author",
|
||||
date_published=datetime.now().strftime("%Y-%m-%d"),
|
||||
publisher_name="Publisher",
|
||||
)
|
||||
elif args.type == "website":
|
||||
schema = generator.generate_website(
|
||||
name=args.name or "Website Name",
|
||||
url=args.url or "https://example.com",
|
||||
description=args.description,
|
||||
)
|
||||
elif args.type == "faq":
|
||||
# Example FAQ
|
||||
schema = generator.generate_faq([
|
||||
{"question": "Question 1?", "answer": "Answer 1"},
|
||||
{"question": "Question 2?", "answer": "Answer 2"},
|
||||
])
|
||||
elif args.type == "breadcrumb":
|
||||
# Example breadcrumb
|
||||
schema = generator.generate_breadcrumb([
|
||||
{"name": "Home", "url": "https://example.com/"},
|
||||
{"name": "Category", "url": "https://example.com/category/"},
|
||||
])
|
||||
elif args.type == "local_business":
|
||||
schema = generator.generate_local_business(
|
||||
name=args.name or "Business Name",
|
||||
business_type="store",
|
||||
address={"street": "123 Main St", "city": "Seoul", "country": "KR"},
|
||||
url=args.url,
|
||||
description=args.description,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported type: {args.type}")
|
||||
|
||||
if args.html:
|
||||
output = generator.to_html_script(schema)
|
||||
else:
|
||||
output = generator.to_json_ld(schema)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
logger.info(f"Schema written to {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating schema: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"@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}}"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"@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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"@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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"@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}}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"@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}}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"@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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"@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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user