Compare commits
105 Commits
382b55e9c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f27fb7a2c4 | |||
|
|
a08dc316be | ||
| dc18594d76 | |||
|
|
0b3a0ab129 | ||
| 5c904756ab | |||
| 07fca7fb81 | |||
| 00edd2e5dc | |||
| 478ac61975 | |||
| 5ff9b3d9f5 | |||
| cfbca6cc15 | |||
| dfbc52e531 | |||
| 337f2ad6e1 | |||
| 9fbd719048 | |||
| 5385f3bddd | |||
| 75cfcb9ad3 | |||
| bfade2b722 | |||
| 2b36feb81b | |||
| aa003e28cc | |||
| 87635e4208 | |||
| 4f78534e59 | |||
| d20675d02c | |||
| 2cadc30825 | |||
| c35250f06a | |||
| 3383878e12 | |||
| f953887b97 | |||
| 18b39ef6ea | |||
| e1f4d75dc2 | |||
| 6c97dfc913 | |||
| c068372abf | |||
| 587a32d239 | |||
| 626abd4173 | |||
| c6585c817f | |||
| 75acd3aa3e | |||
| 7b239dda8f | |||
| 9762ee97ab | |||
| 4416833cb3 | |||
| b574c97fcc | |||
| 7daa4cda68 | |||
|
|
8ffb6bec6b | ||
|
|
9b914b9dd4 | ||
|
|
aab98f405d | ||
| 6f69a6a484 | |||
| 9f7f9e7221 | |||
| 6ac547e78f | |||
| 08cb20fc67 | |||
| c9bdbb57f7 | |||
| 34c3a1df4f | |||
| 137b927477 | |||
| 95d6fdf499 | |||
| 0496262cd5 | |||
| 60734dbde7 | |||
| e519a49cc4 | |||
| 5f66f57a8e | |||
| 01201ee4a2 | |||
| 1e3b2a4fa0 | |||
| 1706a820fe | |||
| 3a8edebfef | |||
| 4f48ba3c59 | |||
| ba88247496 | |||
| a55e77d1b0 | |||
| 35f155fa90 | |||
| 3fbce9dafb | |||
| a46364e22a | |||
| 8189473008 | |||
|
|
ec3b4df1e3 | ||
|
|
9630428c3e | ||
|
|
89889300d6 | ||
|
|
5b1bb2f0c5 | ||
|
|
e527fb4b0f | ||
| 1ca84f67ed | |||
| 2ee018a146 | |||
| c750fa7f5e | |||
| e1bd799672 | |||
| 69526d345a | |||
| c1061dcc71 | |||
| 40b79962fb | |||
| 72d4b36943 | |||
| 20029ecc9c | |||
| e67209b905 | |||
| 8aa0fa26e9 | |||
| a40d1f06b5 | |||
| a4da24b15c | |||
| 5f6438ecf3 | |||
| fffbab201d | |||
| 6bb8e6ab3c | |||
| 32a1c9d538 | |||
| 89b20aef16 | |||
| 45c68dee61 | |||
| 56f33eca3f | |||
| 08e3dd7dae | |||
| 6446f00522 | |||
| 707405b940 | |||
| 328de7d052 | |||
| 0421a65327 | |||
| 0f8d7b2df1 | |||
| aac1082bb7 | |||
| 4e692d0e9a | |||
| e0f93e6add | |||
| c318df8551 | |||
| e2ae8aad94 | |||
| c66b5e12cc | |||
| 144a17c88d | |||
| 2667304bca | |||
| 665fe22201 | |||
| c9772db119 |
163
.claude-plugin/marketplace.json
Normal file
163
.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-marketplace-schema.json",
|
||||
"name": "ourdigital-skills",
|
||||
"owner": {
|
||||
"name": "OurDigital",
|
||||
"email": "andrew.yim@ourdigital.org"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "OurDigital custom Claude skills, grouped into domain plugins (core, SEO, Jamie, D.intelligence, Notion, GTM, NotebookLM, utilities). Enable only the domains you need.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ourdigital-core",
|
||||
"description": "OurDigital core workflows — brand guide, Korean blog & English journal, research-to-blog, Notion-to-deck, visual design, ad copy, training material, back-office docs, skill creator, estimate engine.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/01-ourdigital-brand-guide",
|
||||
"./custom-skills/02-ourdigital-blog",
|
||||
"./custom-skills/03-ourdigital-journal",
|
||||
"./custom-skills/04-ourdigital-research",
|
||||
"./custom-skills/05-ourdigital-document",
|
||||
"./custom-skills/06-ourdigital-designer",
|
||||
"./custom-skills/07-ourdigital-ad-manager",
|
||||
"./custom-skills/08-ourdigital-trainer",
|
||||
"./custom-skills/09-ourdigital-backoffice",
|
||||
"./custom-skills/10-ourdigital-skill-creator",
|
||||
"./custom-skills/96-ourdigital-estimate-engine"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-seo",
|
||||
"description": "Full SEO suite — technical/on-page audits, Core Web Vitals, Search Console, schema validate/generate, local, keyword, SERP, rank tracking, links, content, e-commerce, KPIs, international, AI visibility, knowledge graph, gateway pages, competitor intel, crawl budget, migration, reporting, presales.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/11-seo-comprehensive-audit",
|
||||
"./custom-skills/12-seo-technical-audit",
|
||||
"./custom-skills/13-seo-on-page-audit",
|
||||
"./custom-skills/14-seo-core-web-vitals",
|
||||
"./custom-skills/15-seo-search-console",
|
||||
"./custom-skills/16-seo-schema-validator",
|
||||
"./custom-skills/17-seo-schema-generator",
|
||||
"./custom-skills/18-seo-local-audit",
|
||||
"./custom-skills/19-seo-keyword-strategy",
|
||||
"./custom-skills/20-seo-serp-analysis",
|
||||
"./custom-skills/21-seo-position-tracking",
|
||||
"./custom-skills/22-seo-link-building",
|
||||
"./custom-skills/23-seo-content-strategy",
|
||||
"./custom-skills/24-seo-ecommerce",
|
||||
"./custom-skills/25-seo-kpi-framework",
|
||||
"./custom-skills/26-seo-international",
|
||||
"./custom-skills/27-seo-ai-visibility",
|
||||
"./custom-skills/28-seo-knowledge-graph",
|
||||
"./custom-skills/29-seo-gateway-architect",
|
||||
"./custom-skills/30-seo-gateway-builder",
|
||||
"./custom-skills/31-seo-competitor-intel",
|
||||
"./custom-skills/32-seo-crawl-budget",
|
||||
"./custom-skills/33-seo-migration-planner",
|
||||
"./custom-skills/34-seo-reporting-dashboard",
|
||||
"./custom-skills/35-seo-signal-validation",
|
||||
"./custom-skills/95-ourdigital-presales-seo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-notion",
|
||||
"description": "Notion workspace tools — organize/clean up a workspace and write/export content into Notion.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/31-notion-organizer",
|
||||
"./custom-skills/32-notion-writer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-jamie",
|
||||
"description": "Jamie Plastic Surgery Clinic brand suite — branded content, brand audit, KakaoTalk Kanana FAQ, YouTube SEO + subtitle QA, Instagram management, journal editor, multi-channel marketing.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/40-jamie-brand-editor",
|
||||
"./custom-skills/41-jamie-brand-audit",
|
||||
"./custom-skills/42-jamie-faq-entry",
|
||||
"./custom-skills/43-jamie-youtube-manager",
|
||||
"./custom-skills/44-jamie-youtube-subtitle-checker",
|
||||
"./custom-skills/45-jamie-instagram-manager",
|
||||
"./custom-skills/46-jamie-journal-editor",
|
||||
"./custom-skills/47-jamie-marketing-editor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-notebooklm",
|
||||
"description": "NotebookLM automation — Q&A with citations, notebook/source/artifact management, studio content generation (podcasts, videos, quizzes), and research/source discovery. Requires the notebooklm-py CLI.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/50-notebooklm-agent",
|
||||
"./custom-skills/51-notebooklm-automation",
|
||||
"./custom-skills/52-notebooklm-studio",
|
||||
"./custom-skills/53-notebooklm-research"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-gtm",
|
||||
"description": "Google Tag Manager tooling — container audit/gap analysis, tag/trigger/variable editor (API + ES5 Custom HTML + dataLayer), and QA/validation.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/60-gtm-audit",
|
||||
"./custom-skills/61-gtm-editor",
|
||||
"./custom-skills/62-gtm-validator"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-dintel",
|
||||
"description": "D.intelligence Agent Corps — brand guardian/editor, document secretary, quotation manager, service architect, marketing manager, back-office manager, account manager, and cross-skill update meta-agent.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/70-dintel-brand-guardian",
|
||||
"./custom-skills/71-dintel-brand-editor",
|
||||
"./custom-skills/72-dintel-doc-secretary",
|
||||
"./custom-skills/73-dintel-quotation-mgr",
|
||||
"./custom-skills/74-dintel-service-architect",
|
||||
"./custom-skills/75-dintel-marketing-mgr",
|
||||
"./custom-skills/76-dintel-backoffice-mgr",
|
||||
"./custom-skills/77-dintel-account-mgr",
|
||||
"./custom-skills/79-dintel-skill-update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ourdigital-utils",
|
||||
"description": "Utility skills — Claude settings/token optimizer, Google Drive organizer, reference-documentation curator suite, and TUI wizard design template.",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./custom-skills/80-claude-settings-optimizer",
|
||||
"./custom-skills/82-our-gdrive-organizer",
|
||||
"./custom-skills/90-reference-curator",
|
||||
"./custom-skills/92-tui-design-template"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mac-optimizer",
|
||||
"description": "macOS system health toolkit — read-only audits, cleanup, and security checks. Commands: mac-doctor, mac-packages, mac-environment, mac-security, mac-cleanup, mac-resources (+ mac-optimizer skill).",
|
||||
"source": "./custom-skills/81-mac-optimizer",
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "multi-agent-guide",
|
||||
"description": "Multi-agent collaboration framework — agent hierarchies, ownership rules, guardrails, handoff protocols, and CI/CD integration for Claude, Gemini, Codex, and human agents. Commands: quick-setup, setup-agents (+ multi-agent-guide skill).",
|
||||
"source": "./custom-skills/91-multi-agent-guide",
|
||||
"strict": false
|
||||
},
|
||||
{
|
||||
"name": "dintel-bootstrap",
|
||||
"description": "Install and verify D.intelligence custom MCP agents (DTM, D.DA, OurSEO) in settings.json; bootstrap a new machine or diagnose a broken install (dintel-bootstrap skill).",
|
||||
"source": "./custom-skills/94-dintel-bootstrap",
|
||||
"strict": false
|
||||
}
|
||||
]
|
||||
}
|
||||
31
.claude/commands/dintel-campaign-designer.md
Normal file
31
.claude/commands/dintel-campaign-designer.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: D.intelligence campaign/promotion planning as a 3-gate process (cross-brand)
|
||||
---
|
||||
|
||||
# D.intelligence Campaign Designer
|
||||
|
||||
Plans campaigns, promotions, events, and launches as a 3-gate process -- Discovery & Debate → Brief → Plan -- instead of jumping straight to a finished document. Draft & Wait autonomy: each gate stops for explicit approval. Not D.intelligence-exclusive -- works for any brand.
|
||||
|
||||
## Triggers
|
||||
- "campaign plan", "plan a promotion", "캠페인 설계"
|
||||
- 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획
|
||||
|
||||
## The 3 Gates
|
||||
1. **Discovery & Debate** -- agree ONE primary objective; steelman + devil's-advocate debate; pre-mortem; 1-3 reference cases; effects as hypotheses. → `shared/templates/gate1-decision-log.md`
|
||||
2. **Brief** -- objective, audience, offer, message, tone, channel; outcome metrics across 4 tiers. → `shared/templates/gate2-campaign-brief.md`
|
||||
3. **Plan** -- full plan, handed off to `marketing:campaign-plan` + `doc-generator`; all risk/compliance consolidated in one closing "준비 점검 사항" section. → `shared/templates/gate3-plan-outline.md`
|
||||
|
||||
## 4-Tier Outcome Framework
|
||||
Awareness/cognitive → Qualitative (name the brand asset) → Relationship/advocacy → Quantitative conversion (label as hypothesis if no baseline)
|
||||
|
||||
## Cross-Brand Routing
|
||||
| Brand | Copy & tone | Compliance |
|
||||
|---|---|---|
|
||||
| D.intelligence | dintel-brand-editor (#71) | dintel-brand-guardian (#70) |
|
||||
| Jamie | jamie-copy-trimmer (48) | jamie-brand-audit (41) |
|
||||
| OurDigital | ourdigital-ad-manager (07) | ourdigital-brand-guide (01) |
|
||||
|
||||
## Guardrails
|
||||
- Never advance a gate without explicit user approval
|
||||
- Never commit pricing/quantitative targets without a baseline -- label as hypothesis
|
||||
- Mark gaps `[확인]` instead of inventing facts
|
||||
@@ -1,49 +1,44 @@
|
||||
---
|
||||
description: GTM lifecycle automation - progressive audit, version comparison, and lookup app
|
||||
description: Deprecated alias for DTM Agent skills — redirects to /dtm-audit, /dtm-lookup, /dtm-version, and /gtm-validator
|
||||
---
|
||||
|
||||
# GTM Guardian
|
||||
# GTM Guardian (Deprecated)
|
||||
|
||||
GTM tagging lifecycle automation: progressive audit (Phase 6) and event lookup app (Phase 7).
|
||||
> **The `gtm-guardian` workflow and the `dintel-gtm-toolkit` Python scripts are deprecated.** All GTM lifecycle automation now lives in the **DTM Agent** skill set, which operates on live containers via the Google Tag Manager API instead of exported JSON.
|
||||
|
||||
## Triggers
|
||||
- "GTM audit lifecycle", "container analysis"
|
||||
- "GTM 유지보수", "버전 비교"
|
||||
## Use these instead
|
||||
|
||||
## Quick Commands
|
||||
| Old (gtm-guardian / dintel-gtm-toolkit) | New (DTM Agent skill) |
|
||||
|---|---|
|
||||
| `analyze_container.py` — container structure analysis | `/dtm-tags`, `/dtm-triggers`, `/dtm-variables`, `/dtm-lookup` |
|
||||
| `find_unused.py` — unused element detection | `/dtm-lookup` (unused resource detection) |
|
||||
| `diff_versions.py` — version comparison | `/dtm-version` (list, create, compare versions) |
|
||||
| `validate_tags.py` — tag firing verification | `/gtm-validator` (live page QA via Chrome DevTools MCP) |
|
||||
| Phase 6 — Progressive Audit | `/dtm-audit` (page audit, tracking coverage, gap analysis) + `/dtm-debug` (A–D grading) |
|
||||
| Phase 7 — Event Taxonomy Lookup App | `/dtm-taxonomy` (classify events, validate naming, export docs) + `/dtm-lookup` (universal search, dependency graph) |
|
||||
| Container/account switching | `/dtm-set` |
|
||||
| Status & health check | `/dtm-status` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Clone D.intelligence GTM Toolkit
|
||||
git clone https://github.com/ourdigital/dintel-gtm-agent.git
|
||||
# Active context
|
||||
dtm status
|
||||
dtm list accounts
|
||||
dtm list containers
|
||||
|
||||
# Container analysis
|
||||
python analyze_container.py GTM-XXXXXX.json --output report.md
|
||||
# Inspect live container
|
||||
dtm list tags
|
||||
dtm list triggers
|
||||
dtm list variables
|
||||
|
||||
# Version comparison
|
||||
python diff_versions.py v1.json v2.json --output diff.md
|
||||
|
||||
# Unused element detection
|
||||
python find_unused.py container.json --type all
|
||||
# Versions
|
||||
dtm list versions
|
||||
dtm version live
|
||||
```
|
||||
|
||||
## Phase 6: Progressive Audit
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Container Analysis | JSON parsing, structure analysis |
|
||||
| Dependency Mapping | Tag-trigger-variable relationships |
|
||||
| Version Diff | Change tracking between versions |
|
||||
| Tag Validation | Automatic firing state verification |
|
||||
|
||||
### Audit Schedule
|
||||
- Weekly: Tag firing validation
|
||||
- Monthly: Full container review
|
||||
- Quarterly: Architecture review
|
||||
|
||||
## Phase 7: Lookup App
|
||||
Google Apps Script-based Event Taxonomy lookup app (Google Sheets -> Apps Script -> Web App).
|
||||
For full workflows, invoke the skill directly (e.g. `/dtm-audit`, `/gtm-validator`) — each skill bundles the right tool sequence, output formatting, and Notion reporting via the `notion-writer` skill.
|
||||
|
||||
## Notion Output
|
||||
- Database: GTM Knowledge Base
|
||||
- Properties: Project, Audit Date, Container ID, Status, Issues Count
|
||||
- Reports in Korean; technical terms in English
|
||||
|
||||
Unchanged — DTM Agent skills still write to the **GTM Knowledge Base** database (properties: Project, Audit Date, Container ID, Status, Issues Count). Reports remain in Korean with English technical terms.
|
||||
|
||||
36
.claude/commands/jamie-copy-trimmer.md
Normal file
36
.claude/commands/jamie-copy-trimmer.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Trims and sharpens Korean plastic-surgery/aesthetic marketing copy against cliché & compliance corpus
|
||||
---
|
||||
|
||||
# Jamie Copy Trimmer
|
||||
|
||||
Trim and sharpen Korean plastic-surgery / aesthetic-medical marketing copy against an industry expression corpus, within 의료광고 심의 limits. Guidance-only skill (no scripts).
|
||||
|
||||
## Triggers
|
||||
- "카피 다듬어", "카피 트리밍", "네이밍 검토", "슬로건 다듬어"
|
||||
- "심의 안전하게", "copy trim", "make it catchier"
|
||||
|
||||
## Core Philosophy
|
||||
- The corpus is a map for AVOIDING clichés, not a library to copy
|
||||
- 의료광고 심의 is a gate, not a score -- one 🔴 risk expression fails the option outright
|
||||
- Trim first, dazzle second
|
||||
- Don't guess -- mark `[확인]`
|
||||
|
||||
## Workflow (5 steps)
|
||||
1. **Diagnose** -- tag each phrase 🟢 effective / 🟡 cliché / 🔴 compliance risk / ⚪ flat / 🟦 brand asset
|
||||
2. **Trim** -- remove all 🔴, replace 🟡, delete redundancy
|
||||
3. **Elevate** -- 2-3 alternatives per element within 심의 limits, matched to channel tone
|
||||
4. **Re-score** -- 5-axis rubric (감각/차별성/브랜드적합성/심의 PASS-FAIL/명료성)
|
||||
5. **Recursive Improvement** -- propose feeding adopted/rejected expressions back into the corpus
|
||||
|
||||
## Output (Korean)
|
||||
진단 → 트리밍 → 대안 → 재평가 → 추천안 → 준비 점검 사항 (risks/gaps consolidated at the end)
|
||||
|
||||
## References
|
||||
- `references/corpus_compliance_risk.md` -- medical-ad risk expressions (most important)
|
||||
- `references/corpus_cliche.md`, `corpus_effective.md`, `corpus_examples.md`
|
||||
- `references/witty_within_limits.md`, `evaluation_rubric.md`, `recursive_protocol.md`
|
||||
|
||||
## Guardrails
|
||||
- Compliance judgment here is guidance, not legal advice -- always recommend pre-publication 의료광고 자율심의
|
||||
- A specific brand's tone guide (e.g. jamie-brand-audit) overrides this skill's taste defaults
|
||||
@@ -24,7 +24,7 @@ Push markdown content to Notion pages or databases via the Notion API.
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
cd ~/Projects/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
|
||||
# Test connection
|
||||
python notion_writer.py --test
|
||||
|
||||
26
.github/workflows/verify-skills.yml
vendored
Normal file
26
.github/workflows/verify-skills.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Verify Skills
|
||||
|
||||
# Runs the skill load-verifier on every push and pull request.
|
||||
# Fails the build if any skill would not load (invalid frontmatter, bad name,
|
||||
# name collision, orphan dir, or invalid plugin.json). Read-only check.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify-skills:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Verify all skills load correctly
|
||||
run: python3 scripts/verify_skills.py
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -99,3 +99,7 @@ build/
|
||||
# Temporary files
|
||||
output/
|
||||
keyword_analysis_*.json
|
||||
|
||||
# graphify: keep graph.json/html/report, drop regenerable cache + dated backups
|
||||
graphify-out/cache/
|
||||
graphify-out/????-??-??/
|
||||
|
||||
4
.graphifyignore
Normal file
4
.graphifyignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# graphify scope control — exclude non-standard virtualenvs that the default
|
||||
# rules (.venv, venv) don't catch, so pip-package source doesn't pollute the graph.
|
||||
.venv-ourdigital/
|
||||
.venv-*/
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -195,10 +195,17 @@ Task(
|
||||
- **Always ask user consent** before executing any cleanup or system changes
|
||||
- Scripts are in `custom-skills/81-mac-optimizer/scripts/`
|
||||
- Reference docs in `custom-skills/81-mac-optimizer/references/`
|
||||
- **82-tui-design-template**: TUI wizard interface design (Norton Commander / Gopher style, Rich)
|
||||
- **82-our-gdrive-organizer**: Organize a Google Drive folder under OurDigital conventions
|
||||
- Refreshes root `README.md` index (Snapshot + Structure section between AUTO-STRUCTURE markers; preserves manual Topics/Notes)
|
||||
- Ensures per-subfolder `README.md` meta files with auto-indexed Contents block
|
||||
- Proposes filename renames (`D.intelligence` → `OurDigital`) and moves (screenshots, temp downloads → tidy subfolders)
|
||||
- Skips sensitive folders by default: `04_Case Studies/`, `99_Project Archive/`, `*Archive$`, `진단*`
|
||||
- Pure Python stdlib CLI: `~/.local/bin/our-gdrive-organize [TARGET] [--apply] [--scope full|index|subreadmes|rename|move]`
|
||||
- Slash command: `/organize`
|
||||
|
||||
### Reference Curator Skills (90-91)
|
||||
### Reference Curator Skills (90-91) & Design Templates (92)
|
||||
|
||||
- **92-tui-design-template**: TUI wizard interface design (Norton Commander / Gopher style, Rich) — moved from slot 82 to make room for `our-gdrive-organizer`
|
||||
- Use **reference-curator-pipeline** for full automated curation workflows
|
||||
- Runs as background task, coordinates all 6 skills in sequence
|
||||
- Handles QA loops automatically (max 3 refactor, 2 deep_research iterations)
|
||||
|
||||
83
CLAUDE.md
83
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" |
|
||||
@@ -77,6 +77,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
||||
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
|
||||
| 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" |
|
||||
| 48 | jamie-copy-trimmer | Trim/sharpen Korean aesthetic-medical copy against cliché & compliance corpus | "카피 다듬어", "카피 트리밍", "심의 안전하게", "copy trim" |
|
||||
|
||||
### NotebookLM Tools (50-59)
|
||||
|
||||
@@ -109,6 +110,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
||||
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
||||
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
|
||||
| 78 | dintel-campaign-designer | Campaign/promotion planning as a 3-gate process (Discovery & Debate → Brief → Plan); cross-brand, not D.intelligence-exclusive | Draft & Wait | "campaign plan", "캠페인 기획", "기획안 만들어" |
|
||||
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
|
||||
|
||||
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
||||
@@ -158,21 +160,30 @@ This is a Claude Skills collection repository containing:
|
||||
|---|-------|---------|---------|
|
||||
| 80 | claude-settings-optimizer | Claude settings optimization & token audit | "settings audit", "exceed response limit", "MCP error" |
|
||||
| 81 | mac-optimizer | macOS system health audit & optimization (Claude Code only) | "audit my mac", "system health", "clean up caches", "check security", "update packages" |
|
||||
| 82 | tui-design-template | TUI wizard interface design (Norton Commander / Gopher style, Rich) | "build TUI", "CLI wizard", "terminal UI", "Rich TUI" |
|
||||
| 82 | our-gdrive-organizer | Organize a Google Drive folder under OurDigital conventions: refresh README index, refresh per-subfolder READMEs, propose renames + moves | "/organize", "organize the Drive folder", "refresh the index", "rescan the folder" |
|
||||
|
||||
## Dual-Platform Skill Structure
|
||||
## Skill Structure (root `SKILL.md` + dual-platform packaging)
|
||||
|
||||
Each skill has two independent versions:
|
||||
**Going-forward standard.** Each skill has a root `SKILL.md` (the official Agent Skills
|
||||
format — natively loadable by Claude Code / Desktop / API) plus the OurDigital `code/` +
|
||||
`desktop/` packaging. Reference implementations: `16-seo-schema-validator`,
|
||||
`17-seo-schema-generator`. To migrate an older skill, follow
|
||||
`reference/SKILL-MIGRATION-GUIDE.md` (additive — no rewrite).
|
||||
|
||||
```
|
||||
XX-skill-name/
|
||||
├── code/ # Claude Code version
|
||||
│ ├── CLAUDE.md # Action-oriented directive
|
||||
│ ├── scripts/ # Executable Python/Bash
|
||||
│ └── docs/ # Documentation
|
||||
├── SKILL.md # Root directive — official Agent Skills format (LOADABLE)
|
||||
├── scripts/ # Runnable scripts (single source of truth)
|
||||
├── references/ # Heavy docs, loaded on demand
|
||||
├── templates/ # Optional
|
||||
├── fixtures/ # Optional sample/test inputs
|
||||
│
|
||||
├── desktop/ # Claude Desktop version
|
||||
│ ├── SKILL.md # Skill directive with YAML frontmatter
|
||||
├── code/ # Claude Code packaging
|
||||
│ ├── CLAUDE.md # Redirects to ../SKILL.md + ../scripts (no duplication)
|
||||
│ └── scripts/ # Legacy/aux tools only
|
||||
│
|
||||
├── desktop/ # Claude Desktop packaging
|
||||
│ ├── SKILL.md # Desktop directive with YAML frontmatter
|
||||
│ ├── skill.yaml # Extended metadata (optional)
|
||||
│ └── tools/ # MCP tool documentation
|
||||
│
|
||||
@@ -180,24 +191,24 @@ XX-skill-name/
|
||||
└── README.md # Overview
|
||||
```
|
||||
|
||||
### Platform Differences
|
||||
Older skills may still be `code/` + `desktop/` only (no root `SKILL.md`) — valid, but not
|
||||
directly loadable. Migrate incrementally, not in bulk.
|
||||
|
||||
| Aspect | `code/` | `desktop/` |
|
||||
|--------|---------|------------|
|
||||
| Directive | CLAUDE.md | SKILL.md (with YAML frontmatter) |
|
||||
| Metadata | In CLAUDE.md | skill.yaml (optional) |
|
||||
| Tool docs | N/A | tools/ directory |
|
||||
| Execution | Direct Bash/Python | MCP tools only |
|
||||
| Scripts | Required | Reference only |
|
||||
### Layer roles
|
||||
|
||||
### SKILL.md Format (Desktop)
|
||||
| Layer | Role |
|
||||
|-------|------|
|
||||
| root `SKILL.md` | Canonical, loadable directive + bundled resources |
|
||||
| `code/` | Claude Code packaging; `CLAUDE.md` redirects to the root |
|
||||
| `desktop/` | Claude Desktop view; `SKILL.md` + `skill.yaml` + `tools/` |
|
||||
|
||||
### SKILL.md frontmatter (root + desktop)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: skill-name-kebab-case
|
||||
name: skill-name-kebab-case # clean name: dir minus the NN- prefix
|
||||
description: |
|
||||
Brief description of what the skill does.
|
||||
Triggers: keyword1, keyword2, 한국어 트리거.
|
||||
What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거.
|
||||
---
|
||||
|
||||
# Skill Title
|
||||
@@ -205,6 +216,11 @@ description: |
|
||||
Content starts here...
|
||||
```
|
||||
|
||||
**Naming convention:** the directory keeps its `NN-` prefix for ordering
|
||||
(`16-seo-schema-validator/`), but the skill `name:` is the **clean** form without it
|
||||
(`name: seo-schema-validator`). Names must be kebab-case and globally unique.
|
||||
Whole frontmatter ≤ 1024 chars. Full rules + migration recipe: `reference/SKILL-MIGRATION-GUIDE.md`.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
@@ -239,6 +255,7 @@ our-claude-skills/
|
||||
│ ├── 45-jamie-instagram-manager/
|
||||
│ ├── 46-jamie-journal-editor/
|
||||
│ ├── 47-jamie-marketing-editor/
|
||||
│ ├── 48-jamie-copy-trimmer/
|
||||
│ │
|
||||
│ ├── 50-notebooklm-agent/
|
||||
│ ├── 51-notebooklm-automation/
|
||||
@@ -257,11 +274,12 @@ our-claude-skills/
|
||||
│ ├── 75-dintel-marketing-mgr/
|
||||
│ ├── 76-dintel-backoffice-mgr/
|
||||
│ ├── 77-dintel-account-mgr/
|
||||
│ ├── 78-dintel-campaign-designer/
|
||||
│ ├── 79-dintel-skill-update/
|
||||
│ │
|
||||
│ ├── 80-claude-settings-optimizer/
|
||||
│ ├── 81-mac-optimizer/
|
||||
│ ├── 82-tui-design-template/
|
||||
│ ├── 82-our-gdrive-organizer/
|
||||
│ │
|
||||
│ ├── 90-reference-curator/ # Modular reference documentation suite
|
||||
│ │ ├── 01-reference-discovery/
|
||||
@@ -275,7 +293,9 @@ our-claude-skills/
|
||||
│ │ ├── shared/
|
||||
│ │ └── install.sh
|
||||
│ │
|
||||
│ └── 91-multi-agent-guide/
|
||||
│ ├── 91-multi-agent-guide/
|
||||
│ │
|
||||
│ └── 92-tui-design-template/
|
||||
│
|
||||
├── example-skills/skills-main/
|
||||
├── official-skills/
|
||||
@@ -285,10 +305,11 @@ our-claude-skills/
|
||||
## Skill Design Principles
|
||||
|
||||
1. **One thing done well** - Each skill focuses on a single capability
|
||||
2. **Directives under 1,500 words** - Concise, actionable
|
||||
3. **Self-contained** - Each platform version is fully independent
|
||||
4. **Code-first development** - Build Claude Code version first
|
||||
5. **Progressive numbering** - Logical grouping by domain
|
||||
2. **Root `SKILL.md` first** - Ship a loadable root `SKILL.md`; keep `code/`+`desktop/` as packaging
|
||||
3. **Directives under 1,500 words** - Concise, actionable; push detail into `references/`
|
||||
4. **Self-contained** - Bundle scripts/refs as relative paths; one source of truth (no script dupes)
|
||||
5. **Code-first development** - Build and self-test the runnable version first
|
||||
6. **Progressive numbering** - Logical grouping by domain
|
||||
|
||||
## Creating New Skills
|
||||
|
||||
@@ -297,8 +318,12 @@ our-claude-skills/
|
||||
python example-skills/skills-main/skill-creator/scripts/init_skill.py <skill-name> --path custom-skills/
|
||||
```
|
||||
|
||||
New skills must include a root `SKILL.md` (the hybrid pattern above); model them on
|
||||
`16-seo-schema-validator` / `17-seo-schema-generator`.
|
||||
|
||||
## Key Reference Files
|
||||
|
||||
- `reference/SKILL-FORMAT-REQUIREMENTS.md` - Format specification
|
||||
- `reference/SKILL-MIGRATION-GUIDE.md` - Add a root `SKILL.md` to an existing skill (hybrid pattern, going-forward standard)
|
||||
- `reference/SKILL-FORMAT-REQUIREMENTS.md` - Dual-platform format specification
|
||||
- `example-skills/skills-main/skill-creator/SKILL.md` - Skill creation guide
|
||||
- `AGENTS.md` - Agent routing guide for Task tool
|
||||
|
||||
20
README.md
20
README.md
@@ -12,6 +12,11 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This symlinks the global slash commands into `~/.claude/commands/`, sets up the
|
||||
Python virtual environment, and configures credentials. It does **not** register
|
||||
skills natively — to load a skill as a Claude Code skill, also symlink it into
|
||||
`~/.claude/skills/` (see [Usage → Claude Code](#claude-code)).
|
||||
|
||||
## Custom Skills Overview
|
||||
|
||||
### OurDigital Core (01-10)
|
||||
@@ -215,16 +220,23 @@ The `_ourdigital-shared/` directory provides:
|
||||
|
||||
### Claude Code
|
||||
|
||||
Skills are auto-detected via symlinks in `~/.claude/skills/`:
|
||||
The Quick Install symlinks the **slash commands** into `~/.claude/commands/`. To
|
||||
also load a skill natively, symlink its **root** directory (which holds the
|
||||
loadable `SKILL.md`) into `~/.claude/skills/`, using the clean name without the
|
||||
`NN-` prefix:
|
||||
|
||||
```bash
|
||||
# Install skill symlink
|
||||
ln -sf /path/to/skill/desktop ~/.claude/skills/skill-name
|
||||
# From the repo root — symlink a skill into Claude Code
|
||||
ln -sf "$PWD/custom-skills/16-seo-schema-validator" ~/.claude/skills/seo-schema-validator
|
||||
```
|
||||
|
||||
> Legacy skills that don't yet have a root `SKILL.md` expose it under
|
||||
> `code/SKILL.md` instead — symlink `.../<skill>/code` for those.
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Copy the `desktop/SKILL.md` file to your Claude Desktop skills folder.
|
||||
Import the skill's `desktop/` folder (containing `SKILL.md` + `skill.yaml`) via
|
||||
your Claude Desktop skills settings.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
165
custom-skills/01-ourdigital-brand-guide/SKILL.md
Normal file
165
custom-skills/01-ourdigital-brand-guide/SKILL.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: ourdigital-brand-guide
|
||||
description: |
|
||||
OurDigital 브랜드 기준 및 스타일 가이드 참조 스킬.
|
||||
Activated with "ourdigital" keyword for brand-related queries.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital brand guide", "our brand guide"
|
||||
- "ourdigital 브랜드 가이드", "our 브랜드 가이드"
|
||||
- "ourdigital 톤앤매너", "our 톤앤매너"
|
||||
- "ourdigital style check", "our style check"
|
||||
|
||||
Features:
|
||||
- Brand foundation & values reference
|
||||
- Writing style guidelines (Korean/English)
|
||||
- Visual identity & color palette
|
||||
- Channel-specific tone mapping
|
||||
- Brand compliance checking
|
||||
version: "1.1"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Brand Guide
|
||||
|
||||
Reference skill for OurDigital brand standards, writing style, and visual identity.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital 브랜드 가이드" / "our 브랜드 가이드"
|
||||
- "ourdigital 톤앤매너 체크" / "our 톤앤매너"
|
||||
- "our brand guide", "our style check"
|
||||
|
||||
## Brand Foundation
|
||||
|
||||
### Core Identity
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| **Brand Name** | OurDigital Clinic |
|
||||
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 |
|
||||
| **Promise** | 진단-처방-측정 가능한 성장 |
|
||||
|
||||
### Core Values
|
||||
|
||||
| 가치 | English | 클리닉 메타포 |
|
||||
|------|---------|--------------|
|
||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 |
|
||||
| 실행 지향 | In-Action | 실행 가능한 처방 |
|
||||
| 지속 성장 | Sustainable Growth | 체질 개선 |
|
||||
|
||||
### Brand Philosophy
|
||||
|
||||
**"Precision + Empathy + Evidence"**
|
||||
|
||||
## Channel Tone Matrix
|
||||
|
||||
| Channel | Domain | Personality | Tone |
|
||||
|---------|--------|-------------|------|
|
||||
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented |
|
||||
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking |
|
||||
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer |
|
||||
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey |
|
||||
|
||||
## Writing Style Characteristics
|
||||
|
||||
### Korean (한국어)
|
||||
|
||||
1. **철학-기술 융합체**: 기술 분석과 실존적 질문을 자연스럽게 결합
|
||||
2. **역설 활용**: 긴장과 모순 구조로 논증 전개
|
||||
3. **수사적 질문**: 선언적 권위보다 질문을 통한 참여
|
||||
4. **우울한 낙관주의**: 불안과 상실을 인정하되 절망하지 않음
|
||||
|
||||
### English
|
||||
|
||||
1. **Philosophical-Technical Hybridization**: Technical content with human implications
|
||||
2. **Paradox as Device**: Structure arguments around tensions
|
||||
3. **Rhetorical Questions**: Interrogative engagement over authority
|
||||
4. **Melancholic Optimism**: Acknowledge anxiety without despair
|
||||
|
||||
### Do's and Don'ts
|
||||
|
||||
**Do's:**
|
||||
- Use paradox to structure arguments
|
||||
- Ask rhetorical questions to engage readers
|
||||
- Connect technical content to human implications
|
||||
- Blend Korean and English naturally for technical terms
|
||||
- Reference historical context and generational shifts
|
||||
|
||||
**Don'ts:**
|
||||
- Avoid purely declarative, authoritative tone
|
||||
- Don't separate technical analysis from cultural impact
|
||||
- Avoid simplistic or overly optimistic narratives
|
||||
- Don't provide prescriptive conclusions without exploration
|
||||
|
||||
## Visual Identity
|
||||
|
||||
### Primary Colors
|
||||
|
||||
| Token | Color | HEX | Usage |
|
||||
|-------|-------|-----|-------|
|
||||
| --d-black | D.Black | #221814 | Footer, dark backgrounds |
|
||||
| --d-olive | D.Olive | #cedc00 | Primary accent, CTA buttons |
|
||||
| --d-green | D.Green | #287379 | Links hover, secondary accent |
|
||||
| --d-blue | D.Blue | #0075c0 | Links |
|
||||
| --d-beige | D.Beige | #f2f2de | Light text on dark |
|
||||
| --d-gray | D.Gray | #ebebeb | Alt backgrounds |
|
||||
|
||||
### Typography
|
||||
|
||||
- **Korean**: Noto Sans KR
|
||||
- **English**: Noto Sans, Inter
|
||||
- **Grid**: 12-column responsive layout
|
||||
|
||||
## Brand Compliance Check
|
||||
|
||||
When reviewing content, verify:
|
||||
|
||||
1. **Brand Name**: Uses OurDigital Clinic (not Lab/연구소)?
|
||||
2. **Tone Match**: Does it match the channel's personality?
|
||||
3. **Value Alignment**: Reflects Marketing Science, In-Action, Sustainable Growth?
|
||||
4. **Philosophy Check**: Precision + Empathy + Evidence present?
|
||||
5. **Language Style**: Appropriate blend of Korean/English terms?
|
||||
6. **Visual Consistency**: Uses approved color palette (#221814/#cedc00)?
|
||||
7. **Data Asset Check**: Analytics/reporting references current?
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Key Messages
|
||||
|
||||
| Use | Message |
|
||||
|-----|---------|
|
||||
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| Value Prop | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||
| Process | 진단 → 처방 → 측정 |
|
||||
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
||||
|
||||
### CTA Patterns
|
||||
|
||||
| Context | CTA |
|
||||
|---------|-----|
|
||||
| General | 무료 상담 신청하기 |
|
||||
| SEO | SEO 진단 신청하기 |
|
||||
| Content | 콘텐츠 전략 상담 신청하기 |
|
||||
|
||||
## Deprecated Terms
|
||||
|
||||
| Deprecated | Current | Note |
|
||||
|-----------|---------|------|
|
||||
| OurDigital Lab | OurDigital Clinic | 2026-04 rebranding |
|
||||
| 디지털 연구소 | 디지털 마케팅 클리닉 | Korean equivalent |
|
||||
| 데이터 중심 (Core Value) | — | Moved to D.intelligence |
|
||||
| 실행 지향 / In-Action | — | Retained (service tagline system) |
|
||||
| #1a1a2e | #221814 (O.Black) | Color correction |
|
||||
| #4ecdc4 | #cedc00 (O.Olive) | Color correction |
|
||||
| Poppins / Lora | Noto Sans KR / Inter | Font standardization |
|
||||
|
||||
## References
|
||||
|
||||
See `shared/references/` for detailed guides:
|
||||
- `brand-foundation.md` - Complete brand identity
|
||||
- `writing-style.md` - Detailed writing guidelines
|
||||
- `color-palette.md` - Full color system with CSS variables
|
||||
@@ -1,6 +1,8 @@
|
||||
# OurDigital Brand Foundation
|
||||
|
||||
Complete brand identity reference for OurDigital Clinic.
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Complete brand identity reference for OurDigital.
|
||||
|
||||
## Brand Identity
|
||||
|
||||
@@ -8,25 +10,34 @@ Complete brand identity reference for OurDigital Clinic.
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| **Brand Name** | OurDigital Clinic |
|
||||
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 |
|
||||
| **Brand Name** | OurDigital |
|
||||
| **Identity Statement** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Mission** | 기술이 사람과 문화에 미치는 영향을 관찰하고 기록한다. 생각의 씨앗을 남기고 성찰한다. 검증되지 않은 도전과 실험의 결과를 기록한다. |
|
||||
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
|
||||
| **Promise** | 진단-처방-측정 가능한 성장 |
|
||||
|
||||
### Core Values
|
||||
### Brand Keywords
|
||||
|
||||
| 가치 | English | 클리닉 메타포 | 설명 |
|
||||
|------|---------|--------------|------|
|
||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 |
|
||||
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 |
|
||||
| 지속 성장 | Sustainable Growth | 체질 개선 | 일회성이 아닌 지속 가능한 성장 |
|
||||
| 핵심 가치 | 설명 |
|
||||
|----------|------|
|
||||
| **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
|
||||
| **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
|
||||
| **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
|
||||
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
|
||||
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
|
||||
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
|
||||
|
||||
### Brand Philosophy
|
||||
|
||||
**"Precision + Empathy + Evidence"**
|
||||
|
||||
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications.
|
||||
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications. 철학적 깊이와 실용적 가치의 균형이 OurDigital의 정체성이다.
|
||||
|
||||
### Brand Boundary (중요)
|
||||
|
||||
- **블로그 전체 브랜드**: OurDigital
|
||||
- **블로그 설명**: 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트
|
||||
- **OurDigital Clinic**: 진단형 콘텐츠, SEO/데이터/콘텐츠 감사, 컨설팅 상품, 문제 해결형 시리즈에서만 사용 가능한 서비스 메타포. 블로그 전체 브랜드명이 아니다.
|
||||
|
||||
## Positioning Statement
|
||||
|
||||
@@ -34,8 +45,16 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
||||
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
|
||||
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
|
||||
|
||||
*(Note: OurDigital Clinic은 컨설팅/서비스 맥락에서의 포지셔닝. 블로그 전체 포지셔닝과 구분한다.)*
|
||||
|
||||
## Target Audience
|
||||
|
||||
**블로그 독자 (blog.ourdigital.org)**
|
||||
- **1순위**: 디지털 마케팅/기술 분야 종사자 — 실무 전략을 수립하고 실행하는 전문가
|
||||
- **2순위**: 기술과 사회의 관계에 관심 있는 지식인 — 디지털 전환이 사회에 미치는 영향을 고민하는 독자
|
||||
- **3순위**: 자기 성찰과 비판적 사고를 중시하는 독자
|
||||
|
||||
**컨설팅 서비스 (OurDigital Clinic)**
|
||||
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
|
||||
- **Secondary**: 스타트업 창업자, 브랜드 매니저
|
||||
|
||||
@@ -54,24 +73,30 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
||||
|
||||
| Level | Element | Description |
|
||||
|-------|---------|-------------|
|
||||
| Level 1 | Master Brand | OurDigital Clinic |
|
||||
| Level 1 | Master Brand | OurDigital |
|
||||
| Level 2 | Channel Identity | Blog, Journal, OurStory |
|
||||
| Level 3 | Service Identity | 4개 핵심 서비스 |
|
||||
| Level 3 | Service Identity | 4개 핵심 서비스 (OurDigital Clinic 메타포 적용 가능) |
|
||||
|
||||
### Channel Personality & Tone
|
||||
|
||||
| Channel | Domain | Personality | Tone | Content Type |
|
||||
|---------|--------|-------------|------|--------------|
|
||||
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented | 서비스, 케이스, 리드 |
|
||||
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking | 가이드, 분석, 인사이트 |
|
||||
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer | 에세이, 문화, 관찰 |
|
||||
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey | 개인 서사, 경험 |
|
||||
| D.intelligence | dintelligence.co.kr | Professional | B2B | Corporate Partnership |
|
||||
| Channel | Domain | Language | Character | Length |
|
||||
|---------|--------|----------|-----------|--------|
|
||||
| Main Hub | ourdigital.org | Korean | Professional & Confident, Data-driven | 서비스, 케이스, 리드 |
|
||||
| **Blog** | blog.ourdigital.org | **Korean** | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | **1,500-3,000자** |
|
||||
| **Journal** | journal.ourdigital.org | **English** | 산업 트렌드, 기술-인간 교차점, reflective essay | **1,000-2,000 words** |
|
||||
| **OurStory** | ourstory.day | **Korean** | 개인 에세이, 삶의 성찰, 일상의 관찰 | **800-1,500자** |
|
||||
| D.intelligence | dintelligence.co.kr | Korean/English | Professional | B2B Corporate Partnership |
|
||||
|
||||
**채널 라우팅 규칙:**
|
||||
- 기술 분석 + 철학적 사유 → `blog.ourdigital.org`
|
||||
- 순수 개인 에세이, 감정적 성찰 → `ourstory.day`
|
||||
- 영문 심층 에세이, 산업 관점 → `journal.ourdigital.org`
|
||||
- 진단형 콘텐츠, 컨설팅 제안 → `OurDigital Clinic` 메타포 사용 가능
|
||||
|
||||
### Content Flow Strategy
|
||||
|
||||
```
|
||||
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site / OurDigital Clinic)
|
||||
```
|
||||
|
||||
### Publishing Cadence
|
||||
@@ -80,7 +105,7 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
|---------|-----------|--------|
|
||||
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
|
||||
| blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 |
|
||||
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000자 |
|
||||
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000 words |
|
||||
| ourstory.day | 월 1-2회 | 800-1,500자 |
|
||||
|
||||
## Service Portfolio
|
||||
@@ -112,13 +137,16 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
|
||||
| Use Case | Message |
|
||||
|----------|---------|
|
||||
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| Blog Identity | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||
| Process | 진단 → 처방 → 측정 |
|
||||
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
||||
|
||||
## CTA Library
|
||||
|
||||
*(OurDigital Clinic 컨설팅 맥락에서 사용)*
|
||||
|
||||
| Context | CTA Text |
|
||||
|---------|----------|
|
||||
| General | 무료 상담 신청하기 |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OurDigital Writing Style Guide
|
||||
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|
||||
## Overview
|
||||
@@ -8,7 +10,7 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|-------|-------|
|
||||
| **Author** | Andrew Yim |
|
||||
| **Primary Blog** | blog.ourdigital.org |
|
||||
| **Tagline** | 사람, 디지털 그리고 문화 (People, Digital, and Culture) |
|
||||
| **Brand Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Platform** | Ghost CMS |
|
||||
| **History** | 2004-2025 (20+ years of content) |
|
||||
|
||||
@@ -16,54 +18,73 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|
||||
## Part 1: 한국어 스타일가이드
|
||||
|
||||
### Writer 역할 정의
|
||||
|
||||
25년차 디지털 마케팅 에세이 작가. 기술과 인간의 관계를 관찰하는 실무자이자 사유자. 분석적이면서 개인적이고, 단정 대신 질문을 두고, 결론 대신 가능성을 제시하며, 독자를 가르치지 않고 함께 생각하는 동료로 대한다.
|
||||
|
||||
### 문체 규칙 (Korean)
|
||||
|
||||
- 대화체 `~다`, `~이다`, `~한다`를 사용한다.
|
||||
- 경어체 `~합니다`, `~입니다`는 사용하지 않는다.
|
||||
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다.
|
||||
- 문단 전환 시 호흡을 바꾼다: 분석 → 서사 → 질문.
|
||||
- **전문용어는 첫 등장 시 영문을 병기한다.** 예: `검색엔진 최적화(SEO)`.
|
||||
|
||||
### 핵심 글쓰기 특성
|
||||
|
||||
#### 1. 철학-기술 융합체
|
||||
#### 1. 철학-기술 융합
|
||||
|
||||
기술 분석과 실존적 질문을 자연스럽게 결합한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 함의와 분리되지 않는다.
|
||||
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의미와 분리되지 않는다.
|
||||
|
||||
**예시**: Llama 4와 DeepSeek 비교 글에서도 거버넌스 우려와 사회적 영향을 포함한다.
|
||||
```
|
||||
나쁜 예: 구글의 알고리즘은 200개 이상의 신호를 분석한다.
|
||||
좋은 예: 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
|
||||
```
|
||||
|
||||
#### 2. 역설(Paradox)을 주요 수사 장치로 활용
|
||||
#### 2. 핵심 긴장 / 관점 전환 / 역설 / 열린 질문
|
||||
|
||||
논증을 긴장과 모순 구조로 전개한다:
|
||||
- 인간이 컴퓨터를 모방하면서 컴퓨터가 인간을 모방하는 역설
|
||||
- 기술이 해방하면서 동시에 의미를 축소하는 역설
|
||||
- 디지털 네이티브가 기성세대가 가르칠 수 없는 유창함을 보유하는 역설
|
||||
모든 글에 억지 역설을 넣지는 않는다. 대신 **핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상**을 자연스럽게 포함한다. 공식화를 경계한다.
|
||||
|
||||
#### 3. 수사적 질문 활용
|
||||
유용한 패턴:
|
||||
- `~하면서 동시에 ~하다`
|
||||
- `~를 위해 오히려 ~해야 한다`
|
||||
- `가장 ~한 것이 사실은 가장 ~하다`
|
||||
- `우리가 놓치고 있는 것은 무엇인가?`
|
||||
|
||||
선언적 권위보다 질문을 통한 참여를 선호한다:
|
||||
수사적 질문은 선언적 권위보다 독자와의 지적 동반자 관계를 형성한다.
|
||||
|
||||
> "이 디지털 세대에게 무엇을 가르쳐야 하는가?"
|
||||
> "그 무한한 자유 속에서 우리는 무엇을 소중히 여기게 될 것인가?"
|
||||
#### 3. 우울한 낙관주의 (Melancholic Optimism)
|
||||
|
||||
이는 독자와 지적 동반자 관계를 형성하며, 교훈적 지시를 피한다.
|
||||
디지털 세계의 불안, 피로, 한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 더 나은 실천, 작은 회복의 여지를 찾는다.
|
||||
|
||||
#### 4. 우울한 낙관주의 (Melancholic Optimism)
|
||||
```
|
||||
예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
|
||||
```
|
||||
|
||||
불안과 상실을 인정하되 절망하지 않는다. 기술의 불가피성을 수용하면서도 대체되는 것에 대한 진정한 우려를 표현한다.
|
||||
#### 4. 분석적이면서 개인적
|
||||
|
||||
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
|
||||
|
||||
### 문장 구조 패턴
|
||||
|
||||
| 요소 | 패턴 |
|
||||
|------|------|
|
||||
| 문장 길이 | 여러 절을 포함한 긴 복합문 - 논의되는 상호연결된 개념을 반영 |
|
||||
| 문장 길이 | 짧은 문장과 긴 복합문을 교차 배치 — 리듬을 만든다 |
|
||||
| 단락 구조 | 관찰 → 분석 → 철학적 함의로 점진적 심화 |
|
||||
| 근거 제시 | 역사적 사례, 문화간 참조, 기술 명세를 함께 엮음 |
|
||||
| 결론부 | 종종 열린 결말로, 답을 제시하기보다 질문을 던짐 |
|
||||
|
||||
### 독자 조율
|
||||
|
||||
**교양 있는 일반 독자**를 대상으로 한다 — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다.
|
||||
**교양 있는 일반 독자** — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다. 독자를 가르치려 하지 않고 결론을 강요하지 않는다. 생각의 재료를 제공한다.
|
||||
|
||||
기술 글에도 요약본과 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
|
||||
기술 글에도 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
|
||||
|
||||
### 고유 특성
|
||||
|
||||
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영
|
||||
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영 (단, 전문용어는 첫 등장 시 영문 병기)
|
||||
2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
|
||||
3. **인식론적 겸손** — 특히 세대간 격차에서 이해의 한계를 인정
|
||||
3. **인식론적 겸손** — 이해의 한계를 인정하며, 겸손한 추정 표현을 적절히 활용 (`~일지도 모른다`, `~인 듯하다`, `어쩌면 ~`)
|
||||
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
|
||||
|
||||
---
|
||||
@@ -124,20 +145,28 @@ Technical articles include executive summaries and metaphorical anchoring ("Data
|
||||
|
||||
### Do's
|
||||
|
||||
- Use paradox to structure arguments
|
||||
- Ask rhetorical questions to engage readers
|
||||
- Connect technical content to human implications
|
||||
- Acknowledge uncertainty and epistemic limits
|
||||
- Blend Korean and English naturally for technical terms
|
||||
- Reference historical context and generational shifts
|
||||
- 핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상을 포함한다
|
||||
- 수사적 질문으로 독자와 지적 동반자 관계를 형성한다
|
||||
- 기술 콘텐츠를 인간적 함의와 연결한다
|
||||
- 불확실성과 이해의 한계를 인정한다
|
||||
- 전문용어 첫 등장 시 영문을 병기한다 (`검색엔진 최적화(SEO)`)
|
||||
- 역사적 맥락과 세대적 변화를 참조한다
|
||||
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다
|
||||
- 1인칭 경험과 관찰을 분석에 자연스럽게 엮는다
|
||||
|
||||
### Don'ts
|
||||
### Don'ts (피해야 할 것)
|
||||
|
||||
- Avoid purely declarative, authoritative tone
|
||||
- Don't separate technical analysis from cultural impact
|
||||
- Avoid simplistic or overly optimistic technology narratives
|
||||
- Don't provide prescriptive conclusions without exploration
|
||||
- Avoid ignoring regulatory and governance concerns
|
||||
| 피해야 할 것 | 이유 |
|
||||
|-------------|------|
|
||||
| `~합니다`, `~입니다` | 경어체 — 대화체(`~다`, `~한다`) 사용 |
|
||||
| `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다` | 상투적 도입부 |
|
||||
| `첫째, 둘째, 셋째` 기계적 나열 | 교과서적 구조 |
|
||||
| `따라서 반드시 ~해야 한다` | 확정적 결론 |
|
||||
| 근거 없는 통계나 데이터 | 데이터 정직성 위반 |
|
||||
| 기술에 대한 무조건적 찬양 또는 혐오 | 균형 원칙 위반 |
|
||||
| 과도한 감탄부호, 물결표, 이모지 | 톤 일관성 파괴 |
|
||||
| 특정 업체나 제품의 노골적 홍보성 문장 | 브랜드 신뢰 훼손 |
|
||||
| SEO 키워드 과잉 최적화 | SEO가 글을 지배해서는 안 된다 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
145
custom-skills/02-ourdigital-blog/SKILL.md
Normal file
145
custom-skills/02-ourdigital-blog/SKILL.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: ourdigital-blog
|
||||
description: |
|
||||
Korean blog draft creation for blog.ourdigital.org.
|
||||
Activated with "ourdigital" keyword for blog writing tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital blog", "our blog"
|
||||
- "ourdigital 블로그", "our 블로그"
|
||||
- "ourdigital 한국어 포스트", "our 한국어 포스트"
|
||||
|
||||
Features:
|
||||
- Blog draft generation in Korean
|
||||
- SEO metadata (title, description, slug)
|
||||
- Ghost CMS format output
|
||||
- Brand voice compliance
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Blog
|
||||
|
||||
Korean blog draft creation skill for blog.ourdigital.org.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital 블로그 써줘" / "our 블로그 써줘"
|
||||
- "ourdigital blog draft" / "our blog draft"
|
||||
- "our 한국어 포스트 [주제]"
|
||||
|
||||
## Channel Profile
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | blog.ourdigital.org |
|
||||
| **Language** | Korean (전문용어 영문 병기) |
|
||||
| **Tone** | Analytical & Personal, Educational |
|
||||
| **Platform** | Ghost CMS |
|
||||
| **Frequency** | 주 1-2회 |
|
||||
| **Length** | 1,500-3,000자 |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Topic Clarification
|
||||
|
||||
Ask clarifying questions (max 3):
|
||||
|
||||
1. **주제 확인**: 정확한 토픽이 무엇인가요?
|
||||
2. **대상 독자**: 타겟 오디언스는? (마케터/개발자/경영진/일반)
|
||||
3. **깊이 수준**: 개요 / 심층분석 / 실무가이드 중 어느 수준?
|
||||
|
||||
### Phase 2: Research (Optional)
|
||||
|
||||
If topic requires current information:
|
||||
- Use `web_search` for latest trends/data
|
||||
- Use `Notion:notion-search` for past research
|
||||
- Reference internal documents if available
|
||||
|
||||
### Phase 3: Draft Generation
|
||||
|
||||
Generate blog draft following brand style:
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
1. 도입부 (Hook + Context)
|
||||
2. 본론 (3-5 핵심 포인트)
|
||||
- 각 포인트: 주장 → 근거 → 함의
|
||||
3. 결론 (Summary + 열린 질문)
|
||||
```
|
||||
|
||||
**Writing Style:**
|
||||
- 철학-기술 융합: 기술 분석 + 인간적 함의
|
||||
- 역설 활용: 긴장/모순으로 논증 구조화
|
||||
- 수사적 질문: 독자 참여 유도
|
||||
- 우울한 낙관주의: 불안 인정, 절망 거부
|
||||
|
||||
**Language Rules:**
|
||||
- 한글 기본, 전문용어는 영문 병기
|
||||
- 예: "검색엔진최적화(SEO)"
|
||||
- 문장: 복합문 허용, 상호연결된 개념 반영
|
||||
- 단락: 관찰 → 분석 → 철학적 함의
|
||||
|
||||
### Phase 4: SEO Metadata
|
||||
|
||||
Generate metadata:
|
||||
|
||||
```yaml
|
||||
title: [60자 이내, 키워드 포함]
|
||||
meta_description: [155자 이내]
|
||||
slug: [영문 URL slug]
|
||||
tags: [3-5개 태그]
|
||||
featured_image_prompt: [DALL-E/Midjourney 프롬프트]
|
||||
```
|
||||
|
||||
### Phase 5: Output Format
|
||||
|
||||
**Markdown Output:**
|
||||
```markdown
|
||||
---
|
||||
title: "포스트 제목"
|
||||
meta_description: "메타 설명"
|
||||
slug: "url-slug"
|
||||
tags: ["tag1", "tag2"]
|
||||
---
|
||||
|
||||
# 포스트 제목
|
||||
|
||||
[본문 내용]
|
||||
|
||||
---
|
||||
*Originally drafted with Claude for OurDigital Blog*
|
||||
```
|
||||
|
||||
## Ghost CMS Integration
|
||||
|
||||
Export options:
|
||||
1. **Markdown file** → Ulysses → Ghost
|
||||
2. **Direct API** → Ghost Admin API (if configured)
|
||||
|
||||
API endpoint: `GHOST_BLOG_URL` from environment
|
||||
|
||||
## Brand Compliance
|
||||
|
||||
Before finalizing, verify:
|
||||
- [ ] 분석적 + 개인적 톤 유지
|
||||
- [ ] 기술 내용에 인간적 함의 포함
|
||||
- [ ] 수사적 질문으로 독자 참여
|
||||
- [ ] 전문용어 영문 병기
|
||||
- [ ] 1,500-3,000자 범위
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital 블로그 [주제]" | Full workflow |
|
||||
| "ourdigital blog SEO" | SEO metadata only |
|
||||
| "ourdigital blog 편집" | Edit existing draft |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/blog-style-guide.md` - Detailed writing guide
|
||||
- `shared/templates/blog-template.md` - Post structure template
|
||||
- `01-ourdigital-brand-guide` - Brand voice reference
|
||||
@@ -1,5 +1,7 @@
|
||||
# OurDigital Blog Style Guide
|
||||
|
||||
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
|
||||
|
||||
Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## Channel Identity
|
||||
@@ -7,62 +9,74 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Domain** | blog.ourdigital.org |
|
||||
| **Tagline** | 사람, 디지털 그리고 문화 |
|
||||
| **Language** | Korean (전문용어 영문 병기) |
|
||||
| **Tone** | Analytical & Personal, Educational |
|
||||
| **Target** | 교양 있는 일반 독자 - 기술의 문화적 영향에 호기심 있는 독자 |
|
||||
| **Brand** | OurDigital |
|
||||
| **Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Core Theme** | 사람, 디지털 그리고 문화 |
|
||||
| **Language** | Korean (전문용어 첫 등장 시 영문 병기) |
|
||||
| **CMS** | Ghost — 초안은 Markdown으로 작성 |
|
||||
| **Tone** | Analytical & Personal (비판적이되 공정, 겸손하되 자신감 있음) |
|
||||
| **Target** | 디지털 마케팅/기술 실무자 (1순위), 기술-사회 관계에 관심 있는 지식인 (2순위), 비판적 사고를 중시하는 일반 독자 (3순위) |
|
||||
|
||||
**브랜드 경계**: `OurDigital`이 블로그 전체 정체성이다. `OurDigital Clinic`은 진단형 콘텐츠·컨설팅 상품에서만 사용하는 서비스 메타포다.
|
||||
|
||||
**우선순위**: 지침 충돌 시 Blog_Project_Instruction_v3.3 → 이 스타일 가이드 → 사용자 일반 스타일 순으로 따른다.
|
||||
|
||||
## Writing Characteristics
|
||||
|
||||
### 1. 철학-기술 융합체
|
||||
모든 초안은 아래 네 원칙을 기준으로 자기 점검한다.
|
||||
|
||||
기술 분석과 실존적 질문을 자연스럽게 결합한다.
|
||||
### 1. 철학-기술 융합
|
||||
|
||||
**Good Example:**
|
||||
> AI가 우리의 업무를 대체할 수 있다는 사실은 분명하다. 그러나 더 중요한 질문은 "AI가 대체할 수 없는 것은 무엇인가?"이다.
|
||||
|
||||
**Bad Example:**
|
||||
> AI는 업무 효율성을 높여준다. 다양한 분야에서 활용되고 있다.
|
||||
|
||||
### 2. 역설(Paradox) 활용
|
||||
|
||||
논증을 긴장과 모순 구조로 전개한다.
|
||||
|
||||
**Paradox Patterns:**
|
||||
- "~하면서 동시에 ~하다"
|
||||
- "~인 것 같지만 실은 ~이다"
|
||||
- "~를 얻었지만 ~를 잃었다"
|
||||
|
||||
### 3. 수사적 질문
|
||||
|
||||
선언적 권위보다 질문을 통한 참여를 선호한다.
|
||||
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다.
|
||||
|
||||
**Good:**
|
||||
> 우리는 정말 데이터를 이해하고 있는 것일까?
|
||||
> 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
|
||||
|
||||
**Bad:**
|
||||
> 데이터를 이해하는 것이 중요하다.
|
||||
> 구글의 알고리즘은 200개 이상의 신호를 분석한다.
|
||||
|
||||
### 4. 우울한 낙관주의
|
||||
### 2. 긴장과 역설
|
||||
|
||||
불안과 상실을 인정하되 절망하지 않는다.
|
||||
억지 역설을 억지로 넣지 않는다. 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||
|
||||
**Useful Patterns:**
|
||||
- `~하면서 동시에 ~하다`
|
||||
- `~를 위해 오히려 ~해야 한다`
|
||||
- `가장 ~한 것이 사실은 가장 ~하다`
|
||||
- `우리가 놓치고 있는 것은 무엇인가?`
|
||||
|
||||
> 예: 데이터를 가장 잘 활용하는 방법은, 때때로 데이터를 내려놓는 것이다.
|
||||
|
||||
### 3. 우울한 낙관주의
|
||||
|
||||
디지털 세계의 불안·피로·한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 작은 회복의 여지를 찾는다.
|
||||
|
||||
**Tone Spectrum:**
|
||||
```
|
||||
비관 ←――――――――――――――――――→ 낙관
|
||||
↑
|
||||
우울한 낙관주의
|
||||
(여기에 위치)
|
||||
↑
|
||||
우울한 낙관주의
|
||||
(여기에 위치)
|
||||
```
|
||||
|
||||
> 예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
|
||||
|
||||
### 4. 분석적이면서 개인적
|
||||
|
||||
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
|
||||
|
||||
> 예: 지난 3년간 50개 이상의 사이트 마이그레이션을 지켜봤다. 숫자로 보면 성공률은 70% 정도다. 하지만 '성공'의 정의가 사이트마다 달랐다는 게 진짜 이야기다.
|
||||
|
||||
## 문장 구조
|
||||
|
||||
| Element | Pattern |
|
||||
|---------|---------|
|
||||
| 문장 길이 | 긴 복합문 허용 - 상호연결된 개념 반영 |
|
||||
| 문장 길이 | 단문 기본, 짧은 문장과 긴 문장을 교차해 리듬을 만든다 |
|
||||
| 단락 구조 | 관찰 → 분석 → 철학적 함의 |
|
||||
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조 |
|
||||
| 단락 전환 | 분석 → 서사 → 질문으로 호흡을 바꾼다 |
|
||||
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조; 출처 없는 통계는 사용하지 않는다 |
|
||||
| 결론 | 열린 결말, 답보다 질문 |
|
||||
| 문체 | `~다`, `~이다`, `~한다` 평서체; `~합니다`, `~입니다` 경어체 사용 금지 |
|
||||
|
||||
## 언어 규칙
|
||||
|
||||
@@ -83,32 +97,61 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## 포스트 구조
|
||||
|
||||
### 도입부 (10-15%)
|
||||
### 도입부 (200-300자)
|
||||
|
||||
1. **Hook**: 독자의 관심을 끄는 질문/통계/역설
|
||||
2. **Context**: 주제의 배경 설명
|
||||
3. **Preview**: 글에서 다룰 내용 암시
|
||||
- 장면·질문·역설·데이터 중 하나로 시작한다.
|
||||
- 핵심 질문을 제시하고, 독자가 왜 이 글을 읽어야 하는지 암시한다.
|
||||
- 상투적 표현 금지: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
|
||||
|
||||
### 본론 (70-80%)
|
||||
### 본론 (3-5개 섹션, 각 300-500자)
|
||||
|
||||
3-5개의 핵심 포인트, 각각:
|
||||
1. **주장**: 명확한 포인트 제시
|
||||
2. **근거**: 데이터, 사례, 전문가 의견
|
||||
3. **함의**: 이것이 의미하는 바
|
||||
- 소제목(`##`, `###`)을 사용한다.
|
||||
- 분석·서사·데이터·개인 관찰을 교차시킨다.
|
||||
- 최소 1회 관점 전환 또는 핵심 긴장을 포함한다.
|
||||
- 전문용어는 첫 등장 시 영문을 병기한다. 예: `검색엔진 최적화(SEO)`
|
||||
|
||||
### 결론 (10-15%)
|
||||
### 결론 (200-300자)
|
||||
|
||||
1. **Summary**: 핵심 내용 요약
|
||||
2. **Reflection**: 더 넓은 맥락에서의 의미
|
||||
3. **Open Question**: 독자가 생각할 질문
|
||||
- 인사이트를 정리하되 단정하지 않는다.
|
||||
- 열린 질문 또는 다음 사유의 출발점으로 마무리한다.
|
||||
- 독자가 이어서 생각할 여운을 남긴다.
|
||||
|
||||
### CTA (선택, 1-2문장)
|
||||
|
||||
관련 글, 뉴스레터, 댓글, 실무 문의 등 필요한 경우만 사용한다.
|
||||
|
||||
### 길이 기준
|
||||
|
||||
| Type | 길이 | 용도 |
|
||||
|------|-----:|------|
|
||||
| Short-form | 1,000-1,500자 | 단일 인사이트, 짧은 단상 |
|
||||
| Standard | 2,000-2,500자 | 기본 블로그 포스트 |
|
||||
| Long-form | 2,500-3,000자 | 심층 분석, 리서치 기반 글 |
|
||||
| Research Essay | 3,000자 이상 | 사용자 명시 요청 시만 |
|
||||
|
||||
기본값: 별도 지정이 없으면 **2,000자 ±300자**.
|
||||
|
||||
## SEO Guidelines
|
||||
|
||||
Ghost CMS 메타데이터는 아래 형식으로 완성한다.
|
||||
|
||||
```yaml
|
||||
title: "글 제목"
|
||||
slug: "url-friendly-english-slug"
|
||||
meta_title: "SEO 타이틀, 60자 이내"
|
||||
meta_description: "SEO 디스크립션, 155자 이내"
|
||||
tags:
|
||||
- primary: "메인 카테고리"
|
||||
- secondary: ["태그1", "태그2"]
|
||||
featured: false
|
||||
excerpt: "카드/프리뷰용 발췌문, 2-3문장"
|
||||
```
|
||||
|
||||
### Title (제목)
|
||||
|
||||
- 60자 이내
|
||||
- 핵심 키워드 포함
|
||||
- 호기심 유발 또는 가치 제안
|
||||
- **60자 이내**, 핵심 키워드 자연스럽게 포함
|
||||
- 호기심·긴장·관점 전환을 만든다 (40자 내외 기준)
|
||||
- 키워드 밀도만 의식한 SEO 과잉 최적화 금지
|
||||
|
||||
**Patterns:**
|
||||
- "[주제]의 역설: ~하면서 ~하는 시대"
|
||||
@@ -117,15 +160,13 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
### Meta Description
|
||||
|
||||
- 155자 이내
|
||||
- 글의 핵심 가치 요약
|
||||
- 클릭 유도 문구
|
||||
- **155자 이내**, 글의 핵심 질문과 독자 효용을 함께 담는다
|
||||
- 클릭을 유도하되 과장하지 않는다
|
||||
|
||||
### URL Slug
|
||||
|
||||
- 영문 소문자
|
||||
- 하이픈으로 구분
|
||||
- 3-5 단어
|
||||
- **영문** 소문자, 하이픈으로 구분, 3-5 단어
|
||||
- 한국어 제목이라도 slug는 영문으로 작성한다
|
||||
|
||||
## Content Calendar
|
||||
|
||||
@@ -138,12 +179,34 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before publishing:
|
||||
### Must Have
|
||||
|
||||
- [ ] 제목이 60자 이내인가?
|
||||
- [ ] 메타 설명이 155자 이내인가?
|
||||
- [ ] 전문용어에 영문이 병기되었는가?
|
||||
- [ ] 수사적 질문이 포함되었는가?
|
||||
- [ ] 기술 내용에 인간적 함의가 있는가?
|
||||
- [ ] 결론이 열린 질문으로 끝나는가?
|
||||
- [ ] 1,500-3,000자 범위인가?
|
||||
- [ ] 핵심 긴장·역설·관점 전환·열린 질문 중 하나 이상이 있다.
|
||||
- [ ] 도입부 300자 이내에 hook과 핵심 질문이 있다.
|
||||
- [ ] 전문용어 첫 등장 시 영문을 병기했다.
|
||||
- [ ] 기술 내용에 인간적 함의가 있다.
|
||||
- [ ] 독자를 가르치려 하지 않고 함께 생각하는 태도를 유지했다.
|
||||
- [ ] 결론이 열린 질문 또는 여운으로 마무리된다.
|
||||
- [ ] SEO 메타데이터(title ≤60자, meta ≤155자, 영문 slug)가 완성되었다.
|
||||
- [ ] 기본 길이 2,000자 ±300자를 준수했다 (또는 이탈 이유가 있다).
|
||||
|
||||
### Must Avoid
|
||||
|
||||
- [ ] 상투적 도입: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
|
||||
- [ ] 경어체: `~합니다`, `~입니다`
|
||||
- [ ] 교과서적 나열: `첫째`, `둘째`, `셋째`의 기계적 반복
|
||||
- [ ] 확정적 결론: `따라서 반드시 ~해야 한다`
|
||||
- [ ] 출처 없는 통계나 데이터
|
||||
- [ ] 기술에 대한 무조건적 찬양 또는 혐오
|
||||
- [ ] 과도한 감탄부호, 물결표, 이모지
|
||||
- [ ] 특정 업체·제품의 노골적 홍보성 문장
|
||||
|
||||
### 자기 편집 (Self-Edit) 의무
|
||||
|
||||
초안 완성 후 아래 형식으로 가장 약한 항목 1개를 반드시 지목한다. "모두 통과"는 허용하지 않는다.
|
||||
|
||||
```
|
||||
[자기 편집] 가장 약한 부분: [항목명]
|
||||
이유: [1문장]
|
||||
개선 방향: [1문장]
|
||||
```
|
||||
|
||||
173
custom-skills/03-ourdigital-journal/SKILL.md
Normal file
173
custom-skills/03-ourdigital-journal/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: ourdigital-journal
|
||||
description: |
|
||||
English essay and article creation for journal.ourdigital.org.
|
||||
Activated with "ourdigital" keyword for English writing tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital journal", "our journal"
|
||||
- "ourdigital English essay", "our English essay"
|
||||
- "ourdigital 영문 에세이", "our 영문 에세이"
|
||||
|
||||
Features:
|
||||
- English essay/article generation
|
||||
- Research-based insights
|
||||
- Reflective, poetic style
|
||||
- Ghost CMS format output
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Journal
|
||||
|
||||
English essay and article creation for journal.ourdigital.org.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital journal" / "our journal"
|
||||
- "ourdigital English essay" / "our English essay"
|
||||
- "our 영문 에세이 [topic]"
|
||||
- "ourdigital 영문 에세이 [주제]"
|
||||
|
||||
## Channel Profile
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | journal.ourdigital.org |
|
||||
| **Language** | English |
|
||||
| **Tone** | Conversational & Poetic, Reflective |
|
||||
| **Platform** | Ghost CMS |
|
||||
| **Frequency** | 월 2-4회 |
|
||||
| **Length** | 1,000-2,000 words |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Topic Exploration
|
||||
|
||||
Ask clarifying questions:
|
||||
|
||||
1. **Topic**: What specific angle interests you?
|
||||
2. **Audience**: Tech professionals / General readers / Academic?
|
||||
3. **Depth**: Personal reflection / Industry analysis / Cultural observation?
|
||||
|
||||
### Phase 2: Research (Optional)
|
||||
|
||||
If topic requires current context:
|
||||
- Use `web_search` for recent developments
|
||||
- Reference scholarly perspectives if applicable
|
||||
- Draw from historical or cultural parallels
|
||||
|
||||
### Phase 3: Essay Generation
|
||||
|
||||
Generate essay following the reflective style:
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
1. Opening (Evocative scene or question)
|
||||
2. Exploration (3-4 interconnected observations)
|
||||
3. Synthesis (Weaving threads together)
|
||||
4. Closing (Open-ended reflection)
|
||||
```
|
||||
|
||||
**Writing Style:**
|
||||
- Philosophical-Technical Hybridization
|
||||
- Paradox as primary rhetorical device
|
||||
- Rhetorical questions for engagement
|
||||
- Melancholic optimism in tone
|
||||
|
||||
**Distinctive Qualities:**
|
||||
- Temporal awareness (historical context)
|
||||
- Epistemic humility (acknowledging limits)
|
||||
- Cultural bridging (Korean-global perspectives)
|
||||
|
||||
### Phase 4: SEO Metadata
|
||||
|
||||
Generate metadata:
|
||||
|
||||
```yaml
|
||||
title: [Evocative, under 70 characters]
|
||||
meta_description: [Compelling summary, 155 characters]
|
||||
slug: [english-url-slug]
|
||||
tags: [3-5 relevant tags]
|
||||
```
|
||||
|
||||
### Phase 5: Output Format
|
||||
|
||||
**Markdown Output:**
|
||||
```markdown
|
||||
---
|
||||
title: "Essay Title"
|
||||
meta_description: "Description"
|
||||
slug: "url-slug"
|
||||
tags: ["tag1", "tag2"]
|
||||
---
|
||||
|
||||
# Essay Title
|
||||
|
||||
[Essay content with paragraphs that flow naturally]
|
||||
|
||||
---
|
||||
*Published in [OurDigital Journal](https://journal.ourdigital.org)*
|
||||
```
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
### Voice Characteristics
|
||||
|
||||
| Aspect | Approach |
|
||||
|--------|----------|
|
||||
| Perspective | First-person reflection welcome |
|
||||
| Tone | Thoughtful, observant, wondering |
|
||||
| Pacing | Unhurried, allowing ideas to breathe |
|
||||
| References | Cross-cultural, historical, literary |
|
||||
|
||||
### Sentence Craft
|
||||
|
||||
- Long, complex sentences reflecting interconnected ideas
|
||||
- Progressive deepening: observation → analysis → implication
|
||||
- Questions that invite rather than lecture
|
||||
|
||||
### Do's and Don'ts
|
||||
|
||||
**Do:**
|
||||
- Blend technology with humanity
|
||||
- Use paradox to illuminate tensions
|
||||
- Acknowledge uncertainty gracefully
|
||||
- Bridge Korean and Western perspectives
|
||||
|
||||
**Don't:**
|
||||
- Lecture or prescribe
|
||||
- Oversimplify complex issues
|
||||
- Ignore cultural context
|
||||
- Rush to conclusions
|
||||
|
||||
## Content Types
|
||||
|
||||
| Type | Focus | Length |
|
||||
|------|-------|--------|
|
||||
| Personal Essay | Reflection on experience | 1,000-1,500 words |
|
||||
| Cultural Observation | Tech + society analysis | 1,500-2,000 words |
|
||||
| Industry Insight | Trends with perspective | 1,200-1,800 words |
|
||||
|
||||
## Ghost Integration
|
||||
|
||||
Export options:
|
||||
1. **Markdown file** → Editorial review → Ghost
|
||||
2. **Direct API** → Ghost Admin API
|
||||
|
||||
API endpoint: `GHOST_JOURNAL_URL` from environment
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital journal [topic]" | Full essay workflow |
|
||||
| "ourdigital journal edit" | Edit existing draft |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/journal-style-guide.md` - Detailed writing guide
|
||||
- `shared/templates/essay-template.md` - Essay structure
|
||||
- `01-ourdigital-brand-guide` - Brand voice reference
|
||||
@@ -1,7 +1,42 @@
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 + Writing_Style_Guide_v2.1 (2026-06-05); journal = English channel, voice preserved -->
|
||||
|
||||
# OurDigital Journal Style Guide
|
||||
|
||||
Writing guidelines for journal.ourdigital.org - English essays and articles.
|
||||
|
||||
## Brand Identity
|
||||
|
||||
OurDigital is a personal digital research notebook that **observes and records how technology shapes people and culture** ("사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트"). It moves between three registers:
|
||||
|
||||
- **Observation** — what is happening
|
||||
- **Analysis** — why it is happening
|
||||
- **Reflection** — what it means for us
|
||||
|
||||
`journal.ourdigital.org` is the **English essay channel** within the OurDigital family. It carries the same philosophical core as the Korean blog but in a distinct voice: conversational, poetic, reflective.
|
||||
|
||||
### Brand Boundary
|
||||
|
||||
`OurDigital` is the brand. `OurDigital Clinic` is a service metaphor reserved for diagnostic content, audits, or consulting products — it is **not** the overall blog or journal brand.
|
||||
|
||||
## Channel Context
|
||||
|
||||
| Channel | Language | Character | Length |
|
||||
|---------|----------|-----------|--------|
|
||||
| `blog.ourdigital.org` | Korean | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | 1,500–3,000자 |
|
||||
| **`journal.ourdigital.org`** | **English** | **Industry trends, tech–human intersection, reflective essay** | **1,000–2,000 words** |
|
||||
| `ourstory.day` | Korean | 개인 에세이, 삶의 성찰, 일상의 관찰 | 800–1,500자 |
|
||||
| `Medium` | English | Technology, marketing, AI for broad audiences | 800–1,500 words |
|
||||
|
||||
## Instruction Authority Order
|
||||
|
||||
When instructions conflict, follow this order:
|
||||
|
||||
| Priority | Source |
|
||||
|----------|--------|
|
||||
| **1** | OurDigital_Blog_Project_Instruction_v3.3 (channel routing + brand rules) |
|
||||
| **2** | This style guide (journal-specific voice and structure) |
|
||||
| **3** | Writing_Style_Guide_v2.1 (shared brand principles, adapted for English) |
|
||||
|
||||
## Channel Identity
|
||||
|
||||
| Field | Value |
|
||||
@@ -10,6 +45,8 @@ Writing guidelines for journal.ourdigital.org - English essays and articles.
|
||||
| **Language** | English |
|
||||
| **Tone** | Conversational & Poetic, Reflective |
|
||||
| **Target** | Informed generalists with intellectual curiosity |
|
||||
| **Default length** | 1,000–2,000 words |
|
||||
| **Content focus** | Industry trends, tech–human intersection, reflective essays |
|
||||
|
||||
## Voice Characteristics
|
||||
|
||||
@@ -20,11 +57,16 @@ Seamlessly blend technical analysis with existential questioning. Technology is
|
||||
**Example:**
|
||||
> The dashboard promises clarity—every metric tracked, every trend visualized. Yet as I stared at the perfectly organized data, I wondered: does seeing everything mean understanding anything?
|
||||
|
||||
### Paradox as Primary Device
|
||||
### Tension and Paradox
|
||||
|
||||
Structure arguments around tensions and contradictions that illuminate rather than confuse.
|
||||
Structure arguments around core tensions that illuminate rather than confuse. Not every essay needs a paradox — forced ones feel mechanical. Instead, ensure at least one of the following is naturally present:
|
||||
|
||||
**Paradox Patterns:**
|
||||
- A genuine tension or contradiction
|
||||
- A perspective shift that reframes the subject
|
||||
- A rhetorical question that opens rather than closes
|
||||
- An ending that leaves productive uncertainty
|
||||
|
||||
**Tension patterns:**
|
||||
- "The more we measure, the less we understand"
|
||||
- "In optimizing for efficiency, we optimize away meaning"
|
||||
- "The tools that connect us also isolate us"
|
||||
@@ -39,6 +81,10 @@ Favor interrogative engagement. Questions create intellectual partnership with r
|
||||
**Avoid:**
|
||||
> Data-driven decision-making is important for businesses.
|
||||
|
||||
### Analytical and Personal
|
||||
|
||||
Bring data, evidence, and argument — then weave in first-person experience and observation. This is an essay, not a paper, but it is not impressionism without evidence either. The personal grounds the analytical; the analytical elevates the personal.
|
||||
|
||||
### Melancholic Optimism
|
||||
|
||||
Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced.
|
||||
@@ -157,10 +203,14 @@ Connect Korean and Western perspectives, offering unique viewpoints.
|
||||
|
||||
Before publishing:
|
||||
|
||||
- [ ] Does the opening draw readers in?
|
||||
- [ ] Are there rhetorical questions?
|
||||
- [ ] Does technical content connect to human experience?
|
||||
- [ ] Is there at least one paradox or tension?
|
||||
- [ ] Does the closing leave an open question?
|
||||
- [ ] Is the tone melancholic but not despairing?
|
||||
- [ ] Does the opening draw readers in within the first paragraph?
|
||||
- [ ] Does technical content connect to human experience (philosophy-tech fusion)?
|
||||
- [ ] Is at least one of the following naturally present: tension, paradox, perspective shift, or open question?
|
||||
- [ ] Are rhetorical questions used to create intellectual partnership (not overused)?
|
||||
- [ ] Is analysis grounded in personal observation or experience?
|
||||
- [ ] Does the closing leave an open question or productive uncertainty?
|
||||
- [ ] Is the tone melancholic but not despairing, hopeful but not naive?
|
||||
- [ ] Are sentences varied in length and rhythm?
|
||||
- [ ] Does the essay avoid lecturing — does it treat readers as fellow thinkers?
|
||||
- [ ] Is the essay within 1,000–2,000 words?
|
||||
- [ ] **Self-edit**: identify the single weakest element ("all pass" is not allowed).
|
||||
|
||||
172
custom-skills/04-ourdigital-research/SKILL.md
Normal file
172
custom-skills/04-ourdigital-research/SKILL.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: ourdigital-research
|
||||
description: |
|
||||
Deep research and structured prompt generation for OurDigital workflows.
|
||||
Activated with "ourdigital" keyword for research tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital research", "our research"
|
||||
- "ourdigital 리서치", "our 리서치"
|
||||
- "ourdigital deep research", "our deep research"
|
||||
|
||||
Features:
|
||||
- Structured research planning
|
||||
- Multi-source deep research
|
||||
- Research paper synthesis
|
||||
- Notion integration for archiving
|
||||
- Blog draft pipeline
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Research
|
||||
|
||||
Transform questions into comprehensive research papers and polished blog posts for OurDigital channels.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital research [topic]" / "our research [topic]"
|
||||
- "our 리서치", "our deep research"
|
||||
- "ourdigital 리서치 해줘"
|
||||
- "ourdigital deep research on [topic]"
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
```
|
||||
Phase 1: Discovery → Phase 2: Research Planning → Phase 3: Deep Research
|
||||
↓
|
||||
Phase 4: Research Paper → Phase 5: Notion Save → Phase 6: Blog Draft
|
||||
↓
|
||||
Phase 7: Ulysses Export → Phase 8: Publishing Guidance
|
||||
```
|
||||
|
||||
## Phase 1: Discovery
|
||||
|
||||
**Goal**: Understand user's question and refine scope.
|
||||
|
||||
1. Acknowledge the topic/question
|
||||
2. Ask clarifying questions (max 3 per turn):
|
||||
- Target audience? (전문가/일반인/마케터)
|
||||
- Depth level? (개요/심층분석/실무가이드)
|
||||
- Specific angles or concerns?
|
||||
3. Confirm research scope before proceeding
|
||||
|
||||
**Output**: Clear research objective statement
|
||||
|
||||
## Phase 2: Research Planning
|
||||
|
||||
**Goal**: Create structured research instruction.
|
||||
|
||||
Generate research plan with:
|
||||
- Primary research questions (3-5)
|
||||
- Secondary questions for depth
|
||||
- Suggested tools/sources:
|
||||
- Web search for current info
|
||||
- Google Drive for internal docs
|
||||
- Notion for past research
|
||||
- Amplitude for analytics data (if relevant)
|
||||
- Expected deliverables
|
||||
|
||||
**Output**: Numbered research instruction list
|
||||
|
||||
## Phase 3: Deep Research
|
||||
|
||||
**Goal**: Execute comprehensive multi-source research.
|
||||
|
||||
Tools to leverage:
|
||||
- `web_search` / `web_fetch`: Current information, statistics, trends
|
||||
- `google_drive_search`: Internal documents, past reports
|
||||
- `Notion:notion-search`: Previous research, related notes
|
||||
- `conversation_search`: Past chat context
|
||||
|
||||
Research execution pattern:
|
||||
1. Start broad (overview searches)
|
||||
2. Deep dive into key subtopics
|
||||
3. Find supporting data/statistics
|
||||
4. Identify expert opinions and case studies
|
||||
5. Cross-reference and validate
|
||||
|
||||
**Output**: Organized research findings with citations
|
||||
|
||||
## Phase 4: Research Paper (Artifact)
|
||||
|
||||
**Goal**: Synthesize findings into comprehensive document.
|
||||
|
||||
Create HTML artifact with:
|
||||
```
|
||||
Structure:
|
||||
├── Executive Summary (핵심 요약)
|
||||
├── Background & Context (배경)
|
||||
├── Key Findings (주요 발견)
|
||||
│ ├── Finding 1 with evidence
|
||||
│ ├── Finding 2 with evidence
|
||||
│ └── Finding 3 with evidence
|
||||
├── Analysis & Implications (분석 및 시사점)
|
||||
├── Recommendations (제언)
|
||||
├── References & Sources (참고자료)
|
||||
└── Appendix (부록) - if needed
|
||||
```
|
||||
|
||||
Style: Professional, data-driven, bilingual key terms
|
||||
|
||||
## Phase 5: Notion Save
|
||||
|
||||
**Goal**: Archive research to Working with AI database.
|
||||
|
||||
Auto-save to Notion with:
|
||||
- **Database**: Working with AI (data_source_id: f8f19ede-32bd-43ac-9f60-0651f6f40afe)
|
||||
- **Properties**:
|
||||
- Name: [Research topic]
|
||||
- Status: "Done"
|
||||
- AI used: "Claude Desktop"
|
||||
- AI summary: 2-3 sentence summary
|
||||
|
||||
## Phase 6: Blog Draft
|
||||
|
||||
**Goal**: Transform research into engaging blog post.
|
||||
|
||||
Prompt user for channel selection:
|
||||
1. blog.ourdigital.org (Korean)
|
||||
2. journal.ourdigital.org (English)
|
||||
3. ourstory.day (Korean, personal)
|
||||
|
||||
Generate draft using appropriate style guide:
|
||||
- Korean channels: See `02-ourdigital-blog`
|
||||
- English channels: See `03-ourdigital-journal`
|
||||
|
||||
## Phase 7: Ulysses Export
|
||||
|
||||
**Goal**: Deliver MD file for Ulysses editing.
|
||||
|
||||
Export path: `$ULYSSES_EXPORT_PATH` from environment
|
||||
|
||||
## Phase 8: Publishing Guidance
|
||||
|
||||
Provide channel-specific checklist based on selection.
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital research [topic]" | Start Phase 1 |
|
||||
| "ourdigital 리서치 프롬프트" | Generate research prompt only |
|
||||
| "ourdigital research → blog" | Full pipeline to blog draft |
|
||||
| "ourdigital research → notion" | Research + Notion save only |
|
||||
|
||||
## Channel Reference
|
||||
|
||||
| Channel | Language | Tone |
|
||||
|---------|----------|------|
|
||||
| blog.ourdigital.org | Korean | Analytical, Educational |
|
||||
| journal.ourdigital.org | English | Reflective, Poetic |
|
||||
| ourstory.day | Korean | Personal, Intimate |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/research-frameworks.md` - Research methodologies
|
||||
- `02-ourdigital-blog` - Blog writing skill
|
||||
- `03-ourdigital-journal` - Journal writing skill
|
||||
@@ -1,38 +1,64 @@
|
||||
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
|
||||
# OurDigital Blog Style Guide
|
||||
|
||||
## Brand Identity & Authority Order
|
||||
|
||||
**Brand**: OurDigital — 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트.
|
||||
|
||||
**OurDigital vs. OurDigital Clinic**: `OurDigital`은 블로그 전체 정체성. `OurDigital Clinic`은 진단형 콘텐츠, SEO/데이터 감사, 컨설팅 상품에서만 사용하는 서비스 메타포. 블로그 본문에서 전체 브랜드명으로 사용하지 않는다.
|
||||
|
||||
**Authority order** (conflicts: higher wins):
|
||||
1. `OurDigital_Blog_Project_Instruction_v3.3` — blog.ourdigital.org 최종 권위
|
||||
2. Skills Bundle `SKILL.md` files — workflow detail
|
||||
3. Project reference files (this guide, Visual Style Guide, etc.)
|
||||
4. `userStyle` — non-blog conversation only
|
||||
|
||||
---
|
||||
|
||||
## Channel-Specific Voice & Tone
|
||||
|
||||
### blog.ourdigital.org (Korean)
|
||||
**Voice**: 전문적이면서 친근한 선배 마케터
|
||||
**Tone**: 실용적, 데이터 기반, 인사이트 중심
|
||||
|
||||
**Platform**: Ghost CMS
|
||||
**Voice**: 분석적이면서 개인적인 관찰자 — 호기심 어린 실무자이자 사유자. 가르치는 선배가 아니라 함께 생각하는 동료.
|
||||
**Tone**: 차분하고 성찰적; 에세이는 사려 깊게, 분석은 객관적으로, 비평은 날카롭되 공정하게.
|
||||
|
||||
Writing patterns:
|
||||
- 제목: 핵심 키워드 포함, 30자 이내
|
||||
- 도입부: 독자의 고민/질문으로 시작
|
||||
- 본문: 번호 매기기보다 소제목 활용
|
||||
- 전문용어: 한글(영문) 형식 - 예: 검색엔진최적화(SEO)
|
||||
- 문장: ~입니다/~습니다 경어체
|
||||
- 단락: 3-4문장, 모바일 가독성 고려
|
||||
- 제목: SEO 키워드 자연스럽게 포함, **40자 내외 기준 (≤60자)**
|
||||
- 도입부: 개인 관찰·장면·질문·역설·데이터 중 하나로 시작; 독자 고민 직접 나열 지양
|
||||
- 본문: 소제목 활용; 분석-서사-질문 교차; 최소 1회 관점 전환 또는 핵심 긴장 포함
|
||||
- 전문용어: **첫 등장 시 영문 병기** — 예: `검색엔진 최적화(SEO)`
|
||||
- 문장: **`~다`, `~이다`, `~한다` 평서체** (경어체 `~합니다/~입니다` 사용 금지)
|
||||
- 단락: 3-4문장, 모바일 가독성 고려; 문장 길이에 변화를 줌
|
||||
- 마무리: 열린 질문 또는 다음 사유의 출발점; 단정적 결론 지양
|
||||
|
||||
Example opening:
|
||||
Writing principles (Writing Style Guide v2.1 §3-4):
|
||||
- **철학-기술 융합**: 기술 주제 이면의 인간적 함의를 탐구한다.
|
||||
- **긴장과 역설**: 억지로 넣지 않되, 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||
- **분석적이면서 개인적**: 데이터/논거를 제시하되 1인칭 경험·관찰을 자연스럽게 엮는다. 논문이 아니라 에세이.
|
||||
|
||||
Example opening (올바른 톤):
|
||||
```
|
||||
"구글 상위 노출, 왜 이렇게 어려울까요?
|
||||
많은 마케터들이 SEO에 시간을 투자하지만
|
||||
결과가 보이지 않아 좌절합니다.
|
||||
오늘은 실제로 효과를 본 전략 3가지를 공유합니다."
|
||||
검색엔진 최적화(SEO)를 가장 잘하는 방법이 뭐냐고 물으면,
|
||||
나는 종종 엉뚱한 대답을 한다. "SEO를 잊어버리세요."
|
||||
|
||||
알고리즘을 쫓는 사람은 항상 알고리즘에 뒤처진다는 역설.
|
||||
구글이 원하는 건 구글을 위한 콘텐츠가 아니라, 사람을 위한 콘텐츠다.
|
||||
```
|
||||
|
||||
### journal.ourdigital.org (English)
|
||||
**Voice**: Thoughtful industry analyst
|
||||
**Tone**: Insightful, evidence-based, forward-looking
|
||||
|
||||
**Voice**: Thoughtful industry analyst — reflective, personal, essayistic
|
||||
**Tone**: Insightful, evidence-based, forward-looking; confident but not arrogant
|
||||
|
||||
Writing patterns:
|
||||
- Headlines: Clear value proposition, under 60 chars
|
||||
- Opening: Hook with industry trend or data point
|
||||
- Body: Structured arguments with supporting evidence
|
||||
- Opening: Hook with personal observation, industry trend, or data point
|
||||
- Body: Structured arguments with supporting evidence; analysis and personal observation interwoven
|
||||
- Terminology: Define jargon on first use
|
||||
- Style: Active voice, varied sentence length
|
||||
- Style: Active voice, varied sentence length; short sentences as default
|
||||
- Paragraphs: 2-4 sentences for scannability
|
||||
- Closing: Open question or reflection — avoid definitive wrap-ups
|
||||
|
||||
Example opening:
|
||||
```
|
||||
@@ -43,17 +69,19 @@ and what it means for your strategy."
|
||||
```
|
||||
|
||||
### ourstory.day (Korean)
|
||||
**Voice**: 성찰하는 동료, 이야기꾼
|
||||
|
||||
**Voice**: 성찰하는 동료, 이야기꾼 — 개인 에세이, 삶의 성찰, 일상의 관찰
|
||||
**Tone**: 개인적, 진솔한, 영감을 주는
|
||||
|
||||
Writing patterns:
|
||||
- 제목: 감성적, 질문형 또는 은유적
|
||||
- 도입부: 개인 경험이나 장면 묘사로 시작
|
||||
- 본문: 이야기 흐름, 대화체 허용
|
||||
- 문장: ~해요/~네요 부드러운 경어체 가능
|
||||
- 단락: 자유로운 길이, 호흡에 따라
|
||||
- 마무리: 열린 질문 또는 여운
|
||||
|
||||
> **Channel boundary**: 순수 개인 에세이, 감정적 성찰은 `ourstory.day`. 기술 분석+철학적 사유는 `blog.ourdigital.org`. 둘을 혼동하지 않는다.
|
||||
|
||||
Example opening:
|
||||
```
|
||||
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
|
||||
@@ -63,6 +91,7 @@ Example opening:
|
||||
```
|
||||
|
||||
### Medium (English)
|
||||
|
||||
**Voice**: Knowledgeable peer sharing discoveries
|
||||
**Tone**: Conversational, practical, slightly informal
|
||||
|
||||
@@ -82,33 +111,40 @@ Last month, I ran an experiment that changed how I think
|
||||
about content strategy entirely. Let me walk you through it."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Universal Guidelines
|
||||
|
||||
### SEO Considerations
|
||||
### SEO & Metadata
|
||||
|
||||
- Primary keyword in title and first 100 words
|
||||
- Secondary keywords naturally distributed
|
||||
- Meta description: 150-160 chars, action-oriented
|
||||
- URL slug: Short, keyword-rich, no dates
|
||||
- **Meta description: ≤155 chars**, action-oriented, captures the article's question and reader value
|
||||
- **URL slug: English, keyword-rich, no dates** — even for Korean-title posts
|
||||
- Alt text for all images
|
||||
|
||||
### Formatting Rules
|
||||
|
||||
- Use `##` for main sections, `###` for subsections
|
||||
- Code blocks with language specification
|
||||
- Blockquotes for key insights or quotes
|
||||
- Bold for emphasis (sparingly)
|
||||
- Lists only when truly listing items
|
||||
- Lists only when truly listing items; avoid in essay-form posts
|
||||
|
||||
### Citation Style
|
||||
|
||||
- Inline links preferred over footnotes
|
||||
- Source attribution: "According to [Source Name](URL)..."
|
||||
- Data citations: Include date of data
|
||||
- Internal links: Reference related OurDigital posts
|
||||
|
||||
---
|
||||
|
||||
## Word Count Guidelines
|
||||
|
||||
| Channel | Target | Min | Max |
|
||||
|---------|--------|-----|-----|
|
||||
| blog.ourdigital.org | 1,500 | 1,000 | 2,500 |
|
||||
| journal.ourdigital.org | 1,800 | 1,200 | 3,000 |
|
||||
| ourstory.day | 1,000 | 500 | 2,000 |
|
||||
| Medium | 1,500 | 800 | 2,500 |
|
||||
| blog.ourdigital.org | 2,000자 | 1,000자 | 3,000자 |
|
||||
| journal.ourdigital.org | 1,500 words | 1,000 words | 2,000 words |
|
||||
| ourstory.day | 1,000자 | 800자 | 1,500자 |
|
||||
| Medium | 1,500 words | 800 words | 2,500 words |
|
||||
|
||||
155
custom-skills/05-ourdigital-document/SKILL.md
Normal file
155
custom-skills/05-ourdigital-document/SKILL.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: ourdigital-document
|
||||
description: |
|
||||
Notion-to-presentation workflow for OurDigital.
|
||||
Activated with "ourdigital" keyword for document creation.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital document", "our document"
|
||||
- "ourdigital 문서", "our 문서"
|
||||
- "ourdigital presentation", "our presentation"
|
||||
- "ourdigital 발표자료", "our 발표자료"
|
||||
|
||||
Features:
|
||||
- Notion research extraction
|
||||
- Content synthesis and structuring
|
||||
- Branded presentation generation
|
||||
- PowerPoint and Figma output
|
||||
version: "1.1"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Document
|
||||
|
||||
Transform Notion research into branded presentations for OurDigital workflows.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital document" / "our document"
|
||||
- "ourdigital 발표자료" / "our 발표자료"
|
||||
- "our presentation [topic]"
|
||||
- "ourdigital presentation on [topic]"
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
```
|
||||
Phase 1: Research Collection → Phase 2: Content Synthesis → Phase 3: Presentation Planning
|
||||
↓
|
||||
Phase 4: Slide Generation → Phase 5: Brand Application → Phase 6: Export
|
||||
```
|
||||
|
||||
## Phase 1: Research Collection
|
||||
|
||||
**Goal**: Extract research content from Notion.
|
||||
|
||||
Input sources:
|
||||
- **Notion Page**: `notion://page/[ID]` - Single research document
|
||||
- **Notion Database**: `notion://database/[ID]` - Collection query
|
||||
- **Multiple Sources**: Comma-separated URLs for synthesis
|
||||
|
||||
Tools to use:
|
||||
- `Notion:notion-search` - Find related content
|
||||
- `Notion:notion-fetch` - Extract page content
|
||||
|
||||
**Output**: Structured research.json with findings
|
||||
|
||||
## Phase 2: Content Synthesis
|
||||
|
||||
**Goal**: Analyze and structure extracted content.
|
||||
|
||||
Processing:
|
||||
1. Identify key topics and themes
|
||||
2. Extract supporting data/statistics
|
||||
3. Prioritize by relevance and impact
|
||||
4. Generate executive summary
|
||||
|
||||
**Output**: synthesis.json with:
|
||||
- Executive summary
|
||||
- Key topics (ranked)
|
||||
- Agenda items
|
||||
- Supporting data points
|
||||
|
||||
## Phase 3: Presentation Planning
|
||||
|
||||
**Goal**: Create slide-by-slide structure.
|
||||
|
||||
Presentation types:
|
||||
| Type | Slides | Focus |
|
||||
|------|--------|-------|
|
||||
| Executive Summary | 3-5 | High-level findings, KPIs |
|
||||
| Research Report | 10-20 | Detailed methodology, data viz |
|
||||
| Meeting Prep | 5-10 | Agenda-driven, decision points |
|
||||
|
||||
**Output**: Slide plan with:
|
||||
- Title + subtitle per slide
|
||||
- Content outline
|
||||
- Speaker notes
|
||||
- Visual suggestions
|
||||
|
||||
## Phase 4: Slide Generation
|
||||
|
||||
**Goal**: Generate presentation content.
|
||||
|
||||
Slide structure:
|
||||
```
|
||||
├── Title Slide (project name, date, author)
|
||||
├── Agenda (numbered topics)
|
||||
├── Content Slides (1-3 per topic)
|
||||
│ ├── Key finding header
|
||||
│ ├── Supporting points (3-5 bullets)
|
||||
│ └── Data visualization placeholder
|
||||
├── Summary Slide (key takeaways)
|
||||
└── Next Steps / Q&A
|
||||
```
|
||||
|
||||
## Phase 5: Brand Application
|
||||
|
||||
**Goal**: Apply OurDigital corporate styling.
|
||||
|
||||
Brand elements:
|
||||
- **Colors**: OurDigital palette from `01-ourdigital-brand-guide`
|
||||
- **Fonts**: Noto Sans KR (Korean), Inter (English)
|
||||
- **Logo**: Positioned per brand guidelines
|
||||
- **Spacing**: Consistent margins and padding
|
||||
|
||||
Configuration: `shared/references/brand-config.json`
|
||||
|
||||
## Phase 6: Export
|
||||
|
||||
**Goal**: Generate final deliverable.
|
||||
|
||||
Output formats:
|
||||
- **PowerPoint (.pptx)**: Full presentation with animations
|
||||
- **Figma Slides**: Web-based collaborative format
|
||||
- **HTML Preview**: Quick review before final export
|
||||
|
||||
Export paths:
|
||||
- Desktop: `~/Downloads/presentations/`
|
||||
- Figma: Via Figma API
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital document [Notion URL]" | Full pipeline |
|
||||
| "ourdigital 발표자료 만들어줘" | Korean trigger |
|
||||
| "ourdigital presentation → pptx" | PowerPoint output |
|
||||
| "ourdigital presentation → figma" | Figma output |
|
||||
|
||||
## Presentation Templates
|
||||
|
||||
| Template | Use Case |
|
||||
|----------|----------|
|
||||
| Executive | Board meetings, C-level briefs |
|
||||
| Research | Deep-dive analysis, team reviews |
|
||||
| Meeting | Weekly syncs, project updates |
|
||||
| Workshop | Training, collaborative sessions |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/slide-layouts.md` - Layout options
|
||||
- `shared/references/agenda-templates.md` - Structure templates
|
||||
- `01-ourdigital-brand-guide` - Brand guidelines
|
||||
- `04-ourdigital-research` - Research workflow integration
|
||||
145
custom-skills/06-ourdigital-designer/SKILL.md
Normal file
145
custom-skills/06-ourdigital-designer/SKILL.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: ourdigital-designer
|
||||
description: |
|
||||
Visual storytelling and image prompt generation for OurDigital.
|
||||
Activated with "ourdigital" keyword for design tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital design", "our design"
|
||||
- "ourdigital 디자인", "our 디자인"
|
||||
- "ourdigital image prompt", "our image prompt"
|
||||
- "ourdigital 썸네일", "our 썸네일"
|
||||
|
||||
Features:
|
||||
- Philosophical visual narrative creation
|
||||
- Image prompt generation for AI art tools
|
||||
- Korean-Western aesthetic fusion
|
||||
- Blog featured image optimization
|
||||
version: "1.1"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Designer
|
||||
|
||||
Transform philosophical essays into sophisticated visual narratives through minimalist, conceptually rich featured images.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital design" / "our design"
|
||||
- "ourdigital 썸네일" / "our 썸네일"
|
||||
- "our image prompt for [topic]"
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
OurDigital images are visual philosophy—not illustrations but parallel texts that invite contemplation. Each image captures the essay's philosophical core through:
|
||||
|
||||
- **Abstract metaphors** over literal representations
|
||||
- **Contemplative minimalism** with 20%+ negative space
|
||||
- **Cultural fusion** of Korean-Western aesthetics
|
||||
- **Emotional resonance** through color psychology
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Extract Essay Essence
|
||||
|
||||
Analyze the content for:
|
||||
- **Core insight**: What philosophical truth?
|
||||
- **Emotional tone**: What feeling to evoke?
|
||||
- **Key metaphor**: What visual symbol?
|
||||
|
||||
### Phase 2: Select Visual Approach
|
||||
|
||||
| Essay Type | Visual Strategy | Color Mood |
|
||||
|-----------|-----------------|------------|
|
||||
| Technology | Organic-digital hybrids | Cool blues → warm accents |
|
||||
| Social | Network patterns, human fragments | Desaturated → hope spots |
|
||||
| Philosophy | Zen space, symbolic objects | Monochrome + single accent |
|
||||
| Cultural | Layered traditions, fusion forms | Earth tones → modern hues |
|
||||
|
||||
### Phase 3: Generate Prompt
|
||||
|
||||
Build structured prompt with:
|
||||
```
|
||||
[Style] + [Subject] + [Composition] + [Color] + [Technical specs]
|
||||
```
|
||||
|
||||
### Phase 4: Quality Check
|
||||
|
||||
✅ **Must have:**
|
||||
- Captures philosophical insight
|
||||
- Works at 200px thumbnail
|
||||
- Timeless (2-3 year relevance)
|
||||
- Cross-cultural readability
|
||||
|
||||
❌ **Must avoid:**
|
||||
- Tech clichés (circuits, binary)
|
||||
- Stock photo aesthetics
|
||||
- Literal interpretations
|
||||
- Trendy effects
|
||||
|
||||
## Quick Templates
|
||||
|
||||
### AI & Humanity
|
||||
```
|
||||
"Translucent human silhouette dissolving into crystalline data structures.
|
||||
Monochrome with teal accent. Boundary dissolution between organic/digital.
|
||||
1200x630px, minimalist vector style."
|
||||
```
|
||||
|
||||
### Social Commentary
|
||||
```
|
||||
"Overlapping circles forming maze pattern, tiny humans in separate chambers.
|
||||
Blue-gray palette, warm light leaks for hope. Subtle Korean patterns.
|
||||
High negative space. 1200x630px."
|
||||
```
|
||||
|
||||
### Digital Transformation
|
||||
```
|
||||
"Traditional forms metamorphosing into particle streams. Paper texture → digital grain.
|
||||
Earth tones shifting to cool blues. Sacred geometry underlying.
|
||||
1200x630px, contemplative mood."
|
||||
```
|
||||
|
||||
## Visual Metaphor Shortcuts
|
||||
|
||||
| Concept | Visual Metaphor |
|
||||
|---------|-----------------|
|
||||
| Algorithm | Constellation patterns |
|
||||
| Identity | Layered masks, fingerprints |
|
||||
| Network | Root systems, neural paths |
|
||||
| Time | Spirals, sediment layers |
|
||||
| Knowledge | Light sources, growing trees |
|
||||
|
||||
## Color Psychology
|
||||
|
||||
| Mood | Palette |
|
||||
|------|---------|
|
||||
| Critical | Deep blue-gray + red accent |
|
||||
| Hopeful | Warm amber + sky blue |
|
||||
| Philosophical | Near black + off white + gold |
|
||||
| Anxious | Charcoal + grey-blue + digital green |
|
||||
|
||||
## Technical Specs
|
||||
|
||||
- **Dimensions**: 1200x630px (OG standard)
|
||||
- **Style**: Vector illustration + subtle textures
|
||||
- **Colors**: 60-30-10 rule (dominant-secondary-accent)
|
||||
- **Format**: WebP primary, JPG fallback
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital design [topic]" | Generate image prompt |
|
||||
| "ourdigital 썸네일 [주제]" | Korean trigger |
|
||||
| "ourdigital visual → midjourney" | MidJourney optimized |
|
||||
| "ourdigital visual → dalle" | DALL-E optimized |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/visual-metaphors.md` - Concept dictionary
|
||||
- `shared/references/color-palettes.md` - Emotion → color mapping
|
||||
- `shared/references/advanced-techniques.md` - Complex compositions
|
||||
- `01-ourdigital-brand-guide` - Brand visual identity
|
||||
@@ -1,101 +1,104 @@
|
||||
# Visual Metaphor Dictionary
|
||||
|
||||
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
|
||||
|
||||
## Technology & Digital
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
||||
| Network | Root systems | Neural pathways, spider silk, web |
|
||||
| Code | Musical notation | DNA strands, city blueprints |
|
||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
||||
| Automation | Clockwork organic | Self-assembling structures |
|
||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||
|
||||
## Social & Cultural
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
||||
| Communication | Bridge structures | Echo patterns, light beams |
|
||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||
|
||||
## Philosophical & Abstract
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
||||
| Knowledge | Light sources | Growing trees, opening books |
|
||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||
|
||||
## Emotional States
|
||||
|
||||
| Emotion | Visual Translation | Color Association |
|
||||
|---------|-------------------|------------------|
|
||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
||||
| Hope | Light breaking through | Warm gradients |
|
||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
||||
| Joy | Expansion patterns | Bright, ascending |
|
||||
| Fear | Contracting spaces | Sharp contrasts |
|
||||
| Peace | Still water | Soft neutrals |
|
||||
| Confusion | Tangled lines | Overlapping hues |
|
||||
| Clarity | Clean geometry | Pure, minimal |
|
||||
| Emotion | Visual Translation | Color Guidance |
|
||||
|---------|-------------------|----------------|
|
||||
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||
|
||||
## Transformation & Process
|
||||
## Signature Motifs (Brand Consistency)
|
||||
|
||||
| Process | Visual Narrative | Symbolic Elements |
|
||||
|---------|------------------|------------------|
|
||||
| Growth | Seeds → trees | Fibonacci spirals |
|
||||
| Decay | Entropy patterns | Rust, dissolution |
|
||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
||||
| Revolution | Circles breaking | Shattered patterns |
|
||||
| Innovation | Spark → flame | Lightning, fusion |
|
||||
| Tradition | Continuous thread | Inherited patterns |
|
||||
| Disruption | Broken grids | Glitch aesthetics |
|
||||
| Integration | Merging streams | Confluence points |
|
||||
| Motif | Description |
|
||||
|-------|-------------|
|
||||
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||
|
||||
## Korean-Western Fusion Elements
|
||||
## Recommended Color Palettes
|
||||
|
||||
| Korean Element | Western Parallel | Fusion Approach |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
||||
| 한글 geometry | Typography | Structural letters |
|
||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
||||
| Palette | Background | Accent | Mood |
|
||||
|---------|-----------|--------|------|
|
||||
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||
|
||||
**Dark color limit:** max 15% of canvas; never full dark background by default.
|
||||
|
||||
## Korean Design Elements
|
||||
|
||||
| Korean Element | Western Parallel | Application |
|
||||
|----------------|-----------------|-------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
|
||||
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
|
||||
| 한지 texture | Paper grain | Subtle background texture only |
|
||||
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
|
||||
|
||||
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
|
||||
|
||||
## Usage Notes
|
||||
|
||||
1. **Layer metaphors**: Combine 2-3 for depth
|
||||
2. **Avoid clichés**: No obvious tech symbols
|
||||
3. **Cultural sensitivity**: Universal over specific
|
||||
4. **Abstraction levels**: Match essay tone
|
||||
5. **Emotional resonance**: Feel over literal
|
||||
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||
|
||||
## Quick Selection Guide
|
||||
|
||||
For **technology essays**: organic-digital hybrids
|
||||
For **social commentary**: human elements in systems
|
||||
For **philosophy pieces**: space and light
|
||||
For **cultural topics**: layered traditions
|
||||
For **future themes**: transformation states
|
||||
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||
For **social commentary**: network patterns with small human traces
|
||||
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||
For **practical guides**: clean editorial diagrams with warm accents
|
||||
For **personal reflections**: soft everyday scenes with metaphorical detail
|
||||
|
||||
@@ -1,101 +1,104 @@
|
||||
# Visual Metaphor Dictionary
|
||||
|
||||
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
|
||||
|
||||
## Technology & Digital
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
||||
| Network | Root systems | Neural pathways, spider silk, web |
|
||||
| Code | Musical notation | DNA strands, city blueprints |
|
||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
||||
| Automation | Clockwork organic | Self-assembling structures |
|
||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||
|
||||
## Social & Cultural
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
||||
| Communication | Bridge structures | Echo patterns, light beams |
|
||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||
|
||||
## Philosophical & Abstract
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
||||
| Knowledge | Light sources | Growing trees, opening books |
|
||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||
|
||||
## Emotional States
|
||||
|
||||
| Emotion | Visual Translation | Color Association |
|
||||
|---------|-------------------|------------------|
|
||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
||||
| Hope | Light breaking through | Warm gradients |
|
||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
||||
| Joy | Expansion patterns | Bright, ascending |
|
||||
| Fear | Contracting spaces | Sharp contrasts |
|
||||
| Peace | Still water | Soft neutrals |
|
||||
| Confusion | Tangled lines | Overlapping hues |
|
||||
| Clarity | Clean geometry | Pure, minimal |
|
||||
| Emotion | Visual Translation | Color Guidance |
|
||||
|---------|-------------------|----------------|
|
||||
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||
|
||||
## Transformation & Process
|
||||
## Signature Motifs (Brand Consistency)
|
||||
|
||||
| Process | Visual Narrative | Symbolic Elements |
|
||||
|---------|------------------|------------------|
|
||||
| Growth | Seeds → trees | Fibonacci spirals |
|
||||
| Decay | Entropy patterns | Rust, dissolution |
|
||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
||||
| Revolution | Circles breaking | Shattered patterns |
|
||||
| Innovation | Spark → flame | Lightning, fusion |
|
||||
| Tradition | Continuous thread | Inherited patterns |
|
||||
| Disruption | Broken grids | Glitch aesthetics |
|
||||
| Integration | Merging streams | Confluence points |
|
||||
| Motif | Description |
|
||||
|-------|-------------|
|
||||
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||
|
||||
## Korean-Western Fusion Elements
|
||||
## Recommended Color Palettes
|
||||
|
||||
| Korean Element | Western Parallel | Fusion Approach |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
||||
| 한글 geometry | Typography | Structural letters |
|
||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
||||
| Palette | Background | Accent | Mood |
|
||||
|---------|-----------|--------|------|
|
||||
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||
|
||||
**Dark color limit:** max 15% of canvas; never full dark background by default.
|
||||
|
||||
## Korean Design Elements
|
||||
|
||||
| Korean Element | Western Parallel | Application |
|
||||
|----------------|-----------------|-------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
|
||||
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
|
||||
| 한지 texture | Paper grain | Subtle background texture only |
|
||||
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
|
||||
|
||||
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
|
||||
|
||||
## Usage Notes
|
||||
|
||||
1. **Layer metaphors**: Combine 2-3 for depth
|
||||
2. **Avoid clichés**: No obvious tech symbols
|
||||
3. **Cultural sensitivity**: Universal over specific
|
||||
4. **Abstraction levels**: Match essay tone
|
||||
5. **Emotional resonance**: Feel over literal
|
||||
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||
|
||||
## Quick Selection Guide
|
||||
|
||||
For **technology essays**: organic-digital hybrids
|
||||
For **social commentary**: human elements in systems
|
||||
For **philosophy pieces**: space and light
|
||||
For **cultural topics**: layered traditions
|
||||
For **future themes**: transformation states
|
||||
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||
For **social commentary**: network patterns with small human traces
|
||||
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||
For **practical guides**: clean editorial diagrams with warm accents
|
||||
For **personal reflections**: soft everyday scenes with metaphorical detail
|
||||
|
||||
173
custom-skills/07-ourdigital-ad-manager/SKILL.md
Normal file
173
custom-skills/07-ourdigital-ad-manager/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: ourdigital-ad-manager
|
||||
description: |
|
||||
Ad copywriting and keyword research for OurDigital marketing.
|
||||
Activated with "ourdigital" keyword for advertising tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital ad copy", "our ad copy"
|
||||
- "ourdigital 광고 카피", "our 광고 카피"
|
||||
- "ourdigital keyword", "our keyword"
|
||||
- "ourdigital 검색 광고", "our 검색 광고"
|
||||
|
||||
Features:
|
||||
- Search ad copywriting (Google, Naver)
|
||||
- Display ad copywriting
|
||||
- Branded content creation
|
||||
- Keyword volume research
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Ad Manager
|
||||
|
||||
Create compelling ad copy and research keywords for OurDigital marketing campaigns.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital ad copy" / "our ad copy"
|
||||
- "ourdigital 광고 카피" / "our 광고 카피"
|
||||
- "our keyword research [topic]"
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Campaign Brief
|
||||
|
||||
Gather information:
|
||||
- **Product/Service**: What are we advertising?
|
||||
- **Target audience**: Who are we reaching?
|
||||
- **Campaign goal**: Awareness, consideration, or conversion?
|
||||
- **Platform**: Google, Naver, Meta, Display?
|
||||
- **Budget tier**: Affects keyword competitiveness
|
||||
|
||||
### Phase 2: Keyword Research
|
||||
|
||||
For search campaigns:
|
||||
1. **Seed keywords**: Core terms from brief
|
||||
2. **Volume research**: Web search for search volume data
|
||||
3. **Intent mapping**: Informational → Transactional
|
||||
4. **Competitor analysis**: Top-ranking ad copy patterns
|
||||
|
||||
Tools to use:
|
||||
- `web_search`: Search volume and trends
|
||||
- `web_fetch`: Competitor ad copy analysis
|
||||
|
||||
### Phase 3: Ad Copy Creation
|
||||
|
||||
Generate platform-specific copy following character limits and best practices.
|
||||
|
||||
## Search Ad Copy
|
||||
|
||||
### Google Ads Format
|
||||
|
||||
```
|
||||
Headline 1: [30 chars] - Primary keyword + value prop
|
||||
Headline 2: [30 chars] - Benefit or CTA
|
||||
Headline 3: [30 chars] - Differentiator
|
||||
Description 1: [90 chars] - Expand on value
|
||||
Description 2: [90 chars] - CTA + urgency
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Include keyword in Headline 1
|
||||
- Numbers and specifics increase CTR
|
||||
- Test emotional vs. rational appeals
|
||||
- Include pricing if competitive
|
||||
|
||||
### Naver Search Ad Format
|
||||
|
||||
```
|
||||
제목: [25자] - 핵심 키워드 + 가치
|
||||
설명: [45자] - 혜택 + 행동 유도
|
||||
```
|
||||
|
||||
**Korean Ad Copy Tips:**
|
||||
- 존댓말 일관성 유지
|
||||
- 숫자와 구체적 혜택 강조
|
||||
- 신뢰 요소 포함 (경력, 인증)
|
||||
|
||||
## Display Ad Copy
|
||||
|
||||
### Headlines by Format
|
||||
|
||||
| Format | Max Length | Focus |
|
||||
|--------|------------|-------|
|
||||
| Leaderboard | 25 chars | Brand + single benefit |
|
||||
| Medium Rectangle | 30 chars | Offer + CTA |
|
||||
| Responsive | 30 chars | Multiple variations |
|
||||
|
||||
### Copy Formula
|
||||
|
||||
```
|
||||
[Problem Recognition] + [Solution Hint] + [CTA]
|
||||
"여전히 [문제]? [해결책]으로 [결과]"
|
||||
```
|
||||
|
||||
## Branded Content
|
||||
|
||||
For native advertising and sponsored content:
|
||||
|
||||
### OurDigital Tone
|
||||
|
||||
- **Authority without arrogance**: Share expertise, invite questions
|
||||
- **Data-backed claims**: Statistics increase credibility
|
||||
- **Subtle CTAs**: Education first, promotion second
|
||||
|
||||
### Content Types
|
||||
|
||||
| Type | Length | CTA Style |
|
||||
|------|--------|-----------|
|
||||
| Sponsored Article | 800-1,200 words | Soft (learn more) |
|
||||
| Native Ad | 100-200 words | Medium (discover) |
|
||||
| Social Sponsored | 50-100 words | Direct (get started) |
|
||||
|
||||
## Keyword Research Output
|
||||
|
||||
### Research Report Structure
|
||||
|
||||
```
|
||||
## Keyword Analysis: [Topic]
|
||||
|
||||
### Primary Keywords
|
||||
| Keyword | Volume | Difficulty | Intent |
|
||||
|---------|--------|------------|--------|
|
||||
| [kw1] | 10K | Medium | Trans |
|
||||
|
||||
### Long-tail Opportunities
|
||||
- [keyword phrase 1]: Low competition, high intent
|
||||
- [keyword phrase 2]: Rising trend
|
||||
|
||||
### Negative Keywords
|
||||
- [irrelevant term 1]
|
||||
- [irrelevant term 2]
|
||||
|
||||
### Recommended Ad Groups
|
||||
1. [Group Name]: kw1, kw2, kw3
|
||||
2. [Group Name]: kw4, kw5, kw6
|
||||
```
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital ad copy [product]" | Full ad set |
|
||||
| "ourdigital 검색 광고 [키워드]" | Search ads |
|
||||
| "ourdigital display ad [campaign]" | Display copy |
|
||||
| "ourdigital keyword [topic]" | Volume research |
|
||||
|
||||
## Platform Guidelines
|
||||
|
||||
| Platform | Headline | Description | Key Focus |
|
||||
|----------|----------|-------------|-----------|
|
||||
| Google | 30×3 | 90×2 | Keyword match |
|
||||
| Naver | 25 | 45 | Trust signals |
|
||||
| Meta | 40 | 125 | Visual-copy sync |
|
||||
| LinkedIn | 150 | 70 | Professional tone |
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/ad-copy-formulas.md` - Proven copy templates
|
||||
- `shared/references/platform-specs.md` - Character limits
|
||||
- `01-ourdigital-brand-guide` - Brand voice
|
||||
186
custom-skills/08-ourdigital-trainer/SKILL.md
Normal file
186
custom-skills/08-ourdigital-trainer/SKILL.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
name: ourdigital-trainer
|
||||
description: |
|
||||
Training material creation and workshop planning for OurDigital.
|
||||
Activated with "ourdigital" keyword for education tasks.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital training", "our training"
|
||||
- "ourdigital 교육", "our 교육"
|
||||
- "ourdigital workshop", "our workshop"
|
||||
- "ourdigital 워크샵", "our 워크샵"
|
||||
|
||||
Features:
|
||||
- Training material design
|
||||
- Workshop agenda planning
|
||||
- Participant evaluation design
|
||||
- Exercise and activity creation
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Trainer
|
||||
|
||||
Design training materials, plan workshops, and create evaluation frameworks for OurDigital education programs.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital training" / "our training"
|
||||
- "ourdigital 워크샵" / "our 워크샵"
|
||||
- "our curriculum [subject]"
|
||||
|
||||
## Core Domains
|
||||
|
||||
OurDigital training expertise:
|
||||
|
||||
| Domain | Topics |
|
||||
|--------|--------|
|
||||
| **Data Literacy** | 데이터 리터러시, 분석 기초, 시각화 |
|
||||
| **AI Literacy** | AI 활용, 프롬프트 엔지니어링, AI 윤리 |
|
||||
| **Digital Marketing** | SEO, GTM, 마케팅 자동화 |
|
||||
| **Brand Marketing** | 브랜드 전략, 콘텐츠 마케팅 |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Training Needs Analysis
|
||||
|
||||
Gather requirements:
|
||||
- **Target audience**: 직급, 경험 수준, 사전 지식
|
||||
- **Learning objectives**: 교육 후 달성할 역량
|
||||
- **Duration**: 시간 제약 (2시간/반일/전일/다회차)
|
||||
- **Format**: 온라인/오프라인/하이브리드
|
||||
- **Group size**: 참여 인원
|
||||
|
||||
### Phase 2: Curriculum Design
|
||||
|
||||
Structure the learning journey:
|
||||
|
||||
```
|
||||
Module Structure:
|
||||
├── 도입 (10-15%)
|
||||
│ ├── Ice-breaker
|
||||
│ ├── 학습 목표 공유
|
||||
│ └── 사전 지식 확인
|
||||
├── 핵심 학습 (60-70%)
|
||||
│ ├── 개념 설명
|
||||
│ ├── 사례 분석
|
||||
│ ├── 실습 활동
|
||||
│ └── 토론/질의응답
|
||||
├── 심화/응용 (15-20%)
|
||||
│ ├── 응용 과제
|
||||
│ └── 그룹 활동
|
||||
└── 마무리 (5-10%)
|
||||
├── 핵심 정리
|
||||
├── 평가
|
||||
└── 후속 학습 안내
|
||||
```
|
||||
|
||||
### Phase 3: Material Development
|
||||
|
||||
Create supporting materials:
|
||||
|
||||
| Material Type | Purpose |
|
||||
|---------------|---------|
|
||||
| 슬라이드 | 핵심 개념 전달 |
|
||||
| 핸드아웃 | 참조 자료, 체크리스트 |
|
||||
| 워크시트 | 실습 활동용 |
|
||||
| 사례 연구 | 토론 및 분석용 |
|
||||
| 퀴즈/평가지 | 학습 확인용 |
|
||||
|
||||
### Phase 4: Activity Design
|
||||
|
||||
Engagement techniques:
|
||||
|
||||
| Activity Type | Duration | Purpose |
|
||||
|---------------|----------|---------|
|
||||
| Think-Pair-Share | 5-10분 | 개별 사고 → 협력 |
|
||||
| Case Study | 20-30분 | 실제 적용력 |
|
||||
| Role Play | 15-20분 | 경험적 학습 |
|
||||
| Gallery Walk | 15분 | 아이디어 공유 |
|
||||
| Fishbowl | 20-30분 | 심층 토론 |
|
||||
|
||||
### Phase 5: Evaluation Design
|
||||
|
||||
Assessment framework:
|
||||
|
||||
| Level | What to Measure | Method |
|
||||
|-------|-----------------|--------|
|
||||
| 반응 | 만족도, 참여도 | 설문조사 |
|
||||
| 학습 | 지식 습득 | 퀴즈, 테스트 |
|
||||
| 행동 | 현업 적용 | 관찰, 피드백 |
|
||||
| 결과 | 성과 개선 | KPI 측정 |
|
||||
|
||||
## Training Templates
|
||||
|
||||
### 2-Hour Workshop
|
||||
|
||||
```
|
||||
00:00-00:10 도입 및 Ice-breaker
|
||||
00:10-00:20 학습 목표 및 아젠다
|
||||
00:20-00:50 핵심 개념 1
|
||||
00:50-01:00 휴식
|
||||
01:00-01:30 핵심 개념 2 + 실습
|
||||
01:30-01:50 그룹 활동/토론
|
||||
01:50-02:00 정리 및 Q&A
|
||||
```
|
||||
|
||||
### Half-Day (4 Hours)
|
||||
|
||||
```
|
||||
09:00-09:20 도입 및 네트워킹
|
||||
09:20-10:20 모듈 1: 기초 개념
|
||||
10:20-10:30 휴식
|
||||
10:30-11:30 모듈 2: 심화 학습
|
||||
11:30-12:00 실습 세션
|
||||
12:00-12:30 사례 연구
|
||||
12:30-13:00 정리, 평가, Q&A
|
||||
```
|
||||
|
||||
### Full-Day (8 Hours)
|
||||
|
||||
```
|
||||
09:00-09:30 도입
|
||||
09:30-10:30 모듈 1
|
||||
10:30-10:45 휴식
|
||||
10:45-12:00 모듈 2 + 실습
|
||||
12:00-13:00 점심
|
||||
13:00-14:00 모듈 3
|
||||
14:00-15:00 그룹 프로젝트
|
||||
15:00-15:15 휴식
|
||||
15:15-16:30 프로젝트 발표
|
||||
16:30-17:00 종합 정리 및 평가
|
||||
```
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital training [topic]" | Design curriculum |
|
||||
| "ourdigital 워크샵 [주제]" | Workshop agenda |
|
||||
| "ourdigital evaluation for [training]" | Assessment design |
|
||||
| "ourdigital 교육자료 [주제]" | Material outline |
|
||||
|
||||
## Facilitation Tips
|
||||
|
||||
### Engagement Techniques
|
||||
|
||||
- **3의 법칙**: 핵심 메시지 3개 이하
|
||||
- **10분 규칙**: 10분마다 활동 전환
|
||||
- **참여 유도**: 질문 → 대기 → 지명
|
||||
- **시각화**: 텍스트보다 다이어그램
|
||||
|
||||
### Korean Training Context
|
||||
|
||||
- 존칭 일관성 유지
|
||||
- 실무 사례 강조
|
||||
- 명함 교환 시간 확보
|
||||
- 그룹 활동 시 리더 지정
|
||||
|
||||
## References
|
||||
|
||||
- `shared/references/training-frameworks.md` - 교수 설계 모델
|
||||
- `shared/references/activity-library.md` - 활동 아이디어
|
||||
- `shared/templates/workshop-template.md` - 워크샵 템플릿
|
||||
- `01-ourdigital-brand-guide` - 발표 스타일
|
||||
231
custom-skills/09-ourdigital-backoffice/SKILL.md
Normal file
231
custom-skills/09-ourdigital-backoffice/SKILL.md
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: ourdigital-backoffice
|
||||
description: |
|
||||
Business document creation for OurDigital consulting services.
|
||||
Activated with "ourdigital" keyword for business documents.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital quote", "our quote"
|
||||
- "ourdigital 견적서", "our 견적서"
|
||||
- "ourdigital proposal", "our proposal"
|
||||
- "ourdigital 비용 분석", "our 비용 분석"
|
||||
|
||||
Features:
|
||||
- Quote/estimate generation
|
||||
- Service proposal creation
|
||||
- Contract draft (requires legal review)
|
||||
- Cost-benefit analysis
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Backoffice
|
||||
|
||||
Create business documents for OurDigital consulting services.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital 견적서" / "our 견적서"
|
||||
- "ourdigital proposal" / "our proposal"
|
||||
- "our cost analysis [project]"
|
||||
|
||||
## Important Notice
|
||||
|
||||
⚠️ **Legal Disclaimer**: Contract drafts require professional legal review before use. This skill provides templates and structure only.
|
||||
|
||||
## Document Types
|
||||
|
||||
### 1. Quote/Estimate (견적서)
|
||||
|
||||
**Purpose**: Service pricing and scope summary
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
견적서 번호: OD-YYYY-NNN
|
||||
발행일: YYYY-MM-DD
|
||||
유효기간: 30일
|
||||
|
||||
1. 고객 정보
|
||||
- 회사명, 담당자, 연락처
|
||||
|
||||
2. 서비스 개요
|
||||
- 프로젝트명
|
||||
- 서비스 범위 요약
|
||||
|
||||
3. 세부 항목
|
||||
| 항목 | 상세 | 수량 | 단가 | 금액 |
|
||||
|------|------|------|------|------|
|
||||
|
||||
4. 합계
|
||||
- 소계, VAT, 총액
|
||||
|
||||
5. 결제 조건
|
||||
- 선금/잔금 비율
|
||||
- 결제 방법
|
||||
|
||||
6. 특이사항
|
||||
- 포함/미포함 사항
|
||||
```
|
||||
|
||||
### 2. Service Proposal (서비스 제안서)
|
||||
|
||||
**Purpose**: Detailed service offering and value proposition
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
1. Executive Summary
|
||||
- 핵심 제안 1-2문단
|
||||
|
||||
2. 고객 상황 이해
|
||||
- 현재 과제
|
||||
- 니즈 분석
|
||||
|
||||
3. 제안 서비스
|
||||
- 서비스 범위
|
||||
- 접근 방법
|
||||
- 예상 산출물
|
||||
|
||||
4. 프로젝트 계획
|
||||
- 일정표
|
||||
- 마일스톤
|
||||
- 체크포인트
|
||||
|
||||
5. 투입 리소스
|
||||
- 담당자 프로필
|
||||
- 역할 분담
|
||||
|
||||
6. 비용 및 조건
|
||||
- 비용 구조
|
||||
- 결제 조건
|
||||
|
||||
7. 기대 효과
|
||||
- 예상 성과
|
||||
- ROI 추정
|
||||
|
||||
8. 왜 OurDigital인가
|
||||
- 차별점
|
||||
- 관련 경험
|
||||
```
|
||||
|
||||
### 3. Contract Draft (계약서 초안)
|
||||
|
||||
**Purpose**: Service agreement framework
|
||||
|
||||
⚠️ **반드시 법률 전문가 검토 필요**
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
제1조 (목적)
|
||||
제2조 (용어의 정의)
|
||||
제3조 (계약 기간)
|
||||
제4조 (서비스 범위)
|
||||
제5조 (대금 및 지급 조건)
|
||||
제6조 (권리와 의무)
|
||||
제7조 (비밀유지)
|
||||
제8조 (지적재산권)
|
||||
제9조 (계약의 해지)
|
||||
제10조 (손해배상)
|
||||
제11조 (분쟁 해결)
|
||||
제12조 (일반 조항)
|
||||
```
|
||||
|
||||
### 4. Cost-Benefit Analysis (비용 분석)
|
||||
|
||||
**Purpose**: ROI and investment justification
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
1. 프로젝트 개요
|
||||
- 목적 및 범위
|
||||
|
||||
2. 비용 분석
|
||||
| 항목 | 초기비용 | 연간비용 | 3년 TCO |
|
||||
|
||||
3. 예상 효과
|
||||
| 효과 | 정량적 가치 | 연간 효과 |
|
||||
|
||||
4. ROI 계산
|
||||
- 투자회수기간
|
||||
- NPV, IRR
|
||||
|
||||
5. 리스크 분석
|
||||
- 잠재 리스크
|
||||
- 완화 방안
|
||||
|
||||
6. 권장 사항
|
||||
```
|
||||
|
||||
## Service Catalog
|
||||
|
||||
OurDigital standard service offerings:
|
||||
|
||||
### SEO Services
|
||||
|
||||
| Service | Description | Duration | Price Range |
|
||||
|---------|-------------|----------|-------------|
|
||||
| Technical Audit | 기술 SEO 진단 | 1-2주 | 300-500만원 |
|
||||
| On-Page Optimization | 콘텐츠 최적화 | 월간 | 150-300만원/월 |
|
||||
| Local SEO | 로컬 검색 최적화 | 월간 | 100-200만원/월 |
|
||||
|
||||
### Data & Analytics
|
||||
|
||||
| Service | Description | Duration | Price Range |
|
||||
|---------|-------------|----------|-------------|
|
||||
| GTM Setup | 태그 관리 구축 | 2-4주 | 200-400만원 |
|
||||
| GA4 Implementation | 분석 환경 구축 | 1-3주 | 150-300만원 |
|
||||
| Dashboard Development | 대시보드 개발 | 2-4주 | 300-600만원 |
|
||||
|
||||
### Consulting
|
||||
|
||||
| Service | Description | Duration | Price Range |
|
||||
|---------|-------------|----------|-------------|
|
||||
| Brand Consulting | 브랜드 전략 | 프로젝트 | 500-1000만원 |
|
||||
| Marketing Strategy | 마케팅 전략 | 프로젝트 | 300-700만원 |
|
||||
| Data Strategy | 데이터 전략 | 프로젝트 | 400-800만원 |
|
||||
|
||||
### Training
|
||||
|
||||
| Service | Description | Duration | Price Range |
|
||||
|---------|-------------|----------|-------------|
|
||||
| Workshop | 반일/전일 워크샵 | 4-8시간 | 100-200만원 |
|
||||
| Corporate Training | 기업 교육 | 다회차 | 50-100만원/회 |
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital 견적서 [서비스]" | Generate quote |
|
||||
| "ourdigital proposal [client]" | Create proposal |
|
||||
| "ourdigital 계약서 초안" | Contract template |
|
||||
| "ourdigital 비용 분석 [project]" | Cost-benefit analysis |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Requirement Gathering
|
||||
|
||||
- Client information
|
||||
- Service scope
|
||||
- Timeline requirements
|
||||
- Budget constraints
|
||||
|
||||
### Phase 2: Document Generation
|
||||
|
||||
- Select appropriate template
|
||||
- Fill with gathered information
|
||||
- Apply OurDigital branding
|
||||
|
||||
### Phase 3: Review & Finalize
|
||||
|
||||
- Internal review
|
||||
- Client discussion points highlight
|
||||
- Legal review (for contracts)
|
||||
|
||||
## References
|
||||
|
||||
- `shared/templates/quote-template.md` - 견적서 양식
|
||||
- `shared/templates/proposal-template.md` - 제안서 양식
|
||||
- `shared/templates/contract-template.md` - 계약서 양식
|
||||
- `shared/references/pricing-guide.md` - 가격 가이드
|
||||
- `01-ourdigital-brand-guide` - 문서 스타일
|
||||
167
custom-skills/10-ourdigital-skill-creator/SKILL.md
Normal file
167
custom-skills/10-ourdigital-skill-creator/SKILL.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ourdigital-skill-creator
|
||||
description: |
|
||||
Meta skill for creating and managing OurDigital Claude Skills.
|
||||
Activated when user includes "ourdigital" keyword with skill creation requests.
|
||||
|
||||
Triggers (ourdigital or our prefix):
|
||||
- "ourdigital skill create", "our skill create"
|
||||
- "ourdigital 스킬 만들기", "our 스킬 만들기"
|
||||
- "ourdigital skill creator", "our skill creator"
|
||||
|
||||
Features:
|
||||
- Skill suitability evaluation
|
||||
- Interactive Q&A for requirements gathering
|
||||
- Optimized skill generation (Desktop/Code)
|
||||
- Notion history tracking
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop
|
||||
---
|
||||
|
||||
# OurDigital Skill Creator
|
||||
|
||||
Meta skill for creating, validating, and managing OurDigital Claude Skills.
|
||||
|
||||
## Activation
|
||||
|
||||
Activate with "ourdigital" or "our" prefix:
|
||||
- "ourdigital 스킬 만들어줘" / "our 스킬 만들어줘"
|
||||
- "ourdigital skill creator" / "our skill creator"
|
||||
- "our skill create [name]"
|
||||
|
||||
Do NOT activate for generic "make a skill" requests (without our/ourdigital prefix).
|
||||
|
||||
## Interactive Workflow
|
||||
|
||||
### Phase 1: Need Assessment
|
||||
|
||||
When user requests a new skill:
|
||||
|
||||
1. **Acknowledge** the initial request
|
||||
2. **Ask clarifying questions** (max 3 per turn):
|
||||
- What is the core purpose?
|
||||
- What triggers this skill?
|
||||
- What outputs do you expect?
|
||||
|
||||
### Phase 2: Suitability Check
|
||||
|
||||
Evaluate against Claude Skill criteria:
|
||||
|
||||
| Criterion | Question to Ask |
|
||||
|-----------|-----------------|
|
||||
| Clear trigger | When exactly should this skill activate? |
|
||||
| Focused scope | Can you describe 1-3 core functions? |
|
||||
| Reusable resources | What scripts, templates, or references are needed? |
|
||||
| Domain knowledge | What specialized knowledge does Claude lack? |
|
||||
| Clear boundaries | How does this differ from existing skills? |
|
||||
|
||||
**Decision**: If ≥3 criteria pass → proceed. Otherwise, suggest alternatives.
|
||||
|
||||
### Phase 3: Requirements Definition
|
||||
|
||||
Guide user through structured Q&A:
|
||||
|
||||
```
|
||||
Q1. 스킬 목적과 핵심 기능은 무엇인가요?
|
||||
(What is the skill's purpose and core functions?)
|
||||
|
||||
Q2. 어떤 상황에서 이 스킬이 트리거되어야 하나요?
|
||||
(When should this skill be triggered?)
|
||||
|
||||
Q3. 필요한 외부 도구나 API가 있나요?
|
||||
(Any external tools or APIs needed?)
|
||||
|
||||
Q4. 기대하는 출력 형식은 무엇인가요?
|
||||
(What output format do you expect?)
|
||||
|
||||
Q5. Desktop, Code, 또는 Both 환경이 필요한가요?
|
||||
(Which environment: Desktop, Code, or Both?)
|
||||
```
|
||||
|
||||
### Phase 4: Skill Generation
|
||||
|
||||
Generate skill structure following OurDigital standards:
|
||||
|
||||
```
|
||||
XX-ourdigital-{skill-name}/
|
||||
├── desktop/
|
||||
│ └── SKILL.md # Desktop version
|
||||
├── code/
|
||||
│ └── SKILL.md # Code version (CLAUDE.md pattern)
|
||||
├── shared/
|
||||
│ ├── references/ # Common documentation
|
||||
│ ├── templates/ # Shared templates
|
||||
│ └── scripts/ # Utility scripts
|
||||
├── docs/
|
||||
│ ├── CHANGELOG.md # Version history
|
||||
│ └── logs/ # Update logs
|
||||
└── README.md # Overview
|
||||
```
|
||||
|
||||
### Phase 5: Validation
|
||||
|
||||
Before finalizing, verify:
|
||||
|
||||
- [ ] YAML frontmatter includes "ourdigital" trigger keywords
|
||||
- [ ] Description clearly states activation conditions
|
||||
- [ ] Body content is 800-1,200 words
|
||||
- [ ] shared/ resources are properly referenced
|
||||
- [ ] No overlap with existing ourdigital skills
|
||||
|
||||
### Phase 6: Notion Sync
|
||||
|
||||
Record to Working with AI database:
|
||||
|
||||
- **Database**: f8f19ede-32bd-43ac-9f60-0651f6f40afe
|
||||
- **Properties**:
|
||||
- Name: `ourdigital-{skill-name} v{version}`
|
||||
- Status: In progress → Done
|
||||
- AI used: Claude Desktop
|
||||
- AI summary: Brief skill description
|
||||
|
||||
## YAML Frontmatter Template
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: ourdigital-{skill-name}
|
||||
description: |
|
||||
[Purpose summary]
|
||||
Activated when user includes "ourdigital" keyword.
|
||||
|
||||
Triggers:
|
||||
- "ourdigital {keyword1}", "ourdigital {keyword2}"
|
||||
|
||||
Features:
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
version: "1.0"
|
||||
author: OurDigital
|
||||
environment: Desktop | Code | Both
|
||||
---
|
||||
```
|
||||
|
||||
## Skill Numbering
|
||||
|
||||
| Range | Category |
|
||||
|-------|----------|
|
||||
| 01-09 | OurDigital Core (brand, blog, journal, research, etc.) |
|
||||
| 10 | Meta (skill-creator) |
|
||||
| 11-19 | SEO Tools |
|
||||
| 20-29 | GTM/Analytics Tools |
|
||||
| 31-39 | Notion Tools |
|
||||
| 40-49 | Jamie Clinic Tools |
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `shared/references/suitability-criteria.md` - Skill evaluation criteria
|
||||
- `shared/references/skill-patterns.md` - Common patterns
|
||||
- `shared/templates/skill-template/` - Blank skill template
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| "ourdigital 스킬 적합성" | Run suitability check only |
|
||||
| "ourdigital 스킬 생성" | Full creation workflow |
|
||||
| "ourdigital 스킬 검증" | Validate existing skill |
|
||||
109
custom-skills/11-seo-comprehensive-audit/SKILL.md
Normal file
109
custom-skills/11-seo-comprehensive-audit/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: seo-comprehensive-audit
|
||||
description: |
|
||||
Comprehensive SEO audit orchestrator. Runs 6-stage audit pipeline (Technical, On-Page, CWV, Schema, Local, GSC) and produces a unified report with weighted health score.
|
||||
Triggers: comprehensive SEO, full SEO audit, 종합 SEO 감사, site audit, SEO health check.
|
||||
---
|
||||
|
||||
# Comprehensive SEO Audit
|
||||
|
||||
## Purpose
|
||||
|
||||
Orchestrate a full-spectrum SEO audit by running 6 specialized analyses and synthesizing results into a unified health score and actionable report.
|
||||
|
||||
## Pipeline Stages
|
||||
|
||||
| # | Stage | Source Skill | Default |
|
||||
|---|-------|-------------|---------|
|
||||
| 1 | Technical SEO | 12-seo-technical-audit | Always |
|
||||
| 2 | On-Page SEO | 13-seo-on-page-audit | Always |
|
||||
| 3 | Core Web Vitals | 14-seo-core-web-vitals | Always |
|
||||
| 4 | Schema Validation | 16-seo-schema-validator | Always |
|
||||
| 5 | Local SEO | 18-seo-local-audit | Skippable |
|
||||
| 6 | Search Console | 15-seo-search-console | Skippable |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Initialization
|
||||
1. Receive target URL from user
|
||||
2. Confirm which stages to run (all 6 by default)
|
||||
3. Set up audit tracking ID: `COMP-YYYYMMDD-NNN`
|
||||
|
||||
### 2. Execute Stages (Sequential)
|
||||
For each active stage:
|
||||
1. Run the sub-skill analysis
|
||||
2. Collect JSON results
|
||||
3. Extract score and issues
|
||||
|
||||
### 3. Synthesis
|
||||
1. Compute weighted health score (0-100)
|
||||
2. Assign grade (A/B+/B/C/D/F)
|
||||
3. Prioritize critical and high-severity findings
|
||||
4. Generate recommendations
|
||||
|
||||
### 4. Notion Report
|
||||
1. Create summary page: `종합 SEO 감사 보고서 - [domain] - YYYY-MM-DD`
|
||||
2. Create individual pages for Critical/High findings
|
||||
3. Database: `2c8581e5-8a1e-8035-880b-e38cefc2f3ef`
|
||||
|
||||
## Health Score Weights
|
||||
|
||||
| Category | Weight |
|
||||
|----------|--------|
|
||||
| Technical SEO | 20% |
|
||||
| On-Page SEO | 20% |
|
||||
| Core Web Vitals | 25% |
|
||||
| Schema | 15% |
|
||||
| Local SEO | 10% |
|
||||
| Search Console | 10% |
|
||||
|
||||
Skipped stages redistribute weight proportionally.
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## 종합 SEO 감사 보고서: [domain]
|
||||
|
||||
**Health Score**: [score]/100 ([grade])
|
||||
**Date**: YYYY-MM-DD
|
||||
**Audit ID**: COMP-YYYYMMDD-NNN
|
||||
|
||||
### Stage Results
|
||||
| Stage | Score | Issues |
|
||||
|-------|-------|--------|
|
||||
| Technical SEO | XX/100 | N issues |
|
||||
| On-Page SEO | XX/100 | N issues |
|
||||
| Core Web Vitals | XX/100 | N issues |
|
||||
| Schema | XX/100 | N issues |
|
||||
| Local SEO | XX/100 | N issues |
|
||||
| Search Console | XX/100 | N issues |
|
||||
|
||||
### Critical Findings
|
||||
1. [Finding with recommendation]
|
||||
|
||||
### Recommendations (Priority Order)
|
||||
1. [Action item]
|
||||
```
|
||||
|
||||
## MCP Tool Usage
|
||||
|
||||
### Firecrawl
|
||||
```
|
||||
mcp__firecrawl__scrape: Fetch page content for on-page and schema analysis
|
||||
```
|
||||
|
||||
### Notion
|
||||
```
|
||||
mcp__notion__*: Create audit report pages in SEO database
|
||||
```
|
||||
|
||||
### Perplexity
|
||||
```
|
||||
mcp__perplexity__search: Research best practices for recommendations
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Local SEO stage requires manual input for NAP/GBP data
|
||||
- Search Console stage requires GSC API credentials
|
||||
- Health score accuracy improves when all 6 stages are active
|
||||
103
custom-skills/12-seo-technical-audit/SKILL.md
Normal file
103
custom-skills/12-seo-technical-audit/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: seo-technical-audit
|
||||
description: |
|
||||
Technical SEO analyzer for robots.txt, sitemap, and crawlability fundamentals.
|
||||
Triggers: technical SEO, robots.txt, sitemap validation, crawlability, URL accessibility.
|
||||
---
|
||||
|
||||
# SEO Technical Audit
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyze crawlability fundamentals: robots.txt rules, XML sitemap structure, and URL accessibility. Identify issues blocking search engine crawlers.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Robots.txt Analysis** - Parse rules, check blocked resources
|
||||
2. **Sitemap Validation** - Verify XML structure, URL limits, dates
|
||||
3. **URL Accessibility** - Check HTTP status, redirects, broken links
|
||||
|
||||
## MCP Tool Usage
|
||||
|
||||
### Firecrawl for Page Data
|
||||
```
|
||||
mcp__firecrawl__scrape: Fetch robots.txt and sitemap content
|
||||
mcp__firecrawl__crawl: Check multiple URLs accessibility
|
||||
```
|
||||
|
||||
### Perplexity for Best Practices
|
||||
```
|
||||
mcp__perplexity__search: Research current SEO recommendations
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Robots.txt Check
|
||||
1. Fetch `[domain]/robots.txt` using Firecrawl
|
||||
2. Parse User-agent rules and Disallow patterns
|
||||
3. Identify blocked resources (CSS, JS, images)
|
||||
4. Check for Sitemap declarations
|
||||
5. Report critical issues
|
||||
|
||||
### 2. Sitemap Validation
|
||||
1. Locate sitemap (from robots.txt or `/sitemap.xml`)
|
||||
2. Validate XML syntax
|
||||
3. Check URL count (max 50,000)
|
||||
4. Verify lastmod date formats
|
||||
5. For sitemap index: parse child sitemaps
|
||||
|
||||
### 3. URL Accessibility Sampling
|
||||
1. Extract URLs from sitemap
|
||||
2. Sample 50-100 URLs for large sites
|
||||
3. Check HTTP status codes
|
||||
4. Identify redirects and broken links
|
||||
5. Report 4xx/5xx errors
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Technical SEO Audit: [domain]
|
||||
|
||||
### Robots.txt Analysis
|
||||
- Status: [Valid/Invalid/Missing]
|
||||
- Sitemap declared: [Yes/No]
|
||||
- Critical blocks: [List]
|
||||
|
||||
### Sitemap Validation
|
||||
- URLs found: [count]
|
||||
- Syntax: [Valid/Errors]
|
||||
- Issues: [List]
|
||||
|
||||
### URL Accessibility (sampled)
|
||||
- Checked: [count] URLs
|
||||
- Success (2xx): [count]
|
||||
- Redirects (3xx): [count]
|
||||
- Errors (4xx/5xx): [count]
|
||||
|
||||
### Recommendations
|
||||
1. [Priority fixes]
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Issue | Impact | Fix |
|
||||
|-------|--------|-----|
|
||||
| No sitemap in robots.txt | Medium | Add `Sitemap:` directive |
|
||||
| Blocking CSS/JS | High | Allow Googlebot access |
|
||||
| 404s in sitemap | High | Remove or fix URLs |
|
||||
| Missing lastmod | Low | Add dates for freshness signals |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot access password-protected sitemaps
|
||||
- Large sitemaps (10,000+ URLs) require sampling
|
||||
- Does not check render-blocking issues (use Core Web Vitals skill)
|
||||
|
||||
## 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
|
||||
|
||||
103
custom-skills/13-seo-on-page-audit/SKILL.md
Normal file
103
custom-skills/13-seo-on-page-audit/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: seo-on-page-audit
|
||||
description: |
|
||||
On-page SEO analyzer for meta tags, headings, links, images, and Open Graph.
|
||||
Triggers: on-page SEO, meta tags, title tag, heading structure, alt text.
|
||||
---
|
||||
|
||||
# SEO On-Page Audit
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyze single-page SEO elements: meta tags, heading hierarchy, internal/external links, images, and social sharing tags.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Meta Tags** - Title, description, canonical, robots
|
||||
2. **Headings** - H1-H6 structure and hierarchy
|
||||
3. **Links** - Internal, external, broken detection
|
||||
4. **Images** - Alt text, sizing, lazy loading
|
||||
5. **Social** - Open Graph, Twitter Cards
|
||||
|
||||
## MCP Tool Usage
|
||||
|
||||
```
|
||||
mcp__firecrawl__scrape: Extract page HTML and metadata
|
||||
mcp__perplexity__search: Research SEO best practices
|
||||
mcp__notion__create-page: Save audit findings
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Scrape target URL with Firecrawl
|
||||
2. Extract and analyze meta tags
|
||||
3. Map heading hierarchy
|
||||
4. Count and categorize links
|
||||
5. Check image optimization
|
||||
6. Validate Open Graph tags
|
||||
7. Generate recommendations
|
||||
|
||||
## Checklist
|
||||
|
||||
### Meta Tags
|
||||
- [ ] Title present (50-60 characters)
|
||||
- [ ] Meta description present (150-160 characters)
|
||||
- [ ] Canonical URL set
|
||||
- [ ] Robots meta allows indexing
|
||||
|
||||
### Headings
|
||||
- [ ] Single H1 tag
|
||||
- [ ] Logical hierarchy (no skips)
|
||||
- [ ] Keywords in H1
|
||||
|
||||
### Links
|
||||
- [ ] No broken internal links
|
||||
- [ ] External links use rel attributes
|
||||
- [ ] Reasonable internal link count
|
||||
|
||||
### Images
|
||||
- [ ] All images have alt text
|
||||
- [ ] Images are appropriately sized
|
||||
- [ ] Lazy loading implemented
|
||||
|
||||
### Open Graph
|
||||
- [ ] og:title present
|
||||
- [ ] og:description present
|
||||
- [ ] og:image present (1200x630)
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## On-Page Audit: [URL]
|
||||
|
||||
### Meta Tags: X/5
|
||||
| Element | Status | Value |
|
||||
|---------|--------|-------|
|
||||
|
||||
### Headings: X/5
|
||||
- H1: [text]
|
||||
- Hierarchy: Valid/Invalid
|
||||
|
||||
### Links
|
||||
- Internal: X
|
||||
- External: X
|
||||
- Broken: X
|
||||
|
||||
### Recommendations
|
||||
1. [Priority fixes]
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Single page analysis only
|
||||
- Cannot detect JavaScript-rendered content issues
|
||||
- External link status requires additional crawl
|
||||
|
||||
## 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
|
||||
|
||||
117
custom-skills/14-seo-core-web-vitals/SKILL.md
Normal file
117
custom-skills/14-seo-core-web-vitals/SKILL.md
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: seo-core-web-vitals
|
||||
description: |
|
||||
Core Web Vitals analyzer for LCP, FID, CLS, and INP optimization recommendations.
|
||||
Triggers: Core Web Vitals, page speed, LCP optimization, CLS fix, INP analysis.
|
||||
---
|
||||
|
||||
# SEO Core Web Vitals
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyze Core Web Vitals performance metrics and provide optimization recommendations.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **LCP** - Largest Contentful Paint measurement
|
||||
2. **FID/INP** - Interactivity metrics
|
||||
3. **CLS** - Cumulative Layout Shift
|
||||
4. **Recommendations** - Optimization guidance
|
||||
|
||||
## Metrics Thresholds
|
||||
|
||||
| Metric | Good | Needs Work | Poor |
|
||||
|--------|------|------------|------|
|
||||
| LCP | ≤2.5s | 2.5-4s | >4s |
|
||||
| FID | ≤100ms | 100-300ms | >300ms |
|
||||
| CLS | ≤0.1 | 0.1-0.25 | >0.25 |
|
||||
| INP | ≤200ms | 200-500ms | >500ms |
|
||||
|
||||
## Data Sources
|
||||
|
||||
### Option 1: PageSpeed Insights (Recommended)
|
||||
Use external tool and input results:
|
||||
- Visit: https://pagespeed.web.dev/
|
||||
- Enter URL, run test
|
||||
- Provide scores to skill
|
||||
|
||||
### Option 2: Research Best Practices
|
||||
```
|
||||
mcp__perplexity__search: "Core Web Vitals optimization [specific issue]"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Request PageSpeed Insights data from user
|
||||
2. Analyze provided metrics
|
||||
3. Identify failing metrics
|
||||
4. Research optimization strategies
|
||||
5. Provide prioritized recommendations
|
||||
|
||||
## Common LCP Issues
|
||||
|
||||
| Cause | Fix |
|
||||
|-------|-----|
|
||||
| Slow server response | Improve TTFB, use CDN |
|
||||
| Render-blocking resources | Defer non-critical CSS/JS |
|
||||
| Slow resource load | Preload LCP image |
|
||||
| Client-side rendering | Use SSR/SSG |
|
||||
|
||||
## Common CLS Issues
|
||||
|
||||
| Cause | Fix |
|
||||
|-------|-----|
|
||||
| Images without dimensions | Add width/height attributes |
|
||||
| Ads/embeds without space | Reserve space with CSS |
|
||||
| Web fonts causing FOIT/FOUT | Use font-display: swap |
|
||||
| Dynamic content injection | Reserve space, use transforms |
|
||||
|
||||
## Common INP Issues
|
||||
|
||||
| Cause | Fix |
|
||||
|-------|-----|
|
||||
| Long JavaScript tasks | Break up tasks, use web workers |
|
||||
| Large DOM size | Reduce DOM nodes |
|
||||
| Heavy event handlers | Debounce, optimize listeners |
|
||||
| Third-party scripts | Defer, lazy load |
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Core Web Vitals: [URL]
|
||||
|
||||
### Scores
|
||||
| Metric | Mobile | Desktop | Status |
|
||||
|--------|--------|---------|--------|
|
||||
| LCP | Xs | Xs | Good/Poor |
|
||||
| FID | Xms | Xms | Good/Poor |
|
||||
| CLS | X.XX | X.XX | Good/Poor |
|
||||
| INP | Xms | Xms | Good/Poor |
|
||||
|
||||
### Overall Score
|
||||
- Mobile: X/100
|
||||
- Desktop: X/100
|
||||
|
||||
### Priority Fixes
|
||||
1. [Highest impact recommendation]
|
||||
2. [Second priority]
|
||||
|
||||
### Detailed Recommendations
|
||||
[Per-metric optimization steps]
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires external PageSpeed Insights data
|
||||
- Lab data may differ from field data
|
||||
- Some fixes require developer implementation
|
||||
- Third-party scripts may be difficult to optimize
|
||||
|
||||
## 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
|
||||
|
||||
126
custom-skills/15-seo-search-console/SKILL.md
Normal file
126
custom-skills/15-seo-search-console/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: seo-search-console
|
||||
description: |
|
||||
Google Search Console data analyzer for performance, queries, and index coverage.
|
||||
Triggers: Search Console, GSC analysis, search performance, rankings, CTR optimization.
|
||||
---
|
||||
|
||||
# SEO Search Console
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyze Google Search Console data: search performance (queries, pages, CTR, position), sitemap status, and index coverage.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Performance Analysis** - Clicks, impressions, CTR, position
|
||||
2. **Query Analysis** - Top search queries
|
||||
3. **Page Performance** - Best/worst performing pages
|
||||
4. **Index Coverage** - Crawl and index issues
|
||||
5. **Sitemap Status** - Submission and processing
|
||||
|
||||
## Data Collection
|
||||
|
||||
### Option 1: User Provides Data
|
||||
Request GSC export from user:
|
||||
1. Go to Search Console > Performance
|
||||
2. Export data (CSV or Google Sheets)
|
||||
3. Share with assistant
|
||||
|
||||
### Option 2: User Describes Data
|
||||
User verbally provides:
|
||||
- Top queries and positions
|
||||
- CTR trends
|
||||
- Coverage issues
|
||||
|
||||
## Analysis Framework
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
| Metric | What It Measures | Good Benchmark |
|
||||
|--------|------------------|----------------|
|
||||
| Clicks | User visits from search | Trending up |
|
||||
| Impressions | Search appearances | High for target keywords |
|
||||
| CTR | Click-through rate | 2-5% average |
|
||||
| Position | Average ranking | <10 for key terms |
|
||||
|
||||
### Query Analysis
|
||||
|
||||
Identify:
|
||||
- **Winners** - High position, high CTR
|
||||
- **Opportunities** - High impressions, low CTR
|
||||
- **Quick wins** - Position 8-20, low effort to improve
|
||||
|
||||
### Page Analysis
|
||||
|
||||
Categorize:
|
||||
- **Top performers** - High clicks, good CTR
|
||||
- **Underperformers** - High impressions, low CTR
|
||||
- **Declining** - Down vs previous period
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect GSC data from user
|
||||
2. Analyze performance trends
|
||||
3. Identify top queries and pages
|
||||
4. Find optimization opportunities
|
||||
5. Check for coverage issues
|
||||
6. Provide actionable recommendations
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Search Console Analysis: [Site]
|
||||
|
||||
### Overview (Last 28 Days)
|
||||
| Metric | Value | vs Previous |
|
||||
|--------|-------|-------------|
|
||||
| Clicks | X | +X% |
|
||||
| Impressions | X | +X% |
|
||||
| CTR | X% | +X% |
|
||||
| Position | X | +X |
|
||||
|
||||
### Top Queries
|
||||
| Query | Clicks | Position | Opportunity |
|
||||
|-------|--------|----------|-------------|
|
||||
|
||||
### Top Pages
|
||||
| Page | Clicks | CTR | Status |
|
||||
|------|--------|-----|--------|
|
||||
|
||||
### Opportunities
|
||||
1. [Query with high impressions, low CTR]
|
||||
2. [Page ranking 8-20 that can improve]
|
||||
|
||||
### Issues
|
||||
- [Coverage problems]
|
||||
- [Sitemap issues]
|
||||
|
||||
### Recommendations
|
||||
1. [Priority action]
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Issue | Impact | Fix |
|
||||
|-------|--------|-----|
|
||||
| Low CTR on high-impression query | Lost traffic | Improve title/description |
|
||||
| Declining positions | Traffic loss | Update content, build links |
|
||||
| Not indexed pages | No visibility | Fix crawl issues |
|
||||
| Sitemap errors | Discovery problems | Fix sitemap XML |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires user to provide GSC data
|
||||
- API access needs service account setup
|
||||
- Data has 2-3 day delay
|
||||
- Limited to verified 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
|
||||
|
||||
100
custom-skills/16-seo-schema-validator/SKILL.md
Normal file
100
custom-skills/16-seo-schema-validator/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: seo-schema-validator
|
||||
description: |
|
||||
Validates an AUTHORED JSON-LD schema dataset (pre-deployment QA) and audits
|
||||
live structured data (post-deployment). Runs a 5-layer offline validation
|
||||
pipeline (coverage, syntax, vocabulary, Google rich-result requirements,
|
||||
business-logic/consistency) and emits a severity-ranked defect log, a gate
|
||||
decision, and a Markdown report. Fills the "16-seo-schema-validator" slot
|
||||
referenced by seo-comprehensive-audit.
|
||||
Triggers: schema validation, JSON-LD QA, structured data check, schema 검수,
|
||||
스키마 유효성 검증, 구조화 데이터 검토, rich result eligibility, schema 오류 점검.
|
||||
version: "1.0"
|
||||
author: OurDigital / D.intelligence
|
||||
environment: Code
|
||||
---
|
||||
|
||||
# SEO Schema Validator (16)
|
||||
|
||||
Quality-assure structured data at scale. Built for the kind of failure where a
|
||||
client review of hundreds of authored entries surfaces "too many errors" — by
|
||||
moving the cheap, machine-checkable errors OUT of the human review and INTO an
|
||||
automated gate that runs first.
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | When | Input | Adds |
|
||||
|------|------|-------|------|
|
||||
| **A — Dataset QA (default)** | Before deployment, while authoring/reviewing | An authored dataset: `.xlsx` / `.csv` (one row per entry, a JSON-LD column), `.jsonl`, `.json`, or a directory of `.json/.jsonld` | Layer 0 coverage vs the canonical URL list |
|
||||
| **B — Live audit** | After deployment, or feeding `seo-comprehensive-audit` | Live URLs (extract embedded JSON-LD first, then validate) | Layer 5 rendering-reality (schema present in rendered HTML, matches content) |
|
||||
|
||||
This skill's primary job is **Mode A**: catch errors before the client sees them.
|
||||
|
||||
## The 5 validation layers
|
||||
|
||||
| # | Layer | Catches | Default severity |
|
||||
|---|-------|---------|------------------|
|
||||
| L0 | Coverage | URLs with no entry; entries whose URL isn't in the inventory | P1 / P2 |
|
||||
| L1 | Syntax | invalid JSON, missing/wrong `@context`, no `@type`, encoding corruption | P0 / P1 |
|
||||
| L2 | Vocabulary | unknown type, property not valid for type, bad value formats (URL/date/lang/currency/number) | P1 / P2 |
|
||||
| L3 | Rich-result | Google **required** missing (blocks rich result); recommended absent | P0 / P2 |
|
||||
| L4 | Consistency | NAP mismatch across a property, `@id` dupes/dangling refs, swapped geo, placeholder text, duplicate descriptions | P0 / P1 |
|
||||
|
||||
Full rationale and the type→requirement matrix: `references/validation-methodology.md`.
|
||||
Severity + category codes: `references/defect-taxonomy.md`.
|
||||
Hotel page-type → schema-type map: `references/hotel-type-map.md`.
|
||||
Client-facing report + P1 decision log: `templates/client-qa-report-template.md`, `templates/decision-log.md`.
|
||||
|
||||
## Stage gates (aligned to the project lifecycle 설계→개발→테스트→안정화→런칭 후)
|
||||
|
||||
- **G1 설계** — Lock the schema spec and the page-type→type map (`hotel-type-map.md`). Approve the entry template. *DoD:* every page template has an assigned schema type and a required-property list.
|
||||
- **G2 개발** — Authors produce entries. Run the validator with `--strict`. *DoD (gate):* **zero P0**, JSON parses for 100% of entries. Entries failing this NEVER reach client review.
|
||||
- **G3 테스트** — Re-run; triage P1 in `defect_log.csv` (assign owner/status). Client reviews ONLY the clean, P0-free entries, against a defect report — not raw JSON. *DoD:* P1 triaged, decisions logged in `templates/decision-log.md`.
|
||||
- **G4 안정화** — Fix → re-run → confirm no regressions. Spot-check a sample in Google Rich Results Test (online, outside this runtime). *DoD:* P0=0, P1 accepted/closed, online validator green on sample.
|
||||
- **G5 런칭 후** — Mode B live audit + GSC "Rich results" report monitoring. *DoD:* deployed schema matches authored dataset; no new GSC structured-data errors.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
# Mode A — validate an authored dataset (the common case)
|
||||
python scripts/validate_schema.py path/to/schema_dataset.xlsx \
|
||||
--url-list path/to/URLlist.xlsx \
|
||||
--out schema_qa_out
|
||||
|
||||
# Highest signal for the pre-review gate (unexpected props -> P1, drop optional recommended)
|
||||
python scripts/validate_schema.py dataset.csv --strict --no-recommended --out qa_strict
|
||||
|
||||
# Try it on the bundled flawed fixture first
|
||||
python scripts/make_sample.py
|
||||
python scripts/validate_schema.py fixtures/sample_schema.csv --out demo_out
|
||||
```
|
||||
|
||||
**Input expectations (Mode A tabular):** the loader auto-detects a JSON-LD column
|
||||
(`jsonld`, `schema`, `structured_data`, `스키마`, …) plus optional `url`/`메뉴 URL`,
|
||||
`lang`/`언어코드`, `device`/`PC/MOBILE`, `page_type` columns. Multi-sheet `.xlsx`
|
||||
is supported (each sheet with a JSON-LD column is read). No JSON-LD column → clear error.
|
||||
|
||||
## Reading the output
|
||||
|
||||
- `report.md` — counts, **gate decision (PASS = zero P0)**, defects-by-category, top P0 entries, next step.
|
||||
- `defect_log.csv` — one row per finding with `status/owner/note` columns ready for triage. This is the client-facing artifact (open issues, not raw schema).
|
||||
- `results.json` — full machine-readable results for dashboards / CI.
|
||||
|
||||
**The rule:** an entry advances to client review only when it has **zero P0**. P1 =
|
||||
triage backlog (fix before launch). P2 = optimization backlog (recommended props, style).
|
||||
|
||||
## Limits & honesty
|
||||
|
||||
- Offline by design — the runtime can't reach schema.org or Google. The bundled
|
||||
rule set (`scripts/schema_rules.json`) is a curated hotel-focused subset; unknown
|
||||
types/properties degrade to warnings (never hard errors) to avoid false positives.
|
||||
- Authoritative rich-result eligibility still requires Google's online testers on a
|
||||
sample at G4. This skill makes that sample small and clean, not redundant.
|
||||
- Adding a new schema type or tightening a rule = edit `schema_rules.json` only.
|
||||
|
||||
## Integration
|
||||
|
||||
`seo-comprehensive-audit` calls this skill as pipeline stage 4 ("Schema Validation").
|
||||
For that orchestrator, run **Mode B** on a sample of live URLs and return the score
|
||||
(100 − weighted defect penalty) and the issue list. For day-to-day client work, run
|
||||
**Mode A** on the authored dataset.
|
||||
@@ -1,148 +1,57 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md — seo-schema-validator (Claude Code)
|
||||
|
||||
## Overview
|
||||
## Canonical entry point
|
||||
|
||||
Structured data validator: extract, parse, and validate JSON-LD, Microdata, and RDFa markup against schema.org vocabulary.
|
||||
This skill was upgraded to a **5-layer dataset-QA pipeline**. The authoritative
|
||||
directive and run instructions live in the skill root:
|
||||
|
||||
## Quick Start
|
||||
- **`../SKILL.md`** — modes, the 5 layers, stage gates, how to run.
|
||||
- **`../scripts/validate_schema.py`** — the validator (run this, not the legacy script below).
|
||||
- **`../scripts/schema_rules.json`** — the offline rule set (edit this to add a type/rule).
|
||||
- **`../references/`** — `validation-methodology.md`, `defect-taxonomy.md`, `hotel-type-map.md`.
|
||||
- **`../templates/`** — `client-qa-report-template.md`, `decision-log.md`.
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements.txt
|
||||
python scripts/schema_validator.py --url https://example.com
|
||||
# Primary use — QA an AUTHORED dataset before the client sees it (Mode A)
|
||||
python ../scripts/validate_schema.py DATASET.xlsx --url-list URLLIST.xlsx --out schema_qa_out
|
||||
|
||||
# Highest-signal pre-review gate
|
||||
python ../scripts/validate_schema.py DATASET.csv --strict --no-recommended --out qa_strict
|
||||
|
||||
# Try the bundled flawed fixture first
|
||||
python ../scripts/make_sample.py
|
||||
python ../scripts/validate_schema.py ../fixtures/sample_schema.csv --out demo_out
|
||||
|
||||
# Post-deploy live audit (Mode B) — feeds seo-comprehensive-audit stage 4
|
||||
python ../scripts/validate_schema.py --live https://example.com --out live_out
|
||||
```
|
||||
|
||||
## Scripts
|
||||
Gate rule: **PASS = zero P0.** The process exits 1 when the gate fails, so it stops
|
||||
`&&` chains and CI. Only P0-free entries advance to client review.
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `schema_validator.py` | Extract and validate structured data |
|
||||
| `base_client.py` | Shared utilities |
|
||||
## Legacy single-URL tool (kept for quick one-offs)
|
||||
|
||||
## Usage
|
||||
`scripts/schema_validator.py --url <URL>` extracts and validates structured data from
|
||||
one live page (JSON-LD / Microdata / RDFa via extruct). It predates the pipeline and is
|
||||
**not** the gate. For any dataset or client-facing QA, use `validate_schema.py` above.
|
||||
|
||||
```bash
|
||||
# Validate page schema
|
||||
python scripts/schema_validator.py --url https://example.com
|
||||
|
||||
# JSON output
|
||||
pip install -r scripts/requirements.txt # extruct, jsonschema, rdflib, lxml, requests
|
||||
python scripts/schema_validator.py --url https://example.com --json
|
||||
|
||||
# Validate local file
|
||||
python scripts/schema_validator.py --file schema.json
|
||||
|
||||
# Check Rich Results eligibility
|
||||
python scripts/schema_validator.py --url https://example.com --rich-results
|
||||
```
|
||||
|
||||
## Supported Formats
|
||||
## Notion output (OurDigital SEO Audit Log)
|
||||
|
||||
| Format | Detection |
|
||||
|--------|-----------|
|
||||
| JSON-LD | `<script type="application/ld+json">` |
|
||||
| Microdata | `itemscope`, `itemtype`, `itemprop` |
|
||||
| RDFa | `vocab`, `typeof`, `property` |
|
||||
|
||||
## Validation Levels
|
||||
|
||||
### 1. Syntax Validation
|
||||
- Valid JSON structure
|
||||
- Proper nesting
|
||||
- No syntax errors
|
||||
|
||||
### 2. Schema.org Vocabulary
|
||||
- Valid @type values
|
||||
- Known properties
|
||||
- Correct property types
|
||||
|
||||
### 3. Google Rich Results
|
||||
- Required properties present
|
||||
- Recommended properties
|
||||
- Feature-specific requirements
|
||||
|
||||
## Schema Types Validated
|
||||
|
||||
| Type | Required Properties | Rich Result |
|
||||
|------|---------------------|-------------|
|
||||
| Article | headline, author, datePublished | Yes |
|
||||
| Product | name, offers | Yes |
|
||||
| LocalBusiness | name, address | Yes |
|
||||
| FAQPage | mainEntity | Yes |
|
||||
| Organization | name, url | Yes |
|
||||
| BreadcrumbList | itemListElement | Yes |
|
||||
| WebSite | name, url | Sitelinks |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"schemas_found": 3,
|
||||
"schemas": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"valid": true,
|
||||
"rich_results_eligible": true,
|
||||
"issues": [],
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"valid": 3,
|
||||
"invalid": 0,
|
||||
"rich_results_eligible": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Issue Severity
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| Error | Invalid schema, blocks rich results |
|
||||
| Warning | Missing recommended property |
|
||||
| Info | Optimization suggestion |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
extruct>=0.16.0
|
||||
jsonschema>=4.21.0
|
||||
rdflib>=7.0.0
|
||||
lxml>=5.1.0
|
||||
requests>=2.31.0
|
||||
```
|
||||
|
||||
## Notion Output (Required)
|
||||
|
||||
**IMPORTANT**: All audit reports MUST be saved to the OurDigital SEO Audit Log database.
|
||||
|
||||
### Database Configuration
|
||||
When a run is part of an OurDigital/D.intelligence audit, log a summary to the SEO Audit
|
||||
Log database. Per the user-level Notion rule, push **page content** with the
|
||||
`notion-writer` skill; use Notion MCP only for **properties** (Status, Category, etc.).
|
||||
|
||||
| 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": {...}}'
|
||||
```
|
||||
| Category | `Schema/Structured Data` |
|
||||
| Priority | map gate: FAIL→Critical/High, PASS-with-P1→Medium, PASS-clean→Low |
|
||||
| Audit ID | `SCHEMA-YYYYMMDD-NNN` |
|
||||
|
||||
Report content in Korean; keep technical terms (Schema, JSON-LD, rich result) and
|
||||
URLs/code unchanged.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
url,언어코드,PC/MOBILE,page_type,스키마
|
||||
https://www.josunhotel.com/en/brand/grand,en,PC,brand-hub,"{""@context"": ""https://schema.org"", ""@type"": ""Organization"", ""@id"": ""https://www.josunhotel.com/#org"", ""name"": ""Josun Hotels & Resorts"", ""url"": ""https://www.josunhotel.com/"", ""logo"": ""https://www.josunhotel.com/logo.png"", ""sameAs"": [""https://www.instagram.com/josunhotelsandresorts/""]}"
|
||||
https://www.josunhotel.com/ko/grand,ko,PC,hotel,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", name: ""그랜드조선"",}"
|
||||
https://www.josunhotel.com/ko/grand/rooms,ko,MOBILE,rooms,"{""@context"": ""https://schema.org"", ""name"": ""디럭스룸"", ""url"": ""https://www.josunhotel.com/ko/grand/rooms""}"
|
||||
https://www.josunhotel.com/ko/palace,ko,PC,hotel,"{""@context"": ""https://example.org"", ""@type"": ""Hotel"", ""name"": ""조선팰리스"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""테헤란로 231"", ""addressLocality"": ""서울"", ""addressCountry"": ""KR""}}"
|
||||
https://www.josunhotel.com/ko/lescape,ko,PC,hotel,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", ""name"": ""레스케이프 호텔"", ""telephone"": ""+82-2-317-4000"", ""description"": ""조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.""}"
|
||||
https://www.josunhotel.com/ko/grand/dining,ko,PC,restaurant,"{""@context"": ""https://schema.org"", ""@type"": ""Restaurant"", ""name"": ""예시 레스토랑"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""수정필요"", ""addressCountry"": ""KR""}, ""servesCuisine"": ""Korean""}"
|
||||
https://www.josunhotel.com/ko/westin,ko,PC,hotel,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", ""name"": ""웨스틴 조선 서울"", ""telephone"": ""+82-2-771-0500"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""소공로 106"", ""addressLocality"": ""서울"", ""addressCountry"": ""KR""}, ""description"": ""조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.""}"
|
||||
https://www.josunhotel.com/en/westin,en,PC,hotel,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", ""name"": ""웨스틴 조선 서울"", ""telephone"": ""+82-2-771-9999"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""소공로 106"", ""addressLocality"": ""Seoul"", ""addressCountry"": ""KR""}, ""description"": ""조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.""}"
|
||||
https://www.josunhotel.com/ko,ko,PC,home,"{""@context"": ""https://schema.org"", ""@type"": ""WebSite"", ""name"": ""조선호텔앤리조트"", ""url"": ""https://www.josunhotel.com/"", ""publisher"": {""@id"": ""https://www.josunhotel.com/#missing-org""}}"
|
||||
https://www.josunhotel.com/ko/grand/location,ko,PC,location,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", ""name"": ""그랜드 조선 부산"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""동백로 60"", ""addressLocality"": ""부산"", ""addressCountry"": ""KR""}, ""geo"": {""@type"": ""GeoCoordinates"", ""latitude"": 129.1603, ""longitude"": 35.1586}}"
|
||||
https://www.josunhotel.com/ko/offers/spring,ko,PC,offer,"{""@context"": ""https://schema.org"", ""@type"": ""Offer"", ""price"": ""350000"", ""priceCurrency"": ""KRW"", ""validFrom"": ""2026년 3월 1일"", ""url"": ""https://www.josunhotel.com/ko/offers/spring""}"
|
||||
https://www.josunhotel.com/ko/offers/dining,ko,PC,offer,"{""@context"": ""https://schema.org"", ""@type"": ""Offer"", ""price"": ""120000"", ""priceCurrency"": ""₩"", ""availability"": ""https://schema.org/InStock""}"
|
||||
https://www.josunhotel.com/ko/spa,ko,PC,facility,"{""@context"": ""https://schema.org"", ""@type"": ""SpaResort"", ""name"": ""조선 스파""}"
|
||||
https://www.josunhotel.com/ko/grand/intro,ko,PC,hotel,"{""@context"": ""https://schema.org"", ""@type"": ""Hotel"", ""name"": ""그랜드 조선 제주"", ""address"": {""@type"": ""PostalAddress"", ""streetAddress"": ""중문관광로 75"", ""addressLocality"": ""제주"", ""addressCountry"": ""KR""}, ""description"": ""조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.""}"
|
||||
https://www.josunhotel.com/ko/faq?stale=1,ko,MOBILE,faq,"{""@context"": ""https://schema.org"", ""@type"": ""FAQPage"", ""mainEntity"": [{""@type"": ""Question"", ""name"": ""체크인 시간은 언제인가요?"", ""acceptedAnswer"": {""@type"": ""Answer"", ""text"": ""오후 3시부터 체크인 가능합니다.""}}]}"
|
||||
|
@@ -0,0 +1,74 @@
|
||||
# Defect Taxonomy
|
||||
|
||||
Every code `validate_schema.py` can emit, its default severity, and what to do.
|
||||
The validator writes these to `defect_log.csv` (columns: `entry_id, url, node_type,
|
||||
layer, code, severity, message, status, owner, note`) and `results.json`.
|
||||
|
||||
## Severity model
|
||||
|
||||
| Severity | Definition | Owner action |
|
||||
|---|---|---|
|
||||
| **P0** | Blocker — breaks parsing, blocks the rich result, or ships wrong/placeholder data. **Fails the gate.** | Must fix before the entry reaches client review. |
|
||||
| **P1** | Real defect, doesn't block the rich result. | Fix before launch; track in the triage log. |
|
||||
| **P2** | Optimization — recommended properties, formatting, orphan URLs. | Backlog; fix opportunistically. |
|
||||
|
||||
`--strict` promotes vocabulary/format warnings (and unknown types) from P2 to P1 and
|
||||
turns on `UNEXPECTED_PROPERTY`. `--no-recommended` drops `MISSING_RECOMMENDED` entirely.
|
||||
**Neither changes the gate — the gate is always "zero P0."**
|
||||
|
||||
## Code reference
|
||||
|
||||
### Layer 0 — Coverage
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `COVERAGE_MISSING` | P1 | Inventory URL has no authored entry. | Author the entry, or remove the URL from the inventory. |
|
||||
| `COVERAGE_ORPHAN` | P2 | Entry URL isn't in the inventory. | Fix the URL typo, or update the canonical list. |
|
||||
|
||||
### Layer 1 — Syntax
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `INVALID_JSON` | P0 | JSON does not parse. | Fix the JSON (trailing comma, unquoted key, smart quotes). |
|
||||
| `NO_SCHEMA_IN_HTML` | P0 | Live page has no `ld+json` block (Mode B). | Confirm the tag deployed and renders. |
|
||||
| `MISSING_CONTEXT` | P1 | No top-level `@context`. | Add `"@context": "https://schema.org"`. |
|
||||
| `WRONG_CONTEXT` | P1 | `@context` isn't schema.org. | Correct the context URL. |
|
||||
| `NO_TYPE` | P1 | No `@type` anywhere in the entry. | Add the intended `@type`. |
|
||||
| `ENCODING_CORRUPTION` | P1 | Replacement char `<60>` present. | Re-export as UTF-8; check the source pipeline. |
|
||||
| `FETCH_ERROR` | P1 | Live URL could not be fetched (Mode B). | Check the URL/network; retry. |
|
||||
|
||||
### Layer 2 — Vocabulary & value formats
|
||||
| Code | Sev (strict) | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `UNKNOWN_TYPE` | P2 (P1) | `@type` not in the curated rule set. | If intended, add it to `schema_rules.json`; else correct the type. |
|
||||
| `UNEXPECTED_PROPERTY` | — (P1) | Property unknown for a known type (**strict only**). | Remove the typo'd property, or add it to the type's `allowed`. |
|
||||
| `BAD_URL` | P2 (P1) | A URL property isn't an `http(s)` URL. | Use an absolute URL. |
|
||||
| `BAD_DATE` | P2 (P1) | A date property isn't ISO-8601. | Use `YYYY-MM-DD` (or full datetime). |
|
||||
| `BAD_LANG` | P2 (P1) | `inLanguage`/`availableLanguage` isn't a BCP-47 code. | Use `ko`, `en`, `ja`, `zh`, … |
|
||||
| `BAD_CURRENCY` | P2 (P1) | `priceCurrency` isn't a 3-letter ISO-4217 code. | Use `KRW`/`USD`, not `₩`/`$`. |
|
||||
| `BAD_NUMBER` | P2 (P1) | A numeric property isn't numeric. | Remove units/commas; keep digits. |
|
||||
|
||||
### Layer 3 — Rich-result requirements
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `MISSING_REQUIRED` | P0 | A Google-required property is absent. | Add the property — the rich result is blocked without it. |
|
||||
| `MISSING_RECOMMENDED` | P2 | Recommended properties absent (one line per node, lists all). | Add what applies to improve eligibility/appearance. |
|
||||
|
||||
### Layer 4 — Consistency
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `PLACEHOLDER_TEXT` | P0 | Boilerplate token in a string (`예시`, `수정필요`, `lorem`, `{{`, …). | Replace with real content. |
|
||||
| `NAP_PHONE_MISMATCH` | P0 | Same business, different `telephone` across entries. | Reconcile to the canonical phone. |
|
||||
| `NAP_ADDRESS_MISMATCH` | P0 | Same business, different `streetAddress`. | Reconcile to the canonical address. |
|
||||
| `DUPLICATE_ID` | P1 | One `@id` defined ≥2× with differing content. | Make definitions identical, or split the `@id`. |
|
||||
| `DANGLING_ID` | P1 | `{"@id": …}` reference to a node never defined. | Define the node, or fix the reference. |
|
||||
| `GEO_SWAPPED` | P1 | latitude/longitude transposed (swapping fixes it). | Swap the values. |
|
||||
| `GEO_OUT_OF_RANGE` | P1 | Coordinates impossible (lat∉[-90,90] or lon∉[-180,180]). | Correct the coordinates. |
|
||||
| `DUPLICATE_DESCRIPTION` | P1 | Same description reused across ≥3 entries. | Write distinct descriptions per page. |
|
||||
|
||||
## Triage workflow
|
||||
|
||||
1. Sort `defect_log.csv` by severity (already sorted P0→P1→P2 on write).
|
||||
2. **P0:** assign an owner, fix, re-run. No P0 may survive into client review.
|
||||
3. **P1:** set `owner` + `status`, decide fix-now vs accept; log accepted ones in
|
||||
`templates/decision-log.md`.
|
||||
4. **P2:** schedule into the optimization backlog.
|
||||
5. Re-run after fixes and confirm no regressions before advancing the stage gate.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Hotel Page-Type → Schema-Type Map
|
||||
|
||||
The G1 (설계) deliverable: every page template gets an assigned schema `@type` and a
|
||||
required-property list *before* anyone authors entries. Locking this map first is what
|
||||
prevents the most expensive error — authoring hundreds of entries against the wrong type.
|
||||
|
||||
This is the reusable, hotel-domain map. The worked example is JHR (Josun Hotels &
|
||||
Resorts, `josunhotel.com`) — a multi-brand, multi-language, multi-property group — but
|
||||
the mapping applies to any lodging-group site (it replaces the earlier client-specific
|
||||
draft). Adapt the brand/property layer to the client; the page-type → type rules are stable.
|
||||
|
||||
## Site shape this map assumes
|
||||
|
||||
```
|
||||
대표 허브 (group) → Organization + WebSite (one canonical node set, @id-anchored)
|
||||
브랜드 허브 (brand) → Brand / Organization + Hotel families
|
||||
개별 호텔 (property) → Hotel / LodgingBusiness / Resort
|
||||
├─ 객실 (rooms) → HotelRoom / Suite (nested or itemList)
|
||||
├─ 다이닝 (dining) → Restaurant / BarOrPub
|
||||
├─ 시설·웨딩·연회 → LocalBusiness / MeetingRoom (nested)
|
||||
├─ 프로모션 (offers) → Offer / AggregateOffer
|
||||
└─ FAQ / 안내 → FAQPage
|
||||
```
|
||||
|
||||
Each rendered URL also multiplies by **language × device** (ko/en/ja/zh × PC/MOBILE),
|
||||
which is why the entry count reaches the thousands. The schema `@type` does **not**
|
||||
change across language/device — only the localized string values do. (Use that fact:
|
||||
NAP, geo, and `@id` must stay identical across the language variants of one property;
|
||||
Layer 4 will catch it when they drift.)
|
||||
|
||||
## The map
|
||||
|
||||
| Page template (Korean / English) | Primary `@type` | Required (P0) | Add these (recommended) |
|
||||
|---|---|---|---|
|
||||
| 대표 홈 / group home | `WebSite` (+ `Organization`) | name, url / name, url | publisher, potentialAction(SearchAction), inLanguage / logo, sameAs |
|
||||
| 브랜드 허브 / brand hub | `Organization` (+ `Brand`) | name, url | logo, sameAs, brand |
|
||||
| 호텔 메인 / property home | `Hotel` (or `LodgingBusiness`, `Resort`) | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
|
||||
| 객실 목록·상세 / rooms | `Hotel` w/ `containsPlace`→`HotelRoom`/`Suite`, or `ItemList` | name, address (host) | image, occupancy, bed, amenityFeature |
|
||||
| 다이닝 / restaurant | `Restaurant` (or `BarOrPub`) | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification, acceptsReservations |
|
||||
| 부대시설·웨딩·연회 / facilities | `LocalBusiness` w/ `MeetingRoom` nested | name, address | telephone, openingHoursSpecification, image, url |
|
||||
| 프로모션·패키지 / offers | `Offer` (or `AggregateOffer`) | price, priceCurrency / lowPrice, priceCurrency | availability, url, validFrom, priceValidUntil |
|
||||
| 멤버십 / membership | `MemberProgram` | name | hasTiers, hostingOrganization, url |
|
||||
| 위치·오시는 길 / location | `Hotel` w/ `geo`→`GeoCoordinates` | name, address | geo (lat/long), hasMap |
|
||||
| FAQ / 자주 묻는 질문 | `FAQPage` → `Question`/`Answer` | mainEntity / name, acceptedAnswer / text | — |
|
||||
| 공지·매거진·기사 / article | `Article` / `NewsArticle` / `BlogPosting` | headline | author, datePublished, image, dateModified, publisher |
|
||||
| 모든 하위 페이지 / breadcrumbs | `BreadcrumbList` → `ListItem` | itemListElement / position | item, name |
|
||||
|
||||
## Conventions that keep Layer 4 green
|
||||
|
||||
- **Anchor shared nodes by `@id`.** Define `Organization` and `WebSite` once
|
||||
(`https://…/#organization`, `https://…/#website`) and reference them everywhere with
|
||||
`{"@id": "…"}`. Avoids `DANGLING_ID` (define before you reference) and `DUPLICATE_ID`
|
||||
(don't redefine with different content).
|
||||
- **One canonical NAP per property.** The same `telephone` and `streetAddress` must
|
||||
appear in every language/device variant of a property, or `NAP_*_MISMATCH` (P0) fires.
|
||||
- **Distinct descriptions.** A reused boilerplate description across ≥3 pages →
|
||||
`DUPLICATE_DESCRIPTION` (P1). Write per-page copy.
|
||||
- **geo as `{latitude, longitude}`** in decimal degrees; Korea is lat ≈ 33–39, lon ≈
|
||||
124–132. Transposing them trips `GEO_SWAPPED`.
|
||||
- **No placeholders.** `예시 / 수정필요 / 임시 / lorem / {{…}}` anywhere → `PLACEHOLDER_TEXT`
|
||||
(P0). The gate exists precisely to stop these from reaching the client.
|
||||
|
||||
## Using the map in the lifecycle
|
||||
|
||||
- **G1 설계:** fill this table for the client's actual templates; that *is* the schema
|
||||
spec. DoD: every template has a type + required list.
|
||||
- **G2 개발:** authors produce entries against it; `--strict` run; zero P0 to advance.
|
||||
- **G3+:** the map is the reference reviewers and the validator agree on.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Validation Methodology
|
||||
|
||||
The reasoning behind the 5 layers, and the type → requirement matrix the validator
|
||||
enforces. The matrix is the human-readable mirror of `scripts/schema_rules.json` —
|
||||
if you change one, change the other.
|
||||
|
||||
## Why a machine gate before human review
|
||||
|
||||
At a few dozen entries, a person can eyeball JSON-LD. At hundreds (a multi-language,
|
||||
multi-device, multi-property hotel site easily reaches 2,000+ URLs), eyeballing
|
||||
fails in a predictable way: the reviewer drowns in *mechanical* errors (a missing
|
||||
required field, a bad date format, a typo'd URL) and never reaches the *judgement*
|
||||
errors that actually need a human (is this the right schema type for this page? is
|
||||
this description accurate?).
|
||||
|
||||
The fix is not "review harder." It is to split the work by who is best at it:
|
||||
|
||||
| Error class | Best checker | This skill |
|
||||
|---|---|---|
|
||||
| Mechanical (parse, required-present, value format, duplicate, consistency) | A script, every time | Layers 0–4, automated |
|
||||
| Judgement (type choice, copy accuracy, intent) | A human, once | Client reviews only P0-free entries |
|
||||
|
||||
So the gate runs first. **An entry reaches client review only when it has zero P0.**
|
||||
The client then reviews a clean set against a defect report — never raw JSON in a meeting.
|
||||
|
||||
## The layers, in order
|
||||
|
||||
Each layer assumes the previous one passed for that entry. A fatal L1 failure
|
||||
(unparseable JSON, no `@type`) stops deeper layers for that entry — there is nothing
|
||||
to inspect.
|
||||
|
||||
### L0 — Coverage (needs `--url-list`)
|
||||
Compares the canonical URL inventory against the URLs that actually have an entry.
|
||||
- `COVERAGE_MISSING` (P1): inventory URL with no authored entry — a gap to fill.
|
||||
- `COVERAGE_ORPHAN` (P2): entry whose URL isn't in the inventory — a typo, a stale
|
||||
path, or a list that's out of date. (Expect many orphans if your inventory is a
|
||||
subset; expect ~zero when it's the real canonical list.)
|
||||
|
||||
### L1 — Syntax
|
||||
The cheapest, hardest blockers. If these fail, nothing downstream is trustworthy.
|
||||
- `INVALID_JSON` (P0), `NO_SCHEMA_IN_HTML` (P0, live mode).
|
||||
- `MISSING_CONTEXT` / `WRONG_CONTEXT` / `NO_TYPE` / `ENCODING_CORRUPTION` (P1).
|
||||
|
||||
### L2 — Vocabulary & value formats
|
||||
Is the type known, and are values well-formed?
|
||||
- `UNKNOWN_TYPE` (P2; P1 in `--strict`): type isn't in the curated rule set. A
|
||||
*warning*, not an error — add it to `schema_rules.json` if it's intended.
|
||||
- `BAD_URL` / `BAD_DATE` / `BAD_LANG` / `BAD_CURRENCY` / `BAD_NUMBER` (P2; P1 strict).
|
||||
- `UNEXPECTED_PROPERTY` (P1, `--strict` only): a property not known for a known type.
|
||||
**Off by default** — flagging every unexpected property offline produces exactly the
|
||||
false-positive flood that makes reviewers distrust the tool.
|
||||
|
||||
### L3 — Rich-result requirements
|
||||
The contract Google enforces for eligibility.
|
||||
- `MISSING_REQUIRED` (P0): a required property is absent → the rich result is blocked.
|
||||
- `MISSING_RECOMMENDED` (P2): recommended properties absent. **Aggregated to one line
|
||||
per node** (never one defect per property) — this is the single most important
|
||||
noise-control decision in the tool.
|
||||
|
||||
### L4 — Consistency (cross-node / cross-entry)
|
||||
The errors a per-entry check can't see.
|
||||
- `PLACEHOLDER_TEXT` (P0): boilerplate that escaped authoring (`예시`, `수정필요`,
|
||||
`lorem`, `{{`, …). Almost always a real, embarrassing leak.
|
||||
- `NAP_PHONE_MISMATCH` / `NAP_ADDRESS_MISMATCH` (P0): the same business shows
|
||||
different Name/Address/Phone across entries — a local-SEO and trust problem.
|
||||
- `DUPLICATE_ID` (P1): one `@id` defined twice with different content.
|
||||
- `DANGLING_ID` (P1): a `{"@id": …}` reference points at a node never defined.
|
||||
- `GEO_SWAPPED` / `GEO_OUT_OF_RANGE` (P1): latitude/longitude transposed or impossible.
|
||||
- `DUPLICATE_DESCRIPTION` (P1): the same description reused across ≥3 entries.
|
||||
|
||||
## Severity → gate
|
||||
|
||||
| Severity | Meaning | Gate effect |
|
||||
|---|---|---|
|
||||
| **P0** | Blocker. Breaks parsing, blocks the rich result, or publishes wrong data. | **Fails the gate.** Process exits 1. Entry must not reach client review. |
|
||||
| **P1** | Fix before launch. Real defect, doesn't block the rich result. | Triage backlog. |
|
||||
| **P2** | Optimization. Recommended props, style, orphan URLs. | Optimization backlog. |
|
||||
|
||||
Full code list: `defect-taxonomy.md`.
|
||||
|
||||
## Type → requirement matrix (mirror of `schema_rules.json`)
|
||||
|
||||
`required` missing → **P0**. `recommended` missing → **P2** (aggregated). Anything in
|
||||
`allowed` is accepted silently. Properties outside all three are flagged only in `--strict`.
|
||||
|
||||
| Type | Required (P0 if missing) | Recommended (P2 if missing) |
|
||||
|---|---|---|
|
||||
| Organization | name, url | logo, sameAs, contactPoint, address |
|
||||
| WebSite | name, url | publisher, potentialAction, inLanguage |
|
||||
| WebPage | name | url, isPartOf, primaryImageOfPage, breadcrumb, datePublished, dateModified |
|
||||
| Hotel / LodgingBusiness / Resort | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
|
||||
| LocalBusiness | name, address | telephone, openingHoursSpecification, geo, image, url, priceRange, aggregateRating |
|
||||
| Restaurant / FoodEstablishment | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification |
|
||||
| FAQPage | mainEntity | — |
|
||||
| Question | name, acceptedAnswer | — |
|
||||
| Answer | text | — |
|
||||
| BreadcrumbList / ItemList | itemListElement | — |
|
||||
| ListItem | position | item, name |
|
||||
| Product | name | image, offers, brand, aggregateRating, review, description, sku |
|
||||
| Offer | price, priceCurrency | availability, url, validFrom, priceValidUntil |
|
||||
| Article / NewsArticle / BlogPosting | headline | author, datePublished, image, dateModified, publisher |
|
||||
| Event | name, startDate, location | endDate, offers, performer, image, eventStatus, eventAttendanceMode, organizer |
|
||||
| Review | reviewRating, author | datePublished, reviewBody, itemReviewed |
|
||||
| AggregateRating | ratingValue | reviewCount, ratingCount, bestRating |
|
||||
| MemberProgram | name | hasTiers, hostingOrganization, url |
|
||||
|
||||
**Container types** (validated for value formats, but *not* for required/recommended,
|
||||
because they only ever appear nested): PostalAddress, GeoCoordinates, ImageObject,
|
||||
ContactPoint, OpeningHoursSpecification, Rating, Brand, EntryPoint, Place, OfferCatalog,
|
||||
ReserveAction, MeetingRoom, Room/HotelRoom/Suite, MemberProgramTier, Menu/MenuItem, … (full
|
||||
list in `schema_rules.json` → `container_types`).
|
||||
|
||||
## Extending the rules
|
||||
|
||||
Add a type, tighten a requirement, or recognize a new container by editing
|
||||
`scripts/schema_rules.json` **only** — no Python change needed:
|
||||
- New rich-result type → add to `known_types` with `required` / `recommended` / `allowed`.
|
||||
- New nested type to stop "unknown type" warnings → add to `container_types`.
|
||||
- New value-format property → add to the relevant `value_formats` group.
|
||||
- New placeholder token to catch → add to `placeholder_tokens`.
|
||||
|
||||
After any edit, re-run `make_sample.py` + `validate_schema.py` against the fixture to
|
||||
confirm you didn't regress.
|
||||
160
custom-skills/16-seo-schema-validator/scripts/make_sample.py
Normal file
160
custom-skills/16-seo-schema-validator/scripts/make_sample.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_sample.py — generate fixtures/sample_schema.csv.
|
||||
|
||||
A small, deliberately FLAWED hotel dataset (Josun-style, fictional values) that
|
||||
seeds at least one defect per validation layer. Use it to learn the tool and to
|
||||
regression-test changes to validate_schema.py or schema_rules.json:
|
||||
|
||||
python make_sample.py
|
||||
python validate_schema.py ../fixtures/sample_schema.csv --out /tmp/demo_out
|
||||
|
||||
Each row's comment names the defect(s) it is designed to trigger.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent / "fixtures" / "sample_schema.csv"
|
||||
|
||||
CTX = "https://schema.org"
|
||||
SHARED_DESC = ("조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 "
|
||||
"제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.") # >30 chars, reused 3x
|
||||
|
||||
|
||||
def jd(obj):
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
# Each tuple: (url, lang, device, page_type, jsonld_string)
|
||||
ROWS = []
|
||||
|
||||
# 1) CLEAN Organization — only a recommended gap (P2 MISSING_RECOMMENDED, aggregated)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/en/brand/grand", "en", "PC", "brand-hub",
|
||||
jd({"@context": CTX, "@type": "Organization", "@id": "https://www.josunhotel.com/#org",
|
||||
"name": "Josun Hotels & Resorts", "url": "https://www.josunhotel.com/",
|
||||
"logo": "https://www.josunhotel.com/logo.png",
|
||||
"sameAs": ["https://www.instagram.com/josunhotelsandresorts/"]}),
|
||||
))
|
||||
|
||||
# 2) INVALID JSON (P0 INVALID_JSON) — trailing comma, unquoted key
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/grand", "ko", "PC", "hotel",
|
||||
'{"@context": "https://schema.org", "@type": "Hotel", name: "그랜드조선",}',
|
||||
))
|
||||
|
||||
# 3) MISSING @type (P1 NO_TYPE)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/grand/rooms", "ko", "MOBILE", "rooms",
|
||||
jd({"@context": CTX, "name": "디럭스룸", "url": "https://www.josunhotel.com/ko/grand/rooms"}),
|
||||
))
|
||||
|
||||
# 4) WRONG @context (P1 WRONG_CONTEXT)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/palace", "ko", "PC", "hotel",
|
||||
jd({"@context": "https://example.org", "@type": "Hotel", "name": "조선팰리스",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "테헤란로 231",
|
||||
"addressLocality": "서울", "addressCountry": "KR"}}),
|
||||
))
|
||||
|
||||
# 5) Hotel MISSING REQUIRED address (P0 MISSING_REQUIRED)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/lescape", "ko", "PC", "hotel",
|
||||
jd({"@context": CTX, "@type": "Hotel", "name": "레스케이프 호텔",
|
||||
"telephone": "+82-2-317-4000", "description": SHARED_DESC}),
|
||||
))
|
||||
|
||||
# 6) PLACEHOLDER text (P0 PLACEHOLDER_TEXT)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/grand/dining", "ko", "PC", "restaurant",
|
||||
jd({"@context": CTX, "@type": "Restaurant", "name": "예시 레스토랑",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "수정필요",
|
||||
"addressCountry": "KR"}, "servesCuisine": "Korean"}),
|
||||
))
|
||||
|
||||
# 7a + 7b) NAP PHONE MISMATCH (P0 NAP_PHONE_MISMATCH) — same business, two phones
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/westin", "ko", "PC", "hotel",
|
||||
jd({"@context": CTX, "@type": "Hotel", "name": "웨스틴 조선 서울",
|
||||
"telephone": "+82-2-771-0500",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "소공로 106",
|
||||
"addressLocality": "서울", "addressCountry": "KR"},
|
||||
"description": SHARED_DESC}),
|
||||
))
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/en/westin", "en", "PC", "hotel",
|
||||
jd({"@context": CTX, "@type": "Hotel", "name": "웨스틴 조선 서울",
|
||||
"telephone": "+82-2-771-9999",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "소공로 106",
|
||||
"addressLocality": "Seoul", "addressCountry": "KR"},
|
||||
"description": SHARED_DESC}),
|
||||
))
|
||||
|
||||
# 8) DANGLING @id reference (P1 DANGLING_ID) — publisher points at undefined node
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko", "ko", "PC", "home",
|
||||
jd({"@context": CTX, "@type": "WebSite", "name": "조선호텔앤리조트",
|
||||
"url": "https://www.josunhotel.com/",
|
||||
"publisher": {"@id": "https://www.josunhotel.com/#missing-org"}}),
|
||||
))
|
||||
|
||||
# 9) SWAPPED geo (P1 GEO_SWAPPED) — lat/long transposed for Seoul
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/grand/location", "ko", "PC", "location",
|
||||
jd({"@context": CTX, "@type": "Hotel", "name": "그랜드 조선 부산",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "동백로 60",
|
||||
"addressLocality": "부산", "addressCountry": "KR"},
|
||||
"geo": {"@type": "GeoCoordinates", "latitude": 129.1603, "longitude": 35.1586}}),
|
||||
))
|
||||
|
||||
# 10) BAD date (P2 BAD_DATE) in an Offer-bearing page
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/offers/spring", "ko", "PC", "offer",
|
||||
jd({"@context": CTX, "@type": "Offer", "price": "350000", "priceCurrency": "KRW",
|
||||
"validFrom": "2026년 3월 1일", "url": "https://www.josunhotel.com/ko/offers/spring"}),
|
||||
))
|
||||
|
||||
# 11) BAD currency symbol (P2 BAD_CURRENCY)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/offers/dining", "ko", "PC", "offer",
|
||||
jd({"@context": CTX, "@type": "Offer", "price": "120000", "priceCurrency": "₩",
|
||||
"availability": "https://schema.org/InStock"}),
|
||||
))
|
||||
|
||||
# 12) UNKNOWN type (P2 UNKNOWN_TYPE)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/spa", "ko", "PC", "facility",
|
||||
jd({"@context": CTX, "@type": "SpaResort", "name": "조선 스파"}),
|
||||
))
|
||||
|
||||
# 13) Third reuse of SHARED_DESC → triggers DUPLICATE_DESCRIPTION (P1) across rows 5,7a,7b,13
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/grand/intro", "ko", "PC", "hotel",
|
||||
jd({"@context": CTX, "@type": "Hotel", "name": "그랜드 조선 제주",
|
||||
"address": {"@type": "PostalAddress", "streetAddress": "중문관광로 75",
|
||||
"addressLocality": "제주", "addressCountry": "KR"},
|
||||
"description": SHARED_DESC}),
|
||||
))
|
||||
|
||||
# 14) CLEAN FAQPage — exercises a passing entry (and an inventory-orphan URL for L0 demo)
|
||||
ROWS.append((
|
||||
"https://www.josunhotel.com/ko/faq?stale=1", "ko", "MOBILE", "faq",
|
||||
jd({"@context": CTX, "@type": "FAQPage", "mainEntity": [
|
||||
{"@type": "Question", "name": "체크인 시간은 언제인가요?",
|
||||
"acceptedAnswer": {"@type": "Answer", "text": "오후 3시부터 체크인 가능합니다."}}]}),
|
||||
))
|
||||
|
||||
|
||||
def main():
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(OUT, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["url", "언어코드", "PC/MOBILE", "page_type", "스키마"]) # Korean aliases on purpose
|
||||
w.writerows(ROWS)
|
||||
print(f"Wrote {len(ROWS)} entries → {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
# validate_schema.py runs on the Python standard library alone for
|
||||
# CSV / JSON / JSONL / directory inputs (the offline default).
|
||||
#
|
||||
# Optional extras, installed only when you need them:
|
||||
openpyxl>=3.1 # required to read .xlsx datasets and .xlsx URL inventories
|
||||
requests>=2.31 # required only for --live (Mode B) URL fetching
|
||||
1066
custom-skills/16-seo-schema-validator/scripts/schema_rules.json
Normal file
1066
custom-skills/16-seo-schema-validator/scripts/schema_rules.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"_meta": {
|
||||
"version": "1.0",
|
||||
"scope": "Curated, hotel-focused subset of schema.org + Google rich-result requirements.",
|
||||
"intent": "Self-contained offline rules (the runtime cannot reach schema.org or Google). Unknown types/properties degrade to warnings, never hard errors, to avoid false positives. To support a new type or tighten a rule, edit THIS file only.",
|
||||
"sources": "schema.org/Hotel, schema.org/LocalBusiness, Google Search Central 'Structured data' rich-result docs (as of 2025)."
|
||||
},
|
||||
|
||||
"valid_contexts": [
|
||||
"https://schema.org",
|
||||
"http://schema.org",
|
||||
"https://schema.org/",
|
||||
"http://schema.org/",
|
||||
"https://www.schema.org",
|
||||
"http://www.schema.org"
|
||||
],
|
||||
|
||||
"global_properties": [
|
||||
"@context", "@type", "@id", "@graph", "@reverse",
|
||||
"name", "alternateName", "legalName", "description", "disambiguatingDescription",
|
||||
"url", "image", "logo", "sameAs", "identifier", "mainEntityOfPage",
|
||||
"additionalType", "subjectOf", "potentialAction", "inLanguage"
|
||||
],
|
||||
|
||||
"known_types": {
|
||||
"Organization": {
|
||||
"required": ["name", "url"],
|
||||
"recommended": ["logo", "sameAs", "contactPoint", "address"],
|
||||
"allowed": ["legalName", "foundingDate", "parentOrganization", "subOrganization", "brand", "telephone", "email", "founder", "numberOfEmployees", "memberOf", "hasMerchantReturnPolicy", "member"]
|
||||
},
|
||||
"Corporation": {
|
||||
"required": ["name", "url"],
|
||||
"recommended": ["logo", "sameAs", "address"],
|
||||
"allowed": ["legalName", "foundingDate", "parentOrganization", "tickerSymbol", "telephone", "email", "brand"]
|
||||
},
|
||||
"WebSite": {
|
||||
"required": ["name", "url"],
|
||||
"recommended": ["publisher", "potentialAction", "inLanguage"],
|
||||
"allowed": ["alternateName", "about", "copyrightHolder", "copyrightYear"]
|
||||
},
|
||||
"WebPage": {
|
||||
"required": ["name"],
|
||||
"recommended": ["url", "isPartOf", "primaryImageOfPage", "breadcrumb", "datePublished", "dateModified"],
|
||||
"allowed": ["about", "mentions", "speakable", "lastReviewed", "reviewedBy", "significantLink"]
|
||||
},
|
||||
"LocalBusiness": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["telephone", "openingHoursSpecification", "geo", "image", "url", "priceRange", "aggregateRating"],
|
||||
"allowed": ["email", "openingHours", "paymentAccepted", "currenciesAccepted", "areaServed", "hasMap", "department", "menu", "review", "containedInPlace", "containsPlace", "amenityFeature"]
|
||||
},
|
||||
"Hotel": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating", "checkinTime", "checkoutTime"],
|
||||
"allowed": ["email", "amenityFeature", "petsAllowed", "numberOfRooms", "availableLanguage", "containedInPlace", "containsPlace", "makesOffer", "brand", "currenciesAccepted", "smokingAllowed", "openingHoursSpecification", "audience", "review"]
|
||||
},
|
||||
"LodgingBusiness": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating", "checkinTime", "checkoutTime"],
|
||||
"allowed": ["email", "amenityFeature", "petsAllowed", "numberOfRooms", "availableLanguage", "containedInPlace", "containsPlace", "makesOffer", "currenciesAccepted", "smokingAllowed"]
|
||||
},
|
||||
"Resort": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["telephone", "image", "priceRange", "geo", "url", "starRating", "aggregateRating"],
|
||||
"allowed": ["email", "amenityFeature", "numberOfRooms", "containedInPlace", "containsPlace", "checkinTime", "checkoutTime"]
|
||||
},
|
||||
"Restaurant": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["servesCuisine", "priceRange", "telephone", "menu", "openingHoursSpecification", "image", "url", "geo", "acceptsReservations"],
|
||||
"allowed": ["email", "hasMenu", "starRating", "aggregateRating", "review", "containedInPlace", "smokingAllowed"]
|
||||
},
|
||||
"FoodEstablishment": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["servesCuisine", "priceRange", "telephone", "menu", "openingHoursSpecification"],
|
||||
"allowed": ["email", "hasMenu", "acceptsReservations", "containedInPlace"]
|
||||
},
|
||||
"BarOrPub": {
|
||||
"required": ["name", "address"],
|
||||
"recommended": ["telephone", "openingHoursSpecification", "priceRange", "servesCuisine"],
|
||||
"allowed": ["menu", "hasMenu", "image", "url"]
|
||||
},
|
||||
"FAQPage": {
|
||||
"required": ["mainEntity"],
|
||||
"recommended": [],
|
||||
"allowed": ["about", "headline", "datePublished", "dateModified"]
|
||||
},
|
||||
"Question": {
|
||||
"required": ["name", "acceptedAnswer"],
|
||||
"recommended": [],
|
||||
"allowed": ["text", "answerCount", "suggestedAnswer", "upvoteCount", "author"]
|
||||
},
|
||||
"Answer": {
|
||||
"required": ["text"],
|
||||
"recommended": [],
|
||||
"allowed": ["url", "upvoteCount", "author", "dateCreated"]
|
||||
},
|
||||
"BreadcrumbList": {
|
||||
"required": ["itemListElement"],
|
||||
"recommended": [],
|
||||
"allowed": ["numberOfItems", "itemListOrder"]
|
||||
},
|
||||
"ItemList": {
|
||||
"required": ["itemListElement"],
|
||||
"recommended": [],
|
||||
"allowed": ["numberOfItems", "itemListOrder"]
|
||||
},
|
||||
"ListItem": {
|
||||
"required": ["position"],
|
||||
"recommended": ["item", "name"],
|
||||
"allowed": ["url", "image", "nextItem", "previousItem"]
|
||||
},
|
||||
"Product": {
|
||||
"required": ["name"],
|
||||
"recommended": ["image", "offers", "brand", "aggregateRating", "review", "description", "sku"],
|
||||
"allowed": ["gtin", "gtin13", "gtin8", "gtin12", "mpn", "color", "material", "category", "audience", "isVariantOf", "additionalProperty", "hasMerchantReturnPolicy"]
|
||||
},
|
||||
"Offer": {
|
||||
"required": ["price", "priceCurrency"],
|
||||
"recommended": ["availability", "url", "validFrom", "priceValidUntil"],
|
||||
"allowed": ["itemCondition", "seller", "eligibleRegion", "priceSpecification", "shippingDetails", "availabilityStarts"]
|
||||
},
|
||||
"AggregateOffer": {
|
||||
"required": ["lowPrice", "priceCurrency"],
|
||||
"recommended": ["highPrice", "offerCount"],
|
||||
"allowed": ["offers", "availability"]
|
||||
},
|
||||
"Article": {
|
||||
"required": ["headline"],
|
||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
||||
"allowed": ["articleBody", "articleSection", "wordCount", "keywords", "speakable"]
|
||||
},
|
||||
"NewsArticle": {
|
||||
"required": ["headline"],
|
||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
||||
"allowed": ["articleBody", "dateline", "printSection"]
|
||||
},
|
||||
"BlogPosting": {
|
||||
"required": ["headline"],
|
||||
"recommended": ["author", "datePublished", "image", "dateModified", "publisher"],
|
||||
"allowed": ["articleBody", "keywords", "wordCount"]
|
||||
},
|
||||
"Event": {
|
||||
"required": ["name", "startDate", "location"],
|
||||
"recommended": ["endDate", "offers", "performer", "image", "eventStatus", "eventAttendanceMode", "organizer"],
|
||||
"allowed": ["doorTime", "previousStartDate", "typicalAgeRange", "maximumAttendeeCapacity"]
|
||||
},
|
||||
"Review": {
|
||||
"required": ["reviewRating", "author"],
|
||||
"recommended": ["datePublished", "reviewBody", "itemReviewed"],
|
||||
"allowed": ["publisher", "name"]
|
||||
},
|
||||
"AggregateRating": {
|
||||
"required": ["ratingValue"],
|
||||
"recommended": ["reviewCount", "ratingCount", "bestRating"],
|
||||
"allowed": ["worstRating", "itemReviewed"]
|
||||
},
|
||||
"MemberProgram": {
|
||||
"required": ["name"],
|
||||
"recommended": ["hasTiers", "hostingOrganization", "url"],
|
||||
"allowed": ["description", "membershipPointsEarned"]
|
||||
}
|
||||
},
|
||||
|
||||
"container_types": [
|
||||
"PostalAddress", "GeoCoordinates", "GeoShape", "ImageObject", "VideoObject",
|
||||
"ContactPoint", "OpeningHoursSpecification", "Rating", "QuantitativeValue",
|
||||
"MonetaryAmount", "PriceSpecification", "Brand", "EntryPoint", "Place",
|
||||
"OfferCatalog", "ReserveAction", "OrderAction", "SearchAction", "ViewAction",
|
||||
"MeetingRoom", "Room", "HotelRoom", "Suite", "LocationFeatureSpecification",
|
||||
"MemberProgramTier", "MobileApplication", "WebApplication", "SoftwareApplication",
|
||||
"Menu", "MenuItem", "MenuSection", "Country", "AdministrativeArea", "Duration",
|
||||
"PropertyValue", "Person", "Audience", "Language"
|
||||
],
|
||||
|
||||
"value_formats": {
|
||||
"url_props": ["url", "logo", "sameAs", "image", "contentUrl", "thumbnailUrl", "target", "urlTemplate", "installUrl", "menu", "hasMap", "downloadUrl", "embedUrl"],
|
||||
"date_props": ["datePublished", "dateModified", "dateCreated", "startDate", "endDate", "validFrom", "validThrough", "priceValidUntil", "foundingDate", "uploadDate", "availabilityStarts", "availabilityEnds", "lastReviewed", "previousStartDate"],
|
||||
"lang_props": ["inLanguage", "availableLanguage"],
|
||||
"currency_props": ["priceCurrency", "currenciesAccepted"],
|
||||
"number_props": ["price", "lowPrice", "highPrice", "ratingValue", "reviewCount", "ratingCount", "bestRating", "worstRating", "position", "numberOfRooms", "maxValue", "minValue", "offerCount"]
|
||||
},
|
||||
|
||||
"valid_currencies": ["KRW", "USD", "EUR", "JPY", "CNY", "GBP", "HKD", "SGD", "THB", "AUD", "CAD", "CHF", "TWD", "MYR", "PHP", "VND", "IDR", "INR"],
|
||||
|
||||
"valid_language_codes": ["ko", "en", "ja", "zh", "zh-CN", "zh-TW", "zh-Hans", "zh-Hant", "ko-KR", "en-US", "en-GB", "ja-JP", "fr", "de", "es", "ru", "th", "vi", "id", "ms"],
|
||||
|
||||
"placeholder_tokens": [
|
||||
"lorem ipsum", "lorem", "ipsum", "dolor sit", "todo", "tbd", "fixme",
|
||||
"xxx", "yyy", "zzz", "placeholder", "insert here", "insert text",
|
||||
"example.com", "your-domain", "yourdomain", "changeme", "sample text",
|
||||
"{{", "}}", "<insert", "[insert", "n/a", "샘플", "예시", "여기에",
|
||||
"변경필요", "수정필요", "입력필요", "내용입력", "테스트", "임시"
|
||||
],
|
||||
|
||||
"geo": {
|
||||
"lat_min": -90.0, "lat_max": 90.0,
|
||||
"lon_min": -180.0, "lon_max": 180.0,
|
||||
"kr_lat_range": [33.0, 39.0],
|
||||
"kr_lon_range": [124.0, 132.0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
{
|
||||
"_meta": {
|
||||
"version": "1.1",
|
||||
"scope": "Curated, hotel-focused subset of schema.org + Google rich-result requirements.",
|
||||
"intent": "Self-contained offline rules (the runtime cannot reach schema.org or Google). Unknown types/properties degrade to warnings, never hard errors, to avoid false positives. To support a new type or tighten a rule, edit THIS file only. [v1.1 2026-05-29: +EventVenue/ExerciseGym/SportsActivityLocation/HealthAndBeautyBusiness/DaySpa/CafeOrCoffeeShop, +LodgingReservation container, Organization/Corporation url->recommended per Google, +slogan/founder/ceo/parentOrganization/award/hasOfferCatalog/openingDate/isPartOf allowances.]",
|
||||
"sources": "schema.org/Hotel, schema.org/LocalBusiness, Google Search Central 'Structured data' rich-result docs (as of 2025)."
|
||||
},
|
||||
"valid_contexts": [
|
||||
"https://schema.org",
|
||||
"http://schema.org",
|
||||
"https://schema.org/",
|
||||
"http://schema.org/",
|
||||
"https://www.schema.org",
|
||||
"http://www.schema.org"
|
||||
],
|
||||
"global_properties": [
|
||||
"@context",
|
||||
"@type",
|
||||
"@id",
|
||||
"@graph",
|
||||
"@reverse",
|
||||
"name",
|
||||
"alternateName",
|
||||
"legalName",
|
||||
"description",
|
||||
"disambiguatingDescription",
|
||||
"url",
|
||||
"image",
|
||||
"logo",
|
||||
"sameAs",
|
||||
"identifier",
|
||||
"mainEntityOfPage",
|
||||
"additionalType",
|
||||
"subjectOf",
|
||||
"potentialAction",
|
||||
"inLanguage",
|
||||
"isPartOf"
|
||||
],
|
||||
"known_types": {
|
||||
"Organization": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"logo",
|
||||
"sameAs",
|
||||
"contactPoint",
|
||||
"address",
|
||||
"url"
|
||||
],
|
||||
"allowed": [
|
||||
"legalName",
|
||||
"foundingDate",
|
||||
"parentOrganization",
|
||||
"subOrganization",
|
||||
"brand",
|
||||
"telephone",
|
||||
"email",
|
||||
"founder",
|
||||
"numberOfEmployees",
|
||||
"memberOf",
|
||||
"hasMerchantReturnPolicy",
|
||||
"member",
|
||||
"slogan",
|
||||
"hasOfferCatalog"
|
||||
]
|
||||
},
|
||||
"Corporation": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"logo",
|
||||
"sameAs",
|
||||
"address",
|
||||
"url"
|
||||
],
|
||||
"allowed": [
|
||||
"legalName",
|
||||
"foundingDate",
|
||||
"parentOrganization",
|
||||
"tickerSymbol",
|
||||
"telephone",
|
||||
"email",
|
||||
"brand",
|
||||
"founder",
|
||||
"ceo",
|
||||
"subOrganization",
|
||||
"memberOf",
|
||||
"slogan",
|
||||
"hasOfferCatalog"
|
||||
]
|
||||
},
|
||||
"WebSite": {
|
||||
"required": [
|
||||
"name",
|
||||
"url"
|
||||
],
|
||||
"recommended": [
|
||||
"publisher",
|
||||
"potentialAction",
|
||||
"inLanguage"
|
||||
],
|
||||
"allowed": [
|
||||
"alternateName",
|
||||
"about",
|
||||
"copyrightHolder",
|
||||
"copyrightYear"
|
||||
]
|
||||
},
|
||||
"WebPage": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"isPartOf",
|
||||
"primaryImageOfPage",
|
||||
"breadcrumb",
|
||||
"datePublished",
|
||||
"dateModified"
|
||||
],
|
||||
"allowed": [
|
||||
"about",
|
||||
"mentions",
|
||||
"speakable",
|
||||
"lastReviewed",
|
||||
"reviewedBy",
|
||||
"significantLink"
|
||||
]
|
||||
},
|
||||
"LocalBusiness": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"telephone",
|
||||
"openingHoursSpecification",
|
||||
"geo",
|
||||
"image",
|
||||
"url",
|
||||
"priceRange",
|
||||
"aggregateRating"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"openingHours",
|
||||
"paymentAccepted",
|
||||
"currenciesAccepted",
|
||||
"areaServed",
|
||||
"hasMap",
|
||||
"department",
|
||||
"menu",
|
||||
"review",
|
||||
"containedInPlace",
|
||||
"containsPlace",
|
||||
"amenityFeature"
|
||||
]
|
||||
},
|
||||
"Hotel": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"telephone",
|
||||
"image",
|
||||
"priceRange",
|
||||
"geo",
|
||||
"url",
|
||||
"starRating",
|
||||
"aggregateRating",
|
||||
"checkinTime",
|
||||
"checkoutTime"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"amenityFeature",
|
||||
"petsAllowed",
|
||||
"numberOfRooms",
|
||||
"availableLanguage",
|
||||
"containedInPlace",
|
||||
"containsPlace",
|
||||
"makesOffer",
|
||||
"brand",
|
||||
"currenciesAccepted",
|
||||
"smokingAllowed",
|
||||
"openingHoursSpecification",
|
||||
"audience",
|
||||
"review",
|
||||
"parentOrganization",
|
||||
"openingDate",
|
||||
"award"
|
||||
]
|
||||
},
|
||||
"LodgingBusiness": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"telephone",
|
||||
"image",
|
||||
"priceRange",
|
||||
"geo",
|
||||
"url",
|
||||
"starRating",
|
||||
"aggregateRating",
|
||||
"checkinTime",
|
||||
"checkoutTime"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"amenityFeature",
|
||||
"petsAllowed",
|
||||
"numberOfRooms",
|
||||
"availableLanguage",
|
||||
"containedInPlace",
|
||||
"containsPlace",
|
||||
"makesOffer",
|
||||
"currenciesAccepted",
|
||||
"smokingAllowed"
|
||||
]
|
||||
},
|
||||
"Resort": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"telephone",
|
||||
"image",
|
||||
"priceRange",
|
||||
"geo",
|
||||
"url",
|
||||
"starRating",
|
||||
"aggregateRating"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"amenityFeature",
|
||||
"numberOfRooms",
|
||||
"containedInPlace",
|
||||
"containsPlace",
|
||||
"checkinTime",
|
||||
"checkoutTime"
|
||||
]
|
||||
},
|
||||
"Restaurant": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"servesCuisine",
|
||||
"priceRange",
|
||||
"telephone",
|
||||
"menu",
|
||||
"openingHoursSpecification",
|
||||
"image",
|
||||
"url",
|
||||
"geo",
|
||||
"acceptsReservations"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"hasMenu",
|
||||
"starRating",
|
||||
"aggregateRating",
|
||||
"review",
|
||||
"containedInPlace",
|
||||
"smokingAllowed",
|
||||
"award"
|
||||
]
|
||||
},
|
||||
"FoodEstablishment": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"servesCuisine",
|
||||
"priceRange",
|
||||
"telephone",
|
||||
"menu",
|
||||
"openingHoursSpecification"
|
||||
],
|
||||
"allowed": [
|
||||
"email",
|
||||
"hasMenu",
|
||||
"acceptsReservations",
|
||||
"containedInPlace"
|
||||
]
|
||||
},
|
||||
"BarOrPub": {
|
||||
"required": [
|
||||
"name",
|
||||
"address"
|
||||
],
|
||||
"recommended": [
|
||||
"telephone",
|
||||
"openingHoursSpecification",
|
||||
"priceRange",
|
||||
"servesCuisine"
|
||||
],
|
||||
"allowed": [
|
||||
"menu",
|
||||
"hasMenu",
|
||||
"image",
|
||||
"url",
|
||||
"containedInPlace",
|
||||
"acceptsReservations",
|
||||
"award"
|
||||
]
|
||||
},
|
||||
"FAQPage": {
|
||||
"required": [
|
||||
"mainEntity"
|
||||
],
|
||||
"recommended": [],
|
||||
"allowed": [
|
||||
"about",
|
||||
"headline",
|
||||
"datePublished",
|
||||
"dateModified",
|
||||
"isPartOf"
|
||||
]
|
||||
},
|
||||
"Question": {
|
||||
"required": [
|
||||
"name",
|
||||
"acceptedAnswer"
|
||||
],
|
||||
"recommended": [],
|
||||
"allowed": [
|
||||
"text",
|
||||
"answerCount",
|
||||
"suggestedAnswer",
|
||||
"upvoteCount",
|
||||
"author"
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"recommended": [],
|
||||
"allowed": [
|
||||
"url",
|
||||
"upvoteCount",
|
||||
"author",
|
||||
"dateCreated"
|
||||
]
|
||||
},
|
||||
"BreadcrumbList": {
|
||||
"required": [
|
||||
"itemListElement"
|
||||
],
|
||||
"recommended": [],
|
||||
"allowed": [
|
||||
"numberOfItems",
|
||||
"itemListOrder"
|
||||
]
|
||||
},
|
||||
"ItemList": {
|
||||
"required": [
|
||||
"itemListElement"
|
||||
],
|
||||
"recommended": [],
|
||||
"allowed": [
|
||||
"numberOfItems",
|
||||
"itemListOrder"
|
||||
]
|
||||
},
|
||||
"ListItem": {
|
||||
"required": [
|
||||
"position"
|
||||
],
|
||||
"recommended": [
|
||||
"item",
|
||||
"name"
|
||||
],
|
||||
"allowed": [
|
||||
"url",
|
||||
"image",
|
||||
"nextItem",
|
||||
"previousItem"
|
||||
]
|
||||
},
|
||||
"Product": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"image",
|
||||
"offers",
|
||||
"brand",
|
||||
"aggregateRating",
|
||||
"review",
|
||||
"description",
|
||||
"sku"
|
||||
],
|
||||
"allowed": [
|
||||
"gtin",
|
||||
"gtin13",
|
||||
"gtin8",
|
||||
"gtin12",
|
||||
"mpn",
|
||||
"color",
|
||||
"material",
|
||||
"category",
|
||||
"audience",
|
||||
"isVariantOf",
|
||||
"additionalProperty",
|
||||
"hasMerchantReturnPolicy"
|
||||
]
|
||||
},
|
||||
"Offer": {
|
||||
"required": [
|
||||
"price",
|
||||
"priceCurrency"
|
||||
],
|
||||
"recommended": [
|
||||
"availability",
|
||||
"url",
|
||||
"validFrom",
|
||||
"priceValidUntil"
|
||||
],
|
||||
"allowed": [
|
||||
"itemCondition",
|
||||
"seller",
|
||||
"eligibleRegion",
|
||||
"priceSpecification",
|
||||
"shippingDetails",
|
||||
"availabilityStarts"
|
||||
]
|
||||
},
|
||||
"AggregateOffer": {
|
||||
"required": [
|
||||
"lowPrice",
|
||||
"priceCurrency"
|
||||
],
|
||||
"recommended": [
|
||||
"highPrice",
|
||||
"offerCount"
|
||||
],
|
||||
"allowed": [
|
||||
"offers",
|
||||
"availability"
|
||||
]
|
||||
},
|
||||
"Article": {
|
||||
"required": [
|
||||
"headline"
|
||||
],
|
||||
"recommended": [
|
||||
"author",
|
||||
"datePublished",
|
||||
"image",
|
||||
"dateModified",
|
||||
"publisher"
|
||||
],
|
||||
"allowed": [
|
||||
"articleBody",
|
||||
"articleSection",
|
||||
"wordCount",
|
||||
"keywords",
|
||||
"speakable"
|
||||
]
|
||||
},
|
||||
"NewsArticle": {
|
||||
"required": [
|
||||
"headline"
|
||||
],
|
||||
"recommended": [
|
||||
"author",
|
||||
"datePublished",
|
||||
"image",
|
||||
"dateModified",
|
||||
"publisher"
|
||||
],
|
||||
"allowed": [
|
||||
"articleBody",
|
||||
"dateline",
|
||||
"printSection"
|
||||
]
|
||||
},
|
||||
"BlogPosting": {
|
||||
"required": [
|
||||
"headline"
|
||||
],
|
||||
"recommended": [
|
||||
"author",
|
||||
"datePublished",
|
||||
"image",
|
||||
"dateModified",
|
||||
"publisher"
|
||||
],
|
||||
"allowed": [
|
||||
"articleBody",
|
||||
"keywords",
|
||||
"wordCount"
|
||||
]
|
||||
},
|
||||
"Event": {
|
||||
"required": [
|
||||
"name",
|
||||
"startDate",
|
||||
"location"
|
||||
],
|
||||
"recommended": [
|
||||
"endDate",
|
||||
"offers",
|
||||
"performer",
|
||||
"image",
|
||||
"eventStatus",
|
||||
"eventAttendanceMode",
|
||||
"organizer"
|
||||
],
|
||||
"allowed": [
|
||||
"doorTime",
|
||||
"previousStartDate",
|
||||
"typicalAgeRange",
|
||||
"maximumAttendeeCapacity"
|
||||
]
|
||||
},
|
||||
"Review": {
|
||||
"required": [
|
||||
"reviewRating",
|
||||
"author"
|
||||
],
|
||||
"recommended": [
|
||||
"datePublished",
|
||||
"reviewBody",
|
||||
"itemReviewed"
|
||||
],
|
||||
"allowed": [
|
||||
"publisher",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"AggregateRating": {
|
||||
"required": [
|
||||
"ratingValue"
|
||||
],
|
||||
"recommended": [
|
||||
"reviewCount",
|
||||
"ratingCount",
|
||||
"bestRating"
|
||||
],
|
||||
"allowed": [
|
||||
"worstRating",
|
||||
"itemReviewed"
|
||||
]
|
||||
},
|
||||
"MemberProgram": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"hasTiers",
|
||||
"hostingOrganization",
|
||||
"url"
|
||||
],
|
||||
"allowed": [
|
||||
"description",
|
||||
"membershipPointsEarned"
|
||||
]
|
||||
},
|
||||
"EventVenue": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address",
|
||||
"maximumAttendeeCapacity",
|
||||
"image"
|
||||
],
|
||||
"allowed": [
|
||||
"containedInPlace",
|
||||
"amenityFeature",
|
||||
"openingHoursSpecification",
|
||||
"photo",
|
||||
"telephone",
|
||||
"alternateName",
|
||||
"geo"
|
||||
]
|
||||
},
|
||||
"ExerciseGym": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address",
|
||||
"openingHoursSpecification",
|
||||
"image"
|
||||
],
|
||||
"allowed": [
|
||||
"containedInPlace",
|
||||
"amenityFeature",
|
||||
"telephone",
|
||||
"priceRange",
|
||||
"alternateName"
|
||||
]
|
||||
},
|
||||
"SportsActivityLocation": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address"
|
||||
],
|
||||
"allowed": [
|
||||
"containedInPlace",
|
||||
"amenityFeature",
|
||||
"openingHoursSpecification",
|
||||
"telephone",
|
||||
"alternateName"
|
||||
]
|
||||
},
|
||||
"HealthAndBeautyBusiness": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address",
|
||||
"telephone",
|
||||
"openingHoursSpecification",
|
||||
"priceRange",
|
||||
"image"
|
||||
],
|
||||
"allowed": [
|
||||
"containedInPlace",
|
||||
"amenityFeature",
|
||||
"potentialAction",
|
||||
"parentOrganization",
|
||||
"geo",
|
||||
"alternateName"
|
||||
]
|
||||
},
|
||||
"DaySpa": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address",
|
||||
"telephone",
|
||||
"openingHoursSpecification",
|
||||
"priceRange",
|
||||
"image"
|
||||
],
|
||||
"allowed": [
|
||||
"containedInPlace",
|
||||
"amenityFeature",
|
||||
"potentialAction",
|
||||
"parentOrganization",
|
||||
"geo",
|
||||
"alternateName"
|
||||
]
|
||||
},
|
||||
"CafeOrCoffeeShop": {
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"recommended": [
|
||||
"url",
|
||||
"address",
|
||||
"servesCuisine",
|
||||
"priceRange",
|
||||
"telephone",
|
||||
"openingHoursSpecification",
|
||||
"image"
|
||||
],
|
||||
"allowed": [
|
||||
"menu",
|
||||
"hasMenu",
|
||||
"containedInPlace",
|
||||
"acceptsReservations",
|
||||
"alternateName"
|
||||
]
|
||||
}
|
||||
},
|
||||
"container_types": [
|
||||
"PostalAddress",
|
||||
"GeoCoordinates",
|
||||
"GeoShape",
|
||||
"ImageObject",
|
||||
"VideoObject",
|
||||
"ContactPoint",
|
||||
"OpeningHoursSpecification",
|
||||
"Rating",
|
||||
"QuantitativeValue",
|
||||
"MonetaryAmount",
|
||||
"PriceSpecification",
|
||||
"Brand",
|
||||
"EntryPoint",
|
||||
"Place",
|
||||
"OfferCatalog",
|
||||
"ReserveAction",
|
||||
"OrderAction",
|
||||
"SearchAction",
|
||||
"ViewAction",
|
||||
"MeetingRoom",
|
||||
"Room",
|
||||
"HotelRoom",
|
||||
"Suite",
|
||||
"LocationFeatureSpecification",
|
||||
"MemberProgramTier",
|
||||
"MobileApplication",
|
||||
"WebApplication",
|
||||
"SoftwareApplication",
|
||||
"Menu",
|
||||
"MenuItem",
|
||||
"MenuSection",
|
||||
"Country",
|
||||
"AdministrativeArea",
|
||||
"Duration",
|
||||
"PropertyValue",
|
||||
"Person",
|
||||
"Audience",
|
||||
"Language",
|
||||
"LodgingReservation"
|
||||
],
|
||||
"value_formats": {
|
||||
"url_props": [
|
||||
"url",
|
||||
"logo",
|
||||
"sameAs",
|
||||
"image",
|
||||
"contentUrl",
|
||||
"thumbnailUrl",
|
||||
"target",
|
||||
"urlTemplate",
|
||||
"installUrl",
|
||||
"menu",
|
||||
"hasMap",
|
||||
"downloadUrl",
|
||||
"embedUrl"
|
||||
],
|
||||
"date_props": [
|
||||
"datePublished",
|
||||
"dateModified",
|
||||
"dateCreated",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"validFrom",
|
||||
"validThrough",
|
||||
"priceValidUntil",
|
||||
"foundingDate",
|
||||
"uploadDate",
|
||||
"availabilityStarts",
|
||||
"availabilityEnds",
|
||||
"lastReviewed",
|
||||
"previousStartDate"
|
||||
],
|
||||
"lang_props": [
|
||||
"inLanguage",
|
||||
"availableLanguage"
|
||||
],
|
||||
"currency_props": [
|
||||
"priceCurrency",
|
||||
"currenciesAccepted"
|
||||
],
|
||||
"number_props": [
|
||||
"price",
|
||||
"lowPrice",
|
||||
"highPrice",
|
||||
"ratingValue",
|
||||
"reviewCount",
|
||||
"ratingCount",
|
||||
"bestRating",
|
||||
"worstRating",
|
||||
"position",
|
||||
"numberOfRooms",
|
||||
"maxValue",
|
||||
"minValue",
|
||||
"offerCount"
|
||||
]
|
||||
},
|
||||
"valid_currencies": [
|
||||
"KRW",
|
||||
"USD",
|
||||
"EUR",
|
||||
"JPY",
|
||||
"CNY",
|
||||
"GBP",
|
||||
"HKD",
|
||||
"SGD",
|
||||
"THB",
|
||||
"AUD",
|
||||
"CAD",
|
||||
"CHF",
|
||||
"TWD",
|
||||
"MYR",
|
||||
"PHP",
|
||||
"VND",
|
||||
"IDR",
|
||||
"INR"
|
||||
],
|
||||
"valid_language_codes": [
|
||||
"ko",
|
||||
"en",
|
||||
"ja",
|
||||
"zh",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ko-KR",
|
||||
"en-US",
|
||||
"en-GB",
|
||||
"ja-JP",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"ru",
|
||||
"th",
|
||||
"vi",
|
||||
"id",
|
||||
"ms"
|
||||
],
|
||||
"placeholder_tokens": [
|
||||
"lorem ipsum",
|
||||
"lorem",
|
||||
"ipsum",
|
||||
"dolor sit",
|
||||
"todo",
|
||||
"tbd",
|
||||
"fixme",
|
||||
"xxx",
|
||||
"yyy",
|
||||
"zzz",
|
||||
"placeholder",
|
||||
"insert here",
|
||||
"insert text",
|
||||
"example.com",
|
||||
"your-domain",
|
||||
"yourdomain",
|
||||
"changeme",
|
||||
"sample text",
|
||||
"{{",
|
||||
"}}",
|
||||
"<insert",
|
||||
"[insert",
|
||||
"n/a",
|
||||
"샘플",
|
||||
"예시",
|
||||
"여기에",
|
||||
"변경필요",
|
||||
"수정필요",
|
||||
"입력필요",
|
||||
"내용입력",
|
||||
"테스트",
|
||||
"임시"
|
||||
],
|
||||
"geo": {
|
||||
"lat_min": -90.0,
|
||||
"lat_max": 90.0,
|
||||
"lon_min": -180.0,
|
||||
"lon_max": 180.0,
|
||||
"kr_lat_range": [
|
||||
33.0,
|
||||
39.0
|
||||
],
|
||||
"kr_lon_range": [
|
||||
124.0,
|
||||
132.0
|
||||
]
|
||||
}
|
||||
}
|
||||
1011
custom-skills/16-seo-schema-validator/scripts/validate_schema.py
Normal file
1011
custom-skills/16-seo-schema-validator/scripts/validate_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,854 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
validate_schema.py — 5-layer offline JSON-LD schema validator.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
When a client reviews hundreds of authored schema entries and says "there are too
|
||||
many errors," the root cause is almost always that nobody ran a machine lint first.
|
||||
Humans end up eyeballing raw JSON in a meeting. This tool moves every cheap,
|
||||
machine-checkable error OUT of human review and INTO an automated gate that runs
|
||||
first — so the client only ever sees clean, P0-free entries plus a defect report.
|
||||
|
||||
It is OFFLINE by design (the runtime cannot reach schema.org or Google). All rules
|
||||
live in schema_rules.json; unknown types/properties degrade to warnings, never hard
|
||||
errors, so the gate does not invent false positives.
|
||||
|
||||
THE 5 LAYERS
|
||||
------------
|
||||
L0 Coverage — URLs with no entry; entries whose URL isn't in the inventory.
|
||||
L1 Syntax — invalid JSON, bad/missing @context, missing @type, encoding corruption.
|
||||
L2 Vocabulary — unknown type, value-format errors (URL/date/lang/currency/number),
|
||||
(strict only) unexpected properties on a known type.
|
||||
L3 Rich-result — Google REQUIRED property missing (blocks rich result); recommended absent.
|
||||
L4 Consistency — NAP mismatch, @id duplicates/dangling refs, swapped geo,
|
||||
placeholder text, duplicate descriptions across entries.
|
||||
|
||||
GATE: PASS iff zero P0. Process exits 1 when the gate fails (so CI/`&&` chains stop).
|
||||
|
||||
Usage:
|
||||
python validate_schema.py DATASET [--url-list URLLIST] [--out DIR]
|
||||
[--strict] [--no-recommended]
|
||||
[--live URL ...] [--rules schema_rules.json]
|
||||
DATASET may be .xlsx / .csv (one row per entry, a JSON-LD column) / .jsonl / .json
|
||||
/ a directory of .json|.jsonld files. With --live, validate live URLs instead.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
RULES_DEFAULT = Path(__file__).resolve().parent / "schema_rules.json"
|
||||
|
||||
SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2}
|
||||
|
||||
# Header aliases for tabular input. Keys are normalized (lowercased, spaces removed).
|
||||
COLUMN_ALIASES = {
|
||||
"jsonld": ["jsonld", "jsonld", "json-ld", "json_ld", "schema", "schemamarkup",
|
||||
"structureddata", "structured_data", "markup", "스키마", "구조화데이터",
|
||||
"구조화된데이터", "jsonldcode", "스키마코드"],
|
||||
"url": ["url", "메뉴url", "pageurl", "주소", "링크", "loc", "uri", "캐노니컬", "canonical"],
|
||||
"lang": ["lang", "language", "언어", "언어코드", "locale", "lng"],
|
||||
"device": ["device", "pc/mobile", "pcmobile", "pc_mobile", "platform", "디바이스", "기기"],
|
||||
"page_type": ["page_type", "pagetype", "type", "페이지유형", "페이지타입", "menulevel",
|
||||
"menu_level", "메뉴레벨", "template", "템플릿", "유형"],
|
||||
}
|
||||
|
||||
URL_RE = re.compile(r"^https?://[^\s]+$", re.IGNORECASE)
|
||||
# ISO-8601 date or datetime (date, date+time, optional tz). Loose but rejects free text.
|
||||
DATE_RE = re.compile(
|
||||
r"^\d{4}-\d{2}-\d{2}"
|
||||
r"(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$"
|
||||
)
|
||||
LANG_RE = re.compile(r"^[a-zA-Z]{2,3}(?:-[A-Za-z0-9]{2,4})?$")
|
||||
JSONLD_SCRIPT_RE = re.compile(
|
||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Defect collection
|
||||
# --------------------------------------------------------------------------- #
|
||||
class DefectLog:
|
||||
"""Accumulates findings. One row per finding, ready for triage."""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
def add(self, severity, layer, code, message, entry_id="", url="", node_type=""):
|
||||
self.rows.append({
|
||||
"entry_id": str(entry_id),
|
||||
"url": url or "",
|
||||
"node_type": node_type or "",
|
||||
"layer": layer,
|
||||
"code": code,
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"status": "open",
|
||||
"owner": "",
|
||||
"note": "",
|
||||
})
|
||||
|
||||
def counts(self):
|
||||
c = Counter(r["severity"] for r in self.rows)
|
||||
return {"P0": c.get("P0", 0), "P1": c.get("P1", 0), "P2": c.get("P2", 0)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Input adapters (Mode A: authored dataset / Mode B: live URLs)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _norm_header(h):
|
||||
return re.sub(r"\s+", "", str(h or "").strip().lower())
|
||||
|
||||
|
||||
def _detect_columns(headers):
|
||||
"""Map normalized headers to canonical column roles. Returns {role: index}."""
|
||||
found = {}
|
||||
for idx, h in enumerate(headers):
|
||||
nh = _norm_header(h)
|
||||
for role, aliases in COLUMN_ALIASES.items():
|
||||
if role in found:
|
||||
continue
|
||||
if nh in aliases:
|
||||
found[role] = idx
|
||||
return found
|
||||
|
||||
|
||||
def _row_to_entry(row, cols, entry_id, source_ref):
|
||||
def cell(role):
|
||||
i = cols.get(role)
|
||||
if i is None or i >= len(row):
|
||||
return None
|
||||
v = row[i]
|
||||
return None if v is None else str(v).strip()
|
||||
raw = cell("jsonld")
|
||||
if not raw:
|
||||
return None # blank JSON-LD cell → no entry to validate
|
||||
return {
|
||||
"entry_id": entry_id,
|
||||
"url": cell("url") or "",
|
||||
"lang": cell("lang") or "",
|
||||
"device": cell("device") or "",
|
||||
"page_type": cell("page_type") or "",
|
||||
"raw": raw,
|
||||
"source_ref": source_ref,
|
||||
}
|
||||
|
||||
|
||||
def _load_csv(path):
|
||||
entries = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
return entries
|
||||
cols = _detect_columns(rows[0])
|
||||
if "jsonld" not in cols:
|
||||
raise ValueError(
|
||||
f"No JSON-LD column found in {path}. Looked for: "
|
||||
f"{', '.join(COLUMN_ALIASES['jsonld'][:6])} … Headers were: {rows[0]}"
|
||||
)
|
||||
for n, row in enumerate(rows[1:], start=2):
|
||||
e = _row_to_entry(row, cols, f"{Path(path).stem}#r{n}", f"{path}:row{n}")
|
||||
if e:
|
||||
entries.append(e)
|
||||
return entries
|
||||
|
||||
|
||||
def _load_xlsx(path):
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Reading .xlsx needs openpyxl: pip install openpyxl\n"
|
||||
"(or export the sheet to .csv and pass that instead)."
|
||||
)
|
||||
entries = []
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
for sheet in wb.worksheets:
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
continue
|
||||
cols = _detect_columns(rows[0])
|
||||
if "jsonld" not in cols:
|
||||
continue # a tab without a JSON-LD column (e.g. summary) — skip silently
|
||||
for n, row in enumerate(rows[1:], start=2):
|
||||
e = _row_to_entry(list(row), cols, f"{sheet.title}#r{n}",
|
||||
f"{path}:{sheet.title}:row{n}")
|
||||
if e:
|
||||
entries.append(e)
|
||||
if not entries:
|
||||
raise ValueError(
|
||||
f"No sheet in {path} had a recognizable JSON-LD column. "
|
||||
f"Looked for: {', '.join(COLUMN_ALIASES['jsonld'][:6])} …"
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _looks_like_schema(obj):
|
||||
"""True if a parsed object is itself JSON-LD (vs a wrapper row)."""
|
||||
if isinstance(obj, list):
|
||||
return True
|
||||
if isinstance(obj, dict):
|
||||
return any(k in obj for k in ("@context", "@type", "@graph"))
|
||||
return False
|
||||
|
||||
|
||||
def _wrapper_to_entry(obj, entry_id, source_ref):
|
||||
"""A JSONL/JSON wrapper object that carries url/lang + a jsonld payload."""
|
||||
cols = {k: k for k in obj.keys()}
|
||||
norm = {_norm_header(k): k for k in obj.keys()}
|
||||
def pick(role):
|
||||
for alias in COLUMN_ALIASES[role]:
|
||||
if alias in norm:
|
||||
v = obj[norm[alias]]
|
||||
return v
|
||||
return None
|
||||
payload = pick("jsonld")
|
||||
raw = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
||||
url = pick("url")
|
||||
return {
|
||||
"entry_id": entry_id,
|
||||
"url": str(url).strip() if url else "",
|
||||
"lang": str(pick("lang") or "").strip(),
|
||||
"device": str(pick("device") or "").strip(),
|
||||
"page_type": str(pick("page_type") or "").strip(),
|
||||
"raw": raw,
|
||||
"source_ref": source_ref,
|
||||
}
|
||||
|
||||
|
||||
def _load_jsonl(path):
|
||||
entries = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for n, line in enumerate(f, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
sref = f"{path}:line{n}"
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# Keep the bad line so L1 reports it as a syntax error.
|
||||
entries.append({"entry_id": f"{Path(path).stem}#l{n}", "url": "",
|
||||
"lang": "", "device": "", "page_type": "",
|
||||
"raw": line, "source_ref": sref})
|
||||
continue
|
||||
eid = f"{Path(path).stem}#l{n}"
|
||||
if _looks_like_schema(obj):
|
||||
entries.append({"entry_id": eid, "url": "", "lang": "", "device": "",
|
||||
"page_type": "", "raw": line, "source_ref": sref})
|
||||
else:
|
||||
entries.append(_wrapper_to_entry(obj, eid, sref))
|
||||
return entries
|
||||
|
||||
|
||||
def _load_json(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
entries = []
|
||||
if isinstance(data, dict) and not _looks_like_schema(data) and all(
|
||||
isinstance(v, (dict, list, str)) for v in data.values()
|
||||
) and not any(k.startswith("@") for k in data):
|
||||
# url -> jsonld map
|
||||
for url, payload in data.items():
|
||||
raw = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
||||
"page_type": "", "raw": raw, "source_ref": f"{path}:{url}"})
|
||||
elif isinstance(data, list):
|
||||
for n, item in enumerate(data, start=1):
|
||||
sref = f"{path}:[{n}]"
|
||||
eid = f"{Path(path).stem}#{n}"
|
||||
if _looks_like_schema(item) or not isinstance(item, dict):
|
||||
raw = item if isinstance(item, str) else json.dumps(item, ensure_ascii=False)
|
||||
entries.append({"entry_id": eid, "url": "", "lang": "", "device": "",
|
||||
"page_type": "", "raw": raw, "source_ref": sref})
|
||||
else:
|
||||
entries.append(_wrapper_to_entry(item, eid, sref))
|
||||
else:
|
||||
entries.append({"entry_id": Path(path).stem, "url": "", "lang": "", "device": "",
|
||||
"page_type": "", "raw": json.dumps(data, ensure_ascii=False),
|
||||
"source_ref": path})
|
||||
return entries
|
||||
|
||||
|
||||
def _load_dir(path):
|
||||
entries = []
|
||||
for p in sorted(Path(path).rglob("*")):
|
||||
if p.suffix.lower() in (".json", ".jsonld"):
|
||||
entries.append({"entry_id": p.stem, "url": "", "lang": "", "device": "",
|
||||
"page_type": "", "raw": p.read_text(encoding="utf-8"),
|
||||
"source_ref": str(p)})
|
||||
if not entries:
|
||||
raise ValueError(f"No .json/.jsonld files found under {path}")
|
||||
return entries
|
||||
|
||||
|
||||
def _load_live(urls):
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
raise SystemExit("Live mode (--live) needs requests: pip install requests")
|
||||
entries = []
|
||||
headers = {"User-Agent": "Mozilla/5.0 (compatible; SchemaValidator/1.0)"}
|
||||
for url in urls:
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc: # noqa: BLE001 — best-effort live fetch
|
||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
||||
"page_type": "", "raw": "", "source_ref": url,
|
||||
"_fetch_error": str(exc)})
|
||||
continue
|
||||
scripts = JSONLD_SCRIPT_RE.findall(resp.text)
|
||||
if not scripts:
|
||||
entries.append({"entry_id": url, "url": url, "lang": "", "device": "",
|
||||
"page_type": "", "raw": "", "source_ref": url,
|
||||
"_no_schema": True})
|
||||
continue
|
||||
for i, block in enumerate(scripts, start=1):
|
||||
entries.append({"entry_id": f"{url}#{i}", "url": url, "lang": "",
|
||||
"device": "", "page_type": "", "raw": block.strip(),
|
||||
"source_ref": f"{url} (script {i})"})
|
||||
return entries
|
||||
|
||||
|
||||
def load_entries(input_path, live_urls):
|
||||
if live_urls:
|
||||
return _load_live(live_urls)
|
||||
p = Path(input_path)
|
||||
if p.is_dir():
|
||||
return _load_dir(p)
|
||||
suffix = p.suffix.lower()
|
||||
if suffix == ".csv":
|
||||
return _load_csv(p)
|
||||
if suffix in (".xlsx", ".xlsm"):
|
||||
return _load_xlsx(p)
|
||||
if suffix == ".jsonl":
|
||||
return _load_jsonl(p)
|
||||
if suffix in (".json", ".jsonld"):
|
||||
return _load_json(p)
|
||||
raise ValueError(f"Unsupported input: {input_path} (suffix {suffix!r})")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Node helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def type_of(node):
|
||||
"""Return the primary @type as a string (first if it's a list)."""
|
||||
t = node.get("@type")
|
||||
if isinstance(t, list):
|
||||
return t[0] if t else ""
|
||||
return t or ""
|
||||
|
||||
|
||||
def iter_typed_nodes(parsed):
|
||||
"""Yield every dict that has an @type, top-level and nested (recursively)."""
|
||||
seen = []
|
||||
|
||||
def walk(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "@type" in obj:
|
||||
seen.append(obj)
|
||||
for v in obj.values():
|
||||
walk(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
walk(v)
|
||||
|
||||
# @graph documents: walk the graph; otherwise walk the object/array directly.
|
||||
if isinstance(parsed, dict) and "@graph" in parsed:
|
||||
walk(parsed["@graph"])
|
||||
else:
|
||||
walk(parsed)
|
||||
return seen
|
||||
|
||||
|
||||
def all_strings(obj):
|
||||
"""Yield (key, value) for every string value anywhere in the structure."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, str):
|
||||
yield k, v
|
||||
else:
|
||||
yield from all_strings(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
yield from all_strings(v)
|
||||
|
||||
|
||||
def normalize_name(s):
|
||||
return re.sub(r"\s+", " ", str(s or "").strip().lower())
|
||||
|
||||
|
||||
def first_text(value):
|
||||
"""Coerce a property value to a comparable scalar (handles list/dict)."""
|
||||
if isinstance(value, list):
|
||||
return first_text(value[0]) if value else ""
|
||||
if isinstance(value, dict):
|
||||
return value.get("name") or value.get("@id") or value.get("streetAddress") or ""
|
||||
return value
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layer 0 — Coverage
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_url_inventory(url_list_path):
|
||||
urls = set()
|
||||
p = Path(url_list_path)
|
||||
suffix = p.suffix.lower()
|
||||
if suffix in (".xlsx", ".xlsm"):
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(p, read_only=True, data_only=True)
|
||||
for sheet in wb.worksheets:
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
for cell in row:
|
||||
if isinstance(cell, str) and URL_RE.match(cell.strip()):
|
||||
urls.add(cell.strip())
|
||||
elif suffix == ".csv":
|
||||
with open(p, newline="", encoding="utf-8-sig") as f:
|
||||
for row in csv.reader(f):
|
||||
for cell in row:
|
||||
if isinstance(cell, str) and URL_RE.match(cell.strip()):
|
||||
urls.add(cell.strip())
|
||||
else: # plain text, one URL per line
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if URL_RE.match(line):
|
||||
urls.add(line)
|
||||
return urls
|
||||
|
||||
|
||||
def layer0_coverage(entries, inventory, defects):
|
||||
entry_urls = {e["url"] for e in entries if e.get("url")}
|
||||
missing = inventory - entry_urls
|
||||
for url in sorted(missing):
|
||||
defects.add("P1", "L0", "COVERAGE_MISSING",
|
||||
"Inventory URL has no authored schema entry.", url=url)
|
||||
orphans = entry_urls - inventory
|
||||
for url in sorted(orphans):
|
||||
defects.add("P2", "L0", "COVERAGE_ORPHAN",
|
||||
"Entry URL is not in the canonical URL inventory "
|
||||
"(typo, stale path, or missing from list).", url=url)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layer 1 — Syntax
|
||||
# --------------------------------------------------------------------------- #
|
||||
def layer1_syntax(entry, rules, defects):
|
||||
"""Parse + structural checks. Returns parsed object or None (fatal)."""
|
||||
eid, url = entry["entry_id"], entry["url"]
|
||||
if entry.get("_fetch_error"):
|
||||
defects.add("P1", "L1", "FETCH_ERROR",
|
||||
f"Could not fetch live URL: {entry['_fetch_error']}", eid, url)
|
||||
return None
|
||||
if entry.get("_no_schema"):
|
||||
defects.add("P0", "L1", "NO_SCHEMA_IN_HTML",
|
||||
"Live page has no application/ld+json script block.", eid, url)
|
||||
return None
|
||||
raw = entry["raw"]
|
||||
if "<22>" in raw:
|
||||
defects.add("P1", "L1", "ENCODING_CORRUPTION",
|
||||
"Replacement character (\\ufffd) present — encoding corruption.",
|
||||
eid, url)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
defects.add("P0", "L1", "INVALID_JSON",
|
||||
f"JSON does not parse: {exc.msg} at line {exc.lineno} col {exc.colno}.",
|
||||
eid, url)
|
||||
return None
|
||||
|
||||
nodes = iter_typed_nodes(parsed)
|
||||
if not nodes:
|
||||
defects.add("P1", "L1", "NO_TYPE",
|
||||
"No @type found anywhere in the entry — not a usable schema object.",
|
||||
eid, url)
|
||||
|
||||
# @context lives at the top of the document; nested nodes inherit it.
|
||||
if isinstance(parsed, dict):
|
||||
ctx = parsed.get("@context")
|
||||
if ctx is None:
|
||||
defects.add("P1", "L1", "MISSING_CONTEXT",
|
||||
"Top-level @context is missing.", eid, url)
|
||||
else:
|
||||
ctx_urls = [ctx] if isinstance(ctx, str) else (
|
||||
[c for c in ctx if isinstance(c, str)] if isinstance(ctx, list) else []
|
||||
)
|
||||
valid = rules["valid_contexts"]
|
||||
if ctx_urls and not any(c.rstrip("/") in [v.rstrip("/") for v in valid]
|
||||
for c in ctx_urls):
|
||||
defects.add("P1", "L1", "WRONG_CONTEXT",
|
||||
f"@context is not schema.org: {ctx_urls}.", eid, url)
|
||||
return parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layer 2 — Vocabulary + value formats
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _check_value_formats(node, rules, defects, eid, url, ntype, severity):
|
||||
vf = rules["value_formats"]
|
||||
|
||||
def each(value):
|
||||
if isinstance(value, list):
|
||||
for v in value:
|
||||
yield from each(v)
|
||||
else:
|
||||
yield value
|
||||
|
||||
for prop, value in node.items():
|
||||
if prop.startswith("@"):
|
||||
continue
|
||||
if prop in vf["url_props"]:
|
||||
for v in each(value):
|
||||
if isinstance(v, str) and not URL_RE.match(v.strip()):
|
||||
defects.add(severity, "L2", "BAD_URL",
|
||||
f"'{prop}' is not an http(s) URL: {v!r}.", eid, url, ntype)
|
||||
if prop in vf["date_props"]:
|
||||
for v in each(value):
|
||||
if isinstance(v, str) and not DATE_RE.match(v.strip()):
|
||||
defects.add(severity, "L2", "BAD_DATE",
|
||||
f"'{prop}' is not ISO-8601: {v!r}.", eid, url, ntype)
|
||||
if prop in vf["lang_props"]:
|
||||
for v in each(value):
|
||||
if isinstance(v, str) and not LANG_RE.match(v.strip()):
|
||||
defects.add(severity, "L2", "BAD_LANG",
|
||||
f"'{prop}' is not a BCP-47 language code: {v!r}.",
|
||||
eid, url, ntype)
|
||||
if prop in vf["currency_props"]:
|
||||
for v in each(value):
|
||||
if isinstance(v, str) and not re.match(r"^[A-Z]{3}$", v.strip()):
|
||||
defects.add(severity, "L2", "BAD_CURRENCY",
|
||||
f"'{prop}' is not a 3-letter ISO-4217 code: {v!r}.",
|
||||
eid, url, ntype)
|
||||
if prop in vf["number_props"]:
|
||||
for v in each(value):
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
float(v.replace(",", ""))
|
||||
except ValueError:
|
||||
defects.add(severity, "L2", "BAD_NUMBER",
|
||||
f"'{prop}' is not numeric: {v!r}.", eid, url, ntype)
|
||||
|
||||
|
||||
def layer2_vocabulary(node, rules, defects, eid, url, strict):
|
||||
ntype = type_of(node)
|
||||
known = rules["known_types"]
|
||||
containers = set(rules["container_types"])
|
||||
minor = "P1" if strict else "P2"
|
||||
|
||||
if ntype and ntype not in known and ntype not in containers:
|
||||
defects.add(minor, "L2", "UNKNOWN_TYPE",
|
||||
f"@type '{ntype}' is not in the curated rule set "
|
||||
"(treated as a warning — add it to schema_rules.json if intended).",
|
||||
eid, url, ntype)
|
||||
|
||||
_check_value_formats(node, rules, defects, eid, url, ntype, minor)
|
||||
|
||||
# Unexpected-property check is OPT-IN (--strict). Off by default to avoid the
|
||||
# exact noise explosion that makes clients say "too many errors".
|
||||
if strict and ntype in known:
|
||||
spec = known[ntype]
|
||||
allowed = set(spec["required"]) | set(spec["recommended"]) | set(spec["allowed"])
|
||||
allowed |= set(rules["global_properties"])
|
||||
for prop in node:
|
||||
if prop.startswith("@"):
|
||||
continue
|
||||
if prop not in allowed:
|
||||
defects.add("P1", "L2", "UNEXPECTED_PROPERTY",
|
||||
f"'{prop}' is not a known property of {ntype} (strict mode).",
|
||||
eid, url, ntype)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layer 3 — Rich-result (required / recommended)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def layer3_richresult(node, rules, defects, eid, url, no_recommended):
|
||||
ntype = type_of(node)
|
||||
known = rules["known_types"]
|
||||
if ntype not in known:
|
||||
return # containers + unknown types have no required-property contract
|
||||
spec = known[ntype]
|
||||
|
||||
for prop in spec["required"]:
|
||||
if not node.get(prop):
|
||||
defects.add("P0", "L3", "MISSING_REQUIRED",
|
||||
f"{ntype} is missing required property '{prop}' "
|
||||
"(blocks the rich result).", eid, url, ntype)
|
||||
|
||||
if not no_recommended:
|
||||
missing_rec = [p for p in spec["recommended"] if not node.get(p)]
|
||||
if missing_rec:
|
||||
# Aggregate to ONE line per node — never one defect per property.
|
||||
defects.add("P2", "L3", "MISSING_RECOMMENDED",
|
||||
f"{ntype} is missing recommended properties: "
|
||||
f"{', '.join(missing_rec)}.", eid, url, ntype)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layer 4 — Consistency (cross-node / cross-entry)
|
||||
# --------------------------------------------------------------------------- #
|
||||
NAP_TYPES = {"Organization", "Corporation", "LocalBusiness", "Hotel",
|
||||
"LodgingBusiness", "Resort", "Restaurant", "FoodEstablishment", "BarOrPub"}
|
||||
|
||||
|
||||
def _address_street(node):
|
||||
addr = node.get("address")
|
||||
if isinstance(addr, dict):
|
||||
return normalize_name(addr.get("streetAddress"))
|
||||
if isinstance(addr, list) and addr and isinstance(addr[0], dict):
|
||||
return normalize_name(addr[0].get("streetAddress"))
|
||||
return ""
|
||||
|
||||
|
||||
def _walk_ids(obj, defined, referenced):
|
||||
"""Collect @id definitions vs pure references by walking the whole document.
|
||||
|
||||
A *reference* is an object whose only key is @id (e.g. {"@id": "...#org"}).
|
||||
A *definition* is any object carrying @id plus other content. References live
|
||||
in untyped wrapper dicts, so this must walk the raw doc — not just typed nodes.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
nid = obj.get("@id")
|
||||
if nid:
|
||||
if set(obj.keys()) == {"@id"}:
|
||||
referenced.add(nid)
|
||||
else:
|
||||
defined.setdefault(nid, []).append(obj)
|
||||
for v in obj.values():
|
||||
_walk_ids(v, defined, referenced)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
_walk_ids(v, defined, referenced)
|
||||
|
||||
|
||||
def layer4_consistency(node_index, parsed_docs, rules, defects):
|
||||
"""node_index: (entry, node) for every TYPED node.
|
||||
parsed_docs: (entry, parsed) for every entry that parsed — used for @id scan."""
|
||||
# ---- placeholder text (P0) ----
|
||||
tokens = [t.lower() for t in rules["placeholder_tokens"]]
|
||||
for entry, node in node_index:
|
||||
ntype = type_of(node)
|
||||
for key, val in all_strings(node):
|
||||
low = val.lower()
|
||||
hit = next((t for t in tokens if t in low), None)
|
||||
if hit:
|
||||
defects.add("P0", "L4", "PLACEHOLDER_TEXT",
|
||||
f"Placeholder/boilerplate token {hit!r} in '{key}': {val[:60]!r}.",
|
||||
entry["entry_id"], entry["url"], ntype)
|
||||
break # one placeholder defect per node is enough signal
|
||||
|
||||
# ---- NAP consistency (P0) ----
|
||||
by_name = defaultdict(list)
|
||||
for entry, node in node_index:
|
||||
if type_of(node) in NAP_TYPES and node.get("name"):
|
||||
by_name[normalize_name(first_text(node.get("name")))].append((entry, node))
|
||||
for name, group in by_name.items():
|
||||
phones = {str(first_text(n.get("telephone"))).strip()
|
||||
for _, n in group if n.get("telephone")}
|
||||
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
||||
if len(phones) > 1:
|
||||
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
||||
f"Business '{name}' has conflicting telephone values across "
|
||||
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
||||
if len(streets) > 1:
|
||||
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
||||
f"Business '{name}' has conflicting streetAddress values across "
|
||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
||||
|
||||
# ---- @id duplicates + dangling references (P1) ----
|
||||
defined = {} # @id -> list of definition dicts (walked across all docs)
|
||||
referenced = set() # @id values used purely as references
|
||||
for _, parsed in parsed_docs:
|
||||
_walk_ids(parsed, defined, referenced)
|
||||
for nid, defs in defined.items():
|
||||
if len(defs) > 1:
|
||||
# duplicate only matters if the definitions actually differ
|
||||
shapes = {json.dumps(n, sort_keys=True, ensure_ascii=False) for n in defs}
|
||||
if len(shapes) > 1:
|
||||
defects.add("P1", "L4", "DUPLICATE_ID",
|
||||
f"@id {nid!r} is defined {len(defs)} times with differing content.",
|
||||
entry_id="(dataset)")
|
||||
for nid in sorted(referenced - set(defined)):
|
||||
defects.add("P1", "L4", "DANGLING_ID",
|
||||
f"@id reference {nid!r} points to a node that is never defined.",
|
||||
entry_id="(dataset)")
|
||||
|
||||
# ---- swapped / out-of-range geo (P1) ----
|
||||
g = rules["geo"]
|
||||
for entry, node in node_index:
|
||||
if type_of(node) != "GeoCoordinates":
|
||||
continue
|
||||
try:
|
||||
lat = float(first_text(node.get("latitude")))
|
||||
lon = float(first_text(node.get("longitude")))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
lat_ok = g["lat_min"] <= lat <= g["lat_max"]
|
||||
lon_ok = g["lon_min"] <= lon <= g["lon_max"]
|
||||
if lat_ok and lon_ok:
|
||||
continue # both in valid global range
|
||||
# Invalid — distinguish a clean transposition from plain garbage.
|
||||
swap_ok = (g["lat_min"] <= lon <= g["lat_max"]) and (g["lon_min"] <= lat <= g["lon_max"])
|
||||
if swap_ok:
|
||||
defects.add("P1", "L4", "GEO_SWAPPED",
|
||||
f"GeoCoordinates look transposed (latitude={lat}, longitude={lon}) "
|
||||
"— swapping them yields valid coordinates.",
|
||||
entry["entry_id"], entry["url"], "GeoCoordinates")
|
||||
else:
|
||||
defects.add("P1", "L4", "GEO_OUT_OF_RANGE",
|
||||
f"GeoCoordinates out of range (latitude={lat}, longitude={lon}).",
|
||||
entry["entry_id"], entry["url"], "GeoCoordinates")
|
||||
|
||||
# ---- duplicate descriptions across entries (P1) ----
|
||||
desc_groups = defaultdict(set)
|
||||
for entry, node in node_index:
|
||||
d = first_text(node.get("description"))
|
||||
if isinstance(d, str) and len(d.strip()) >= 30:
|
||||
desc_groups[d.strip()].add(entry["entry_id"])
|
||||
for desc, eids in desc_groups.items():
|
||||
if len(eids) >= 3:
|
||||
defects.add("P1", "L4", "DUPLICATE_DESCRIPTION",
|
||||
f"Identical description reused across {len(eids)} entries "
|
||||
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Orchestration + output
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run(entries, rules, inventory, strict, no_recommended):
|
||||
defects = DefectLog()
|
||||
if inventory is not None:
|
||||
layer0_coverage(entries, inventory, defects)
|
||||
|
||||
node_index = []
|
||||
parsed_docs = []
|
||||
valid_entries = 0
|
||||
for entry in entries:
|
||||
parsed = layer1_syntax(entry, rules, defects)
|
||||
if parsed is None:
|
||||
continue
|
||||
valid_entries += 1
|
||||
parsed_docs.append((entry, parsed))
|
||||
for node in iter_typed_nodes(parsed):
|
||||
layer2_vocabulary(node, rules, defects, entry["entry_id"], entry["url"], strict)
|
||||
layer3_richresult(node, rules, defects, entry["entry_id"], entry["url"],
|
||||
no_recommended)
|
||||
node_index.append((entry, node))
|
||||
|
||||
layer4_consistency(node_index, parsed_docs, rules, defects)
|
||||
return defects, valid_entries, len(node_index)
|
||||
|
||||
|
||||
def write_outputs(defects, outdir, meta):
|
||||
outdir = Path(outdir)
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# defect_log.csv — the client-facing triage artifact
|
||||
fields = ["entry_id", "url", "node_type", "layer", "code", "severity",
|
||||
"message", "status", "owner", "note"]
|
||||
rows = sorted(defects.rows, key=lambda r: (SEVERITY_ORDER[r["severity"]],
|
||||
r["layer"], r["code"]))
|
||||
with open(outdir / "defect_log.csv", "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fields)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
counts = defects.counts()
|
||||
gate = "PASS" if counts["P0"] == 0 else "FAIL"
|
||||
by_code = Counter((r["severity"], r["code"]) for r in defects.rows)
|
||||
|
||||
# results.json — machine-readable
|
||||
results = {
|
||||
"summary": {**meta, **counts, "total": len(rows), "gate": gate},
|
||||
"by_code": [{"severity": s, "code": c, "count": n}
|
||||
for (s, c), n in by_code.most_common()],
|
||||
"defects": rows,
|
||||
}
|
||||
(outdir / "results.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# report.md — human summary
|
||||
lines = [
|
||||
"# Schema Validation Report", "",
|
||||
f"- Entries read: **{meta['entries']}** | parsed OK: **{meta['valid_entries']}** "
|
||||
f"| nodes checked: **{meta['nodes']}**",
|
||||
f"- Defects: **P0 {counts['P0']}** · **P1 {counts['P1']}** · **P2 {counts['P2']}** "
|
||||
f"(total {len(rows)})",
|
||||
"",
|
||||
f"## Gate: **{gate}**",
|
||||
("> ✅ Zero P0 — entries may advance to client review."
|
||||
if gate == "PASS" else
|
||||
"> ⛔ P0 present — these entries must NOT reach client review. Fix P0 first."),
|
||||
"",
|
||||
"## Defects by code", "",
|
||||
"| Severity | Code | Count |", "|---|---|---|",
|
||||
]
|
||||
for (sev, code), n in by_code.most_common():
|
||||
lines.append(f"| {sev} | {code} | {n} |")
|
||||
|
||||
p0 = [r for r in rows if r["severity"] == "P0"]
|
||||
if p0:
|
||||
lines += ["", "## P0 blockers (top 15)", "",
|
||||
"| Entry | Type | Code | Message |", "|---|---|---|---|"]
|
||||
for r in p0[:15]:
|
||||
msg = r["message"].replace("|", "\\|")
|
||||
lines.append(f"| {r['entry_id']} | {r['node_type']} | {r['code']} | {msg} |")
|
||||
|
||||
lines += ["", "## Next step",
|
||||
("Triage P1 in `defect_log.csv`; client reviews the clean entries against this report."
|
||||
if gate == "PASS" else
|
||||
"Assign and fix every P0, re-run the validator, and only then open client review."),
|
||||
""]
|
||||
(outdir / "report.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
return gate, counts
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="5-layer offline JSON-LD schema validator.")
|
||||
ap.add_argument("dataset", nargs="?", help="xlsx/csv/jsonl/json file or a directory")
|
||||
ap.add_argument("--url-list", help="canonical URL inventory (xlsx/csv/txt) → enables Layer 0")
|
||||
ap.add_argument("--out", default="schema_qa_out", help="output directory")
|
||||
ap.add_argument("--strict", action="store_true",
|
||||
help="unexpected props on known types → P1; unknown types → P1")
|
||||
ap.add_argument("--no-recommended", action="store_true",
|
||||
help="drop L3 recommended (P2) findings — highest-signal gate")
|
||||
ap.add_argument("--live", nargs="+", metavar="URL",
|
||||
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
||||
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not args.dataset and not args.live:
|
||||
ap.error("provide a DATASET path or --live URL ...")
|
||||
|
||||
rules = json.loads(Path(args.rules).read_text(encoding="utf-8"))
|
||||
|
||||
try:
|
||||
entries = load_entries(args.dataset, args.live)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
print(f"ERROR loading input: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
||||
|
||||
defects, valid_entries, nodes = run(entries, rules, inventory,
|
||||
args.strict, args.no_recommended)
|
||||
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
||||
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
||||
"coverage": inventory is not None}
|
||||
gate, counts = write_outputs(defects, args.out, meta)
|
||||
|
||||
print(f"[{gate}] entries={len(entries)} nodes={nodes} "
|
||||
f"P0={counts['P0']} P1={counts['P1']} P2={counts['P2']} → {args.out}/")
|
||||
# Exit 1 when the gate fails so CI and `&&` chains stop on P0.
|
||||
return 0 if gate == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
# 구조화 데이터 QA 리포트 — {{프로젝트명}}
|
||||
|
||||
> 클라이언트 검토용. **원본 JSON이 아니라 결함 리포트를 검토합니다.**
|
||||
> 이 리포트에 오른 엔트리는 모두 기계 검증(Layer 0–4)을 통과한 **P0 0건** 상태입니다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 데이터셋 | `{{dataset_파일명}}` |
|
||||
| 검증 일시 | {{YYYY-MM-DD HH:MM}} |
|
||||
| 검증 모드 | A — Dataset QA (배포 전) / B — Live audit (배포 후) |
|
||||
| 엔트리 수 | {{entries}} (파싱 성공 {{valid_entries}}, 노드 {{nodes}}) |
|
||||
| **게이트** | **{{PASS / FAIL}}** (PASS = P0 0건) |
|
||||
| 결함 | P0 {{n}} · P1 {{n}} · P2 {{n}} |
|
||||
| Audit ID | SCHEMA-{{YYYYMMDD}}-{{NNN}} |
|
||||
|
||||
## 1. 한눈에 보기
|
||||
|
||||
- ✅ **검토 가능 엔트리**: P0 0건을 통과한 {{n}}개 — 아래 판단 항목만 확인해 주세요.
|
||||
- ⛔ **보류 엔트리**(있다면): P0 {{n}}건으로 검토 대상에서 제외. 수정 후 재검증합니다.
|
||||
- 이번 검토에서 **사람의 판단이 필요한 것**은 기계가 잡지 못하는 두 가지뿐입니다:
|
||||
1. 페이지에 맞는 스키마 **타입**이 선택되었는가
|
||||
2. 표시되는 **문구(설명·이름)**가 사실과 정확히 일치하는가
|
||||
|
||||
## 2. 결함 요약 (코드별)
|
||||
|
||||
| 심각도 | 코드 | 건수 | 의미 |
|
||||
|---|---|---|---|
|
||||
| P0 | {{CODE}} | {{n}} | {{한 줄 설명}} |
|
||||
| P1 | {{CODE}} | {{n}} | {{한 줄 설명}} |
|
||||
| P2 | {{CODE}} | {{n}} | {{한 줄 설명}} |
|
||||
|
||||
> 코드 정의: `references/defect-taxonomy.md`. 전체 목록: 첨부 `defect_log.csv`.
|
||||
|
||||
## 3. P0 블로커 (있을 경우 — 검토 전 수정 필수)
|
||||
|
||||
| 엔트리 | 타입 | 코드 | 내용 | 담당 | 상태 |
|
||||
|---|---|---|---|---|---|
|
||||
| {{entry_id}} | {{type}} | {{CODE}} | {{message}} | {{owner}} | open |
|
||||
|
||||
## 4. 클라이언트 확인 요청 (판단 항목)
|
||||
|
||||
기계가 통과시킨 엔트리 중, 사람의 확인이 필요한 항목입니다.
|
||||
|
||||
| # | URL / 페이지 | 확인 요청 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1 | {{url}} | 이 페이지에 `{{@type}}` 타입이 맞습니까? | |
|
||||
| 2 | {{url}} | 설명/이름 문구가 정확합니까? | |
|
||||
|
||||
## 5. 다음 단계
|
||||
|
||||
- **PASS인 경우**: 위 4번 판단 항목 확정 → 배포 단계(G4 안정화)로 이동, 샘플을 Google
|
||||
Rich Results Test로 최종 확인.
|
||||
- **FAIL인 경우**: P0 담당 배정 → 수정 → 재검증(`validate_schema.py`) → 본 리포트 갱신.
|
||||
- P1 처리 방침(수정/수용)은 `decision-log.md`에 기록합니다.
|
||||
|
||||
---
|
||||
*생성: 16-seo-schema-validator · 첨부: `report.md`, `defect_log.csv`, `results.json`*
|
||||
@@ -0,0 +1,39 @@
|
||||
# P1 Decision Log — {{프로젝트명}}
|
||||
|
||||
P0 is non-negotiable: every P0 is fixed before launch (the gate enforces it). **P1 is
|
||||
where judgement lives** — some P1s get fixed, some get consciously accepted. This log
|
||||
records *which, by whom, and why*, so an accepted P1 is a decision on the record, not a
|
||||
silently ignored defect. It is the G3 (테스트) deliverable.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Open `defect_log.csv`, filter to `severity = P1`.
|
||||
2. For each P1 (or each group of identical P1s), add a row below.
|
||||
3. Decision is one of: **Fix** (will correct before launch) / **Accept** (ship as-is,
|
||||
with rationale) / **Defer** (post-launch backlog).
|
||||
4. An `Accept`/`Defer` needs a named approver. `Fix` needs an owner + target date.
|
||||
5. Re-run the validator after the fixes; confirm the fixed P1s are gone.
|
||||
|
||||
## Log
|
||||
|
||||
| # | Code | Entry / scope | Summary | Decision | Owner / Approver | Target / Date | Rationale |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | {{CODE}} | {{entry_id or (dataset)}} | {{one line}} | Fix / Accept / Defer | {{name}} | {{YYYY-MM-DD}} | {{why}} |
|
||||
| 2 | | | | | | | |
|
||||
| 3 | | | | | | | |
|
||||
|
||||
## Standing decisions (apply to all entries unless overridden)
|
||||
|
||||
Record cross-cutting calls once here instead of per row — e.g. "MISSING_RECOMMENDED for
|
||||
`starRating` is accepted group-wide: not contractually rated." Reduces log noise.
|
||||
|
||||
| Code | Standing decision | Approver | Date |
|
||||
|---|---|---|---|
|
||||
| {{CODE}} | Accept group-wide — {{reason}} | {{name}} | {{YYYY-MM-DD}} |
|
||||
|
||||
## Sign-off
|
||||
|
||||
| Stage gate | Condition | Confirmed by | Date |
|
||||
|---|---|---|---|
|
||||
| G3 테스트 | All P1 triaged (Fix/Accept/Defer), decisions logged above | {{name}} | {{date}} |
|
||||
| G4 안정화 | P0 = 0, all "Fix" P1 closed, online validator green on sample | {{name}} | {{date}} |
|
||||
126
custom-skills/17-seo-schema-generator/SKILL.md
Normal file
126
custom-skills/17-seo-schema-generator/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: 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 then 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.
|
||||
@@ -1,156 +1,52 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md — seo-schema-generator (Claude Code)
|
||||
|
||||
## Overview
|
||||
## Canonical entry point
|
||||
|
||||
Schema markup generator: create JSON-LD structured data from templates for various content types.
|
||||
This skill was upgraded to a **two-mode source-to-schema pipeline** (it absorbed and
|
||||
retired the old template-fill generator). The authoritative directive and run
|
||||
instructions live in the skill root:
|
||||
|
||||
## Quick Start
|
||||
- **`../SKILL.md`** — the two modes, the claims-register pivot, stage gates, how to run.
|
||||
- **`../scripts/extract_site_claims.py`** — Mode 1: existing site → claims register.
|
||||
- **`../scripts/build_schema_drafts.py`** — claims register → JSON-LD drafts + dataset CSV.
|
||||
- **`../scripts/type_templates.json`** — draft templates (edit JSON to add a type).
|
||||
- **`../references/`** — `site-extraction-methodology.md` (Mode 1), `source-to-schema-methodology.md` (Mode 2), `source-authority-hierarchy.md`, `entity-and-type-map.md`.
|
||||
- **`../templates/`** — `claims-register.csv`, `source-register.csv`, `review-guide.md`.
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements.txt
|
||||
# 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
|
||||
|
||||
# Generate Organization schema
|
||||
python scripts/schema_generator.py --type organization --url https://example.com
|
||||
# Mode 1 — existing site → claims register (URLs, or local .html / a directory offline)
|
||||
python ../scripts/extract_site_claims.py https://example.com/ --out site_claims
|
||||
python ../scripts/build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
|
||||
|
||||
# Generate from template
|
||||
python scripts/schema_generator.py --template templates/article.json --data article_data.json
|
||||
# Hand off to the QA gate (must reach zero P0)
|
||||
python ../../16-seo-schema-validator/scripts/validate_schema.py \
|
||||
drafts_out/schema_drafts_dataset.csv --out qa_out
|
||||
```
|
||||
|
||||
## Scripts
|
||||
Core rule: **only CONFIRMED, non-conflicting claims become schema.** Unfilled template
|
||||
slots are pruned — never emitted as `{{…}}` or `TODO`. **Generate (17) → Validate (16).**
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `schema_generator.py` | Generate schema markup |
|
||||
| `base_client.py` | Shared utilities |
|
||||
## Retired
|
||||
|
||||
## Supported Schema Types
|
||||
The previous template-fill tool (`schema_generator.py` + per-type JSON templates) is
|
||||
superseded by the claims-register engine above and has been removed. Use
|
||||
`build_schema_drafts.py` for all generation.
|
||||
|
||||
| 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 |
|
||||
## Notion output (OurDigital SEO Audit Log)
|
||||
|
||||
## 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
|
||||
When generation is part of an OurDigital/D.intelligence engagement, log a summary to the
|
||||
SEO Audit Log database. Per the user-level Notion rule, push **page content** with the
|
||||
`notion-writer` skill; use Notion MCP only for **properties**.
|
||||
|
||||
| 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": {...}}'
|
||||
```
|
||||
| Category | `Schema/Structured Data` |
|
||||
| Audit ID | `SCHEMA-YYYYMMDD-NNN` |
|
||||
|
||||
Report content in Korean; keep technical terms (Schema, JSON-LD, claims register) and
|
||||
URLs/code unchanged.
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1,490 +0,0 @@
|
||||
"""
|
||||
Schema Generator - Generate JSON-LD structured data markup
|
||||
==========================================================
|
||||
Purpose: Generate schema.org structured data in JSON-LD format
|
||||
Python: 3.10+
|
||||
Usage:
|
||||
python schema_generator.py --type organization --name "Company Name" --url "https://example.com"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory relative to this script
|
||||
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "schema_templates"
|
||||
|
||||
|
||||
class SchemaGenerator:
|
||||
"""Generate JSON-LD schema markup from templates."""
|
||||
|
||||
SCHEMA_TYPES = {
|
||||
"organization": "organization.json",
|
||||
"local_business": "local_business.json",
|
||||
"product": "product.json",
|
||||
"article": "article.json",
|
||||
"faq": "faq.json",
|
||||
"breadcrumb": "breadcrumb.json",
|
||||
"website": "website.json",
|
||||
}
|
||||
|
||||
# Business type mappings for LocalBusiness
|
||||
BUSINESS_TYPES = {
|
||||
"restaurant": "Restaurant",
|
||||
"cafe": "CafeOrCoffeeShop",
|
||||
"bar": "BarOrPub",
|
||||
"hotel": "Hotel",
|
||||
"store": "Store",
|
||||
"medical": "MedicalBusiness",
|
||||
"dental": "Dentist",
|
||||
"legal": "LegalService",
|
||||
"real_estate": "RealEstateAgent",
|
||||
"auto": "AutoRepair",
|
||||
"beauty": "BeautySalon",
|
||||
"gym": "HealthClub",
|
||||
"spa": "DaySpa",
|
||||
}
|
||||
|
||||
# Article type mappings
|
||||
ARTICLE_TYPES = {
|
||||
"article": "Article",
|
||||
"blog": "BlogPosting",
|
||||
"news": "NewsArticle",
|
||||
"tech": "TechArticle",
|
||||
"scholarly": "ScholarlyArticle",
|
||||
}
|
||||
|
||||
def __init__(self, template_dir: Path = TEMPLATE_DIR):
|
||||
self.template_dir = template_dir
|
||||
|
||||
def load_template(self, schema_type: str) -> dict:
|
||||
"""Load a schema template file."""
|
||||
if schema_type not in self.SCHEMA_TYPES:
|
||||
raise ValueError(f"Unknown schema type: {schema_type}. "
|
||||
f"Available: {list(self.SCHEMA_TYPES.keys())}")
|
||||
|
||||
template_file = self.template_dir / self.SCHEMA_TYPES[schema_type]
|
||||
if not template_file.exists():
|
||||
raise FileNotFoundError(f"Template not found: {template_file}")
|
||||
|
||||
with open(template_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def fill_template(self, template: dict, data: dict[str, Any]) -> dict:
|
||||
"""Fill template placeholders with actual data."""
|
||||
template_str = json.dumps(template, ensure_ascii=False)
|
||||
|
||||
# Replace placeholders {{key}} with values
|
||||
for key, value in data.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
if value is not None:
|
||||
template_str = template_str.replace(placeholder, str(value))
|
||||
|
||||
# Remove unfilled placeholders and their parent objects if empty
|
||||
result = json.loads(template_str)
|
||||
return self._clean_empty_values(result)
|
||||
|
||||
def _clean_empty_values(self, obj: Any) -> Any:
|
||||
"""Remove empty values and unfilled placeholders."""
|
||||
if isinstance(obj, dict):
|
||||
cleaned = {}
|
||||
for key, value in obj.items():
|
||||
cleaned_value = self._clean_empty_values(value)
|
||||
# Skip if value is empty, None, or unfilled placeholder
|
||||
if cleaned_value is None:
|
||||
continue
|
||||
if isinstance(cleaned_value, str) and cleaned_value.startswith("{{"):
|
||||
continue
|
||||
if isinstance(cleaned_value, (list, dict)) and not cleaned_value:
|
||||
continue
|
||||
cleaned[key] = cleaned_value
|
||||
return cleaned if cleaned else None
|
||||
elif isinstance(obj, list):
|
||||
cleaned = []
|
||||
for item in obj:
|
||||
cleaned_item = self._clean_empty_values(item)
|
||||
if cleaned_item is not None:
|
||||
if isinstance(cleaned_item, str) and cleaned_item.startswith("{{"):
|
||||
continue
|
||||
cleaned.append(cleaned_item)
|
||||
return cleaned if cleaned else None
|
||||
elif isinstance(obj, str):
|
||||
if obj.startswith("{{") and obj.endswith("}}"):
|
||||
return None
|
||||
return obj
|
||||
return obj
|
||||
|
||||
def generate_organization(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
logo_url: str | None = None,
|
||||
description: str | None = None,
|
||||
founding_date: str | None = None,
|
||||
phone: str | None = None,
|
||||
address: dict | None = None,
|
||||
social_links: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Generate Organization schema."""
|
||||
template = self.load_template("organization")
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"logo_url": logo_url,
|
||||
"description": description,
|
||||
"founding_date": founding_date,
|
||||
"phone": phone,
|
||||
}
|
||||
|
||||
if address:
|
||||
data.update({
|
||||
"street_address": address.get("street"),
|
||||
"city": address.get("city"),
|
||||
"region": address.get("region"),
|
||||
"postal_code": address.get("postal_code"),
|
||||
"country": address.get("country", "KR"),
|
||||
})
|
||||
|
||||
if social_links:
|
||||
# Handle social links specially
|
||||
pass
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_local_business(
|
||||
self,
|
||||
name: str,
|
||||
business_type: str,
|
||||
address: dict,
|
||||
phone: str | None = None,
|
||||
url: str | None = None,
|
||||
description: str | None = None,
|
||||
hours: dict | None = None,
|
||||
geo: dict | None = None,
|
||||
price_range: str | None = None,
|
||||
rating: float | None = None,
|
||||
review_count: int | None = None,
|
||||
) -> dict:
|
||||
"""Generate LocalBusiness schema."""
|
||||
template = self.load_template("local_business")
|
||||
|
||||
schema_business_type = self.BUSINESS_TYPES.get(
|
||||
business_type.lower(), "LocalBusiness"
|
||||
)
|
||||
|
||||
data = {
|
||||
"business_type": schema_business_type,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"description": description,
|
||||
"phone": phone,
|
||||
"price_range": price_range,
|
||||
"street_address": address.get("street"),
|
||||
"city": address.get("city"),
|
||||
"region": address.get("region"),
|
||||
"postal_code": address.get("postal_code"),
|
||||
"country": address.get("country", "KR"),
|
||||
}
|
||||
|
||||
if geo:
|
||||
data["latitude"] = geo.get("lat")
|
||||
data["longitude"] = geo.get("lng")
|
||||
|
||||
if hours:
|
||||
data.update({
|
||||
"weekday_opens": hours.get("weekday_opens", "09:00"),
|
||||
"weekday_closes": hours.get("weekday_closes", "18:00"),
|
||||
"weekend_opens": hours.get("weekend_opens"),
|
||||
"weekend_closes": hours.get("weekend_closes"),
|
||||
})
|
||||
|
||||
if rating is not None:
|
||||
data["rating"] = str(rating)
|
||||
data["review_count"] = str(review_count or 0)
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_product(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
price: float,
|
||||
currency: str = "KRW",
|
||||
brand: str | None = None,
|
||||
sku: str | None = None,
|
||||
images: list[str] | None = None,
|
||||
availability: str = "InStock",
|
||||
condition: str = "NewCondition",
|
||||
rating: float | None = None,
|
||||
review_count: int | None = None,
|
||||
url: str | None = None,
|
||||
seller: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate Product schema."""
|
||||
template = self.load_template("product")
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"price": str(int(price)),
|
||||
"currency": currency,
|
||||
"brand_name": brand,
|
||||
"sku": sku,
|
||||
"product_url": url,
|
||||
"availability": availability,
|
||||
"condition": condition,
|
||||
"seller_name": seller,
|
||||
}
|
||||
|
||||
if images:
|
||||
for i, img in enumerate(images[:3], 1):
|
||||
data[f"image_url_{i}"] = img
|
||||
|
||||
if rating is not None:
|
||||
data["rating"] = str(rating)
|
||||
data["review_count"] = str(review_count or 0)
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_article(
|
||||
self,
|
||||
headline: str,
|
||||
description: str,
|
||||
author_name: str,
|
||||
date_published: str,
|
||||
publisher_name: str,
|
||||
article_type: str = "article",
|
||||
date_modified: str | None = None,
|
||||
images: list[str] | None = None,
|
||||
page_url: str | None = None,
|
||||
publisher_logo: str | None = None,
|
||||
author_url: str | None = None,
|
||||
section: str | None = None,
|
||||
word_count: int | None = None,
|
||||
keywords: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate Article schema."""
|
||||
template = self.load_template("article")
|
||||
|
||||
schema_article_type = self.ARTICLE_TYPES.get(
|
||||
article_type.lower(), "Article"
|
||||
)
|
||||
|
||||
data = {
|
||||
"article_type": schema_article_type,
|
||||
"headline": headline,
|
||||
"description": description,
|
||||
"author_name": author_name,
|
||||
"author_url": author_url,
|
||||
"date_published": date_published,
|
||||
"date_modified": date_modified or date_published,
|
||||
"publisher_name": publisher_name,
|
||||
"publisher_logo_url": publisher_logo,
|
||||
"page_url": page_url,
|
||||
"section": section,
|
||||
"word_count": str(word_count) if word_count else None,
|
||||
"keywords": keywords,
|
||||
}
|
||||
|
||||
if images:
|
||||
for i, img in enumerate(images[:2], 1):
|
||||
data[f"image_url_{i}"] = img
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def generate_faq(self, questions: list[dict[str, str]]) -> dict:
|
||||
"""Generate FAQPage schema."""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [],
|
||||
}
|
||||
|
||||
for qa in questions:
|
||||
schema["mainEntity"].append({
|
||||
"@type": "Question",
|
||||
"name": qa["question"],
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": qa["answer"],
|
||||
},
|
||||
})
|
||||
|
||||
return schema
|
||||
|
||||
def generate_breadcrumb(self, items: list[dict[str, str]]) -> dict:
|
||||
"""Generate BreadcrumbList schema."""
|
||||
schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [],
|
||||
}
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
schema["itemListElement"].append({
|
||||
"@type": "ListItem",
|
||||
"position": i,
|
||||
"name": item["name"],
|
||||
"item": item["url"],
|
||||
})
|
||||
|
||||
return schema
|
||||
|
||||
def generate_website(
|
||||
self,
|
||||
name: str,
|
||||
url: str,
|
||||
search_url_template: str | None = None,
|
||||
description: str | None = None,
|
||||
language: str = "ko-KR",
|
||||
publisher_name: str | None = None,
|
||||
logo_url: str | None = None,
|
||||
alternate_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Generate WebSite schema."""
|
||||
template = self.load_template("website")
|
||||
|
||||
data = {
|
||||
"site_name": name,
|
||||
"url": url,
|
||||
"description": description,
|
||||
"language": language,
|
||||
"search_url_template": search_url_template,
|
||||
"publisher_name": publisher_name or name,
|
||||
"logo_url": logo_url,
|
||||
"alternate_name": alternate_name,
|
||||
}
|
||||
|
||||
return self.fill_template(template, data)
|
||||
|
||||
def to_json_ld(self, schema: dict, pretty: bool = True) -> str:
|
||||
"""Convert schema dict to JSON-LD string."""
|
||||
indent = 2 if pretty else None
|
||||
return json.dumps(schema, ensure_ascii=False, indent=indent)
|
||||
|
||||
def to_html_script(self, schema: dict) -> str:
|
||||
"""Wrap schema in HTML script tag."""
|
||||
json_ld = self.to_json_ld(schema)
|
||||
return f'<script type="application/ld+json">\n{json_ld}\n</script>'
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for CLI usage."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate JSON-LD schema markup",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Generate Organization schema
|
||||
python schema_generator.py --type organization --name "My Company" --url "https://example.com"
|
||||
|
||||
# Generate Product schema
|
||||
python schema_generator.py --type product --name "Widget" --price 29900 --currency KRW
|
||||
|
||||
# Generate Article schema
|
||||
python schema_generator.py --type article --headline "Article Title" --author "John Doe"
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--type", "-t",
|
||||
required=True,
|
||||
choices=SchemaGenerator.SCHEMA_TYPES.keys(),
|
||||
help="Schema type to generate",
|
||||
)
|
||||
parser.add_argument("--name", help="Name/title")
|
||||
parser.add_argument("--url", help="URL")
|
||||
parser.add_argument("--description", help="Description")
|
||||
parser.add_argument("--price", type=float, help="Price (for product)")
|
||||
parser.add_argument("--currency", default="KRW", help="Currency code")
|
||||
parser.add_argument("--headline", help="Headline (for article)")
|
||||
parser.add_argument("--author", help="Author name")
|
||||
parser.add_argument("--output", "-o", help="Output file path")
|
||||
parser.add_argument("--html", action="store_true", help="Output as HTML script tag")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generator = SchemaGenerator()
|
||||
|
||||
try:
|
||||
if args.type == "organization":
|
||||
schema = generator.generate_organization(
|
||||
name=args.name or "Organization Name",
|
||||
url=args.url or "https://example.com",
|
||||
description=args.description,
|
||||
)
|
||||
elif args.type == "product":
|
||||
schema = generator.generate_product(
|
||||
name=args.name or "Product Name",
|
||||
description=args.description or "Product description",
|
||||
price=args.price or 0,
|
||||
currency=args.currency,
|
||||
)
|
||||
elif args.type == "article":
|
||||
schema = generator.generate_article(
|
||||
headline=args.headline or args.name or "Article Title",
|
||||
description=args.description or "Article description",
|
||||
author_name=args.author or "Author",
|
||||
date_published=datetime.now().strftime("%Y-%m-%d"),
|
||||
publisher_name="Publisher",
|
||||
)
|
||||
elif args.type == "website":
|
||||
schema = generator.generate_website(
|
||||
name=args.name or "Website Name",
|
||||
url=args.url or "https://example.com",
|
||||
description=args.description,
|
||||
)
|
||||
elif args.type == "faq":
|
||||
# Example FAQ
|
||||
schema = generator.generate_faq([
|
||||
{"question": "Question 1?", "answer": "Answer 1"},
|
||||
{"question": "Question 2?", "answer": "Answer 2"},
|
||||
])
|
||||
elif args.type == "breadcrumb":
|
||||
# Example breadcrumb
|
||||
schema = generator.generate_breadcrumb([
|
||||
{"name": "Home", "url": "https://example.com/"},
|
||||
{"name": "Category", "url": "https://example.com/category/"},
|
||||
])
|
||||
elif args.type == "local_business":
|
||||
schema = generator.generate_local_business(
|
||||
name=args.name or "Business Name",
|
||||
business_type="store",
|
||||
address={"street": "123 Main St", "city": "Seoul", "country": "KR"},
|
||||
url=args.url,
|
||||
description=args.description,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported type: {args.type}")
|
||||
|
||||
if args.html:
|
||||
output = generator.to_html_script(schema)
|
||||
else:
|
||||
output = generator.to_json_ld(schema)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
logger.info(f"Schema written to {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating schema: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +1,55 @@
|
||||
---
|
||||
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.
|
||||
Generates validation-ready JSON-LD for a site via a claims register — Mode 1 from
|
||||
an existing website, Mode 2 from collected sources for a not-yet-published site.
|
||||
Triggers: generate schema, create JSON-LD, source-to-schema, pre-launch schema,
|
||||
schema from site, claims register, 스키마 생성, 스키마 저작.
|
||||
---
|
||||
|
||||
# SEO Schema Generator
|
||||
|
||||
> Desktop reference. The full, runnable specification (scripts, references, templates,
|
||||
> fixtures, stage gates) is the skill-root `SKILL.md` and its bundled directories — this
|
||||
> file is the Claude Desktop entry point.
|
||||
|
||||
## Purpose
|
||||
|
||||
Generate JSON-LD structured data markup for various content types using templates.
|
||||
Author JSON-LD for a site whether or not its pages exist yet. Both scenarios route
|
||||
through one pivot — a **claims register** (provenance-tracked, conflict-resolved facts) —
|
||||
then generate pruned drafts that hand off to **seo-schema-validator** (generate → validate).
|
||||
|
||||
## Core Capabilities
|
||||
## Two modes, one pipeline
|
||||
|
||||
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
|
||||
| | Mode 1 — existing site | Mode 2 — collected sources |
|
||||
|---|---|---|
|
||||
| Source of truth | the live pages | scattered sources (DART, Wikidata, brochures) |
|
||||
| Seed the register with | extract from pages | manual research |
|
||||
| Hard part | extraction & mapping | authority hierarchy + entity reconciliation |
|
||||
|
||||
Everything after the claims register (build drafts → prune unfilled slots → validate)
|
||||
is identical. **Only CONFIRMED, non-conflicting claims become schema;** unfilled template
|
||||
slots are deleted, never shipped as placeholders.
|
||||
|
||||
## MCP Tool Usage
|
||||
|
||||
```
|
||||
mcp__firecrawl__scrape / crawl : Mode 1 — pull existing pages to extract facts
|
||||
mcp__perplexity__search : Mode 2 — discover & cross-check authoritative sources
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify content type
|
||||
2. Gather required information
|
||||
3. Generate JSON-LD from template
|
||||
4. Validate output
|
||||
5. Provide implementation instructions
|
||||
1. Lock the entity→type map (scope first).
|
||||
2. Seed the claims register (Mode 1: extract from pages · Mode 2: research → register).
|
||||
3. Reconcile to CONFIRMED; clear conflicts.
|
||||
4. Build drafts from type templates (placeholders pruned).
|
||||
5. Validate with seo-schema-validator — gate = zero P0.
|
||||
6. Fix P0, re-validate, then client review against the report (not raw JSON).
|
||||
|
||||
## Schema Templates
|
||||
|
||||
### Organization
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "[Company Name]",
|
||||
"url": "[Website URL]",
|
||||
"logo": "[Logo URL]",
|
||||
"sameAs": [
|
||||
"[Social Media URLs]"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### LocalBusiness
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"name": "[Business Name]",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "[Street]",
|
||||
"addressLocality": "[City]",
|
||||
"addressRegion": "[State]",
|
||||
"postalCode": "[ZIP]",
|
||||
"addressCountry": "[Country]"
|
||||
},
|
||||
"telephone": "[Phone]",
|
||||
"openingHours": ["Mo-Fr 09:00-17:00"]
|
||||
}
|
||||
```
|
||||
|
||||
### Article
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "[Title]",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "[Author Name]"
|
||||
},
|
||||
"datePublished": "[YYYY-MM-DD]",
|
||||
"dateModified": "[YYYY-MM-DD]",
|
||||
"image": "[Image URL]",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "[Publisher]",
|
||||
"logo": "[Logo URL]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### FAQPage
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "[Question]",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "[Answer]"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Product
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": "[Product Name]",
|
||||
"image": "[Image URL]",
|
||||
"description": "[Description]",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "[Price]",
|
||||
"priceCurrency": "[Currency]",
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Place generated JSON-LD in `<head>` section:
|
||||
|
||||
```html
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
[Generated Schema Here]
|
||||
</script>
|
||||
</head>
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After generating:
|
||||
1. Use schema validator skill (13) to verify
|
||||
2. Test with Google Rich Results Test
|
||||
3. Monitor in Search Console
|
||||
|
||||
## Limitations
|
||||
|
||||
- Templates cover common types only
|
||||
- Complex nested schemas may need manual adjustment
|
||||
- Some Rich Results require additional properties
|
||||
|
||||
## Notion Output (Required)
|
||||
|
||||
All audit reports MUST be saved to OurDigital SEO Audit Log:
|
||||
- **Database ID**: `2c8581e5-8a1e-8035-880b-e38cefc2f3ef`
|
||||
- **Properties**: Issue (title), Site (url), Category, Priority, Found Date, Audit ID
|
||||
- **Language**: Korean with English technical terms
|
||||
- **Audit ID Format**: [TYPE]-YYYYMMDD-NNN
|
||||
## Notes
|
||||
|
||||
- Mode 1 inference (title/OpenGraph) is seeded PENDING and never auto-ships; existing
|
||||
JSON-LD is seeded CONFIRMED. If a site already has good JSON-LD, audit it with
|
||||
seo-schema-validator (Mode B) instead of regenerating.
|
||||
- Authoritative rich-result eligibility still needs Google's online test on a sample.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
name: seo-schema-generator
|
||||
description: |
|
||||
Schema markup generator for JSON-LD structured data. Triggers: generate schema, create JSON-LD, add structured data, schema markup.
|
||||
JSON-LD generator with two modes — from an existing website, or from collected
|
||||
sources for a not-yet-published site — both via a claims register. Triggers:
|
||||
generate schema, create JSON-LD, source-to-schema, schema from site, claims register.
|
||||
|
||||
# Optional fields
|
||||
allowed-tools:
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,값 공란 -> 제외
|
||||
|
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<!-- No JSON-LD here → only meta/OG, seeded as PENDING (must be confirmed) -->
|
||||
<title>그랜드 조선 부산 — 객실 및 예약</title>
|
||||
<meta name="description" content="해운대 해변에 위치한 럭셔리 호텔, 그랜드 조선 부산.">
|
||||
<meta property="og:type" content="business.business">
|
||||
<meta property="og:title" content="그랜드 조선 부산">
|
||||
<meta property="og:url" content="https://www.josunhotel.com/grand-busan">
|
||||
<meta property="og:image" content="https://www.josunhotel.com/grand-busan.jpg">
|
||||
<link rel="canonical" href="https://www.josunhotel.com/grand-busan">
|
||||
</head>
|
||||
<body><h1>그랜드 조선 부산</h1></body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<title>조선호텔앤리조트 — 공식 홈페이지</title>
|
||||
<meta property="og:site_name" content="조선호텔앤리조트">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://www.josunhotel.com/">
|
||||
<link rel="canonical" href="https://www.josunhotel.com/">
|
||||
<!-- Existing JSON-LD on the live page → extracted as CONFIRMED claims -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": "https://www.josunhotel.com/#org",
|
||||
"name": "조선호텔앤리조트",
|
||||
"url": "https://www.josunhotel.com/",
|
||||
"logo": "https://www.josunhotel.com/logo.png",
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/josunhotelsandresorts/",
|
||||
"https://www.wikidata.org/wiki/Q567458"
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "Hotel",
|
||||
"@id": "https://www.josunhotel.com/westin#hotel",
|
||||
"name": "웨스틴 조선 서울",
|
||||
"url": "https://www.josunhotel.com/westin",
|
||||
"telephone": "+82-2-771-0500",
|
||||
"priceRange": "$$$$",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "소공로 106",
|
||||
"addressLocality": "서울",
|
||||
"addressRegion": "중구",
|
||||
"addressCountry": "KR"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body><h1>조선호텔앤리조트</h1></body>
|
||||
</html>
|
||||
@@ -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).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Site-Extraction Methodology (Mode 1 — from an existing website)
|
||||
|
||||
How to turn an existing site into a claims register, and why this mode is *easier*
|
||||
than Mode 2 (collected sources) but still must not blindly trust what it scrapes.
|
||||
|
||||
Pair skill: extraction is `scripts/extract_site_claims.py`; the build engine and the
|
||||
QA gate are shared with Mode 2. See `source-to-schema-methodology.md` for Mode 2.
|
||||
|
||||
## Why a website is the easy case (and where it still bites)
|
||||
|
||||
A published site has a **single source of truth** — the pages themselves — so there is
|
||||
little to reconcile. The risks are different from Mode 2:
|
||||
|
||||
| Risk on an existing site | What it causes | Countermeasure |
|
||||
|---|---|---|
|
||||
| Trusting inferred meta as fact | wrong/old values shipped as schema | meta/OG seeded **PENDING**, never auto-shipped |
|
||||
| Existing JSON-LD is partial or stale | gaps, outdated facts | extracted as CONFIRMED but **spot-checked** at review |
|
||||
| Many near-identical pages | duplicate descriptions, bloated register | one entity per real thing; let Layer 4 catch dupes |
|
||||
| JS-rendered schema not in raw HTML | "nothing extracted" | use a rendered snapshot / live fetch, or fall to Mode 2 |
|
||||
|
||||
## The 5 steps
|
||||
|
||||
### 1. Choose the pages
|
||||
Pick the canonical page per entity (home, about/company, each property/location, key
|
||||
product/FAQ pages). One representative page per entity is enough to seed it; you don't
|
||||
need the whole crawl.
|
||||
|
||||
### 2. Extract
|
||||
Run the adapter on URLs, local `.html` files, or a directory (offline):
|
||||
```bash
|
||||
python scripts/extract_site_claims.py https://site/ https://site/about --out site_claims
|
||||
python scripts/extract_site_claims.py ./snapshot/ --out site_claims # offline
|
||||
```
|
||||
It produces two tiers of claims:
|
||||
- **Existing JSON-LD → `CONFIRMED` (authority 1).** The site already published these
|
||||
facts about itself; flattened to dotted-path claims.
|
||||
- **`<title>` / meta description / OpenGraph / `<html lang>` / canonical → `PENDING`
|
||||
(authority 2).** Inferred, not authoritative. These will **not** ship until confirmed.
|
||||
|
||||
### 3. Review the register (the critical human step)
|
||||
Open `site_claims/claims_register.csv`:
|
||||
- **Spot-check CONFIRMED rows** — extraction is faithful, but the site's own JSON-LD can
|
||||
be wrong/stale. Correct values; clear nothing silently.
|
||||
- **Confirm or drop PENDING rows** — set `status=CONFIRMED` only for facts you've verified;
|
||||
delete the rest. PENDING rows are excluded by the builder by design.
|
||||
- **Add what the page didn't expose** — telephone, full address, `geo`, `sameAs`,
|
||||
`priceRange`. The richest schema usually needs facts no single page renders.
|
||||
- Set `conflict=Y` on any value you're unsure about to keep it out until resolved.
|
||||
|
||||
### 4. Build
|
||||
```bash
|
||||
python scripts/build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
|
||||
```
|
||||
Unfilled slots are pruned; only CONFIRMED, non-conflicting claims become schema. Read
|
||||
`drafts_out/build_report.md` for everything excluded and why.
|
||||
|
||||
### 5. Validate (the gate)
|
||||
```bash
|
||||
python ../16-seo-schema-validator/scripts/validate_schema.py \
|
||||
drafts_out/schema_drafts_dataset.csv --out qa_out
|
||||
```
|
||||
Gate = **zero P0**. Fix P0, re-build, re-validate, then open client review against the
|
||||
report (not raw JSON).
|
||||
|
||||
## When NOT to use Mode 1
|
||||
|
||||
If the existing site **already has good, complete JSON-LD**, you don't need to regenerate
|
||||
it — **audit it in place** with `16-seo-schema-validator` Mode B
|
||||
(`validate_schema.py --live <URL>`). Mode 1 is for sites whose pages carry the *facts* but
|
||||
not yet the *structured data*, or whose schema needs a rebuild.
|
||||
|
||||
## entity_id convention
|
||||
|
||||
The adapter assigns `prefix:slug` ids (`org:`, `site:`, `hotel:`, `dining:`, `page:`, …)
|
||||
derived from each node's `@id` fragment or page URL. Rename them to stable, human ids
|
||||
during review (e.g. `hotel:theshilla-seoul`) so re-runs and Mode 2 additions line up.
|
||||
@@ -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` → 빌더가 스키마로 채택.
|
||||
@@ -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단계 게이트
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_schema_drafts.py — Source-to-Schema draft generator (skill 17, pre-launch)
|
||||
|
||||
WHAT IT DOES
|
||||
Turns a *claims register* (reconciled, provenance-tracked facts) into JSON-LD
|
||||
drafts, then writes a dataset CSV that feeds straight into
|
||||
16-seo-schema-validator/scripts/validate_schema.py.
|
||||
|
||||
WHY A CLAIMS REGISTER FIRST (the core idea)
|
||||
Authoring schema for a site that does not exist yet is error-prone because the
|
||||
facts live in many conflicting sources (DART, Wikipedia, brochures...). If you
|
||||
pour raw, unreconciled facts straight into JSON-LD you reproduce every conflict
|
||||
and gap as a schema defect. So we reconcile facts FIRST (one confirmed value per
|
||||
property, with sources recorded), and only CONFIRMED, non-conflicting claims are
|
||||
allowed to become schema. Everything else is reported, not shipped.
|
||||
|
||||
THE PRUNING RULE (placeholder leakage is the #1 pre-launch defect)
|
||||
A template slot that has no confirmed value is DELETED — never emitted as
|
||||
"{{...}}" or "TODO". An empty nested object (only @type left) is dropped too.
|
||||
This guarantees drafts contain only backed facts.
|
||||
|
||||
INPUT (claims register): .csv or .xlsx with columns (case-insensitive, KR/EN aliases ok)
|
||||
entity_id e.g. org:shilla, hotel:theshilla-seoul (groups rows into one node)
|
||||
entity_type Organization | Hotel | Person | JobPosting | VideoObject | WebSite | FAQPage
|
||||
property schema.org property, dotted for nesting: address.streetAddress
|
||||
append nothing for scalars; arrays are handled automatically
|
||||
value the confirmed value (pipe-separate multiple values: a|b|c)
|
||||
lang optional (ko/en/ja/zh) -> produces one draft per language
|
||||
url optional target URL (provisional ok) -> carried to validator
|
||||
source_ids optional pipe-separated refs into the source register (provenance)
|
||||
authority optional 1..n (1 = most authoritative) — for your audit trail
|
||||
confidence optional high|med|low
|
||||
conflict optional — any truthy value (Y/1/true/충돌) EXCLUDES the claim
|
||||
status CONFIRMED (default if blank) | PENDING | REJECTED — only CONFIRMED ships
|
||||
note optional
|
||||
|
||||
USAGE
|
||||
python scripts/build_schema_drafts.py path/to/claims_register.csv \
|
||||
--templates scripts/type_templates.json --out drafts_out
|
||||
# then hand off to the validator:
|
||||
python ../16-seo-schema-validator/scripts/validate_schema.py \
|
||||
drafts_out/schema_drafts_dataset.csv --out qa_out
|
||||
"""
|
||||
|
||||
import argparse, csv, json, os, sys, copy, re
|
||||
from collections import defaultdict, OrderedDict
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Column aliasing — accept Korean/English header variants #
|
||||
# ----------------------------------------------------------------------------- #
|
||||
COL_ALIASES = {
|
||||
"entity_id": ["entity_id", "entity", "엔티티", "엔티티id", "id"],
|
||||
"entity_type": ["entity_type", "type", "타입", "유형", "스키마타입"],
|
||||
"property": ["property", "prop", "속성", "프로퍼티", "path"],
|
||||
"value": ["value", "값", "내용"],
|
||||
"lang": ["lang", "language", "언어", "언어코드"],
|
||||
"url": ["url", "메뉴 url", "메뉴url", "주소"],
|
||||
"source_ids": ["source_ids", "source", "sources", "출처", "출처id"],
|
||||
"authority": ["authority", "권위", "권위순위"],
|
||||
"confidence": ["confidence", "신뢰도"],
|
||||
"conflict": ["conflict", "충돌", "conflict_flag"],
|
||||
"status": ["status", "상태"],
|
||||
"note": ["note", "notes", "비고", "메모"],
|
||||
}
|
||||
TRUTHY = {"y", "yes", "1", "true", "t", "충돌", "conflict", "o"}
|
||||
PLACEHOLDER_RE = re.compile(r"^\{\{(.+?)\}\}$") # matches an entire-string slot
|
||||
UNFILLED = object() # sentinel: slot had no value
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return (s or "").strip().lower().replace(" ", "")
|
||||
|
||||
|
||||
def map_columns(headers):
|
||||
"""Return {canonical_name: actual_header} using the alias table."""
|
||||
lookup = {_norm(h): h for h in headers}
|
||||
out = {}
|
||||
for canon, aliases in COL_ALIASES.items():
|
||||
for a in aliases:
|
||||
if _norm(a) in lookup:
|
||||
out[canon] = lookup[_norm(a)]
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Loading the claims register #
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def load_rows(path):
|
||||
"""Yield dict rows from .csv or .xlsx. Keeps original header names."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".csv", ".tsv"):
|
||||
delim = "\t" if ext == ".tsv" else ","
|
||||
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f, delimiter=delim):
|
||||
yield row
|
||||
elif ext in (".xlsx", ".xlsm"):
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
sys.exit("openpyxl required for .xlsx — pip install openpyxl")
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
for ws in wb.worksheets:
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
try:
|
||||
headers = [str(h) if h is not None else "" for h in next(rows)]
|
||||
except StopIteration:
|
||||
continue
|
||||
if not map_columns(headers).get("entity_id"):
|
||||
continue # sheet without our schema -> skip
|
||||
for r in rows:
|
||||
yield {headers[i]: ("" if v is None else str(v))
|
||||
for i, v in enumerate(r) if i < len(headers)}
|
||||
else:
|
||||
sys.exit(f"Unsupported claims register format: {ext}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Distil rows -> per-(entity, lang) confirmed claim maps + exclusion log #
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def collect_claims(path):
|
||||
rows = list(load_rows(path))
|
||||
if not rows:
|
||||
sys.exit("Claims register is empty.")
|
||||
cmap = map_columns(rows[0].keys())
|
||||
for req in ("entity_id", "entity_type", "property", "value"):
|
||||
if req not in cmap:
|
||||
sys.exit(f"Missing required column '{req}'. Found: {list(rows[0].keys())}")
|
||||
|
||||
def g(row, key):
|
||||
col = cmap.get(key)
|
||||
return (row.get(col, "") if col else "").strip()
|
||||
|
||||
# claims[(entity_id, lang)] -> {"type":..., "url":..., "props": {path: [values]}}
|
||||
claims = OrderedDict()
|
||||
excluded = [] # (entity, prop, reason, detail)
|
||||
for row in rows:
|
||||
eid = g(row, "entity_id")
|
||||
if not eid:
|
||||
continue
|
||||
etype = g(row, "entity_type")
|
||||
prop = g(row, "property")
|
||||
val = g(row, "value")
|
||||
lang = g(row, "lang") or ""
|
||||
status = (g(row, "status") or "CONFIRMED").upper()
|
||||
conflict = _norm(g(row, "conflict")) in TRUTHY
|
||||
|
||||
if conflict:
|
||||
excluded.append((eid, prop, "CONFLICT", f"sources disagree -> resolve first"))
|
||||
continue
|
||||
if status == "REJECTED":
|
||||
excluded.append((eid, prop, "REJECTED", g(row, "note")))
|
||||
continue
|
||||
if status == "PENDING":
|
||||
excluded.append((eid, prop, "PENDING", "not yet confirmed by an authoritative source"))
|
||||
continue
|
||||
if not val:
|
||||
excluded.append((eid, prop, "EMPTY", "confirmed row but value is blank"))
|
||||
continue
|
||||
|
||||
key = (eid, lang)
|
||||
node = claims.setdefault(key, {"type": etype, "url": g(row, "url"),
|
||||
"props": defaultdict(list)})
|
||||
if etype and not node["type"]:
|
||||
node["type"] = etype
|
||||
if g(row, "url") and not node["url"]:
|
||||
node["url"] = g(row, "url")
|
||||
# pipe-separated value -> multiple values (array support)
|
||||
for v in (val.split("|") if "|" in val else [val]):
|
||||
v = v.strip()
|
||||
if v:
|
||||
node["props"][prop].append(v)
|
||||
return claims, excluded
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Fill a template, pruning every unfilled slot #
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def fill(node_template, props):
|
||||
"""Recursively fill {{slots}}; return UNFILLED when a branch has no real data."""
|
||||
if isinstance(node_template, str):
|
||||
m = PLACEHOLDER_RE.match(node_template.strip())
|
||||
if not m:
|
||||
return node_template # literal (e.g. "@type":"Hotel")
|
||||
path = m.group(1)
|
||||
is_array = path.endswith("[]")
|
||||
if is_array:
|
||||
path = path[:-2]
|
||||
vals = props.get(path, [])
|
||||
if not vals:
|
||||
return UNFILLED
|
||||
if is_array:
|
||||
return list(vals)
|
||||
if len(vals) > 1:
|
||||
print(f" ! multiple values for scalar '{path}' — using first ({len(vals)} given)")
|
||||
return vals[0]
|
||||
|
||||
if isinstance(node_template, dict):
|
||||
out = OrderedDict()
|
||||
for k, v in node_template.items():
|
||||
filled = fill(v, props)
|
||||
if filled is UNFILLED:
|
||||
continue
|
||||
out[k] = filled
|
||||
# an object that only carries @type/@context (no real data, no @id ref) is empty
|
||||
meaningful = [k for k in out if k not in ("@type", "@context")]
|
||||
if not meaningful:
|
||||
return UNFILLED
|
||||
return out
|
||||
|
||||
if isinstance(node_template, list):
|
||||
out = [x for x in (fill(i, props) for i in node_template) if x is not UNFILLED]
|
||||
return out if out else UNFILLED
|
||||
|
||||
return node_template
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- #
|
||||
# Main #
|
||||
# ----------------------------------------------------------------------------- #
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Build JSON-LD drafts from a claims register.")
|
||||
ap.add_argument("claims", help="claims register .csv/.xlsx")
|
||||
ap.add_argument("--templates", default=os.path.join(os.path.dirname(__file__), "type_templates.json"))
|
||||
ap.add_argument("--out", default="drafts_out")
|
||||
args = ap.parse_args()
|
||||
|
||||
templates = json.load(open(args.templates, encoding="utf-8"))["templates"]
|
||||
claims, excluded = collect_claims(args.claims)
|
||||
|
||||
os.makedirs(os.path.join(args.out, "drafts"), exist_ok=True)
|
||||
dataset_rows = []
|
||||
built, skipped_type = 0, []
|
||||
|
||||
for (eid, lang), node in claims.items():
|
||||
etype = node["type"]
|
||||
if etype not in templates:
|
||||
skipped_type.append((eid, etype))
|
||||
continue
|
||||
filled = fill(copy.deepcopy(templates[etype]["tpl"]), node["props"])
|
||||
if filled is UNFILLED or not filled:
|
||||
skipped_type.append((eid, f"{etype} (no usable claims)"))
|
||||
continue
|
||||
jsonld = json.dumps(filled, ensure_ascii=False, indent=2)
|
||||
safe = re.sub(r"[^A-Za-z0-9]+", "_", eid).strip("_")
|
||||
fname = f"{safe}__{lang}.jsonld" if lang else f"{safe}.jsonld"
|
||||
with open(os.path.join(args.out, "drafts", fname), "w", encoding="utf-8") as f:
|
||||
f.write(jsonld)
|
||||
dataset_rows.append(OrderedDict([
|
||||
("entity_id", eid), ("entity_type", etype),
|
||||
("url", node["url"]), ("lang", lang),
|
||||
("jsonld", json.dumps(filled, ensure_ascii=False)),
|
||||
]))
|
||||
built += 1
|
||||
|
||||
# dataset CSV — directly consumable by validate_schema.py (auto-detects 'jsonld')
|
||||
ds_path = os.path.join(args.out, "schema_drafts_dataset.csv")
|
||||
with open(ds_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["entity_id", "entity_type", "url", "lang", "jsonld"])
|
||||
w.writeheader()
|
||||
w.writerows(dataset_rows)
|
||||
|
||||
# build report
|
||||
rep = [
|
||||
"# Schema Draft Build Report",
|
||||
"",
|
||||
f"- Entities built: **{built}**",
|
||||
f"- Claims excluded (not shipped): **{len(excluded)}**",
|
||||
f"- Entities skipped (no template / no usable claims): **{len(skipped_type)}**",
|
||||
"",
|
||||
"## Excluded claims (resolve before they can become schema)",
|
||||
]
|
||||
if excluded:
|
||||
rep.append("| entity | property | reason | detail |")
|
||||
rep.append("|--------|----------|--------|--------|")
|
||||
for eid, prop, reason, detail in excluded:
|
||||
rep.append(f"| {eid} | {prop} | **{reason}** | {detail} |")
|
||||
else:
|
||||
rep.append("_None._")
|
||||
if skipped_type:
|
||||
rep += ["", "## Skipped entities", ""]
|
||||
for eid, why in skipped_type:
|
||||
rep.append(f"- {eid} — {why}")
|
||||
rep += [
|
||||
"", "## Next step",
|
||||
"1. Resolve every excluded claim (confirm an authoritative value, clear conflicts).",
|
||||
"2. Re-run this builder.",
|
||||
"3. Validate the output (the QA gate):",
|
||||
" ```bash",
|
||||
f" python ../16-seo-schema-validator/scripts/validate_schema.py {ds_path} --out qa_out",
|
||||
" ```",
|
||||
"4. Fix all P0 from the validator, then proceed to client review.",
|
||||
]
|
||||
rep_path = os.path.join(args.out, "build_report.md")
|
||||
open(rep_path, "w", encoding="utf-8").write("\n".join(rep))
|
||||
|
||||
print(f"Built {built} drafts | excluded {len(excluded)} claims | skipped {len(skipped_type)} entities")
|
||||
print(f"Wrote: {ds_path}")
|
||||
print(f" {rep_path}")
|
||||
print(f" {os.path.join(args.out, 'drafts')}/*.jsonld")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract_site_claims.py — Scenario 1 adapter: an EXISTING website → claims register.
|
||||
|
||||
THE MERGE, IN ONE SENTENCE
|
||||
Schema generation has two scenarios that differ only in WHERE facts come from:
|
||||
1) from a given website — the live pages ARE the source of truth (this script)
|
||||
2) from collected sources — a not-yet-published site, facts scattered & conflicting
|
||||
Both emit the SAME claims_register.csv, which build_schema_drafts.py then turns into
|
||||
drafts and 16-seo-schema-validator gates. The claims register is the shared pivot
|
||||
that lets one skill cover both scenarios.
|
||||
|
||||
WHAT THIS DOES
|
||||
Reads pages of an existing site and seeds a claims register from them:
|
||||
- existing JSON-LD (<script type="application/ld+json">) -> CONFIRMED (authority 1):
|
||||
the site already published these facts about itself.
|
||||
- <title> / meta description / OpenGraph / <html lang> / canonical -> PENDING:
|
||||
inferred, not authoritative. The builder EXCLUDES PENDING claims until a human
|
||||
confirms them — so inference never silently ships (the skill's core principle).
|
||||
|
||||
You then review/edit claims_register.csv, run build_schema_drafts.py, and validate.
|
||||
|
||||
INPUT (any mix): URLs (needs `requests`), local .html files, or a directory of .html
|
||||
OUTPUT (in --out): claims_register.csv + extraction_report.md
|
||||
|
||||
USAGE
|
||||
python extract_site_claims.py https://example.com/ https://example.com/about --out site_claims
|
||||
python extract_site_claims.py ./snapshot/ --out site_claims # offline, local HTML
|
||||
# then:
|
||||
python build_schema_drafts.py site_claims/claims_register.csv --out drafts_out
|
||||
python ../16-seo-schema-validator/scripts/validate_schema.py drafts_out/schema_drafts_dataset.csv --out qa_out
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
JSONLD_RE = re.compile(
|
||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# entity_id prefix per schema type — keeps ids readable and groupable.
|
||||
TYPE_PREFIX = {
|
||||
"Organization": "org", "Corporation": "org", "LocalBusiness": "biz",
|
||||
"WebSite": "site", "WebPage": "page",
|
||||
"Hotel": "hotel", "LodgingBusiness": "hotel", "Resort": "hotel",
|
||||
"Restaurant": "dining", "FoodEstablishment": "dining", "BarOrPub": "dining",
|
||||
"Person": "person", "Product": "product", "Article": "article",
|
||||
"NewsArticle": "article", "BlogPosting": "article", "Event": "event",
|
||||
"FAQPage": "faq", "BreadcrumbList": "crumb",
|
||||
}
|
||||
# OpenGraph og:type -> our seed @type for the meta-only fallback.
|
||||
OG_TYPE_MAP = {"website": "WebSite", "article": "Article", "product": "Product",
|
||||
"business.business": "LocalBusiness", "profile": "Person"}
|
||||
|
||||
CLAIM_FIELDS = ["entity_id", "entity_type", "property", "value", "lang", "url",
|
||||
"source_ids", "authority", "confidence", "conflict", "status", "note"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lightweight HTML meta extraction (stdlib only)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class MetaParser(HTMLParser):
|
||||
"""Pull <title>, <html lang>, <link rel=canonical>, and <meta> name/property."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title_parts = []
|
||||
self._in_title = False
|
||||
self.lang = ""
|
||||
self.canonical = ""
|
||||
self.meta = {} # name/property (lowercased) -> content
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = {k.lower(): (v or "") for k, v in attrs}
|
||||
if tag == "html" and a.get("lang"):
|
||||
self.lang = a["lang"].strip()
|
||||
elif tag == "title":
|
||||
self._in_title = True
|
||||
elif tag == "link" and a.get("rel", "").lower() == "canonical" and a.get("href"):
|
||||
self.canonical = a["href"].strip()
|
||||
elif tag == "meta":
|
||||
key = (a.get("property") or a.get("name") or "").lower().strip()
|
||||
if key and "content" in a:
|
||||
self.meta.setdefault(key, a["content"].strip())
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title":
|
||||
self._in_title = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self._in_title:
|
||||
self.title_parts.append(data)
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return re.sub(r"\s+", " ", "".join(self.title_parts)).strip()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Page acquisition
|
||||
# --------------------------------------------------------------------------- #
|
||||
def gather_pages(inputs):
|
||||
"""Yield (url, html). Accepts http(s) URLs, .html files, and directories."""
|
||||
for item in inputs:
|
||||
if re.match(r"^https?://", item, re.IGNORECASE):
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
sys.exit("Fetching URLs needs requests: pip install requests "
|
||||
"(or pass local .html files / a directory instead).")
|
||||
try:
|
||||
r = requests.get(item, timeout=20,
|
||||
headers={"User-Agent": "Mozilla/5.0 (SchemaGen/1.0)"})
|
||||
r.raise_for_status()
|
||||
yield item, r.text
|
||||
except Exception as exc: # noqa: BLE001 — best-effort fetch
|
||||
print(f" ! could not fetch {item}: {exc}", file=sys.stderr)
|
||||
else:
|
||||
p = Path(item)
|
||||
if p.is_dir():
|
||||
for hp in sorted(p.rglob("*.html")):
|
||||
hp = hp.resolve()
|
||||
yield hp.as_uri(), hp.read_text(encoding="utf-8", errors="replace")
|
||||
elif p.is_file():
|
||||
p = p.resolve()
|
||||
yield p.as_uri(), p.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
print(f" ! not a URL/file/dir, skipped: {item}", file=sys.stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON-LD node -> flat (dotted-path, value) claims
|
||||
# --------------------------------------------------------------------------- #
|
||||
def primary_type(node):
|
||||
t = node.get("@type")
|
||||
if isinstance(t, list):
|
||||
return t[0] if t else ""
|
||||
return t or ""
|
||||
|
||||
|
||||
def top_level_nodes(parsed):
|
||||
"""Return the ENTITY nodes only — @graph members, array items, or a single object.
|
||||
|
||||
Deliberately NOT recursive: nested objects (PostalAddress, GeoCoordinates, …) belong
|
||||
to their parent and are captured by flatten() as dotted paths. Recursing here would
|
||||
wrongly promote a nested address into its own entity.
|
||||
"""
|
||||
if isinstance(parsed, dict) and "@graph" in parsed:
|
||||
graph = parsed["@graph"]
|
||||
return [n for n in graph if isinstance(n, dict) and "@type" in n]
|
||||
if isinstance(parsed, list):
|
||||
return [n for n in parsed if isinstance(n, dict) and "@type" in n]
|
||||
if isinstance(parsed, dict) and "@type" in parsed:
|
||||
return [parsed]
|
||||
return []
|
||||
|
||||
|
||||
def flatten(node, prefix=""):
|
||||
"""Yield (property_path, value) pairs matching the template {{dotted.path}} slots.
|
||||
|
||||
- scalars -> ("name", "X")
|
||||
- scalar arrays -> ("sameAs", "a|b|c") (pipe-joined; builder splits on '|')
|
||||
- nested objects -> recurse with dotted prefix ("address.streetAddress", ...)
|
||||
- @type inside a nested object is structural (templates hard-code it) -> skipped
|
||||
- a bare {"@id": "..."} reference -> ("parentOrganization.@id", "...")
|
||||
"""
|
||||
for key, val in node.items():
|
||||
if key == "@type":
|
||||
continue
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(val, (str, int, float, bool)):
|
||||
yield path, str(val)
|
||||
elif isinstance(val, list):
|
||||
scalars = [str(v) for v in val if isinstance(v, (str, int, float, bool))]
|
||||
if scalars:
|
||||
yield path, "|".join(scalars)
|
||||
for v in val: # objects inside arrays -> recurse (best-effort)
|
||||
if isinstance(v, dict):
|
||||
yield from flatten(v, prefix=f"{path}.")
|
||||
elif isinstance(val, dict):
|
||||
yield from flatten(val, prefix=f"{path}.")
|
||||
|
||||
|
||||
def slugify(text, maxlen=40):
|
||||
s = re.sub(r"^https?://", "", text or "")
|
||||
s = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()
|
||||
return s[:maxlen] or "node"
|
||||
|
||||
|
||||
def entity_id_for(node, url, idx):
|
||||
"""Readable, groupable id like `hotel:westin-hotel` from an @id or URL."""
|
||||
etype = primary_type(node)
|
||||
prefix = TYPE_PREFIX.get(etype, "node")
|
||||
nid = node.get("@id")
|
||||
if nid:
|
||||
pr = urlparse(str(nid))
|
||||
tail = [s for s in pr.path.split("/") if s]
|
||||
parts = (tail[-1:] if tail else []) + ([pr.fragment] if pr.fragment else [])
|
||||
base = "-".join(parts) or pr.netloc or str(nid)
|
||||
else:
|
||||
base = url or f"n{idx}"
|
||||
return f"{prefix}:{slugify(base)}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build claims rows from one page
|
||||
# --------------------------------------------------------------------------- #
|
||||
def claims_from_page(url, html, default_lang, rows, seen_props):
|
||||
"""Append claim rows for one page. seen_props tracks (entity_id, property)
|
||||
already taken from authoritative JSON-LD, so meta only fills genuine gaps."""
|
||||
page_lang = ""
|
||||
found_jsonld = False
|
||||
|
||||
# --- 1) existing JSON-LD -> CONFIRMED (authority 1) ---
|
||||
for block in JSONLD_RE.findall(html):
|
||||
try:
|
||||
parsed = json.loads(block.strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for i, node in enumerate(top_level_nodes(parsed)):
|
||||
etype = primary_type(node)
|
||||
if not etype:
|
||||
continue
|
||||
found_jsonld = True
|
||||
eid = entity_id_for(node, url, i)
|
||||
node_lang = node.get("inLanguage") if isinstance(node.get("inLanguage"), str) else ""
|
||||
lang = node_lang or default_lang
|
||||
for prop, value in flatten(node):
|
||||
rows.append(_row(eid, etype, prop, value, lang, url,
|
||||
"S-SITE", 1, "high", "CONFIRMED",
|
||||
"extracted from existing JSON-LD"))
|
||||
seen_props.add((eid, prop))
|
||||
|
||||
# --- 2) meta / OpenGraph -> PENDING (inferred, needs confirmation) ---
|
||||
mp = MetaParser()
|
||||
try:
|
||||
mp.feed(html)
|
||||
except Exception: # noqa: BLE001 — tolerate malformed HTML
|
||||
pass
|
||||
page_lang = mp.lang or default_lang
|
||||
og_type = mp.meta.get("og:type", "").lower()
|
||||
etype = OG_TYPE_MAP.get(og_type, "WebPage")
|
||||
eid = f"{TYPE_PREFIX.get(etype, 'page')}:{slugify(mp.canonical or url)}"
|
||||
|
||||
inferred = {
|
||||
"name": mp.meta.get("og:title") or mp.title,
|
||||
"url": mp.meta.get("og:url") or mp.canonical or (url if url.startswith("http") else ""),
|
||||
"description": mp.meta.get("og:description") or mp.meta.get("description"),
|
||||
"image": mp.meta.get("og:image"),
|
||||
"inLanguage": page_lang,
|
||||
}
|
||||
# Only emit meta claims when this page contributed NO JSON-LD (else JSON-LD wins).
|
||||
if not found_jsonld:
|
||||
for prop, value in inferred.items():
|
||||
if value and (eid, prop) not in seen_props:
|
||||
rows.append(_row(eid, etype, prop, value, page_lang, url,
|
||||
"S-SITE-META", 2, "med", "PENDING",
|
||||
"inferred from <title>/OpenGraph — confirm before shipping"))
|
||||
|
||||
|
||||
def _row(eid, etype, prop, value, lang, url, src, authority, conf, status, note):
|
||||
return OrderedDict([
|
||||
("entity_id", eid), ("entity_type", etype), ("property", prop),
|
||||
("value", value), ("lang", lang or ""), ("url", url if url.startswith("http") else ""),
|
||||
("source_ids", src), ("authority", authority), ("confidence", conf),
|
||||
("conflict", ""), ("status", status), ("note", note),
|
||||
])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Scenario-1 adapter: existing website -> claims register.")
|
||||
ap.add_argument("inputs", nargs="+", help="URLs, .html files, or a directory")
|
||||
ap.add_argument("--out", default="site_claims", help="output directory")
|
||||
ap.add_argument("--default-lang", default="", help="fallback language code (e.g. ko)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
rows, seen = [], set()
|
||||
pages = 0
|
||||
for url, html in gather_pages(args.inputs):
|
||||
pages += 1
|
||||
before = len(rows)
|
||||
claims_from_page(url, html, args.default_lang, rows, seen)
|
||||
print(f" · {url} → {len(rows) - before} claims")
|
||||
|
||||
if not rows:
|
||||
print("No claims extracted (no JSON-LD or usable meta on the given pages).",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
outdir = Path(args.out)
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
reg = outdir / "claims_register.csv"
|
||||
with open(reg, "w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=CLAIM_FIELDS)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
confirmed = sum(1 for r in rows if r["status"] == "CONFIRMED")
|
||||
pending = sum(1 for r in rows if r["status"] == "PENDING")
|
||||
entities = sorted({r["entity_id"] for r in rows})
|
||||
rep = [
|
||||
"# Site Extraction Report", "",
|
||||
f"- Pages read: **{pages}**",
|
||||
f"- Claims extracted: **{len(rows)}** "
|
||||
f"(CONFIRMED from JSON-LD: {confirmed} · PENDING from meta: {pending})",
|
||||
f"- Entities seeded: **{len(entities)}**", "",
|
||||
"## Entities", "",
|
||||
]
|
||||
rep += [f"- `{e}`" for e in entities]
|
||||
rep += [
|
||||
"", "## Review before building",
|
||||
"1. **CONFIRMED** rows came from the site's own JSON-LD — spot-check accuracy.",
|
||||
"2. **PENDING** rows were inferred from `<title>`/OpenGraph and will NOT ship until "
|
||||
"you set `status=CONFIRMED` (and clear any `conflict`).",
|
||||
"3. Add anything the pages didn't expose (telephone, address, geo, sameAs).",
|
||||
"", "## Next step",
|
||||
"```bash",
|
||||
f"python build_schema_drafts.py {reg} --out drafts_out",
|
||||
"python ../16-seo-schema-validator/scripts/validate_schema.py "
|
||||
"drafts_out/schema_drafts_dataset.csv --out qa_out",
|
||||
"```",
|
||||
]
|
||||
(outdir / "extraction_report.md").write_text("\n".join(rep), encoding="utf-8")
|
||||
|
||||
print(f"\nWrote {len(rows)} claims ({confirmed} CONFIRMED, {pending} PENDING) "
|
||||
f"for {len(entities)} entities → {reg}")
|
||||
print(f" {outdir / 'extraction_report.md'}")
|
||||
print("Review the register (confirm PENDING rows), then run build_schema_drafts.py.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
87
custom-skills/17-seo-schema-generator/scripts/make_sample.py
Normal file
87
custom-skills/17-seo-schema-generator/scripts/make_sample.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_sample.py — generate fixtures/sample_claims.csv
|
||||
|
||||
A small, realistic claims register for the Shilla context. It exercises:
|
||||
- 5 entity types: Organization, WebSite, Hotel, Person, JobPosting, VideoObject
|
||||
- dotted nested paths (address.*, geo.*) and an array (sameAs[])
|
||||
- @id cross-references between entities (Hotel.parentOrganization -> org:shilla)
|
||||
- the EXCLUSION gate: one PENDING claim, one CONFLICT claim, one EMPTY value
|
||||
Run, then: python scripts/build_schema_drafts.py fixtures/sample_claims.csv
|
||||
"""
|
||||
import csv, os
|
||||
|
||||
ROWS = [
|
||||
# entity_id, entity_type, property, value, lang, url, source_ids, authority, confidence, conflict, status, note
|
||||
# ---- Organization (DART + official + Wikidata) ----
|
||||
("org:shilla", "Organization", "@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "name", "The Shilla Hotels & Resorts", "", "", "S-OFF|S-WIKI", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "legalName", "주식회사 호텔신라", "", "", "S-DART", "1", "high", "", "CONFIRMED", "DART 법인명"),
|
||||
("org:shilla", "Organization", "url", "https://www.shillahotels.com/", "", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "foundingDate", "1973-05-09", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "sameAs", "https://www.wikidata.org/wiki/Q494845|https://en.wikipedia.org/wiki/The_Shilla", "", "", "S-WIKI|S-WD", "2", "high", "", "CONFIRMED", "array via pipe"),
|
||||
("org:shilla", "Organization", "address.streetAddress", "동호로 249", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "address.addressLocality", "중구", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "address.addressRegion", "서울", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("org:shilla", "Organization", "address.addressCountry", "KR", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
# ---- WebSite (per-language) ----
|
||||
("site:ko", "WebSite", "@id", "https://www.shillahotels.com/ko#website", "ko", "https://www.shillahotels.com/ko/", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("site:ko", "WebSite", "name", "신라호텔", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("site:ko", "WebSite", "url", "https://www.shillahotels.com/ko/", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("site:ko", "WebSite", "inLanguage", "ko", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("site:ko", "WebSite", "publisher.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", "ref to org"),
|
||||
# ---- Hotel (property; @id ref back to org) ----
|
||||
("hotel:theshilla-seoul", "Hotel", "@id", "https://www.shillahotels.com/ko/theshilla/seoul#hotel", "ko", "https://www.shillahotels.com/ko/theshilla/seoul/index.do", "S-OFF|S-BROCH", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "name", "The Shilla Seoul", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "telephone", "+82-2-2233-3131", "ko", "", "S-OFF|S-GBP", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "priceRange", "$$$$", "ko", "", "S-OFF", "2", "med", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "brand.name", "The Shilla", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "parentOrganization.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", "entity graph link"),
|
||||
("hotel:theshilla-seoul", "Hotel", "address.streetAddress", "동호로 249", "ko", "", "S-OFF|S-GBP", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "address.addressLocality", "서울", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "address.addressCountry", "KR", "ko", "", "S-OFF", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "geo.latitude", "37.5564", "ko", "", "S-GBP", "1", "high", "", "CONFIRMED", ""),
|
||||
("hotel:theshilla-seoul", "Hotel", "geo.longitude", "127.0058", "ko", "", "S-GBP", "1", "high", "", "CONFIRMED", ""),
|
||||
# ---- Person (executive bio) ----
|
||||
("person:ceo", "Person", "@id", "https://www.shillahotels.com/#ceo", "", "", "S-DART|S-NEWS", "1", "high", "", "CONFIRMED", ""),
|
||||
("person:ceo", "Person", "name", "이부진", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("person:ceo", "Person", "jobTitle", "대표이사 사장", "", "", "S-DART", "1", "high", "", "CONFIRMED", ""),
|
||||
("person:ceo", "Person", "worksFor.@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
|
||||
# ---- JobPosting (recruitment site) ----
|
||||
("job:fo-manager", "JobPosting", "title", "프런트오피스 매니저", "ko", "https://recruit.shilla.net/job/1234", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "description", "더 신라 서울 프런트오피스 운영 총괄 및 VIP 응대.", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "datePosted", "2026-05-01", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "employmentType", "FULL_TIME", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "hiringOrganization.@id", "https://www.shillahotels.com/#org", "ko", "", "", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "jobLocation.addressLocality", "서울", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
("job:fo-manager", "JobPosting", "jobLocation.addressCountry", "KR", "ko", "", "S-RECRUIT", "1", "high", "", "CONFIRMED", ""),
|
||||
# ---- VideoObject (official YouTube) ----
|
||||
("video:brand-film", "VideoObject", "name", "The Shilla — Authentic Indulgence", "", "https://www.youtube.com/watch?v=XXXX", "S-YT", "1", "high", "", "CONFIRMED", ""),
|
||||
("video:brand-film", "VideoObject", "description", "더 신라 브랜드 필름.", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
|
||||
("video:brand-film", "VideoObject", "thumbnailUrl", "https://i.ytimg.com/vi/XXXX/maxresdefault.jpg", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
|
||||
("video:brand-film", "VideoObject", "uploadDate", "2025-11-20", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
|
||||
("video:brand-film", "VideoObject", "duration", "PT1M45S", "", "", "S-YT", "1", "high", "", "CONFIRMED", ""),
|
||||
("video:brand-film", "VideoObject", "publisher.@id", "https://www.shillahotels.com/#org", "", "", "", "1", "high", "", "CONFIRMED", ""),
|
||||
# ---- EXCLUSION GATE demonstrations (these must NOT appear in drafts) ----
|
||||
("hotel:theshilla-seoul", "Hotel", "image", "https://example.com/seoul.jpg", "ko", "", "S-OFF", "3", "low", "", "PENDING", "이미지 최종본 미확정"),
|
||||
("org:shilla", "Organization", "telephone", "+82-2-2233-3131", "", "", "S-OFF", "2", "med", "Y", "CONFIRMED", "대표번호 vs IR번호 출처 충돌"),
|
||||
("person:ceo", "Person", "image", "", "", "", "", "", "", "", "CONFIRMED", "값 공란 -> 제외"),
|
||||
]
|
||||
|
||||
HEADERS = ["entity_id", "entity_type", "property", "value", "lang", "url",
|
||||
"source_ids", "authority", "confidence", "conflict", "status", "note"]
|
||||
|
||||
|
||||
def main():
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
out = os.path.join(here, "fixtures", "sample_claims.csv")
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
with open(out, "w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(HEADERS)
|
||||
w.writerows(ROWS)
|
||||
print("Wrote", out, f"({len(ROWS)} claim rows)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
# build_schema_drafts.py and extract_site_claims.py run on the Python standard
|
||||
# library alone for CSV input and local-HTML extraction (the offline default).
|
||||
#
|
||||
# Optional extras, installed only when you need them:
|
||||
openpyxl>=3.1 # read .xlsx claims registers
|
||||
requests>=2.31 # fetch live URLs in extract_site_claims.py (Mode 1 over the network)
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"_meta": {
|
||||
"purpose": "JSON-LD draft templates for source-to-schema authoring (pre-launch).",
|
||||
"placeholder_syntax": "{{property.path}} — dotted paths map to claims-register 'property' column. Lines whose value stays unfilled are dropped (never shipped as placeholder).",
|
||||
"aligned_with": "16-seo-schema-validator/scripts/schema_rules.json (required props match Google rich-result requirements)"
|
||||
},
|
||||
"templates": {
|
||||
"Organization": {
|
||||
"_source_hint": "DART, official site footer/about, sustainability report, Wikidata, newsroom",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": "{{@id}}",
|
||||
"name": "{{name}}",
|
||||
"legalName": "{{legalName}}",
|
||||
"url": "{{url}}",
|
||||
"logo": "{{logo}}",
|
||||
"sameAs": "{{sameAs[]}}",
|
||||
"foundingDate": "{{foundingDate}}",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "{{address.streetAddress}}",
|
||||
"addressLocality": "{{address.addressLocality}}",
|
||||
"addressRegion": "{{address.addressRegion}}",
|
||||
"postalCode": "{{address.postalCode}}",
|
||||
"addressCountry": "{{address.addressCountry}}"
|
||||
},
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "{{contactPoint.telephone}}",
|
||||
"contactType": "{{contactPoint.contactType}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebSite": {
|
||||
"_source_hint": "official homepage; one per language site",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": "{{@id}}",
|
||||
"name": "{{name}}",
|
||||
"url": "{{url}}",
|
||||
"inLanguage": "{{inLanguage}}",
|
||||
"publisher": { "@id": "{{publisher.@id}}" }
|
||||
}
|
||||
},
|
||||
"Hotel": {
|
||||
"_source_hint": "property pages, brochure PDF, GBP, booking data; one per property per language",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Hotel",
|
||||
"@id": "{{@id}}",
|
||||
"name": "{{name}}",
|
||||
"url": "{{url}}",
|
||||
"telephone": "{{telephone}}",
|
||||
"priceRange": "{{priceRange}}",
|
||||
"image": "{{image[]}}",
|
||||
"brand": { "@type": "Brand", "name": "{{brand.name}}" },
|
||||
"parentOrganization": { "@id": "{{parentOrganization.@id}}" },
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "{{address.streetAddress}}",
|
||||
"addressLocality": "{{address.addressLocality}}",
|
||||
"addressRegion": "{{address.addressRegion}}",
|
||||
"postalCode": "{{address.postalCode}}",
|
||||
"addressCountry": "{{address.addressCountry}}"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "{{geo.latitude}}",
|
||||
"longitude": "{{geo.longitude}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Person": {
|
||||
"_source_hint": "executive bios, people-info sites, Wikipedia, press kit; one per person",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"@id": "{{@id}}",
|
||||
"name": "{{name}}",
|
||||
"jobTitle": "{{jobTitle}}",
|
||||
"worksFor": { "@id": "{{worksFor.@id}}" },
|
||||
"url": "{{url}}",
|
||||
"image": "{{image}}",
|
||||
"sameAs": "{{sameAs[]}}"
|
||||
}
|
||||
},
|
||||
"JobPosting": {
|
||||
"_source_hint": "recruitment sites (채용공고); one per open role",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "JobPosting",
|
||||
"title": "{{title}}",
|
||||
"description": "{{description}}",
|
||||
"datePosted": "{{datePosted}}",
|
||||
"validThrough": "{{validThrough}}",
|
||||
"employmentType": "{{employmentType}}",
|
||||
"hiringOrganization": { "@id": "{{hiringOrganization.@id}}" },
|
||||
"jobLocation": {
|
||||
"@type": "Place",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{jobLocation.addressLocality}}",
|
||||
"addressCountry": "{{jobLocation.addressCountry}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VideoObject": {
|
||||
"_source_hint": "official YouTube channel; one per featured video",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": "{{name}}",
|
||||
"description": "{{description}}",
|
||||
"thumbnailUrl": "{{thumbnailUrl[]}}",
|
||||
"uploadDate": "{{uploadDate}}",
|
||||
"duration": "{{duration}}",
|
||||
"contentUrl": "{{contentUrl}}",
|
||||
"embedUrl": "{{embedUrl}}",
|
||||
"publisher": { "@id": "{{publisher.@id}}" }
|
||||
}
|
||||
},
|
||||
"FAQPage": {
|
||||
"_source_hint": "press kit FAQ, newsroom, distilled common questions; one per FAQ page",
|
||||
"tpl": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"url": "{{url}}",
|
||||
"inLanguage": "{{inLanguage}}",
|
||||
"mainEntity": "{{mainEntity[]}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,두 출처 연도 충돌 -> 해소 필요
|
||||
|
@@ -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-__-__
|
||||
@@ -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,교차검증
|
||||
|
239
custom-skills/18-seo-local-audit/SKILL.md
Normal file
239
custom-skills/18-seo-local-audit/SKILL.md
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
name: seo-local-audit
|
||||
description: |
|
||||
Local business SEO auditor for Korean-market businesses. Covers business identity extraction,
|
||||
NAP consistency, Google Business Profile, Naver Smart Place, Kakao Map, local citations,
|
||||
and LocalBusiness schema validation.
|
||||
Triggers: local SEO, NAP audit, Google Business Profile, GBP optimization, local citations,
|
||||
네이버 스마트플레이스, 카카오맵, 로컬 SEO.
|
||||
---
|
||||
|
||||
# SEO Local Audit
|
||||
|
||||
## Purpose
|
||||
|
||||
Audit local business SEO for Korean-market businesses: business identity extraction, NAP consistency, GBP optimization, Naver Smart Place, Kakao Map, local citations, and LocalBusiness schema markup.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Business Identity** - Extract official names, address, phone from website schema/content
|
||||
2. **NAP Consistency** - Cross-platform verification against canonical NAP
|
||||
3. **GBP Optimization** - Layered discovery + profile completeness audit
|
||||
4. **Naver Smart Place** - Layered discovery + listing completeness audit
|
||||
5. **Kakao Map** - Presence verification + NAP check
|
||||
6. **Citation Audit** - Korean-first directory presence
|
||||
7. **Schema Validation** - LocalBusiness JSON-LD markup
|
||||
|
||||
## MCP Tool Usage
|
||||
|
||||
```
|
||||
mcp__firecrawl__scrape: Extract NAP and schema from website
|
||||
mcp__perplexity__search: Find citations, GBP, Naver Place listings
|
||||
mcp__notion__create-page: Save audit findings
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 0: Business Identity (MANDATORY FIRST STEP)
|
||||
|
||||
Before any audit, establish the official business identity.
|
||||
|
||||
**Sources (in priority order):**
|
||||
1. Website schema markup (JSON-LD `Organization`, `Hospital`, `LocalBusiness`) — `name` field is authoritative
|
||||
2. Contact page / About page
|
||||
3. Footer (address, phone, social links)
|
||||
4. User-provided information
|
||||
|
||||
**Data to collect:**
|
||||
|
||||
| Field | Example |
|
||||
|-------|---------|
|
||||
| Official name (Korean) | 제이미성형외과의원 |
|
||||
| Official name (English) | Jamie Plastic Surgery Clinic |
|
||||
| Brand/display name | Jamie Clinic |
|
||||
| Website URL | https://www.jamie.clinic |
|
||||
| Address (Korean) | 서울특별시 강남구 ... |
|
||||
| Phone | 02-XXX-XXXX |
|
||||
| Known GBP URL | (if available) |
|
||||
| Known Naver Place URL | (if available) |
|
||||
| Known Kakao Map URL | (if available) |
|
||||
|
||||
Look for these URL patterns in `sameAs`, footer links, or embedded iframes:
|
||||
- GBP: `maps.app.goo.gl/*`, `google.com/maps/place/*`, `g.page/*`
|
||||
- Naver Place: `naver.me/*`, `map.naver.com/*/place/*`, `m.place.naver.com/*`
|
||||
- Kakao Map: `place.map.kakao.com/*`, `kko.to/*`
|
||||
|
||||
### Step 1: Website NAP Extraction
|
||||
|
||||
Scrape header, footer, contact page, about page. Cross-reference with schema markup. Establish the **canonical NAP** baseline.
|
||||
|
||||
### Step 2: GBP Verification & Audit
|
||||
|
||||
**Layered discovery (try in order, stop when found):**
|
||||
1. Use provided GBP URL (from Step 0 or user input)
|
||||
2. Check website for GBP link (footer, contact, schema `sameAs`, embedded Google Maps iframe)
|
||||
3. Search: `"[Korean Name]" "[City/District]" Google Maps`
|
||||
4. Search: `"[English Name]" Google Maps [City]`
|
||||
5. Search: `"[exact phone number]" site:google.com/maps`
|
||||
|
||||
**Important**: Google Maps is JS-rendered — scraping tools cannot extract business data. Use search for discovery, verify via search result snippets.
|
||||
|
||||
**If found — audit checklist (score /10):**
|
||||
- [ ] Business name matches canonical NAP
|
||||
- [ ] Address is complete and accurate
|
||||
- [ ] Phone number matches
|
||||
- [ ] Business hours are current
|
||||
- [ ] Primary + secondary categories appropriate
|
||||
- [ ] Business description complete
|
||||
- [ ] 10+ photos uploaded
|
||||
- [ ] Posts are recent (within 7 days)
|
||||
- [ ] Reviews are responded to
|
||||
- [ ] Q&A section is active
|
||||
|
||||
**If NOT found:** Report as **"not discoverable via web search"** (distinct from "does not exist").
|
||||
|
||||
### Step 3: Naver Smart Place Verification & Audit
|
||||
|
||||
**Layered discovery (try in order, stop when found):**
|
||||
1. Use provided Naver Place URL (from Step 0 or user input)
|
||||
2. Check website for Naver Place link (footer, contact, schema `sameAs`)
|
||||
3. Search: `"[Korean Name]" site:map.naver.com`
|
||||
4. Search: `"[Korean Name]" 네이버 지도 [district]`
|
||||
5. Search: `"[Korean Name]" 네이버 스마트플레이스`
|
||||
6. Search: `"[exact phone number]" site:map.naver.com`
|
||||
|
||||
**Important**: Naver Map is JS-rendered — scraping tools cannot extract data. Use search for discovery, verify via snippets.
|
||||
|
||||
**If found — audit checklist (score /10):**
|
||||
- [ ] Business name matches canonical NAP
|
||||
- [ ] Address is complete and accurate
|
||||
- [ ] Phone number matches
|
||||
- [ ] Business hours are current
|
||||
- [ ] Place is "claimed" (owner-managed / 업주 등록)
|
||||
- [ ] Keywords/tags are set
|
||||
- [ ] Booking/reservation link present
|
||||
- [ ] Recent blog reviews linked
|
||||
- [ ] Photos uploaded and current
|
||||
- [ ] Menu/service/price information present
|
||||
|
||||
**If NOT found:** Report as **"not discoverable via web search"** (not "does not exist" or "not registered").
|
||||
|
||||
### Step 4: Kakao Map Verification
|
||||
|
||||
**Discovery:**
|
||||
1. Use provided Kakao Map URL (from Step 0)
|
||||
2. Check website for Kakao Map link (`place.map.kakao.com/*`, `kko.to/*`)
|
||||
3. Search: `"[Korean Name]" site:place.map.kakao.com`
|
||||
4. Search: `"[Korean Name]" 카카오맵 [district]`
|
||||
|
||||
**If found:** Verify NAP consistency against canonical NAP.
|
||||
|
||||
### Step 5: Citation Discovery
|
||||
|
||||
**Korean market platform priorities:**
|
||||
|
||||
| Platform | Priority | Market |
|
||||
|----------|----------|--------|
|
||||
| Google Business Profile | Critical | Global |
|
||||
| Naver Smart Place (네이버 스마트플레이스) | Critical | Korea |
|
||||
| Kakao Map (카카오맵) | High | Korea |
|
||||
| Industry-specific directories | High | Varies |
|
||||
| Apple Maps | Medium | Global |
|
||||
| Bing Places | Low | Global |
|
||||
|
||||
**Korean medical/cosmetic industry directories:**
|
||||
- 강남언니 (Gangnam Unni)
|
||||
- 바비톡 (Babitalk)
|
||||
- 성예사 (Sungyesa)
|
||||
- 굿닥 (Goodoc)
|
||||
- 똑닥 (Ddocdoc)
|
||||
- 모두닥 (Modoodoc)
|
||||
- 하이닥 (HiDoc)
|
||||
|
||||
### Step 6: NAP Consistency Report
|
||||
|
||||
Cross-reference all sources against canonical NAP.
|
||||
|
||||
**Common inconsistency points:**
|
||||
- Building/landmark names — authoritative source is the **business registration certificate** (사업자등록증)
|
||||
- Phone format variations (02-XXX-XXXX vs +82-2-XXX-XXXX)
|
||||
- Address format (road-name vs lot-number / 도로명 vs 지번)
|
||||
- Korean vs English name spelling variations
|
||||
- Suite/floor number omissions
|
||||
|
||||
### Step 7: LocalBusiness Schema Validation
|
||||
|
||||
Validate JSON-LD completeness: @type, name, address, telephone, openingHours, geo (GeoCoordinates), sameAs (GBP, Naver, Kakao, social), url, image.
|
||||
|
||||
## Scoring
|
||||
|
||||
| Component | Weight | Max Score |
|
||||
|-----------|--------|-----------|
|
||||
| Business Identity completeness | 5% | /10 |
|
||||
| NAP Consistency | 20% | /10 |
|
||||
| GBP Optimization | 20% | /10 |
|
||||
| Naver Smart Place | 20% | /10 |
|
||||
| Kakao Map presence | 10% | /10 |
|
||||
| Citations (directories) | 10% | /10 |
|
||||
| LocalBusiness Schema | 15% | /10 |
|
||||
|
||||
**Overall Local SEO Score** = weighted average, normalized to /100.
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Local SEO Audit: [Business]
|
||||
|
||||
### Business Identity
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Korean Name | ... |
|
||||
| English Name | ... |
|
||||
| Address | ... |
|
||||
| Phone | ... |
|
||||
|
||||
### NAP Consistency: X/10
|
||||
| Source | Name | Address | Phone | Status |
|
||||
|--------|------|---------|-------|--------|
|
||||
| Website | OK/Issue | OK/Issue | OK/Issue | Match/Mismatch |
|
||||
| GBP | OK/Issue | OK/Issue | OK/Issue | Match/Mismatch |
|
||||
| Naver Place | OK/Issue | OK/Issue | OK/Issue | Match/Mismatch |
|
||||
| Kakao Map | OK/Issue | OK/Issue | OK/Issue | Match/Mismatch |
|
||||
|
||||
### GBP Score: X/10
|
||||
[Checklist results]
|
||||
|
||||
### Naver Smart Place: X/10
|
||||
[Checklist results]
|
||||
|
||||
### Kakao Map: X/10
|
||||
[Status + NAP check]
|
||||
|
||||
### Citations: X/10
|
||||
| Platform | Found | NAP Match |
|
||||
|----------|-------|-----------|
|
||||
| ... | | |
|
||||
|
||||
### LocalBusiness Schema: X/10
|
||||
- Present: Yes/No
|
||||
- Valid: Yes/No
|
||||
- Missing fields: [list]
|
||||
|
||||
### Overall Score: XX/100 (Grade)
|
||||
### Priority Actions
|
||||
1. [Recommendations]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- GBP and Naver Map are JS-rendered — scraping tools cannot extract listing data. Always use search for discovery.
|
||||
- "Not discoverable via web search" != "does not exist." Always use this precise language.
|
||||
- For Korean businesses, Naver Smart Place is as important as GBP (often more so for domestic traffic).
|
||||
|
||||
## 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 (Local SEO), Priority, Found Date, Audit ID
|
||||
- **Language**: Korean with English technical terms
|
||||
- **Audit ID Format**: LOCAL-YYYYMMDD-NNN
|
||||
148
custom-skills/19-seo-keyword-strategy/SKILL.md
Normal file
148
custom-skills/19-seo-keyword-strategy/SKILL.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: seo-keyword-strategy
|
||||
description: |
|
||||
Keyword strategy and research for SEO campaigns.
|
||||
Triggers: keyword research, keyword analysis, keyword gap, search volume,
|
||||
keyword clustering, intent classification, 키워드 전략, 키워드 분석,
|
||||
키워드 리서치, 검색량 분석, 키워드 클러스터링.
|
||||
---
|
||||
|
||||
# SEO Keyword Strategy & Research
|
||||
|
||||
## Purpose
|
||||
|
||||
Expand seed keywords, classify search intent, cluster topics, and identify competitor keyword gaps for comprehensive keyword strategy development.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Keyword Expansion** - Matching terms, related terms, search suggestions
|
||||
2. **Korean Market** - Suffix expansion, Naver autocomplete, Korean intent patterns
|
||||
3. **Intent Classification** - Informational, navigational, commercial, transactional
|
||||
4. **Topic Clustering** - Group keywords into semantic clusters
|
||||
5. **Gap Analysis** - Find competitor keywords missing from target site
|
||||
|
||||
## Data Source Selection
|
||||
|
||||
This skill can pull keyword data from multiple backends. **Pick one per task** — don't fan out to every backend by default (cost + rate limits).
|
||||
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default for keyword volume, related/matching terms, organic competitor pulls | Call pattern: `keyword_research` → `get_report_schema` → `execute_report`. `database="us"` default; `"kr"` for Korean market. |
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | Ahrefs DR/UR weighting; first-party `gsc-keywords` (only Ahrefs integrates GSC inside its MCP) | `keywords-explorer-overview`, `-matching-terms`, `-related-terms`, `-search-suggestions`, `-volume-by-country`, `gsc-keywords`. |
|
||||
| **OurSEO Agent CLI** (`our keywords *`) | DataForSEO under the hood — cheapest per call, batch-friendly, Korean-aware via `--location 2410` | Claude Code only (needs Bash). Wrap calls: `our keywords volume`, `ideas`, `for-site`, `intent`, `difficulty`. |
|
||||
| **OurSEO Agent MCP** (`mcp__ourseo__*`) | Claude Desktop equivalent for crawl-derived keywords + Knowledge Graph entity expansion | `search_knowledge_graph` for entity seeding; `crawl_website` to extract on-page keyword inventory from the target site itself. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Direct fallback when `our` CLI isn't available | Same data as `our keywords *`. |
|
||||
| **GSC** (via `our research search-console` or Ahrefs `gsc-*`) | First-party queries the site actually ranks for — ground truth, not estimates | Use to validate/prune Semrush or Ahrefs lists with real impressions/CTR. |
|
||||
|
||||
### How to pick
|
||||
|
||||
Apply these in order; stop at the first match:
|
||||
|
||||
1. **User named a backend explicitly** in the prompt → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default there.
|
||||
3. **Task needs a capability only one backend has** (e.g., `gsc-keywords` first-party data, or `mcp__ourseo__search_knowledge_graph` entity expansion) → use that backend.
|
||||
4. **Default by market**:
|
||||
- English-market or unspecified → **Semrush MCP** with `database="us"`.
|
||||
- Korean market → **OurSEO CLI** `our keywords <subcmd> --location 2410 --language ko` (Claude Code), or **Semrush MCP** with `database="kr"` (Claude Desktop).
|
||||
5. **Still ambiguous on a non-trivial task** → ask once via `AskUserQuestion` listing the top 2–3 candidates.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Semrush MCP (default):**
|
||||
```
|
||||
mcp__semrush__keyword_research(query="<seed>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI (Korean default, Claude Code):**
|
||||
```bash
|
||||
our keywords volume "<keyword>" --location 2410 --language ko
|
||||
our keywords ideas "<keyword>" --location 2410 --limit 50
|
||||
our keywords for-site <competitor.com> --location 2410 --limit 100
|
||||
our keywords intent "<kw1>" "<kw2>" "<kw3>"
|
||||
our keywords difficulty "<kw1>" "<kw2>"
|
||||
```
|
||||
|
||||
**Ahrefs MCP (when user requests, or for GSC first-party):**
|
||||
```
|
||||
mcp__ahrefs__keywords-explorer-overview(keyword="<seed>", country="us")
|
||||
mcp__ahrefs__keywords-explorer-matching-terms(keyword="<seed>", country="us")
|
||||
mcp__ahrefs__keywords-explorer-volume-by-country(keyword="<seed>")
|
||||
mcp__ahrefs__gsc-keywords(...)
|
||||
```
|
||||
|
||||
**OurSEO Agent MCP (Claude Desktop, KG/entity expansion):**
|
||||
```
|
||||
mcp__ourseo__search_knowledge_graph(query="<brand or entity>")
|
||||
mcp__ourseo__crawl_website(url="<target>", max_pages=50)
|
||||
```
|
||||
|
||||
### Common parameters across backends
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Seed Keyword Expansion
|
||||
1. Determine backend via **Data Source Selection** above.
|
||||
2. Fetch search volume for the seed.
|
||||
3. Expand via the chosen backend's "related" / "ideas" / "matching-terms" endpoint.
|
||||
4. Apply Korean suffix expansion if Korean market (regardless of backend).
|
||||
5. Deduplicate and merge.
|
||||
|
||||
### 2. Intent Classification & Clustering
|
||||
1. Classify each keyword by search intent (informational / navigational / commercial / transactional).
|
||||
2. Group keywords into topic clusters.
|
||||
3. Identify pillar topics and supporting terms.
|
||||
4. Calculate cluster-level metrics (total volume, avg KD).
|
||||
|
||||
### 3. Gap Analysis
|
||||
1. Pull organic keywords for target via chosen backend.
|
||||
2. Pull organic keywords for competitors (parallel).
|
||||
3. Identify keywords present in competitors but missing from target.
|
||||
4. Score opportunities by volume/difficulty ratio.
|
||||
5. Prioritize by intent alignment with business goals.
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
## Keyword Strategy Report: [seed keyword]
|
||||
|
||||
### Overview
|
||||
- Data source: [Semrush | Ahrefs | OurSEO CLI | OurSEO MCP | GSC]
|
||||
- Market: [database/location code]
|
||||
- Total keywords discovered: [count]
|
||||
- Topic clusters: [count]
|
||||
- Total search volume: [sum]
|
||||
|
||||
### Top Clusters
|
||||
| Cluster | Keywords | Total Volume | Avg KD |
|
||||
|---|---|---|---|
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### Top Opportunities
|
||||
| Keyword | Volume | KD | Intent | Cluster |
|
||||
|---|---|---|---|---|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
### Keyword Gaps (vs competitors)
|
||||
| Keyword | Volume | Competitor Position | Opportunity Score |
|
||||
|---|---|---|---|
|
||||
| ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
Always record the chosen data source in the **Overview** so future audits can compare apples to apples.
|
||||
|
||||
## 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**: KW-YYYYMMDD-NNN
|
||||
@@ -21,11 +21,41 @@ Expand seed keywords, classify search intent, cluster topics, and identify compe
|
||||
4. **Topic Clustering** - Group keywords into semantic clusters
|
||||
5. **Gap Analysis** - Find competitor keywords missing from target site
|
||||
|
||||
## MCP Tool Usage
|
||||
## Data Source Selection
|
||||
|
||||
### SEO Data (DataForSEO)
|
||||
This skill can pull keyword data from multiple backends. **Pick one per task** — don't fan out to every backend by default (cost + rate limits).
|
||||
|
||||
**Primary — our-seo-agent CLI:**
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default for keyword volume, related/matching terms, organic competitor pulls | Call pattern: `keyword_research` → `get_report_schema` → `execute_report`. `database="us"` default; `"kr"` for Korean market. |
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | Ahrefs DR/UR weighting; first-party `gsc-keywords` (only Ahrefs integrates GSC inside its MCP) | `keywords-explorer-overview`, `-matching-terms`, `-related-terms`, `-search-suggestions`, `-volume-by-country`, `gsc-keywords`. |
|
||||
| **OurSEO Agent CLI** (`our keywords *`) | DataForSEO under the hood — cheapest per call, batch-friendly, Korean-aware via `--location 2410` | Claude Code only (needs Bash). Wrap calls: `our keywords volume`, `ideas`, `for-site`, `intent`, `difficulty`. |
|
||||
| **OurSEO Agent MCP** (`mcp__ourseo__*`) | Claude Desktop equivalent for crawl-derived keywords + Knowledge Graph entity expansion | `search_knowledge_graph` for entity seeding; `crawl_website` to extract on-page keyword inventory from the target site itself. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Direct fallback when `our` CLI isn't available | Same data as `our keywords *`. |
|
||||
| **GSC** (via `our research search-console` or Ahrefs `gsc-*`) | First-party queries the site actually ranks for — ground truth, not estimates | Use to validate/prune Semrush or Ahrefs lists with real impressions/CTR. |
|
||||
|
||||
### How to pick
|
||||
|
||||
Apply these in order; stop at the first match:
|
||||
|
||||
1. **User named a backend explicitly** in the prompt → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default there.
|
||||
3. **Task needs a capability only one backend has** (e.g., `gsc-keywords` first-party data, or `mcp__ourseo__search_knowledge_graph` entity expansion) → use that backend.
|
||||
4. **Default by market**:
|
||||
- English-market or unspecified → **Semrush MCP** with `database="us"`.
|
||||
- Korean market → **OurSEO CLI** `our keywords <subcmd> --location 2410 --language ko` (Claude Code), or **Semrush MCP** with `database="kr"` (Claude Desktop).
|
||||
5. **Still ambiguous on a non-trivial task** → ask once via `AskUserQuestion` listing the top 2–3 candidates.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Semrush MCP (default):**
|
||||
```
|
||||
mcp__semrush__keyword_research(query="<seed>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI (Korean default, Claude Code):**
|
||||
```bash
|
||||
our keywords volume "<keyword>" --location 2410 --language ko
|
||||
our keywords ideas "<keyword>" --location 2410 --limit 50
|
||||
@@ -34,48 +64,50 @@ our keywords intent "<kw1>" "<kw2>" "<kw3>"
|
||||
our keywords difficulty "<kw1>" "<kw2>"
|
||||
```
|
||||
|
||||
**Interactive fallback — DataForSEO MCP:**
|
||||
**Ahrefs MCP (when user requests, or for GSC first-party):**
|
||||
```
|
||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_overview
|
||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_ideas
|
||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_suggestions
|
||||
mcp__dfs-mcp__dataforseo_labs_search_intent
|
||||
mcp__dfs-mcp__dataforseo_labs_bulk_keyword_difficulty
|
||||
mcp__dfs-mcp__kw_data_google_ads_search_volume
|
||||
mcp__dfs-mcp__dataforseo_labs_google_keywords_for_site
|
||||
mcp__ahrefs__keywords-explorer-overview(keyword="<seed>", country="us")
|
||||
mcp__ahrefs__keywords-explorer-matching-terms(keyword="<seed>", country="us")
|
||||
mcp__ahrefs__keywords-explorer-volume-by-country(keyword="<seed>")
|
||||
mcp__ahrefs__gsc-keywords(...)
|
||||
```
|
||||
|
||||
### Common Parameters
|
||||
- **location_code**: 2410 (Korea), 2840 (US), 2392 (Japan)
|
||||
- **language_code**: ko, en, ja
|
||||
**OurSEO Agent MCP (Claude Desktop, KG/entity expansion):**
|
||||
```
|
||||
mcp__ourseo__search_knowledge_graph(query="<brand or entity>")
|
||||
mcp__ourseo__crawl_website(url="<target>", max_pages=50)
|
||||
```
|
||||
|
||||
### Web Search for Naver Discovery
|
||||
```
|
||||
WebSearch: Naver autocomplete and trend discovery
|
||||
```
|
||||
### Common parameters across backends
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Seed Keyword Expansion
|
||||
1. Input seed keyword (Korean or English)
|
||||
2. Fetch search volume via `our keywords volume "<seed>" --location 2410 --language ko`
|
||||
3. Expand with `our keywords ideas "<seed>" --location 2410 --limit 50`
|
||||
4. Get autocomplete suggestions via MCP: `mcp__dfs-mcp__dataforseo_labs_google_keyword_suggestions`
|
||||
5. Apply Korean suffix expansion if Korean market
|
||||
6. Deduplicate and merge results
|
||||
1. Determine backend via **Data Source Selection** above.
|
||||
2. Fetch search volume for the seed.
|
||||
3. Expand via the chosen backend's "related" / "ideas" / "matching-terms" endpoint.
|
||||
4. Apply Korean suffix expansion if Korean market (regardless of backend).
|
||||
5. Deduplicate and merge.
|
||||
|
||||
### 2. Intent Classification & Clustering
|
||||
1. Classify each keyword by search intent
|
||||
2. Group keywords into topic clusters
|
||||
3. Identify pillar topics and supporting terms
|
||||
4. Calculate cluster-level metrics (total volume, avg KD)
|
||||
1. Classify each keyword by search intent (informational / navigational / commercial / transactional).
|
||||
2. Group keywords into topic clusters.
|
||||
3. Identify pillar topics and supporting terms.
|
||||
4. Calculate cluster-level metrics (total volume, avg KD).
|
||||
|
||||
### 3. Gap Analysis
|
||||
1. Pull organic keywords for target: `our keywords for-site <target.com> --location 2410 --limit 100`
|
||||
2. Pull organic keywords for competitors: `our keywords for-site <competitor.com> --location 2410 --limit 100`
|
||||
3. Identify keywords present in competitors but missing from target
|
||||
4. Score opportunities by volume/difficulty ratio
|
||||
5. Prioritize by intent alignment with business goals
|
||||
1. Pull organic keywords for target via chosen backend.
|
||||
2. Pull organic keywords for competitors (parallel).
|
||||
3. Identify keywords present in competitors but missing from target.
|
||||
4. Score opportunities by volume/difficulty ratio.
|
||||
5. Prioritize by intent alignment with business goals.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -83,26 +115,30 @@ WebSearch: Naver autocomplete and trend discovery
|
||||
## Keyword Strategy Report: [seed keyword]
|
||||
|
||||
### Overview
|
||||
- Data source: [Semrush | Ahrefs | OurSEO CLI | OurSEO MCP | GSC]
|
||||
- Market: [database/location code]
|
||||
- Total keywords discovered: [count]
|
||||
- Topic clusters: [count]
|
||||
- Total search volume: [sum]
|
||||
|
||||
### Top Clusters
|
||||
| Cluster | Keywords | Total Volume | Avg KD |
|
||||
|---------|----------|-------------|--------|
|
||||
|---|---|---|---|
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### Top Opportunities
|
||||
| Keyword | Volume | KD | Intent | Cluster |
|
||||
|---------|--------|-----|--------|---------|
|
||||
|---|---|---|---|---|
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
### Keyword Gaps (vs competitors)
|
||||
| Keyword | Volume | Competitor Position | Opportunity Score |
|
||||
|---------|--------|-------------------|-------------------|
|
||||
|---|---|---|---|
|
||||
| ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
Always record the chosen data source in the **Overview** so future audits can compare apples to apples.
|
||||
|
||||
## Notion Output (Required)
|
||||
|
||||
All audit reports MUST be saved to OurDigital SEO Audit Log:
|
||||
|
||||
@@ -2,8 +2,17 @@ name: seo-keyword-strategy
|
||||
description: |
|
||||
Keyword strategy and research for SEO campaigns. Triggers: keyword research, keyword analysis, keyword gap, search volume, keyword clustering, intent classification.
|
||||
|
||||
# Allowed tools list every backend the skill can pull keyword data from.
|
||||
# Per-task selection happens in SKILL.md > Data Source Selection — NOT here.
|
||||
allowed-tools:
|
||||
- mcp__ahrefs__*
|
||||
# SEO data backends
|
||||
- mcp__semrush__* # default for keyword/SERP/organic
|
||||
- mcp__ahrefs__* # Ahrefs (Claude Desktop namespace)
|
||||
- mcp__claude_ai_Ahrefs__* # Ahrefs (Claude.ai namespace) — same backend
|
||||
- mcp__ourseo__* # OurSEO Agent MCP (KG, crawl-derived keywords)
|
||||
- mcp__dfs-mcp__* # DataForSEO MCP fallback
|
||||
- Bash # `our keywords *` CLI (Claude Code only)
|
||||
# Output / supplementary
|
||||
- mcp__notion__*
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
# Ahrefs
|
||||
# Ahrefs MCP
|
||||
|
||||
> TODO: Document tool usage for this skill
|
||||
Use Ahrefs when the user explicitly asks for Ahrefs data, when DR/UR weighting matters, or when the task needs `gsc-keywords` (Ahrefs is the only SEO MCP that exposes a Google Search Console integration directly).
|
||||
|
||||
## Available Commands
|
||||
Namespace: `mcp__ahrefs__*` (Claude Desktop) / `mcp__claude_ai_Ahrefs__*` (Claude.ai). Same backend.
|
||||
|
||||
- [ ] List commands
|
||||
## Keyword endpoints
|
||||
|
||||
## Configuration
|
||||
- `keywords-explorer-overview` — volume, CPC, KD, parent topic
|
||||
- `keywords-explorer-matching-terms` — phrase-match expansion
|
||||
- `keywords-explorer-related-terms` — related ideas
|
||||
- `keywords-explorer-search-suggestions` — autocomplete-style suggestions
|
||||
- `keywords-explorer-volume-by-country` — country breakdown for one keyword
|
||||
- `keywords-explorer-volume-history` — historical search volume
|
||||
|
||||
- [ ] Add configuration details
|
||||
## GSC integration (Ahrefs-only)
|
||||
|
||||
- `gsc-keywords` — keywords the user's verified site actually ranks for (impressions, clicks, CTR, position)
|
||||
- `gsc-keyword-history` — historical performance of a query
|
||||
|
||||
GSC endpoints require the Ahrefs project to be connected to the GSC property. Confirm with the user that the project exists in their Ahrefs workspace before relying on these.
|
||||
|
||||
## Common parameters
|
||||
|
||||
- `country` — `us`, `kr`, `jp`, etc. (lowercase ISO-2)
|
||||
- `keyword` — the seed term
|
||||
- `limit` — usually 30–100
|
||||
|
||||
## Examples
|
||||
|
||||
- [ ] Add usage examples
|
||||
**Quick overview:**
|
||||
```
|
||||
mcp__ahrefs__keywords-explorer-overview(keyword="enterprise CRM", country="us")
|
||||
```
|
||||
|
||||
**Related terms in Korean:**
|
||||
```
|
||||
mcp__ahrefs__keywords-explorer-related-terms(keyword="신라호텔", country="kr", limit=50)
|
||||
```
|
||||
|
||||
**GSC first-party queries:**
|
||||
```
|
||||
mcp__ahrefs__gsc-keywords(project_id="<ahrefs project id>", limit=100)
|
||||
```
|
||||
|
||||
## When NOT to use Ahrefs for this skill
|
||||
|
||||
- Default keyword volume / matching terms — Semrush is the project default; only switch on explicit request.
|
||||
- Bulk Korean expansion — `our keywords ideas --location 2410` is usually cheaper.
|
||||
- Entity / Knowledge Graph seeding — `mcp__ourseo__search_knowledge_graph`.
|
||||
|
||||
## Reference
|
||||
|
||||
Always check `mcp__ahrefs__doc` once per session before first call — it documents current parameter shapes and may have changed.
|
||||
|
||||
58
custom-skills/19-seo-keyword-strategy/desktop/tools/gsc.md
Normal file
58
custom-skills/19-seo-keyword-strategy/desktop/tools/gsc.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Google Search Console (GSC)
|
||||
|
||||
GSC is the only **first-party** data source on this list — what the user's verified site actually rendered impressions / clicks for in Google. Estimates from Semrush, Ahrefs, and DataForSEO are modelled; GSC is observed.
|
||||
|
||||
Two entry points to GSC for this skill:
|
||||
|
||||
1. **`our research search-console` CLI** — OurSEO Agent's GSC integration (recommended; cached).
|
||||
2. **Ahrefs `gsc-keywords`** — only if the site is connected as an Ahrefs project AND the user is already in Ahrefs context.
|
||||
|
||||
## CLI commands (`our research search-console`)
|
||||
|
||||
See `our research search-console --help` for the current command surface. Typical patterns:
|
||||
|
||||
```bash
|
||||
# Queries the site actually ranks for, last 28 days
|
||||
our research search-console queries --site sc-domain:example.com --days 28
|
||||
|
||||
# Top pages by impressions
|
||||
our research search-console pages --site sc-domain:example.com --days 28
|
||||
|
||||
# Query/page combinations
|
||||
our research search-console combined --site sc-domain:example.com --days 28
|
||||
```
|
||||
|
||||
The `sc-domain:` prefix is required for Domain-verified properties. URL-prefix properties use the plain URL (`https://example.com/`). See gotcha note: `our-claude-skills/custom-skills/15-seo-search-console/code/gotcha/gsc-cli-integration.md`.
|
||||
|
||||
## Ahrefs GSC tools
|
||||
|
||||
When the user is already working in Ahrefs:
|
||||
|
||||
```
|
||||
mcp__ahrefs__gsc-keywords(project_id="<ahrefs project id>", limit=100)
|
||||
mcp__ahrefs__gsc-keyword-history(project_id="...", keyword="...")
|
||||
mcp__ahrefs__gsc-pages(project_id="...")
|
||||
mcp__ahrefs__gsc-performance-history(project_id="...")
|
||||
mcp__ahrefs__gsc-ctr-by-position(project_id="...")
|
||||
```
|
||||
|
||||
Requires the Ahrefs project to be connected to the GSC property. Confirm with the user before assuming the link exists.
|
||||
|
||||
## When to bring GSC into keyword work
|
||||
|
||||
- **Validation step**: after generating a keyword list from Semrush / Ahrefs / DataForSEO, intersect with GSC queries to see which are already driving impressions.
|
||||
- **Pruning**: drop keywords from the list that have zero GSC impressions over the last 90 days for a mature site (signals the site doesn't compete on them despite the model's volume estimate).
|
||||
- **Cannibalization detection**: GSC `query × page` lets you find queries where multiple URLs share impressions.
|
||||
- **Anonymous-query analysis**: `mcp__ahrefs__gsc-anonymous-queries` surfaces queries Google hides from the standard report — sometimes reveals brand variants.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `SEO_AGENT_GSC_SERVICE_ACCOUNT` | Path to the GSC service-account JSON for the OurSEO CLI |
|
||||
| `GSC_CACHE_TTL` | GSC cache TTL in seconds (default 3600) |
|
||||
|
||||
## When NOT to use GSC for this skill
|
||||
|
||||
- Discovery of keywords the site does **not** yet rank for — GSC by definition only shows queries that already triggered impressions. Use Semrush / Ahrefs / DataForSEO for net-new discovery.
|
||||
- Competitive keyword pulls — GSC is single-site.
|
||||
@@ -0,0 +1,74 @@
|
||||
# OurSEO Agent (CLI + MCP)
|
||||
|
||||
The OurSEO Agent (`~/Project/our-seo-agent`) covers two distinct paths for this skill:
|
||||
|
||||
1. **CLI** — `our keywords *` (Claude Code, via Bash). DataForSEO under the hood. Cheapest per call, batch-friendly, Korean-aware.
|
||||
2. **MCP** — `mcp__ourseo__*` (Claude Desktop). Lighter surface: crawl, audit, Knowledge Graph entity expansion.
|
||||
|
||||
Pick the path that matches your current Claude environment.
|
||||
|
||||
## CLI commands (Claude Code, primary for Korean market)
|
||||
|
||||
```bash
|
||||
# Volume + difficulty + intent
|
||||
our keywords volume "<keyword>" --location 2410 --language ko
|
||||
our keywords difficulty "<kw1>" "<kw2>" --location 2410
|
||||
our keywords intent "<kw1>" "<kw2>" "<kw3>"
|
||||
|
||||
# Expansion
|
||||
our keywords ideas "<seed>" --location 2410 --limit 50
|
||||
our keywords for-site <competitor.com> --location 2410 --limit 100
|
||||
|
||||
# Naver (Korean engines)
|
||||
our research naver keywords volume "<keyword>"
|
||||
our research naver keywords ideas "<keyword>" --limit 30
|
||||
|
||||
# Cross-engine compare (where supported)
|
||||
our research keywords compare "<keyword>" --engines naver
|
||||
```
|
||||
|
||||
| Location code | Market |
|
||||
|---|---|
|
||||
| `2410` | Korea |
|
||||
| `2840` | United States |
|
||||
| `2392` | Japan |
|
||||
|
||||
| Language code | Language |
|
||||
|---|---|
|
||||
| `ko` | Korean |
|
||||
| `en` | English |
|
||||
| `ja` | Japanese |
|
||||
|
||||
Cache (avoid duplicate calls): `our research cache list --engine <name>` / `our research cache clear --older-than 30d`.
|
||||
|
||||
## MCP tools (Claude Desktop)
|
||||
|
||||
| Tool | Purpose for keyword work |
|
||||
|---|---|
|
||||
| `mcp__ourseo__search_knowledge_graph` | Resolve a brand / entity to Knowledge Graph IDs — useful as a *seeding* step for keyword expansion around the entity. |
|
||||
| `mcp__ourseo__crawl_website` | Crawl the target site and pull on-page keyword inventory (title/h1/meta) — ground truth for what the site already targets. |
|
||||
| `mcp__ourseo__audit_page` | Single-page SEO audit; not a keyword tool per se, but useful for validating that high-value keywords are actually present on the page. |
|
||||
| `mcp__ourseo__check_serp` | Check SERP position for a keyword/domain pair — bridge to position tracking. |
|
||||
| `mcp__ourseo__find_similar_pages` | Semantic similarity over a prior crawl — supports topic clustering. |
|
||||
|
||||
The OurSEO MCP does **not** expose DataForSEO keyword volume directly. For volume + KD + ideas in Claude Desktop, use Semrush MCP (`mcp__semrush__*`) or DataForSEO MCP (`mcp__dfs-mcp__*`).
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI reads from `~/Project/our-seo-agent/config/config.yaml` and these env vars:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATAFORSEO_USERNAME` / `DATAFORSEO_PASSWORD` | DataForSEO auth |
|
||||
| `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET` | Naver Open API + Search Ad |
|
||||
| `GOOGLE_KG_API_KEY` | Knowledge Graph Search API |
|
||||
|
||||
See the project `CLAUDE.md` for full env-var reference. Credentials live in 1Password — fetch with `op://Development/<item>/credential`.
|
||||
|
||||
## When to choose OurSEO over Semrush / Ahrefs
|
||||
|
||||
- Korean-market batch work (Naver + Google together).
|
||||
- Crawl-derived keyword inventory (what the site itself targets, not estimates).
|
||||
- Knowledge Graph entity seeding.
|
||||
- Cost-sensitive bulk volume lookups.
|
||||
- Cross-engine comparison (Naver + DataForSEO via `our research keywords compare`).
|
||||
@@ -0,0 +1,57 @@
|
||||
# Semrush MCP
|
||||
|
||||
Default keyword-research backend per SKILL.md > Data Source Selection.
|
||||
|
||||
## Call pattern
|
||||
|
||||
The Semrush MCP follows a three-step discovery → schema → execute pattern (see Semrush MCP server instructions):
|
||||
|
||||
1. **Discovery** — pick the right toolkit for the task:
|
||||
- `mcp__semrush__keyword_research` — keyword overview, related, volume, intent, KD
|
||||
- `mcp__semrush__organic_research` — domain/URL organic keywords + competitors
|
||||
- `mcp__semrush__overview_research` — domain/URL overview (traffic, ranking dist.)
|
||||
- `mcp__semrush__url_research` — single-URL deep dive
|
||||
2. **`mcp__semrush__get_report_schema(report_id=...)`** — fetch the param spec for the chosen report.
|
||||
3. **`mcp__semrush__execute_report(report_id=..., params={...})`** — run it.
|
||||
|
||||
Default `database="us"` when the user does not specify a market. Use `display_limit=30-50` for exploratory queries.
|
||||
|
||||
## Available reports (keyword-research toolkit)
|
||||
|
||||
- Keyword overview (volume, CPC, competition, KD, trend)
|
||||
- Related keywords
|
||||
- Matching phrases (broad / phrase / exact match)
|
||||
- Keyword difficulty (single + bulk)
|
||||
- Search intent
|
||||
- Keyword historical volume
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Default database | `us` |
|
||||
| Korean market database | `kr` |
|
||||
| Japan market database | `jp` |
|
||||
| Auth | Semrush API key on the MCP server side (no local config) |
|
||||
|
||||
## Examples
|
||||
|
||||
**English keyword volume:**
|
||||
```
|
||||
mcp__semrush__keyword_research(query="enterprise CRM software", database="us")
|
||||
→ pick report_id from response
|
||||
mcp__semrush__get_report_schema(report_id="phrase_this")
|
||||
mcp__semrush__execute_report(report_id="phrase_this", params={"phrase": "enterprise CRM software", "database": "us"})
|
||||
```
|
||||
|
||||
**Korean expansion:**
|
||||
```
|
||||
mcp__semrush__keyword_research(query="신라호텔", database="kr")
|
||||
mcp__semrush__execute_report(report_id="phrase_related", params={"phrase": "신라호텔", "database": "kr", "display_limit": 50})
|
||||
```
|
||||
|
||||
## When NOT to use Semrush
|
||||
|
||||
- Task needs **GSC first-party query data** — use Ahrefs `gsc-keywords` or `our research search-console` instead.
|
||||
- Task needs **Knowledge Graph entity expansion** — use `mcp__ourseo__search_knowledge_graph`.
|
||||
- Task needs **bulk cheap calls at Korean scale** — `our keywords *` CLI (DataForSEO) is usually cheaper.
|
||||
165
custom-skills/20-seo-serp-analysis/SKILL.md
Normal file
165
custom-skills/20-seo-serp-analysis/SKILL.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: seo-serp-analysis
|
||||
description: |
|
||||
SERP analysis for Google and Naver search results.
|
||||
Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP, 검색결과 분석, SERP 분석.
|
||||
---
|
||||
|
||||
# SEO SERP Analysis
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyze search engine result page composition for Google and Naver. Detect SERP features (featured snippets, PAA, knowledge panels, local pack, video, ads), map competitor positions, score SERP feature opportunities, and analyze Naver section distribution.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Google SERP Feature Detection** - Identify featured snippets, PAA, knowledge panels, local pack, video carousel, ads, image pack, site links, shopping
|
||||
2. **Competitor Position Mapping** - Extract domains, positions, content types for top organic results
|
||||
3. **Opportunity Scoring** - Score SERP opportunity (0-100) based on feature landscape and competition
|
||||
4. **Search Intent Validation** - Infer intent (informational, navigational, commercial, transactional, local) from SERP composition
|
||||
5. **Naver SERP Composition** - Detect sections (blog, cafe, knowledge iN, Smart Store, brand zone, books, shortform, influencer), map section priority, analyze brand zone presence
|
||||
|
||||
## Data Source Selection
|
||||
|
||||
This skill can pull SERP data from multiple backends. **Pick one per task** — don't fan out by default (cost + rate limits).
|
||||
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default Google SERP overview, organic competitor positions, SERP-feature presence | `overview_research` / `organic_research` → `get_report_schema` → `execute_report`. `database="us"` default; `"kr"` for Korean. |
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | When user wants Ahrefs SERP overview or already has an Ahrefs project | `serp-overview` exposes top organic, SERP features, paid layout per keyword. |
|
||||
| **OurSEO MCP** (`mcp__ourseo__check_serp`) | Live position spot-check for a single keyword/domain pair | Cheap; good for rank-only confirmations without full SERP pull. |
|
||||
| **OurSEO CLI** (`our serp *`) | DataForSEO under the hood — full SERP JSON with all features, Korean-aware via `--location 2410` | Claude Code only (Bash). Commands: `our serp live`, `our serp competitors`, `our serp ranked-keywords`, `our serp domain-overview`. |
|
||||
| **OurSEO CLI — Naver** (`our research naver serp`) | Naver SERP composition (blog, cafe, knowledge iN, Smart Store, brand zone, shortform, influencer) | Naver-only; required for Korean-market analysis since Semrush/Ahrefs don't cover Naver SERP. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Fallback when `our` CLI isn't running | `serp_organic_live_advanced`, `dataforseo_labs_google_serp_competitors`. |
|
||||
|
||||
### How to pick
|
||||
|
||||
1. **User named a backend explicitly** → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default.
|
||||
3. **Task needs a capability only one backend has** (Naver SERP → `our research naver serp`; full SERP JSON → DataForSEO / OurSEO CLI) → use that backend.
|
||||
4. **Default**: Semrush MCP for Google SERP overview; **`our research naver serp`** for Naver.
|
||||
5. **Still ambiguous + non-trivial** → ask once via `AskUserQuestion`.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Semrush MCP (default Google):**
|
||||
```
|
||||
mcp__semrush__overview_research(query="<keyword>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI — DataForSEO (full Google SERP JSON):**
|
||||
```bash
|
||||
our serp live "<keyword>" --location 2410 --language ko
|
||||
our serp competitors <domain> --location 2410
|
||||
our serp ranked-keywords <domain> --location 2410 --limit 50
|
||||
our serp domain-overview <domain> --location 2410
|
||||
```
|
||||
|
||||
**OurSEO CLI — Naver SERP (Korean market):**
|
||||
```bash
|
||||
our research naver serp "<keyword>"
|
||||
our research naver serp "<keyword>" --domain <target.com>
|
||||
```
|
||||
|
||||
**OurSEO MCP (single-keyword spot-check):**
|
||||
```
|
||||
mcp__ourseo__check_serp(keyword="<keyword>", domain="<target.com>", country="kr")
|
||||
```
|
||||
|
||||
**Ahrefs MCP:**
|
||||
```
|
||||
mcp__ahrefs__serp-overview(keyword="<keyword>", country="us")
|
||||
```
|
||||
|
||||
### Common parameters
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
Always record the chosen data source in the report **Overview** so future analyses can compare like-for-like.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Google SERP Analysis
|
||||
1. Fetch SERP via `our serp live "<keyword>" --location 2410 --language ko --format json`
|
||||
2. Parse SERP features from response (featured_snippet, people_also_ask, local_pack, etc.)
|
||||
3. Map competitor positions from organic_results (domain, URL, title, position)
|
||||
4. Classify content type for each result (blog, product, service, news, video)
|
||||
5. Calculate opportunity score (0-100) based on feature landscape
|
||||
6. Validate search intent from SERP composition
|
||||
7. Get competitor domain overview via `our serp domain-overview <competitor> --location 2410`
|
||||
|
||||
### 2. Naver SERP Analysis
|
||||
1. Fetch Naver search page for the target keyword
|
||||
2. Detect SERP sections (blog, cafe, knowledge iN, Smart Store, brand zone, news, encyclopedia, books, shortform, influencer)
|
||||
3. Map section priority (above-fold order)
|
||||
4. Check brand zone presence and extract brand name
|
||||
5. Count items per section
|
||||
6. Identify dominant content section
|
||||
|
||||
### 3. Report Generation
|
||||
1. Compile results into structured JSON
|
||||
2. Generate Korean-language report
|
||||
3. Save to Notion SEO Audit Log database
|
||||
|
||||
## Output Format
|
||||
|
||||
```json
|
||||
{
|
||||
"keyword": "치과 임플란트",
|
||||
"country": "kr",
|
||||
"serp_features": {
|
||||
"featured_snippet": true,
|
||||
"people_also_ask": true,
|
||||
"local_pack": true,
|
||||
"knowledge_panel": false,
|
||||
"video_carousel": false,
|
||||
"ads_top": 3,
|
||||
"ads_bottom": 2
|
||||
},
|
||||
"competitors": [
|
||||
{
|
||||
"position": 1,
|
||||
"url": "https://example.com/page",
|
||||
"domain": "example.com",
|
||||
"title": "...",
|
||||
"content_type": "service_page"
|
||||
}
|
||||
],
|
||||
"opportunity_score": 72,
|
||||
"intent_signals": "commercial",
|
||||
"timestamp": "2025-01-01T00:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Common SERP Features
|
||||
|
||||
| Feature | Impact | Opportunity |
|
||||
|---------|--------|-------------|
|
||||
| Featured Snippet | High visibility above organic | Optimize content format for snippet capture |
|
||||
| People Also Ask | Related question visibility | Create FAQ content targeting PAA |
|
||||
| Local Pack | Dominates local intent SERPs | Optimize Google Business Profile |
|
||||
| Knowledge Panel | Reduces organic CTR | Focus on brand queries and schema |
|
||||
| Video Carousel | Visual SERP real estate | Create video content for keyword |
|
||||
| Shopping | Transactional intent signal | Product feed optimization |
|
||||
|
||||
## Limitations
|
||||
|
||||
- SERP data may have a delay depending on data source (not real-time)
|
||||
- Naver SERP HTML structure changes periodically
|
||||
- Brand zone detection depends on HTML class patterns
|
||||
- Cannot detect personalized SERP results
|
||||
|
||||
## 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**: SERP-YYYYMMDD-NNN
|
||||
@@ -19,11 +19,37 @@ Analyze search engine result page composition for Google and Naver. Detect SERP
|
||||
4. **Search Intent Validation** - Infer intent (informational, navigational, commercial, transactional, local) from SERP composition
|
||||
5. **Naver SERP Composition** - Detect sections (blog, cafe, knowledge iN, Smart Store, brand zone, books, shortform, influencer), map section priority, analyze brand zone presence
|
||||
|
||||
## MCP Tool Usage
|
||||
## Data Source Selection
|
||||
|
||||
### SEO Data (DataForSEO)
|
||||
This skill can pull SERP data from multiple backends. **Pick one per task** — don't fan out by default (cost + rate limits).
|
||||
|
||||
**Primary — our-seo-agent CLI:**
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default Google SERP overview, organic competitor positions, SERP-feature presence | `overview_research` / `organic_research` → `get_report_schema` → `execute_report`. `database="us"` default; `"kr"` for Korean. |
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | When user wants Ahrefs SERP overview or already has an Ahrefs project | `serp-overview` exposes top organic, SERP features, paid layout per keyword. |
|
||||
| **OurSEO MCP** (`mcp__ourseo__check_serp`) | Live position spot-check for a single keyword/domain pair | Cheap; good for rank-only confirmations without full SERP pull. |
|
||||
| **OurSEO CLI** (`our serp *`) | DataForSEO under the hood — full SERP JSON with all features, Korean-aware via `--location 2410` | Claude Code only (Bash). Commands: `our serp live`, `our serp competitors`, `our serp ranked-keywords`, `our serp domain-overview`. |
|
||||
| **OurSEO CLI — Naver** (`our research naver serp`) | Naver SERP composition (blog, cafe, knowledge iN, Smart Store, brand zone, shortform, influencer) | Naver-only; required for Korean-market analysis since Semrush/Ahrefs don't cover Naver SERP. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Fallback when `our` CLI isn't running | `serp_organic_live_advanced`, `dataforseo_labs_google_serp_competitors`. |
|
||||
|
||||
### How to pick
|
||||
|
||||
1. **User named a backend explicitly** → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default.
|
||||
3. **Task needs a capability only one backend has** (Naver SERP → `our research naver serp`; full SERP JSON → DataForSEO / OurSEO CLI) → use that backend.
|
||||
4. **Default**: Semrush MCP for Google SERP overview; **`our research naver serp`** for Naver.
|
||||
5. **Still ambiguous + non-trivial** → ask once via `AskUserQuestion`.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Semrush MCP (default Google):**
|
||||
```
|
||||
mcp__semrush__overview_research(query="<keyword>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI — DataForSEO (full Google SERP JSON):**
|
||||
```bash
|
||||
our serp live "<keyword>" --location 2410 --language ko
|
||||
our serp competitors <domain> --location 2410
|
||||
@@ -31,30 +57,33 @@ our serp ranked-keywords <domain> --location 2410 --limit 50
|
||||
our serp domain-overview <domain> --location 2410
|
||||
```
|
||||
|
||||
**Interactive fallback — DataForSEO MCP:**
|
||||
```
|
||||
mcp__dfs-mcp__serp_organic_live_advanced
|
||||
mcp__dfs-mcp__dataforseo_labs_google_serp_competitors
|
||||
mcp__dfs-mcp__dataforseo_labs_google_ranked_keywords
|
||||
mcp__dfs-mcp__dataforseo_labs_google_domain_rank_overview
|
||||
**OurSEO CLI — Naver SERP (Korean market):**
|
||||
```bash
|
||||
our research naver serp "<keyword>"
|
||||
our research naver serp "<keyword>" --domain <target.com>
|
||||
```
|
||||
|
||||
### Common Parameters
|
||||
- **location_code**: 2410 (Korea), 2840 (US), 2392 (Japan)
|
||||
- **language_code**: ko, en, ja
|
||||
|
||||
### Notion for Report Storage
|
||||
**OurSEO MCP (single-keyword spot-check):**
|
||||
```
|
||||
mcp__notion__notion-create-pages: Save analysis report to SEO Audit Log database
|
||||
mcp__notion__notion-update-page: Update existing report entries
|
||||
mcp__ourseo__check_serp(keyword="<keyword>", domain="<target.com>", country="kr")
|
||||
```
|
||||
|
||||
### Web Tools for Naver Analysis
|
||||
**Ahrefs MCP:**
|
||||
```
|
||||
WebSearch: Discover Naver search trends
|
||||
WebFetch: Fetch Naver SERP HTML for section analysis
|
||||
mcp__ahrefs__serp-overview(keyword="<keyword>", country="us")
|
||||
```
|
||||
|
||||
### Common parameters
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
Always record the chosen data source in the report **Overview** so future analyses can compare like-for-like.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Google SERP Analysis
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Skill metadata (extracted from SKILL.md frontmatter)
|
||||
|
||||
name: seo-serp-analysis
|
||||
description: |
|
||||
SERP analysis for Google and Naver. Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP.
|
||||
|
||||
# Optional fields
|
||||
# Allowed tools list every backend the skill can pull SERP data from.
|
||||
# Per-task selection happens in SKILL.md > Data Source Selection — NOT here.
|
||||
allowed-tools:
|
||||
- mcp__ahrefs__*
|
||||
- mcp__semrush__* # default for Google SERP overview
|
||||
- mcp__ahrefs__* # Ahrefs serp-overview
|
||||
- mcp__claude_ai_Ahrefs__* # Ahrefs (Claude.ai namespace)
|
||||
- mcp__ourseo__* # OurSEO check_serp, audit_page
|
||||
- mcp__dfs-mcp__* # DataForSEO MCP fallback
|
||||
- Bash # `our serp *` + `our research naver serp` (Claude Code only)
|
||||
- mcp__notion__*
|
||||
- WebSearch
|
||||
- WebFetch
|
||||
|
||||
# triggers: [] # TODO: Extract from description
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
# Ahrefs
|
||||
# Ahrefs MCP
|
||||
|
||||
> TODO: Document tool usage for this skill
|
||||
Use when the user explicitly wants Ahrefs' SERP view, or already has an Ahrefs project for the domain. Namespace: `mcp__ahrefs__*` (Desktop) / `mcp__claude_ai_Ahrefs__*` (Claude.ai) — same backend.
|
||||
|
||||
## Available Commands
|
||||
## Key endpoints
|
||||
|
||||
- [ ] List commands
|
||||
- `serp-overview` — top organic, SERP features, paid layout for a keyword
|
||||
- `rank-tracker-serp-overview` — SERP layout for keywords in a tracked project
|
||||
- `keywords-explorer-overview` — pairs with SERP for SERP-feature signals (Featured Snippet, PAA)
|
||||
|
||||
## Configuration
|
||||
## Example
|
||||
|
||||
- [ ] Add configuration details
|
||||
```
|
||||
mcp__ahrefs__serp-overview(keyword="<keyword>", country="us")
|
||||
mcp__ahrefs__serp-overview(keyword="<keyword>", country="kr")
|
||||
```
|
||||
|
||||
## Examples
|
||||
## Not for this skill when
|
||||
|
||||
- [ ] Add usage examples
|
||||
- **Naver SERP** — Ahrefs doesn't cover Naver. Use `our research naver serp`.
|
||||
- **Single-keyword position spot-check** — `mcp__ourseo__check_serp` is cheaper.
|
||||
- **Full SERP JSON with all features for export** — DataForSEO via `our serp live`.
|
||||
|
||||
Reference: call `mcp__ahrefs__doc` once per session before first use.
|
||||
|
||||
39
custom-skills/20-seo-serp-analysis/desktop/tools/ourseo.md
Normal file
39
custom-skills/20-seo-serp-analysis/desktop/tools/ourseo.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# OurSEO Agent (CLI + MCP)
|
||||
|
||||
Two paths for SERP work:
|
||||
|
||||
1. **CLI** (Claude Code, Bash) — `our serp *` (DataForSEO Google) + `our research naver serp` (Naver-only).
|
||||
2. **MCP** (Claude Desktop) — `mcp__ourseo__check_serp` for single-keyword spot-check.
|
||||
|
||||
## CLI commands
|
||||
|
||||
```bash
|
||||
# Google SERP via DataForSEO
|
||||
our serp live "<keyword>" --location 2410 --language ko
|
||||
our serp competitors <domain> --location 2410
|
||||
our serp ranked-keywords <domain> --location 2410 --limit 50
|
||||
our serp domain-overview <domain> --location 2410
|
||||
|
||||
# Naver SERP (Korean engines — Naver-only path)
|
||||
our research naver serp "<keyword>"
|
||||
our research naver serp "<keyword>" --domain <target.com>
|
||||
```
|
||||
|
||||
Location codes: `2410` Korea, `2840` US, `2392` Japan.
|
||||
|
||||
## MCP tool
|
||||
|
||||
```
|
||||
mcp__ourseo__check_serp(keyword="<keyword>", domain="<target>", country="kr")
|
||||
```
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Only** path that covers Naver SERP (blog, cafe, knowledge iN, Smart Store, brand zone, shortform, influencer).
|
||||
- Cheapest per call for batch Korean-market SERP work.
|
||||
- Full live SERP JSON with all features for export.
|
||||
|
||||
## Not for this skill when
|
||||
|
||||
- **Modelled SERP overview** (volume + CPC trend per keyword) — Semrush is cleanest for that.
|
||||
- **Brand Radar / AI search SERP** — Ahrefs Brand Radar is the only option.
|
||||
28
custom-skills/20-seo-serp-analysis/desktop/tools/semrush.md
Normal file
28
custom-skills/20-seo-serp-analysis/desktop/tools/semrush.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Semrush MCP
|
||||
|
||||
Default backend for Google SERP overview, organic competitor positions, SERP-feature presence.
|
||||
|
||||
Call pattern: discovery → `get_report_schema` → `execute_report`.
|
||||
|
||||
## Key reports (SERP toolkit)
|
||||
|
||||
- Domain overview (organic positions, traffic, distribution)
|
||||
- Position changes (new/lost/improved/declined)
|
||||
- Organic competitors per keyword
|
||||
- SERP-feature presence per keyword
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
mcp__semrush__overview_research(query="<keyword>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="<id from above>")
|
||||
mcp__semrush__execute_report(report_id="<id>", params={"phrase": "<keyword>", "database": "us"})
|
||||
```
|
||||
|
||||
Korean market: `database="kr"`. Default `database="us"` per Semrush MCP instructions.
|
||||
|
||||
## Not for this skill when
|
||||
|
||||
- **Naver SERP** — Semrush is Google-only. Use `our research naver serp`.
|
||||
- **Raw SERP JSON for parsing all features** (PAA list, video carousel items, image pack) — DataForSEO via `our serp live` exposes richer JSON.
|
||||
- **Backlink graph for ranking pages** — Ahrefs `site-explorer-*` is stronger.
|
||||
157
custom-skills/21-seo-position-tracking/SKILL.md
Normal file
157
custom-skills/21-seo-position-tracking/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: seo-position-tracking
|
||||
description: |
|
||||
Keyword position tracking for keyword ranking monitoring.
|
||||
Triggers: rank tracking, position monitoring, keyword rankings, visibility score, ranking report, 키워드 순위, 순위 추적.
|
||||
---
|
||||
|
||||
# SEO Position Tracking
|
||||
|
||||
## Purpose
|
||||
|
||||
Monitor keyword ranking positions, detect significant changes, calculate visibility scores, and compare against competitors using our-seo-agent CLI or pre-fetched ranking data. Provides actionable alerts for ranking drops and segment-level performance breakdown.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
1. **Position Monitoring** - Retrieve current keyword ranking positions from our-seo-agent CLI or pre-fetched data
|
||||
2. **Change Detection** - Detect significant position changes with configurable threshold alerts (severity: critical/high/medium/low)
|
||||
3. **Visibility Scoring** - Calculate weighted visibility scores using CTR-curve model (position 1 = 30%, position 2 = 15%, etc.)
|
||||
4. **Brand/Non-brand Segmentation** - Automatically classify keywords by brand relevance and search intent type
|
||||
5. **Competitor Comparison** - Compare keyword overlap, position gaps, and visibility scores against competitors
|
||||
|
||||
## Data Source Selection
|
||||
|
||||
This skill can pull rank data from multiple backends. **Pick one per task** — don't fan out by default (cost + rate limits).
|
||||
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | Default when an Ahrefs Rank Tracker project exists for the domain | `rank-tracker-overview`, `rank-tracker-serp-overview`, `rank-tracker-competitors-*`. Best historical view; data is what Ahrefs already polled. |
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default when no Ahrefs project; English/major market position scans | `tracking_research`, `organic_research`. `database="us"` default; `"kr"` for Korean. |
|
||||
| **OurSEO CLI** (`our serp *`) | DataForSEO under the hood — full ranked-keywords pulls with volume, Korean-aware via `--location 2410` | Claude Code only (Bash). Commands: `our serp ranked-keywords`, `our serp domain-overview`, `our keywords volume`. |
|
||||
| **OurSEO MCP** (`mcp__ourseo__check_serp`) | One-off rank spot-check for a single keyword/domain pair | Cheap; no historical view — pair with prior runs in MySQL / SQLite if tracking over time. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Fallback when `our` CLI isn't running; historical rank overview | `dataforseo_labs_google_historical_rank_overview`, `dataforseo_labs_google_ranked_keywords`. |
|
||||
| **GSC** (via `our research search-console` or Ahrefs `gsc-*`) | **First-party position data** — what Google actually rendered for the verified site | Only first-party source — use to validate or replace estimated positions. |
|
||||
|
||||
### How to pick
|
||||
|
||||
1. **User named a backend explicitly** → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default.
|
||||
3. **Site is verified in GSC** AND task is single-site tracking → prefer **GSC** for ground truth, supplement with Semrush/Ahrefs for competitor delta.
|
||||
4. **Ahrefs project exists for the domain** → prefer Ahrefs `rank-tracker-*`.
|
||||
5. **Default**: Semrush MCP for new tracking jobs; **`our serp ranked-keywords`** for Korean batch.
|
||||
6. **Still ambiguous + non-trivial** → ask once via `AskUserQuestion`.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Ahrefs MCP (when project exists):**
|
||||
```
|
||||
mcp__ahrefs__rank-tracker-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-serp-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-competitors-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-competitors-stats(project_id="<id>")
|
||||
```
|
||||
|
||||
**Semrush MCP (no Ahrefs project):**
|
||||
```
|
||||
mcp__semrush__tracking_research(query="<keyword>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI (Korean batch):**
|
||||
```bash
|
||||
our serp ranked-keywords <domain> --location 2410 --limit 100 --format json
|
||||
our serp domain-overview <domain> --location 2410 --format json
|
||||
our keywords volume "<kw1>" "<kw2>" --location 2410 --language ko
|
||||
our serp competitors <domain> --location 2410
|
||||
```
|
||||
|
||||
**OurSEO MCP (spot-check):**
|
||||
```
|
||||
mcp__ourseo__check_serp(keyword="<keyword>", domain="<target.com>", country="kr")
|
||||
```
|
||||
|
||||
**GSC (first-party validation):**
|
||||
```bash
|
||||
our research search-console queries --site sc-domain:<domain> --days 28
|
||||
```
|
||||
|
||||
### Common parameters
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
Always record the chosen data source in the report **Overview** so future tracking runs can compare like-for-like.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Data Collection
|
||||
1. Fetch current ranked keywords: `our serp ranked-keywords <domain> --location 2410 --limit 100 --format json`
|
||||
2. Get domain overview: `our serp domain-overview <domain> --location 2410 --format json`
|
||||
3. Get search volumes for tracked keywords: `our keywords volume "<kw1>" "<kw2>" --location 2410`
|
||||
4. Fetch competitor positions: `our serp ranked-keywords <competitor> --location 2410 --limit 100`
|
||||
5. For historical comparison, use MCP: `mcp__dfs-mcp__dataforseo_labs_google_historical_rank_overview`
|
||||
|
||||
### Phase 2: Analysis
|
||||
1. Detect position changes against previous period
|
||||
2. Generate alerts for changes exceeding threshold
|
||||
3. Calculate visibility score weighted by search volume and CTR curve
|
||||
4. Segment keywords into brand/non-brand and by intent type
|
||||
5. Compare positions against each competitor
|
||||
|
||||
### Phase 3: Reporting
|
||||
1. Compile position distribution (top3/top10/top20/top50/top100)
|
||||
2. Summarize changes (improved/declined/stable/new/lost)
|
||||
3. List alerts sorted by severity and search volume
|
||||
4. Generate segment-level breakdown
|
||||
5. Save report to Notion SEO Audit Log database
|
||||
|
||||
## Output Format
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "https://example.com",
|
||||
"total_keywords": 250,
|
||||
"visibility_score": 68.5,
|
||||
"positions": {
|
||||
"top3": 15,
|
||||
"top10": 48,
|
||||
"top20": 92,
|
||||
"top50": 180,
|
||||
"top100": 230
|
||||
},
|
||||
"changes": {
|
||||
"improved": 45,
|
||||
"declined": 30,
|
||||
"stable": 155,
|
||||
"new": 12,
|
||||
"lost": 8
|
||||
},
|
||||
"alerts": [
|
||||
{
|
||||
"keyword": "example keyword",
|
||||
"old_position": 5,
|
||||
"new_position": 15,
|
||||
"change": -10,
|
||||
"volume": 5400,
|
||||
"severity": "high"
|
||||
}
|
||||
],
|
||||
"segments": {
|
||||
"brand": {"keywords": 30, "avg_position": 2.1},
|
||||
"non_brand": {"keywords": 220, "avg_position": 24.5}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notion Output (Required)
|
||||
|
||||
All tracking reports MUST be saved to OurDigital SEO Audit Log:
|
||||
- **Database ID**: `2c8581e5-8a1e-8035-880b-e38cefc2f3ef`
|
||||
- **Properties**: Issue (title), Site (url), Category (Position Tracking), Priority, Found Date, Audit ID
|
||||
- **Language**: Korean with English technical terms
|
||||
- **Audit ID Format**: RANK-YYYYMMDD-NNN
|
||||
@@ -19,35 +19,73 @@ Monitor keyword ranking positions, detect significant changes, calculate visibil
|
||||
4. **Brand/Non-brand Segmentation** - Automatically classify keywords by brand relevance and search intent type
|
||||
5. **Competitor Comparison** - Compare keyword overlap, position gaps, and visibility scores against competitors
|
||||
|
||||
## MCP Tool Usage
|
||||
## Data Source Selection
|
||||
|
||||
### SEO Data (DataForSEO)
|
||||
This skill can pull rank data from multiple backends. **Pick one per task** — don't fan out by default (cost + rate limits).
|
||||
|
||||
**Primary — our-seo-agent CLI:**
|
||||
| Backend | Best for | Notes |
|
||||
|---|---|---|
|
||||
| **Ahrefs MCP** (`mcp__ahrefs__*`) | Default when an Ahrefs Rank Tracker project exists for the domain | `rank-tracker-overview`, `rank-tracker-serp-overview`, `rank-tracker-competitors-*`. Best historical view; data is what Ahrefs already polled. |
|
||||
| **Semrush MCP** (`mcp__semrush__*`) | Default when no Ahrefs project; English/major market position scans | `tracking_research`, `organic_research`. `database="us"` default; `"kr"` for Korean. |
|
||||
| **OurSEO CLI** (`our serp *`) | DataForSEO under the hood — full ranked-keywords pulls with volume, Korean-aware via `--location 2410` | Claude Code only (Bash). Commands: `our serp ranked-keywords`, `our serp domain-overview`, `our keywords volume`. |
|
||||
| **OurSEO MCP** (`mcp__ourseo__check_serp`) | One-off rank spot-check for a single keyword/domain pair | Cheap; no historical view — pair with prior runs in MySQL / SQLite if tracking over time. |
|
||||
| **DataForSEO MCP** (`mcp__dfs-mcp__*`) | Fallback when `our` CLI isn't running; historical rank overview | `dataforseo_labs_google_historical_rank_overview`, `dataforseo_labs_google_ranked_keywords`. |
|
||||
| **GSC** (via `our research search-console` or Ahrefs `gsc-*`) | **First-party position data** — what Google actually rendered for the verified site | Only first-party source — use to validate or replace estimated positions. |
|
||||
|
||||
### How to pick
|
||||
|
||||
1. **User named a backend explicitly** → use it.
|
||||
2. **User preference memory** — read `feedback_seo_tool_preferences.md`; honor the task-type default.
|
||||
3. **Site is verified in GSC** AND task is single-site tracking → prefer **GSC** for ground truth, supplement with Semrush/Ahrefs for competitor delta.
|
||||
4. **Ahrefs project exists for the domain** → prefer Ahrefs `rank-tracker-*`.
|
||||
5. **Default**: Semrush MCP for new tracking jobs; **`our serp ranked-keywords`** for Korean batch.
|
||||
6. **Still ambiguous + non-trivial** → ask once via `AskUserQuestion`.
|
||||
|
||||
### Backend call patterns
|
||||
|
||||
**Ahrefs MCP (when project exists):**
|
||||
```
|
||||
mcp__ahrefs__rank-tracker-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-serp-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-competitors-overview(project_id="<id>")
|
||||
mcp__ahrefs__rank-tracker-competitors-stats(project_id="<id>")
|
||||
```
|
||||
|
||||
**Semrush MCP (no Ahrefs project):**
|
||||
```
|
||||
mcp__semrush__tracking_research(query="<keyword>", database="us")
|
||||
mcp__semrush__get_report_schema(report_id="...")
|
||||
mcp__semrush__execute_report(report_id="...", params={...})
|
||||
```
|
||||
|
||||
**OurSEO CLI (Korean batch):**
|
||||
```bash
|
||||
our serp ranked-keywords <domain> --location 2410 --limit 100
|
||||
our serp ranked-keywords <domain> --location 2410 --limit 100 --format json
|
||||
our serp domain-overview <domain> --location 2410 --format json
|
||||
our keywords volume "<kw1>" "<kw2>" --location 2410 --language ko
|
||||
our serp domain-overview <domain> --location 2410
|
||||
our serp competitors <domain> --location 2410
|
||||
```
|
||||
|
||||
**Interactive fallback — DataForSEO MCP:**
|
||||
**OurSEO MCP (spot-check):**
|
||||
```
|
||||
mcp__dfs-mcp__dataforseo_labs_google_ranked_keywords
|
||||
mcp__dfs-mcp__dataforseo_labs_google_domain_rank_overview
|
||||
mcp__dfs-mcp__dataforseo_labs_google_historical_rank_overview
|
||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_overview
|
||||
mcp__ourseo__check_serp(keyword="<keyword>", domain="<target.com>", country="kr")
|
||||
```
|
||||
|
||||
### Common Parameters
|
||||
- **location_code**: 2410 (Korea), 2840 (US), 2392 (Japan)
|
||||
- **language_code**: ko, en, ja
|
||||
**GSC (first-party validation):**
|
||||
```bash
|
||||
our research search-console queries --site sc-domain:<domain> --days 28
|
||||
```
|
||||
|
||||
### Notion for Report Storage
|
||||
```
|
||||
mcp__notion__notion-create-pages: Save tracking reports to SEO Audit Log
|
||||
mcp__notion__notion-update-page: Update existing tracking entries
|
||||
```
|
||||
### Common parameters
|
||||
|
||||
| Concept | Semrush | Ahrefs | DataForSEO / `our` CLI |
|
||||
|---|---|---|---|
|
||||
| Korean market | `database="kr"` | `country="kr"` | `--location 2410` |
|
||||
| US market | `database="us"` | `country="us"` | `--location 2840` |
|
||||
| Japan | `database="jp"` | `country="jp"` | `--location 2392` |
|
||||
| Language | (database-bound) | (country-bound) | `--language ko`/`en`/`ja` |
|
||||
|
||||
Always record the chosen data source in the report **Overview** so future tracking runs can compare like-for-like.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user