Compare commits
114 Commits
500d352026
...
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 | |||
| 382b55e9c8 | |||
| b1c2dca080 | |||
| f215c11c32 | |||
| 133df68b81 | |||
| 292cd132c2 | |||
| 7c8dbaeb78 | |||
| 322f47dd6a | |||
| c35a28780d | |||
| f292408ea8 |
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
|
||||||
@@ -6,6 +6,20 @@ description: GTM page audit - scan fired tags, gap analysis, tag design from DOM
|
|||||||
|
|
||||||
Comprehensive Google Tag Manager audit using Playwright to scan live pages for container health, tag firing, dataLayer events, form tracking, and e-commerce checkout flows.
|
Comprehensive Google Tag Manager audit using Playwright to scan live pages for container health, tag firing, dataLayer events, form tracking, and e-commerce checkout flows.
|
||||||
|
|
||||||
|
## MANDATORY: Knowledge Base Read/Write
|
||||||
|
|
||||||
|
**Before starting any audit:**
|
||||||
|
1. Identify the target client from container ID or URL
|
||||||
|
2. Read `knowledge-base/accounts/<client>/profile.md` — URL patterns, known issues, platform stack, past findings
|
||||||
|
3. Read `knowledge-base/accounts/<client>/*.md` — taxonomy, naming issues to exclude from new findings
|
||||||
|
4. Skim `knowledge-base/logs/<client>/` — past session context
|
||||||
|
|
||||||
|
**After completing audit, write a session log:**
|
||||||
|
- Write to `knowledge-base/logs/<client>/YYYY-MM-DD-<description>.md`
|
||||||
|
- Include: date, container ID, status, issues found (root causes + specific IDs), lessons learned
|
||||||
|
- Update `knowledge-base/accounts/<client>/profile.md` if you discovered persistent facts
|
||||||
|
- See `AGENTS.md` for full format template
|
||||||
|
|
||||||
## Triggers
|
## Triggers
|
||||||
- "audit GTM", "check dataLayer", "GTM 검사", "scan GTM tags", "audit tags on page", "check tag firing"
|
- "audit GTM", "check dataLayer", "GTM 검사", "scan GTM tags", "audit tags on page", "check tag firing"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ Priority reads per task:
|
|||||||
- "create GTM tag", "generate dataLayer", "modify trigger"
|
- "create GTM tag", "generate dataLayer", "modify trigger"
|
||||||
- "update variable", "write custom HTML", "manage GTM"
|
- "update variable", "write custom HTML", "manage GTM"
|
||||||
|
|
||||||
|
## MANDATORY: Knowledge Base Read/Write
|
||||||
|
|
||||||
|
**Before creating or modifying ANY tags, triggers, or variables:**
|
||||||
|
1. Identify the target client from container ID
|
||||||
|
2. Read `knowledge-base/accounts/<client>/profile.md` — key variables, URL patterns, platform stack, known issues
|
||||||
|
3. Read `knowledge-base/accounts/<client>/*.md` — taxonomy reveals existing event names and naming conventions
|
||||||
|
4. Skim `knowledge-base/logs/<client>/` — past fixes reveal patterns (e.g., "form POST loses URL params")
|
||||||
|
|
||||||
|
**After completing tag management, write a session log:**
|
||||||
|
- Write to `knowledge-base/logs/<client>/YYYY-MM-DD-<description>.md`
|
||||||
|
- Include: date, container ID, tags affected (IDs), what was created/modified/deleted, cHTML snippets
|
||||||
|
- Update `knowledge-base/accounts/<client>/profile.md` if you discovered persistent facts
|
||||||
|
- See `AGENTS.md` for full format template
|
||||||
|
|
||||||
## MANDATORY: dataLayer First Workflow
|
## MANDATORY: dataLayer First Workflow
|
||||||
|
|
||||||
1. **Design dataLayer push FIRST** (match client's tech stack: vanilla JS/React/Vue/PHP)
|
1. **Design dataLayer push FIRST** (match client's tech stack: vanilla JS/React/Vue/PHP)
|
||||||
|
|||||||
@@ -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
|
## Use these instead
|
||||||
- "GTM audit lifecycle", "container analysis"
|
|
||||||
- "GTM 유지보수", "버전 비교"
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Clone D.intelligence GTM Toolkit
|
# Active context
|
||||||
git clone https://github.com/ourdigital/dintel-gtm-agent.git
|
dtm status
|
||||||
|
dtm list accounts
|
||||||
|
dtm list containers
|
||||||
|
|
||||||
# Container analysis
|
# Inspect live container
|
||||||
python analyze_container.py GTM-XXXXXX.json --output report.md
|
dtm list tags
|
||||||
|
dtm list triggers
|
||||||
|
dtm list variables
|
||||||
|
|
||||||
# Version comparison
|
# Versions
|
||||||
python diff_versions.py v1.json v2.json --output diff.md
|
dtm list versions
|
||||||
|
dtm version live
|
||||||
# Unused element detection
|
|
||||||
python find_unused.py container.json --type all
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 6: Progressive Audit
|
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.
|
||||||
|
|
||||||
| 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).
|
|
||||||
|
|
||||||
## Notion Output
|
## Notion Output
|
||||||
- Database: GTM Knowledge Base
|
|
||||||
- Properties: Project, Audit Date, Container ID, Status, Issues Count
|
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.
|
||||||
- Reports in Korean; technical terms in English
|
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ Priority reads per task:
|
|||||||
- "validate tags", "QA GTM", "debug GTM"
|
- "validate tags", "QA GTM", "debug GTM"
|
||||||
- "naming conventions", "GTM best practice"
|
- "naming conventions", "GTM best practice"
|
||||||
|
|
||||||
|
## MANDATORY: Knowledge Base Read/Write
|
||||||
|
|
||||||
|
**Before starting any validation or QA:**
|
||||||
|
1. Identify the target client from container ID or URL
|
||||||
|
2. Read `knowledge-base/accounts/<client>/profile.md` — naming conventions, known issues, platform stack
|
||||||
|
3. Read `knowledge-base/accounts/<client>/*.md` — taxonomy defines expected events; naming fix plans reveal known violations
|
||||||
|
4. Skim `knowledge-base/logs/<client>/` — past QA reveals recurring issues and known false positives
|
||||||
|
|
||||||
|
**After completing validation, write a session log:**
|
||||||
|
- Write to `knowledge-base/logs/<client>/YYYY-MM-DD-<description>.md`
|
||||||
|
- Include: date, container ID, pass/fail results, broken triggers (IDs), new naming violations
|
||||||
|
- Update `knowledge-base/accounts/<client>/profile.md` if you discovered persistent facts
|
||||||
|
- See `AGENTS.md` for full format template
|
||||||
|
|
||||||
## Validation Modes
|
## Validation Modes
|
||||||
|
|
||||||
### 1. Tag Firing Verification
|
### 1. Tag Firing Verification
|
||||||
|
|||||||
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
|
||||||
40
.claude/commands/jamie-marketing-editor.md
Normal file
40
.claude/commands/jamie-marketing-editor.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Jamie Clinic multi-channel marketing content editor with compliance checking
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jamie Marketing Editor
|
||||||
|
|
||||||
|
Marketing content across all Jamie Clinic digital channels: website, blog, SNS, ads, email, KakaoTalk.
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
- "Jamie marketing", "제이미 마케팅"
|
||||||
|
- "광고 카피", "SNS 콘텐츠", "ad copy"
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python code/scripts/compliance_checker.py --input draft.md
|
||||||
|
python code/scripts/compliance_checker.py --input draft.md --verbose --output report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channel Tone Guide
|
||||||
|
- **Website**: Professional, educational, trust-building
|
||||||
|
- **Blog**: Educational, accessible, SEO-optimized
|
||||||
|
- **Instagram**: Sensory, concise, trendy + hashtags
|
||||||
|
- **YouTube**: Professional, step-by-step with visuals
|
||||||
|
- **KakaoTalk**: Friendly, helpful, action-oriented
|
||||||
|
- **Ads**: Factual, compliant, no superlatives
|
||||||
|
|
||||||
|
## Brand Pillars
|
||||||
|
- Safety, Naturalness, Transparency, Quality Assurance
|
||||||
|
- Key differentiators: director's personal care, 5-year AS, revision expertise
|
||||||
|
|
||||||
|
## Compliance Rules (Korean Medical Ad Law)
|
||||||
|
- No exaggerated claims or guarantee language
|
||||||
|
- No patient testimonials
|
||||||
|
- No before/after comparisons without disclaimers
|
||||||
|
- No competitor comparisons
|
||||||
|
- Required side-effect and individual variation disclosures
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. Define channel + audience -> 2. Generate content -> 3. Run compliance checker -> 4. Submit to jamie-brand-audit
|
||||||
@@ -24,7 +24,7 @@ Push markdown content to Notion pages or databases via the Notion API.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
```bash
|
```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
|
# Test connection
|
||||||
python notion_writer.py --test
|
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
|
# Temporary files
|
||||||
output/
|
output/
|
||||||
keyword_analysis_*.json
|
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-*/
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -148,6 +148,7 @@ Three specialized skills with clear handoff workflow: **audit → edit → valid
|
|||||||
- Brand compliance is critical - check `references/` for guidelines
|
- Brand compliance is critical - check `references/` for guidelines
|
||||||
- Korean language content - verify encoding in scripts
|
- Korean language content - verify encoding in scripts
|
||||||
- Instagram/YouTube skills may need API credentials
|
- Instagram/YouTube skills may need API credentials
|
||||||
|
- **42-jamie-faq-entry**: KakaoTalk Kanana chatbot Q&A entries (medical ad compliance, character limits)
|
||||||
- **46-jamie-journal-editor**: Journal/blog articles for journal.jamie.clinic in Dr. Jung's voice
|
- **46-jamie-journal-editor**: Journal/blog articles for journal.jamie.clinic in Dr. Jung's voice
|
||||||
- **47-jamie-marketing-editor**: Multi-channel marketing (ads, SNS, email) with compliance checker script
|
- **47-jamie-marketing-editor**: Multi-channel marketing (ads, SNS, email) with compliance checker script
|
||||||
|
|
||||||
@@ -194,10 +195,17 @@ Task(
|
|||||||
- **Always ask user consent** before executing any cleanup or system changes
|
- **Always ask user consent** before executing any cleanup or system changes
|
||||||
- Scripts are in `custom-skills/81-mac-optimizer/scripts/`
|
- Scripts are in `custom-skills/81-mac-optimizer/scripts/`
|
||||||
- Reference docs in `custom-skills/81-mac-optimizer/references/`
|
- 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
|
- Use **reference-curator-pipeline** for full automated curation workflows
|
||||||
- Runs as background task, coordinates all 6 skills in sequence
|
- Runs as background task, coordinates all 6 skills in sequence
|
||||||
- Handles QA loops automatically (max 3 refactor, 2 deep_research iterations)
|
- Handles QA loops automatically (max 3 refactor, 2 deep_research iterations)
|
||||||
|
|||||||
87
CLAUDE.md
87
CLAUDE.md
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
**GitHub**: https://github.com/ourdigital/our-claude-skills
|
**GitHub**: https://github.com/ourdigital/our-claude-skills
|
||||||
|
|
||||||
This is a Claude Skills collection repository containing:
|
This is a Claude Skills collection repository containing:
|
||||||
- **custom-skills/**: 64 custom skills for OurDigital workflows, SEO, GTM, Jamie Brand, NotebookLM, Notion, D.intelligence Agent Corps, Reference Curation, Mac Optimizer, TUI Designer, and Multi-Agent Collaboration
|
- **custom-skills/**: 65 custom skills for OurDigital workflows, SEO, GTM, Jamie Brand, NotebookLM, Notion, D.intelligence Agent Corps, Reference Curation, Mac Optimizer, TUI Designer, and Multi-Agent Collaboration
|
||||||
- **example-skills/**: Reference examples from Anthropic's official skills repository
|
- **example-skills/**: Reference examples from Anthropic's official skills repository
|
||||||
- **official-skills/**: Notion integration skills (3rd party)
|
- **official-skills/**: Notion integration skills (3rd party)
|
||||||
- **reference/**: Skill format requirements documentation
|
- **reference/**: Skill format requirements documentation
|
||||||
@@ -39,7 +39,7 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 14 | seo-core-web-vitals | LCP, CLS, FID, INP metrics | "Core Web Vitals", "page speed" |
|
| 14 | seo-core-web-vitals | LCP, CLS, FID, INP metrics | "Core Web Vitals", "page speed" |
|
||||||
| 15 | seo-search-console | GSC data analysis | "Search Console", "rankings" |
|
| 15 | seo-search-console | GSC data analysis | "Search Console", "rankings" |
|
||||||
| 16 | seo-schema-validator | Structured data validation | "validate schema", "JSON-LD" |
|
| 16 | seo-schema-validator | Structured data validation | "validate schema", "JSON-LD" |
|
||||||
| 17 | seo-schema-generator | Schema markup creation | "generate schema", "create JSON-LD" |
|
| 17 | seo-schema-generator | JSON-LD generation — Mode 1 from existing site, Mode 2 from collected sources → claims register → drafts → validate (16) | "generate schema", "create JSON-LD", "source-to-schema", "schema from site" |
|
||||||
| 18 | seo-local-audit | NAP, GBP, citations | "local SEO", "Google Business Profile" |
|
| 18 | seo-local-audit | NAP, GBP, citations | "local SEO", "Google Business Profile" |
|
||||||
| 19 | seo-keyword-strategy | Keyword expansion, intent, clustering, gaps | "keyword research", "keyword strategy" |
|
| 19 | seo-keyword-strategy | Keyword expansion, intent, clustering, gaps | "keyword research", "keyword strategy" |
|
||||||
| 20 | seo-serp-analysis | Google/Naver SERP features, competitor positions | "SERP analysis", "SERP features" |
|
| 20 | seo-serp-analysis | Google/Naver SERP features, competitor positions | "SERP analysis", "SERP features" |
|
||||||
@@ -71,11 +71,13 @@ This is a Claude Skills collection repository containing:
|
|||||||
|---|-------|---------|---------|
|
|---|-------|---------|---------|
|
||||||
| 40 | jamie-brand-editor | Branded content generation | "write Jamie blog", "Jamie content" |
|
| 40 | jamie-brand-editor | Branded content generation | "write Jamie blog", "Jamie content" |
|
||||||
| 41 | jamie-brand-audit | Content review/compliance | "review content", "brand audit" |
|
| 41 | jamie-brand-audit | Content review/compliance | "review content", "brand audit" |
|
||||||
|
| 42 | jamie-faq-entry | KakaoTalk Kanana chatbot Q&A entries | "카나나 답변", "Kanana Q&A", "카카오 상담 답변" |
|
||||||
| 43 | jamie-youtube-manager | YouTube SEO audit & management | "YouTube SEO", "YT optimization" |
|
| 43 | jamie-youtube-manager | YouTube SEO audit & management | "YouTube SEO", "YT optimization" |
|
||||||
| 44 | jamie-youtube-subtitle-checker | YouTube subtitle validation | "check subtitles", "subtitle QA" |
|
| 44 | jamie-youtube-subtitle-checker | YouTube subtitle validation | "check subtitles", "subtitle QA" |
|
||||||
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
||||||
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
|
| 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", "제이미 마케팅", "광고 카피" |
|
| 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)
|
### NotebookLM Tools (50-59)
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
||||||
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
||||||
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
|
| 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", "스킬 업데이트" |
|
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
|
||||||
|
|
||||||
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
||||||
@@ -157,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" |
|
| 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" |
|
| 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/
|
XX-skill-name/
|
||||||
├── code/ # Claude Code version
|
├── SKILL.md # Root directive — official Agent Skills format (LOADABLE)
|
||||||
│ ├── CLAUDE.md # Action-oriented directive
|
├── scripts/ # Runnable scripts (single source of truth)
|
||||||
│ ├── scripts/ # Executable Python/Bash
|
├── references/ # Heavy docs, loaded on demand
|
||||||
│ └── docs/ # Documentation
|
├── templates/ # Optional
|
||||||
|
├── fixtures/ # Optional sample/test inputs
|
||||||
│
|
│
|
||||||
├── desktop/ # Claude Desktop version
|
├── code/ # Claude Code packaging
|
||||||
│ ├── SKILL.md # Skill directive with YAML frontmatter
|
│ ├── 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)
|
│ ├── skill.yaml # Extended metadata (optional)
|
||||||
│ └── tools/ # MCP tool documentation
|
│ └── tools/ # MCP tool documentation
|
||||||
│
|
│
|
||||||
@@ -179,24 +191,24 @@ XX-skill-name/
|
|||||||
└── README.md # Overview
|
└── 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/` |
|
### Layer roles
|
||||||
|--------|---------|------------|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### 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
|
```markdown
|
||||||
---
|
---
|
||||||
name: skill-name-kebab-case
|
name: skill-name-kebab-case # clean name: dir minus the NN- prefix
|
||||||
description: |
|
description: |
|
||||||
Brief description of what the skill does.
|
What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거.
|
||||||
Triggers: keyword1, keyword2, 한국어 트리거.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Skill Title
|
# Skill Title
|
||||||
@@ -204,6 +216,11 @@ description: |
|
|||||||
Content starts here...
|
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
|
## Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -232,11 +249,13 @@ our-claude-skills/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── 40-jamie-brand-editor/
|
│ ├── 40-jamie-brand-editor/
|
||||||
│ ├── 41-jamie-brand-audit/
|
│ ├── 41-jamie-brand-audit/
|
||||||
|
│ ├── 42-jamie-faq-entry/
|
||||||
│ ├── 43-jamie-youtube-manager/
|
│ ├── 43-jamie-youtube-manager/
|
||||||
│ ├── 44-jamie-youtube-subtitle-checker/
|
│ ├── 44-jamie-youtube-subtitle-checker/
|
||||||
│ ├── 45-jamie-instagram-manager/
|
│ ├── 45-jamie-instagram-manager/
|
||||||
│ ├── 46-jamie-journal-editor/
|
│ ├── 46-jamie-journal-editor/
|
||||||
│ ├── 47-jamie-marketing-editor/
|
│ ├── 47-jamie-marketing-editor/
|
||||||
|
│ ├── 48-jamie-copy-trimmer/
|
||||||
│ │
|
│ │
|
||||||
│ ├── 50-notebooklm-agent/
|
│ ├── 50-notebooklm-agent/
|
||||||
│ ├── 51-notebooklm-automation/
|
│ ├── 51-notebooklm-automation/
|
||||||
@@ -255,11 +274,12 @@ our-claude-skills/
|
|||||||
│ ├── 75-dintel-marketing-mgr/
|
│ ├── 75-dintel-marketing-mgr/
|
||||||
│ ├── 76-dintel-backoffice-mgr/
|
│ ├── 76-dintel-backoffice-mgr/
|
||||||
│ ├── 77-dintel-account-mgr/
|
│ ├── 77-dintel-account-mgr/
|
||||||
|
│ ├── 78-dintel-campaign-designer/
|
||||||
│ ├── 79-dintel-skill-update/
|
│ ├── 79-dintel-skill-update/
|
||||||
│ │
|
│ │
|
||||||
│ ├── 80-claude-settings-optimizer/
|
│ ├── 80-claude-settings-optimizer/
|
||||||
│ ├── 81-mac-optimizer/
|
│ ├── 81-mac-optimizer/
|
||||||
│ ├── 82-tui-design-template/
|
│ ├── 82-our-gdrive-organizer/
|
||||||
│ │
|
│ │
|
||||||
│ ├── 90-reference-curator/ # Modular reference documentation suite
|
│ ├── 90-reference-curator/ # Modular reference documentation suite
|
||||||
│ │ ├── 01-reference-discovery/
|
│ │ ├── 01-reference-discovery/
|
||||||
@@ -273,7 +293,9 @@ our-claude-skills/
|
|||||||
│ │ ├── shared/
|
│ │ ├── shared/
|
||||||
│ │ └── install.sh
|
│ │ └── install.sh
|
||||||
│ │
|
│ │
|
||||||
│ └── 91-multi-agent-guide/
|
│ ├── 91-multi-agent-guide/
|
||||||
|
│ │
|
||||||
|
│ └── 92-tui-design-template/
|
||||||
│
|
│
|
||||||
├── example-skills/skills-main/
|
├── example-skills/skills-main/
|
||||||
├── official-skills/
|
├── official-skills/
|
||||||
@@ -283,10 +305,11 @@ our-claude-skills/
|
|||||||
## Skill Design Principles
|
## Skill Design Principles
|
||||||
|
|
||||||
1. **One thing done well** - Each skill focuses on a single capability
|
1. **One thing done well** - Each skill focuses on a single capability
|
||||||
2. **Directives under 1,500 words** - Concise, actionable
|
2. **Root `SKILL.md` first** - Ship a loadable root `SKILL.md`; keep `code/`+`desktop/` as packaging
|
||||||
3. **Self-contained** - Each platform version is fully independent
|
3. **Directives under 1,500 words** - Concise, actionable; push detail into `references/`
|
||||||
4. **Code-first development** - Build Claude Code version first
|
4. **Self-contained** - Bundle scripts/refs as relative paths; one source of truth (no script dupes)
|
||||||
5. **Progressive numbering** - Logical grouping by domain
|
5. **Code-first development** - Build and self-test the runnable version first
|
||||||
|
6. **Progressive numbering** - Logical grouping by domain
|
||||||
|
|
||||||
## Creating New Skills
|
## Creating New Skills
|
||||||
|
|
||||||
@@ -295,8 +318,12 @@ our-claude-skills/
|
|||||||
python example-skills/skills-main/skill-creator/scripts/init_skill.py <skill-name> --path custom-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
|
## 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
|
- `example-skills/skills-main/skill-creator/SKILL.md` - Skill creation guide
|
||||||
- `AGENTS.md` - Agent routing guide for Task tool
|
- `AGENTS.md` - Agent routing guide for Task tool
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Internal R&D Repository** - This repository is restricted for internal use only.
|
> **Internal R&D Repository** - This repository is restricted for internal use only.
|
||||||
|
|
||||||
A collection of **64 custom Claude Skills** for OurDigital workflows, D.intelligence Agent Corps (9-agent business operations suite), Jamie Plastic Surgery Clinic brand management, SEO/GTM tools, NotebookLM automation, Notion integrations, reference documentation curation, macOS system optimization, TUI wizard design, and multi-agent collaboration.
|
A collection of **65 custom Claude Skills** for OurDigital workflows, D.intelligence Agent Corps (9-agent business operations suite), Jamie Plastic Surgery Clinic brand management, SEO/GTM tools, NotebookLM automation, Notion integrations, reference documentation curation, macOS system optimization, TUI wizard design, and multi-agent collaboration.
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
@@ -12,6 +12,11 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
|
|||||||
./install.sh
|
./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
|
## Custom Skills Overview
|
||||||
|
|
||||||
### OurDigital Core (01-10)
|
### OurDigital Core (01-10)
|
||||||
@@ -71,6 +76,7 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
|
|||||||
|---|-------|---------|
|
|---|-------|---------|
|
||||||
| 40 | `jamie-brand-editor` | Branded content generation |
|
| 40 | `jamie-brand-editor` | Branded content generation |
|
||||||
| 41 | `jamie-brand-audit` | Content review/compliance |
|
| 41 | `jamie-brand-audit` | Content review/compliance |
|
||||||
|
| 42 | `jamie-faq-entry` | KakaoTalk Kanana chatbot Q&A entries |
|
||||||
| 43 | `jamie-youtube-manager` | YouTube SEO audit & management |
|
| 43 | `jamie-youtube-manager` | YouTube SEO audit & management |
|
||||||
| 44 | `jamie-youtube-subtitle-checker` | YouTube subtitle validation |
|
| 44 | `jamie-youtube-subtitle-checker` | YouTube subtitle validation |
|
||||||
| 45 | `jamie-instagram-manager` | Instagram account management |
|
| 45 | `jamie-instagram-manager` | Instagram account management |
|
||||||
@@ -214,16 +220,23 @@ The `_ourdigital-shared/` directory provides:
|
|||||||
|
|
||||||
### Claude Code
|
### 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
|
```bash
|
||||||
# Install skill symlink
|
# From the repo root — symlink a skill into Claude Code
|
||||||
ln -sf /path/to/skill/desktop ~/.claude/skills/skill-name
|
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
|
### 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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ Only activates with "ourdigital" keyword:
|
|||||||
|
|
||||||
### Core Values
|
### Core Values
|
||||||
|
|
||||||
- **Data-driven** (데이터 중심) - 정밀 검사
|
|
||||||
- **In-Action** (실행 지향) - 실행 가능한 처방
|
|
||||||
- **Marketing Science** (마케팅 과학) - 근거 중심 의학
|
- **Marketing Science** (마케팅 과학) - 근거 중심 의학
|
||||||
|
- **In-Action** (실행 지향) - 실행 가능한 처방
|
||||||
|
- **Sustainable Growth** (지속 성장) - 체질 개선
|
||||||
|
|
||||||
### Channel Tones
|
### Channel Tones
|
||||||
|
|
||||||
@@ -83,6 +83,6 @@ Only activates with "ourdigital" keyword:
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
- Current: 1.0.0
|
- Current: 1.1.0
|
||||||
- Author: OurDigital
|
- Author: OurDigital
|
||||||
- Environment: Both (Desktop & Code)
|
- Environment: Both (Desktop & Code)
|
||||||
|
|||||||
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
|
||||||
@@ -14,7 +14,7 @@ description: |
|
|||||||
- Writing style guidelines
|
- Writing style guidelines
|
||||||
- Visual identity standards
|
- Visual identity standards
|
||||||
- Brand compliance checking
|
- Brand compliance checking
|
||||||
version: "1.0"
|
version: "1.1"
|
||||||
author: OurDigital
|
author: OurDigital
|
||||||
environment: Code
|
environment: Code
|
||||||
---
|
---
|
||||||
@@ -45,9 +45,9 @@ Philosophy: Precision + Empathy + Evidence
|
|||||||
|
|
||||||
| Value | Korean | Metaphor |
|
| Value | Korean | Metaphor |
|
||||||
|-------|--------|----------|
|
|-------|--------|----------|
|
||||||
| Data-driven | 데이터 중심 | 정밀 검사 |
|
|
||||||
| In-Action | 실행 지향 | 실행 가능한 처방 |
|
|
||||||
| Marketing Science | 마케팅 과학 | 근거 중심 의학 |
|
| Marketing Science | 마케팅 과학 | 근거 중심 의학 |
|
||||||
|
| In-Action | 실행 지향 | 실행 가능한 처방 |
|
||||||
|
| Sustainable Growth | 지속 성장 | 체질 개선 |
|
||||||
|
|
||||||
## Channel Tone
|
## Channel Tone
|
||||||
|
|
||||||
@@ -109,18 +109,20 @@ Philosophy: Precision + Empathy + Evidence
|
|||||||
|
|
||||||
Check content against:
|
Check content against:
|
||||||
|
|
||||||
1. ✓ Channel tone match
|
1. ✓ Brand name correct (OurDigital Clinic, not Lab/연구소)
|
||||||
2. ✓ Core values reflected
|
2. ✓ Channel tone match
|
||||||
3. ✓ Philosophy alignment
|
3. ✓ Core values reflected (마케팅 과학, 실행 지향, 지속 성장)
|
||||||
4. ✓ Language style correct
|
4. ✓ Philosophy alignment
|
||||||
5. ✓ Color palette used
|
5. ✓ Language style correct
|
||||||
|
6. ✓ Color palette used (#221814/#cedc00)
|
||||||
|
7. ✓ Data asset check (analytics/reporting references current)
|
||||||
|
|
||||||
## Key Messages
|
## Key Messages
|
||||||
|
|
||||||
| Purpose | Message |
|
| Purpose | Message |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| Tagline | 우리 디지털 클리닉 |
|
| Tagline | 우리 디지털 클리닉 |
|
||||||
| Value | 데이터로 진단하고, 실행으로 처방합니다 |
|
| Value | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||||
| Process | 진단 → 처방 → 측정 |
|
| Process | 진단 → 처방 → 측정 |
|
||||||
|
|
||||||
## CTA Library
|
## CTA Library
|
||||||
@@ -132,6 +134,18 @@ SEO 진단 신청하기
|
|||||||
맞춤 견적 상담받기
|
맞춤 견적 상담받기
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
## File References
|
## File References
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ description: |
|
|||||||
- Visual identity & color palette
|
- Visual identity & color palette
|
||||||
- Channel-specific tone mapping
|
- Channel-specific tone mapping
|
||||||
- Brand compliance checking
|
- Brand compliance checking
|
||||||
version: "1.0"
|
version: "1.1"
|
||||||
author: OurDigital
|
author: OurDigital
|
||||||
environment: Desktop
|
environment: Desktop
|
||||||
---
|
---
|
||||||
@@ -47,9 +47,9 @@ Activate with "ourdigital" or "our" prefix:
|
|||||||
|
|
||||||
| 가치 | English | 클리닉 메타포 |
|
| 가치 | English | 클리닉 메타포 |
|
||||||
|------|---------|--------------|
|
|------|---------|--------------|
|
||||||
| 데이터 중심 | Data-driven | 정밀 검사 |
|
|
||||||
| 실행 지향 | In-Action | 실행 가능한 처방 |
|
|
||||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 |
|
| 마케팅 과학 | Marketing Science | 근거 중심 의학 |
|
||||||
|
| 실행 지향 | In-Action | 실행 가능한 처방 |
|
||||||
|
| 지속 성장 | Sustainable Growth | 체질 개선 |
|
||||||
|
|
||||||
### Brand Philosophy
|
### Brand Philosophy
|
||||||
|
|
||||||
@@ -118,11 +118,13 @@ Activate with "ourdigital" or "our" prefix:
|
|||||||
|
|
||||||
When reviewing content, verify:
|
When reviewing content, verify:
|
||||||
|
|
||||||
1. **Tone Match**: Does it match the channel's personality?
|
1. **Brand Name**: Uses OurDigital Clinic (not Lab/연구소)?
|
||||||
2. **Value Alignment**: Reflects Data-driven, In-Action, Marketing Science?
|
2. **Tone Match**: Does it match the channel's personality?
|
||||||
3. **Philosophy Check**: Precision + Empathy + Evidence present?
|
3. **Value Alignment**: Reflects Marketing Science, In-Action, Sustainable Growth?
|
||||||
4. **Language Style**: Appropriate blend of Korean/English terms?
|
4. **Philosophy Check**: Precision + Empathy + Evidence present?
|
||||||
5. **Visual Consistency**: Uses approved color palette?
|
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
|
## Quick Reference
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ When reviewing content, verify:
|
|||||||
| Use | Message |
|
| Use | Message |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||||
| Value Prop | 데이터로 진단하고, 실행으로 처방합니다 |
|
| Value Prop | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||||
| Process | 진단 → 처방 → 측정 |
|
| Process | 진단 → 처방 → 측정 |
|
||||||
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
||||||
|
|
||||||
@@ -143,6 +145,18 @@ When reviewing content, verify:
|
|||||||
| SEO | SEO 진단 신청하기 |
|
| SEO | SEO 진단 신청하기 |
|
||||||
| Content | 콘텐츠 전략 상담 신청하기 |
|
| 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
|
## References
|
||||||
|
|
||||||
See `shared/references/` for detailed guides:
|
See `shared/references/` for detailed guides:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# OurDigital Brand Foundation
|
# 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
|
## Brand Identity
|
||||||
|
|
||||||
@@ -8,25 +10,34 @@ Complete brand identity reference for OurDigital Clinic.
|
|||||||
|
|
||||||
| Element | Content |
|
| Element | Content |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| **Brand Name** | OurDigital Clinic |
|
| **Brand Name** | OurDigital |
|
||||||
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
| **Identity Statement** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||||
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 |
|
| **Mission** | 기술이 사람과 문화에 미치는 영향을 관찰하고 기록한다. 생각의 씨앗을 남기고 성찰한다. 검증되지 않은 도전과 실험의 결과를 기록한다. |
|
||||||
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
|
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
|
||||||
| **Promise** | 진단-처방-측정 가능한 성장 |
|
| **Promise** | 진단-처방-측정 가능한 성장 |
|
||||||
|
|
||||||
### Core Values
|
### Brand Keywords
|
||||||
|
|
||||||
| 가치 | English | 클리닉 메타포 | 설명 |
|
| 핵심 가치 | 설명 |
|
||||||
|------|---------|--------------|------|
|
|----------|------|
|
||||||
| 데이터 중심 | Data-driven | 정밀 검사 | 감이 아닌 데이터로 판단 |
|
| **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
|
||||||
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 |
|
| **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
|
||||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 |
|
| **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
|
||||||
|
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
|
||||||
|
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
|
||||||
|
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
|
||||||
|
|
||||||
### Brand Philosophy
|
### Brand Philosophy
|
||||||
|
|
||||||
**"Precision + Empathy + Evidence"**
|
**"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
|
## Positioning Statement
|
||||||
|
|
||||||
@@ -34,8 +45,16 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
|||||||
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
|
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
|
||||||
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
|
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
|
||||||
|
|
||||||
|
*(Note: OurDigital Clinic은 컨설팅/서비스 맥락에서의 포지셔닝. 블로그 전체 포지셔닝과 구분한다.)*
|
||||||
|
|
||||||
## Target Audience
|
## Target Audience
|
||||||
|
|
||||||
|
**블로그 독자 (blog.ourdigital.org)**
|
||||||
|
- **1순위**: 디지털 마케팅/기술 분야 종사자 — 실무 전략을 수립하고 실행하는 전문가
|
||||||
|
- **2순위**: 기술과 사회의 관계에 관심 있는 지식인 — 디지털 전환이 사회에 미치는 영향을 고민하는 독자
|
||||||
|
- **3순위**: 자기 성찰과 비판적 사고를 중시하는 독자
|
||||||
|
|
||||||
|
**컨설팅 서비스 (OurDigital Clinic)**
|
||||||
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
|
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
|
||||||
- **Secondary**: 스타트업 창업자, 브랜드 매니저
|
- **Secondary**: 스타트업 창업자, 브랜드 매니저
|
||||||
|
|
||||||
@@ -54,24 +73,30 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
|||||||
|
|
||||||
| Level | Element | Description |
|
| Level | Element | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| Level 1 | Master Brand | OurDigital Clinic |
|
| Level 1 | Master Brand | OurDigital |
|
||||||
| Level 2 | Channel Identity | Blog, Journal, OurStory |
|
| Level 2 | Channel Identity | Blog, Journal, OurStory |
|
||||||
| Level 3 | Service Identity | 4개 핵심 서비스 |
|
| Level 3 | Service Identity | 4개 핵심 서비스 (OurDigital Clinic 메타포 적용 가능) |
|
||||||
|
|
||||||
### Channel Personality & Tone
|
### Channel Personality & Tone
|
||||||
|
|
||||||
| Channel | Domain | Personality | Tone | Content Type |
|
| Channel | Domain | Language | Character | Length |
|
||||||
|---------|--------|-------------|------|--------------|
|
|---------|--------|----------|-----------|--------|
|
||||||
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented | 서비스, 케이스, 리드 |
|
| Main Hub | ourdigital.org | Korean | Professional & Confident, Data-driven | 서비스, 케이스, 리드 |
|
||||||
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking | 가이드, 분석, 인사이트 |
|
| **Blog** | blog.ourdigital.org | **Korean** | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | **1,500-3,000자** |
|
||||||
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer | 에세이, 문화, 관찰 |
|
| **Journal** | journal.ourdigital.org | **English** | 산업 트렌드, 기술-인간 교차점, reflective essay | **1,000-2,000 words** |
|
||||||
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey | 개인 서사, 경험 |
|
| **OurStory** | ourstory.day | **Korean** | 개인 에세이, 삶의 성찰, 일상의 관찰 | **800-1,500자** |
|
||||||
| D.intelligence | dintelligence.co.kr | Professional | B2B | Corporate Partnership |
|
| D.intelligence | dintelligence.co.kr | Korean/English | Professional | B2B Corporate Partnership |
|
||||||
|
|
||||||
|
**채널 라우팅 규칙:**
|
||||||
|
- 기술 분석 + 철학적 사유 → `blog.ourdigital.org`
|
||||||
|
- 순수 개인 에세이, 감정적 성찰 → `ourstory.day`
|
||||||
|
- 영문 심층 에세이, 산업 관점 → `journal.ourdigital.org`
|
||||||
|
- 진단형 콘텐츠, 컨설팅 제안 → `OurDigital Clinic` 메타포 사용 가능
|
||||||
|
|
||||||
### Content Flow Strategy
|
### Content Flow Strategy
|
||||||
|
|
||||||
```
|
```
|
||||||
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site / OurDigital Clinic)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publishing Cadence
|
### Publishing Cadence
|
||||||
@@ -80,7 +105,7 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
|||||||
|---------|-----------|--------|
|
|---------|-----------|--------|
|
||||||
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
|
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
|
||||||
| blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 |
|
| 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자 |
|
| ourstory.day | 월 1-2회 | 800-1,500자 |
|
||||||
|
|
||||||
## Service Portfolio
|
## Service Portfolio
|
||||||
@@ -112,13 +137,16 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
|||||||
|
|
||||||
| Use Case | Message |
|
| Use Case | Message |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
| Blog Identity | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||||
| Value Proposition | 데이터로 진단하고, 실행으로 처방합니다 |
|
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||||
|
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||||
| Process | 진단 → 처방 → 측정 |
|
| Process | 진단 → 처방 → 측정 |
|
||||||
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
||||||
|
|
||||||
## CTA Library
|
## CTA Library
|
||||||
|
|
||||||
|
*(OurDigital Clinic 컨설팅 맥락에서 사용)*
|
||||||
|
|
||||||
| Context | CTA Text |
|
| Context | CTA Text |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| General | 무료 상담 신청하기 |
|
| General | 무료 상담 신청하기 |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# OurDigital Writing Style Guide
|
# 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.
|
Comprehensive writing guidelines for OurDigital content across all channels.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -8,7 +10,7 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
|||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Author** | Andrew Yim |
|
| **Author** | Andrew Yim |
|
||||||
| **Primary Blog** | blog.ourdigital.org |
|
| **Primary Blog** | blog.ourdigital.org |
|
||||||
| **Tagline** | 사람, 디지털 그리고 문화 (People, Digital, and Culture) |
|
| **Brand Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||||
| **Platform** | Ghost CMS |
|
| **Platform** | Ghost CMS |
|
||||||
| **History** | 2004-2025 (20+ years of content) |
|
| **History** | 2004-2025 (20+ years of content) |
|
||||||
|
|
||||||
@@ -16,54 +18,73 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
|||||||
|
|
||||||
## Part 1: 한국어 스타일가이드
|
## 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. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
|
2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
|
||||||
3. **인식론적 겸손** — 특히 세대간 격차에서 이해의 한계를 인정
|
3. **인식론적 겸손** — 이해의 한계를 인정하며, 겸손한 추정 표현을 적절히 활용 (`~일지도 모른다`, `~인 듯하다`, `어쩌면 ~`)
|
||||||
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
|
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -124,20 +145,28 @@ Technical articles include executive summaries and metaphorical anchoring ("Data
|
|||||||
|
|
||||||
### Do's
|
### 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
|
- 전문용어 첫 등장 시 영문을 병기한다 (`검색엔진 최적화(SEO)`)
|
||||||
- Reference historical context and generational shifts
|
- 역사적 맥락과 세대적 변화를 참조한다
|
||||||
|
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다
|
||||||
|
- 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
|
# 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.
|
Detailed writing guidelines for blog.ourdigital.org.
|
||||||
|
|
||||||
## Channel Identity
|
## Channel Identity
|
||||||
@@ -7,45 +9,47 @@ Detailed writing guidelines for blog.ourdigital.org.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Domain** | blog.ourdigital.org |
|
| **Domain** | blog.ourdigital.org |
|
||||||
| **Tagline** | 사람, 디지털 그리고 문화 |
|
| **Brand** | OurDigital |
|
||||||
| **Language** | Korean (전문용어 영문 병기) |
|
| **Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||||
| **Tone** | Analytical & Personal, Educational |
|
| **Core Theme** | 사람, 디지털 그리고 문화 |
|
||||||
| **Target** | 교양 있는 일반 독자 - 기술의 문화적 영향에 호기심 있는 독자 |
|
| **Language** | Korean (전문용어 첫 등장 시 영문 병기) |
|
||||||
|
| **CMS** | Ghost — 초안은 Markdown으로 작성 |
|
||||||
|
| **Tone** | Analytical & Personal (비판적이되 공정, 겸손하되 자신감 있음) |
|
||||||
|
| **Target** | 디지털 마케팅/기술 실무자 (1순위), 기술-사회 관계에 관심 있는 지식인 (2순위), 비판적 사고를 중시하는 일반 독자 (3순위) |
|
||||||
|
|
||||||
|
**브랜드 경계**: `OurDigital`이 블로그 전체 정체성이다. `OurDigital Clinic`은 진단형 콘텐츠·컨설팅 상품에서만 사용하는 서비스 메타포다.
|
||||||
|
|
||||||
|
**우선순위**: 지침 충돌 시 Blog_Project_Instruction_v3.3 → 이 스타일 가이드 → 사용자 일반 스타일 순으로 따른다.
|
||||||
|
|
||||||
## Writing Characteristics
|
## Writing Characteristics
|
||||||
|
|
||||||
### 1. 철학-기술 융합체
|
모든 초안은 아래 네 원칙을 기준으로 자기 점검한다.
|
||||||
|
|
||||||
기술 분석과 실존적 질문을 자연스럽게 결합한다.
|
### 1. 철학-기술 융합
|
||||||
|
|
||||||
**Good Example:**
|
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다.
|
||||||
> AI가 우리의 업무를 대체할 수 있다는 사실은 분명하다. 그러나 더 중요한 질문은 "AI가 대체할 수 없는 것은 무엇인가?"이다.
|
|
||||||
|
|
||||||
**Bad Example:**
|
|
||||||
> AI는 업무 효율성을 높여준다. 다양한 분야에서 활용되고 있다.
|
|
||||||
|
|
||||||
### 2. 역설(Paradox) 활용
|
|
||||||
|
|
||||||
논증을 긴장과 모순 구조로 전개한다.
|
|
||||||
|
|
||||||
**Paradox Patterns:**
|
|
||||||
- "~하면서 동시에 ~하다"
|
|
||||||
- "~인 것 같지만 실은 ~이다"
|
|
||||||
- "~를 얻었지만 ~를 잃었다"
|
|
||||||
|
|
||||||
### 3. 수사적 질문
|
|
||||||
|
|
||||||
선언적 권위보다 질문을 통한 참여를 선호한다.
|
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> 우리는 정말 데이터를 이해하고 있는 것일까?
|
> 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
|
||||||
|
|
||||||
**Bad:**
|
**Bad:**
|
||||||
> 데이터를 이해하는 것이 중요하다.
|
> 구글의 알고리즘은 200개 이상의 신호를 분석한다.
|
||||||
|
|
||||||
### 4. 우울한 낙관주의
|
### 2. 긴장과 역설
|
||||||
|
|
||||||
불안과 상실을 인정하되 절망하지 않는다.
|
억지 역설을 억지로 넣지 않는다. 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||||
|
|
||||||
|
**Useful Patterns:**
|
||||||
|
- `~하면서 동시에 ~하다`
|
||||||
|
- `~를 위해 오히려 ~해야 한다`
|
||||||
|
- `가장 ~한 것이 사실은 가장 ~하다`
|
||||||
|
- `우리가 놓치고 있는 것은 무엇인가?`
|
||||||
|
|
||||||
|
> 예: 데이터를 가장 잘 활용하는 방법은, 때때로 데이터를 내려놓는 것이다.
|
||||||
|
|
||||||
|
### 3. 우울한 낙관주의
|
||||||
|
|
||||||
|
디지털 세계의 불안·피로·한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 작은 회복의 여지를 찾는다.
|
||||||
|
|
||||||
**Tone Spectrum:**
|
**Tone Spectrum:**
|
||||||
```
|
```
|
||||||
@@ -55,14 +59,24 @@ Detailed writing guidelines for blog.ourdigital.org.
|
|||||||
(여기에 위치)
|
(여기에 위치)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
|
||||||
|
|
||||||
|
### 4. 분석적이면서 개인적
|
||||||
|
|
||||||
|
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
|
||||||
|
|
||||||
|
> 예: 지난 3년간 50개 이상의 사이트 마이그레이션을 지켜봤다. 숫자로 보면 성공률은 70% 정도다. 하지만 '성공'의 정의가 사이트마다 달랐다는 게 진짜 이야기다.
|
||||||
|
|
||||||
## 문장 구조
|
## 문장 구조
|
||||||
|
|
||||||
| Element | Pattern |
|
| 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. **근거**: 데이터, 사례, 전문가 의견
|
- 최소 1회 관점 전환 또는 핵심 긴장을 포함한다.
|
||||||
3. **함의**: 이것이 의미하는 바
|
- 전문용어는 첫 등장 시 영문을 병기한다. 예: `검색엔진 최적화(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
|
## 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 (제목)
|
### Title (제목)
|
||||||
|
|
||||||
- 60자 이내
|
- **60자 이내**, 핵심 키워드 자연스럽게 포함
|
||||||
- 핵심 키워드 포함
|
- 호기심·긴장·관점 전환을 만든다 (40자 내외 기준)
|
||||||
- 호기심 유발 또는 가치 제안
|
- 키워드 밀도만 의식한 SEO 과잉 최적화 금지
|
||||||
|
|
||||||
**Patterns:**
|
**Patterns:**
|
||||||
- "[주제]의 역설: ~하면서 ~하는 시대"
|
- "[주제]의 역설: ~하면서 ~하는 시대"
|
||||||
@@ -117,15 +160,13 @@ Detailed writing guidelines for blog.ourdigital.org.
|
|||||||
|
|
||||||
### Meta Description
|
### Meta Description
|
||||||
|
|
||||||
- 155자 이내
|
- **155자 이내**, 글의 핵심 질문과 독자 효용을 함께 담는다
|
||||||
- 글의 핵심 가치 요약
|
- 클릭을 유도하되 과장하지 않는다
|
||||||
- 클릭 유도 문구
|
|
||||||
|
|
||||||
### URL Slug
|
### URL Slug
|
||||||
|
|
||||||
- 영문 소문자
|
- **영문** 소문자, 하이픈으로 구분, 3-5 단어
|
||||||
- 하이픈으로 구분
|
- 한국어 제목이라도 slug는 영문으로 작성한다
|
||||||
- 3-5 단어
|
|
||||||
|
|
||||||
## Content Calendar
|
## Content Calendar
|
||||||
|
|
||||||
@@ -138,12 +179,34 @@ Detailed writing guidelines for blog.ourdigital.org.
|
|||||||
|
|
||||||
## Quality Checklist
|
## Quality Checklist
|
||||||
|
|
||||||
Before publishing:
|
### Must Have
|
||||||
|
|
||||||
- [ ] 제목이 60자 이내인가?
|
- [ ] 핵심 긴장·역설·관점 전환·열린 질문 중 하나 이상이 있다.
|
||||||
- [ ] 메타 설명이 155자 이내인가?
|
- [ ] 도입부 300자 이내에 hook과 핵심 질문이 있다.
|
||||||
- [ ] 전문용어에 영문이 병기되었는가?
|
- [ ] 전문용어 첫 등장 시 영문을 병기했다.
|
||||||
- [ ] 수사적 질문이 포함되었는가?
|
- [ ] 기술 내용에 인간적 함의가 있다.
|
||||||
- [ ] 기술 내용에 인간적 함의가 있는가?
|
- [ ] 독자를 가르치려 하지 않고 함께 생각하는 태도를 유지했다.
|
||||||
- [ ] 결론이 열린 질문으로 끝나는가?
|
- [ ] 결론이 열린 질문 또는 여운으로 마무리된다.
|
||||||
- [ ] 1,500-3,000자 범위인가?
|
- [ ] 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
|
# OurDigital Journal Style Guide
|
||||||
|
|
||||||
Writing guidelines for journal.ourdigital.org - English essays and articles.
|
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
|
## Channel Identity
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -10,6 +45,8 @@ Writing guidelines for journal.ourdigital.org - English essays and articles.
|
|||||||
| **Language** | English |
|
| **Language** | English |
|
||||||
| **Tone** | Conversational & Poetic, Reflective |
|
| **Tone** | Conversational & Poetic, Reflective |
|
||||||
| **Target** | Informed generalists with intellectual curiosity |
|
| **Target** | Informed generalists with intellectual curiosity |
|
||||||
|
| **Default length** | 1,000–2,000 words |
|
||||||
|
| **Content focus** | Industry trends, tech–human intersection, reflective essays |
|
||||||
|
|
||||||
## Voice Characteristics
|
## Voice Characteristics
|
||||||
|
|
||||||
@@ -20,11 +57,16 @@ Seamlessly blend technical analysis with existential questioning. Technology is
|
|||||||
**Example:**
|
**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?
|
> 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"
|
- "The more we measure, the less we understand"
|
||||||
- "In optimizing for efficiency, we optimize away meaning"
|
- "In optimizing for efficiency, we optimize away meaning"
|
||||||
- "The tools that connect us also isolate us"
|
- "The tools that connect us also isolate us"
|
||||||
@@ -39,6 +81,10 @@ Favor interrogative engagement. Questions create intellectual partnership with r
|
|||||||
**Avoid:**
|
**Avoid:**
|
||||||
> Data-driven decision-making is important for businesses.
|
> 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
|
### Melancholic Optimism
|
||||||
|
|
||||||
Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced.
|
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:
|
Before publishing:
|
||||||
|
|
||||||
- [ ] Does the opening draw readers in?
|
- [ ] Does the opening draw readers in within the first paragraph?
|
||||||
- [ ] Are there rhetorical questions?
|
- [ ] Does technical content connect to human experience (philosophy-tech fusion)?
|
||||||
- [ ] Does technical content connect to human experience?
|
- [ ] Is at least one of the following naturally present: tension, paradox, perspective shift, or open question?
|
||||||
- [ ] Is there at least one paradox or tension?
|
- [ ] Are rhetorical questions used to create intellectual partnership (not overused)?
|
||||||
- [ ] Does the closing leave an open question?
|
- [ ] Is analysis grounded in personal observation or experience?
|
||||||
- [ ] Is the tone melancholic but not despairing?
|
- [ ] 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?
|
- [ ] 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
|
# 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
|
## Channel-Specific Voice & Tone
|
||||||
|
|
||||||
### blog.ourdigital.org (Korean)
|
### blog.ourdigital.org (Korean)
|
||||||
**Voice**: 전문적이면서 친근한 선배 마케터
|
|
||||||
**Tone**: 실용적, 데이터 기반, 인사이트 중심
|
**Platform**: Ghost CMS
|
||||||
|
**Voice**: 분석적이면서 개인적인 관찰자 — 호기심 어린 실무자이자 사유자. 가르치는 선배가 아니라 함께 생각하는 동료.
|
||||||
|
**Tone**: 차분하고 성찰적; 에세이는 사려 깊게, 분석은 객관적으로, 비평은 날카롭되 공정하게.
|
||||||
|
|
||||||
Writing patterns:
|
Writing patterns:
|
||||||
- 제목: 핵심 키워드 포함, 30자 이내
|
- 제목: SEO 키워드 자연스럽게 포함, **40자 내외 기준 (≤60자)**
|
||||||
- 도입부: 독자의 고민/질문으로 시작
|
- 도입부: 개인 관찰·장면·질문·역설·데이터 중 하나로 시작; 독자 고민 직접 나열 지양
|
||||||
- 본문: 번호 매기기보다 소제목 활용
|
- 본문: 소제목 활용; 분석-서사-질문 교차; 최소 1회 관점 전환 또는 핵심 긴장 포함
|
||||||
- 전문용어: 한글(영문) 형식 - 예: 검색엔진최적화(SEO)
|
- 전문용어: **첫 등장 시 영문 병기** — 예: `검색엔진 최적화(SEO)`
|
||||||
- 문장: ~입니다/~습니다 경어체
|
- 문장: **`~다`, `~이다`, `~한다` 평서체** (경어체 `~합니다/~입니다` 사용 금지)
|
||||||
- 단락: 3-4문장, 모바일 가독성 고려
|
- 단락: 3-4문장, 모바일 가독성 고려; 문장 길이에 변화를 줌
|
||||||
|
- 마무리: 열린 질문 또는 다음 사유의 출발점; 단정적 결론 지양
|
||||||
|
|
||||||
Example opening:
|
Writing principles (Writing Style Guide v2.1 §3-4):
|
||||||
|
- **철학-기술 융합**: 기술 주제 이면의 인간적 함의를 탐구한다.
|
||||||
|
- **긴장과 역설**: 억지로 넣지 않되, 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||||
|
- **분석적이면서 개인적**: 데이터/논거를 제시하되 1인칭 경험·관찰을 자연스럽게 엮는다. 논문이 아니라 에세이.
|
||||||
|
|
||||||
|
Example opening (올바른 톤):
|
||||||
```
|
```
|
||||||
"구글 상위 노출, 왜 이렇게 어려울까요?
|
검색엔진 최적화(SEO)를 가장 잘하는 방법이 뭐냐고 물으면,
|
||||||
많은 마케터들이 SEO에 시간을 투자하지만
|
나는 종종 엉뚱한 대답을 한다. "SEO를 잊어버리세요."
|
||||||
결과가 보이지 않아 좌절합니다.
|
|
||||||
오늘은 실제로 효과를 본 전략 3가지를 공유합니다."
|
알고리즘을 쫓는 사람은 항상 알고리즘에 뒤처진다는 역설.
|
||||||
|
구글이 원하는 건 구글을 위한 콘텐츠가 아니라, 사람을 위한 콘텐츠다.
|
||||||
```
|
```
|
||||||
|
|
||||||
### journal.ourdigital.org (English)
|
### 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:
|
Writing patterns:
|
||||||
- Headlines: Clear value proposition, under 60 chars
|
- Headlines: Clear value proposition, under 60 chars
|
||||||
- Opening: Hook with industry trend or data point
|
- Opening: Hook with personal observation, industry trend, or data point
|
||||||
- Body: Structured arguments with supporting evidence
|
- Body: Structured arguments with supporting evidence; analysis and personal observation interwoven
|
||||||
- Terminology: Define jargon on first use
|
- 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
|
- Paragraphs: 2-4 sentences for scannability
|
||||||
|
- Closing: Open question or reflection — avoid definitive wrap-ups
|
||||||
|
|
||||||
Example opening:
|
Example opening:
|
||||||
```
|
```
|
||||||
@@ -43,17 +69,19 @@ and what it means for your strategy."
|
|||||||
```
|
```
|
||||||
|
|
||||||
### ourstory.day (Korean)
|
### ourstory.day (Korean)
|
||||||
**Voice**: 성찰하는 동료, 이야기꾼
|
|
||||||
|
**Voice**: 성찰하는 동료, 이야기꾼 — 개인 에세이, 삶의 성찰, 일상의 관찰
|
||||||
**Tone**: 개인적, 진솔한, 영감을 주는
|
**Tone**: 개인적, 진솔한, 영감을 주는
|
||||||
|
|
||||||
Writing patterns:
|
Writing patterns:
|
||||||
- 제목: 감성적, 질문형 또는 은유적
|
- 제목: 감성적, 질문형 또는 은유적
|
||||||
- 도입부: 개인 경험이나 장면 묘사로 시작
|
- 도입부: 개인 경험이나 장면 묘사로 시작
|
||||||
- 본문: 이야기 흐름, 대화체 허용
|
- 본문: 이야기 흐름, 대화체 허용
|
||||||
- 문장: ~해요/~네요 부드러운 경어체 가능
|
|
||||||
- 단락: 자유로운 길이, 호흡에 따라
|
- 단락: 자유로운 길이, 호흡에 따라
|
||||||
- 마무리: 열린 질문 또는 여운
|
- 마무리: 열린 질문 또는 여운
|
||||||
|
|
||||||
|
> **Channel boundary**: 순수 개인 에세이, 감정적 성찰은 `ourstory.day`. 기술 분석+철학적 사유는 `blog.ourdigital.org`. 둘을 혼동하지 않는다.
|
||||||
|
|
||||||
Example opening:
|
Example opening:
|
||||||
```
|
```
|
||||||
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
|
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
|
||||||
@@ -63,6 +91,7 @@ Example opening:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Medium (English)
|
### Medium (English)
|
||||||
|
|
||||||
**Voice**: Knowledgeable peer sharing discoveries
|
**Voice**: Knowledgeable peer sharing discoveries
|
||||||
**Tone**: Conversational, practical, slightly informal
|
**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."
|
about content strategy entirely. Let me walk you through it."
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Universal Guidelines
|
## Universal Guidelines
|
||||||
|
|
||||||
### SEO Considerations
|
### SEO & Metadata
|
||||||
|
|
||||||
- Primary keyword in title and first 100 words
|
- Primary keyword in title and first 100 words
|
||||||
- Secondary keywords naturally distributed
|
- Secondary keywords naturally distributed
|
||||||
- Meta description: 150-160 chars, action-oriented
|
- **Meta description: ≤155 chars**, action-oriented, captures the article's question and reader value
|
||||||
- URL slug: Short, keyword-rich, no dates
|
- **URL slug: English, keyword-rich, no dates** — even for Korean-title posts
|
||||||
- Alt text for all images
|
- Alt text for all images
|
||||||
|
|
||||||
### Formatting Rules
|
### Formatting Rules
|
||||||
|
|
||||||
- Use `##` for main sections, `###` for subsections
|
- Use `##` for main sections, `###` for subsections
|
||||||
- Code blocks with language specification
|
- Code blocks with language specification
|
||||||
- Blockquotes for key insights or quotes
|
- Blockquotes for key insights or quotes
|
||||||
- Bold for emphasis (sparingly)
|
- Bold for emphasis (sparingly)
|
||||||
- Lists only when truly listing items
|
- Lists only when truly listing items; avoid in essay-form posts
|
||||||
|
|
||||||
### Citation Style
|
### Citation Style
|
||||||
|
|
||||||
- Inline links preferred over footnotes
|
- Inline links preferred over footnotes
|
||||||
- Source attribution: "According to [Source Name](URL)..."
|
- Source attribution: "According to [Source Name](URL)..."
|
||||||
- Data citations: Include date of data
|
- Data citations: Include date of data
|
||||||
- Internal links: Reference related OurDigital posts
|
- Internal links: Reference related OurDigital posts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Word Count Guidelines
|
## Word Count Guidelines
|
||||||
|
|
||||||
| Channel | Target | Min | Max |
|
| Channel | Target | Min | Max |
|
||||||
|---------|--------|-----|-----|
|
|---------|--------|-----|-----|
|
||||||
| blog.ourdigital.org | 1,500 | 1,000 | 2,500 |
|
| blog.ourdigital.org | 2,000자 | 1,000자 | 3,000자 |
|
||||||
| journal.ourdigital.org | 1,800 | 1,200 | 3,000 |
|
| journal.ourdigital.org | 1,500 words | 1,000 words | 2,000 words |
|
||||||
| ourstory.day | 1,000 | 500 | 2,000 |
|
| ourstory.day | 1,000자 | 800자 | 1,500자 |
|
||||||
| Medium | 1,500 | 800 | 2,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
|
||||||
@@ -103,7 +103,7 @@ python code/scripts/apply_brand.py synthesis.json --preview
|
|||||||
See `code/assets/brand_config.json` for:
|
See `code/assets/brand_config.json` for:
|
||||||
- Logo placement
|
- Logo placement
|
||||||
- Color scheme (OurDigital palette)
|
- Color scheme (OurDigital palette)
|
||||||
- Font settings (Poppins/Lora)
|
- Font settings (Noto Sans KR/Inter)
|
||||||
- Slide templates
|
- Slide templates
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
"surface": "#f1f3f4"
|
"surface": "#f1f3f4"
|
||||||
},
|
},
|
||||||
"fonts": {
|
"fonts": {
|
||||||
"heading": "Poppins",
|
"heading": "Inter",
|
||||||
"subheading": "Poppins",
|
"subheading": "Inter",
|
||||||
"body": "Lora",
|
"body": "Noto Sans KR",
|
||||||
"caption": "Arial",
|
"caption": "Arial",
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"heading": "Arial",
|
"heading": "Arial",
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ Layout patterns and best practices for different slide types.
|
|||||||
### Font Pairing
|
### Font Pairing
|
||||||
```
|
```
|
||||||
Heading Font + Body Font
|
Heading Font + Body Font
|
||||||
- Poppins + Lora
|
- Inter + Noto Sans KR (OurDigital primary)
|
||||||
- Arial + Georgia
|
- Arial + Georgia
|
||||||
- Helvetica + Times
|
- Helvetica + Times
|
||||||
- Roboto + Merriweather
|
- Roboto + Merriweather
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Slide structure:
|
|||||||
|
|
||||||
Brand elements:
|
Brand elements:
|
||||||
- **Colors**: OurDigital palette from `01-ourdigital-brand-guide`
|
- **Colors**: OurDigital palette from `01-ourdigital-brand-guide`
|
||||||
- **Fonts**: Poppins (headings), Lora (body)
|
- **Fonts**: Noto Sans KR (Korean), Inter (English)
|
||||||
- **Logo**: Positioned per brand guidelines
|
- **Logo**: Positioned per brand guidelines
|
||||||
- **Spacing**: Consistent margins and padding
|
- **Spacing**: Consistent margins and padding
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ Layout patterns and best practices for different slide types.
|
|||||||
### Font Pairing
|
### Font Pairing
|
||||||
```
|
```
|
||||||
Heading Font + Body Font
|
Heading Font + Body Font
|
||||||
- Poppins + Lora
|
- Inter + Noto Sans KR (OurDigital primary)
|
||||||
- Arial + Georgia
|
- Arial + Georgia
|
||||||
- Helvetica + Times
|
- Helvetica + Times
|
||||||
- Roboto + Merriweather
|
- Roboto + Merriweather
|
||||||
|
|||||||
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
|
# Visual Metaphor Dictionary
|
||||||
|
|
||||||
|
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||||
|
|
||||||
Quick reference for translating abstract concepts into visual elements.
|
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
|
## Technology & Digital
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||||
| Network | Root systems | Neural pathways, spider silk, web |
|
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||||
| Code | Musical notation | DNA strands, city blueprints |
|
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||||
| Automation | Clockwork organic | Self-assembling structures |
|
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
|
||||||
|
|
||||||
## Social & Cultural
|
## Social & Cultural
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||||
| Communication | Bridge structures | Echo patterns, light beams |
|
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
|
||||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
|
||||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
|
||||||
|
|
||||||
## Philosophical & Abstract
|
## Philosophical & Abstract
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||||
| Knowledge | Light sources | Growing trees, opening books |
|
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
|
||||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
|
||||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
|
||||||
|
|
||||||
## Emotional States
|
## Emotional States
|
||||||
|
|
||||||
| Emotion | Visual Translation | Color Association |
|
| Emotion | Visual Translation | Color Guidance |
|
||||||
|---------|-------------------|------------------|
|
|---------|-------------------|----------------|
|
||||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||||
| Hope | Light breaking through | Warm gradients |
|
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||||
| Joy | Expansion patterns | Bright, ascending |
|
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||||
| Fear | Contracting spaces | Sharp contrasts |
|
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||||
| Peace | Still water | Soft neutrals |
|
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||||
| Confusion | Tangled lines | Overlapping hues |
|
|
||||||
| Clarity | Clean geometry | Pure, minimal |
|
|
||||||
|
|
||||||
## Transformation & Process
|
## Signature Motifs (Brand Consistency)
|
||||||
|
|
||||||
| Process | Visual Narrative | Symbolic Elements |
|
| Motif | Description |
|
||||||
|---------|------------------|------------------|
|
|-------|-------------|
|
||||||
| Growth | Seeds → trees | Fibonacci spirals |
|
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||||
| Decay | Entropy patterns | Rust, dissolution |
|
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||||
| Revolution | Circles breaking | Shattered patterns |
|
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||||
| Innovation | Spark → flame | Lightning, fusion |
|
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||||
| Tradition | Continuous thread | Inherited patterns |
|
|
||||||
| Disruption | Broken grids | Glitch aesthetics |
|
|
||||||
| Integration | Merging streams | Confluence points |
|
|
||||||
|
|
||||||
## Korean-Western Fusion Elements
|
## Recommended Color Palettes
|
||||||
|
|
||||||
| Korean Element | Western Parallel | Fusion Approach |
|
| Palette | Background | Accent | Mood |
|
||||||
|---------------|-----------------|-----------------|
|
|---------|-----------|--------|------|
|
||||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||||
| 한글 geometry | Typography | Structural letters |
|
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
|
||||||
|
**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
|
## Usage Notes
|
||||||
|
|
||||||
1. **Layer metaphors**: Combine 2-3 for depth
|
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||||
2. **Avoid clichés**: No obvious tech symbols
|
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||||
3. **Cultural sensitivity**: Universal over specific
|
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||||
4. **Abstraction levels**: Match essay tone
|
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||||
5. **Emotional resonance**: Feel over literal
|
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||||
|
|
||||||
## Quick Selection Guide
|
## Quick Selection Guide
|
||||||
|
|
||||||
For **technology essays**: organic-digital hybrids
|
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||||
For **social commentary**: human elements in systems
|
For **social commentary**: network patterns with small human traces
|
||||||
For **philosophy pieces**: space and light
|
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||||
For **cultural topics**: layered traditions
|
For **practical guides**: clean editorial diagrams with warm accents
|
||||||
For **future themes**: transformation states
|
For **personal reflections**: soft everyday scenes with metaphorical detail
|
||||||
|
|||||||
@@ -1,101 +1,104 @@
|
|||||||
# Visual Metaphor Dictionary
|
# Visual Metaphor Dictionary
|
||||||
|
|
||||||
|
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||||
|
|
||||||
Quick reference for translating abstract concepts into visual elements.
|
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
|
## Technology & Digital
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||||
| Network | Root systems | Neural pathways, spider silk, web |
|
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||||
| Code | Musical notation | DNA strands, city blueprints |
|
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||||
| Automation | Clockwork organic | Self-assembling structures |
|
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
|
||||||
|
|
||||||
## Social & Cultural
|
## Social & Cultural
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||||
| Communication | Bridge structures | Echo patterns, light beams |
|
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
|
||||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
|
||||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
|
||||||
|
|
||||||
## Philosophical & Abstract
|
## Philosophical & Abstract
|
||||||
|
|
||||||
| Concept | Primary Metaphor | Alternative Visuals |
|
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||||
|---------|-----------------|-------------------|
|
|---------|-------------------|--------------|-------|
|
||||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||||
| Knowledge | Light sources | Growing trees, opening books |
|
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
|
||||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
|
||||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
|
||||||
|
|
||||||
## Emotional States
|
## Emotional States
|
||||||
|
|
||||||
| Emotion | Visual Translation | Color Association |
|
| Emotion | Visual Translation | Color Guidance |
|
||||||
|---------|-------------------|------------------|
|
|---------|-------------------|----------------|
|
||||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||||
| Hope | Light breaking through | Warm gradients |
|
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||||
| Joy | Expansion patterns | Bright, ascending |
|
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||||
| Fear | Contracting spaces | Sharp contrasts |
|
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||||
| Peace | Still water | Soft neutrals |
|
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||||
| Confusion | Tangled lines | Overlapping hues |
|
|
||||||
| Clarity | Clean geometry | Pure, minimal |
|
|
||||||
|
|
||||||
## Transformation & Process
|
## Signature Motifs (Brand Consistency)
|
||||||
|
|
||||||
| Process | Visual Narrative | Symbolic Elements |
|
| Motif | Description |
|
||||||
|---------|------------------|------------------|
|
|-------|-------------|
|
||||||
| Growth | Seeds → trees | Fibonacci spirals |
|
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||||
| Decay | Entropy patterns | Rust, dissolution |
|
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||||
| Revolution | Circles breaking | Shattered patterns |
|
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||||
| Innovation | Spark → flame | Lightning, fusion |
|
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||||
| Tradition | Continuous thread | Inherited patterns |
|
|
||||||
| Disruption | Broken grids | Glitch aesthetics |
|
|
||||||
| Integration | Merging streams | Confluence points |
|
|
||||||
|
|
||||||
## Korean-Western Fusion Elements
|
## Recommended Color Palettes
|
||||||
|
|
||||||
| Korean Element | Western Parallel | Fusion Approach |
|
| Palette | Background | Accent | Mood |
|
||||||
|---------------|-----------------|-----------------|
|
|---------|-----------|--------|------|
|
||||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||||
| 한글 geometry | Typography | Structural letters |
|
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
|
||||||
|
**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
|
## Usage Notes
|
||||||
|
|
||||||
1. **Layer metaphors**: Combine 2-3 for depth
|
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||||
2. **Avoid clichés**: No obvious tech symbols
|
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||||
3. **Cultural sensitivity**: Universal over specific
|
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||||
4. **Abstraction levels**: Match essay tone
|
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||||
5. **Emotional resonance**: Feel over literal
|
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||||
|
|
||||||
## Quick Selection Guide
|
## Quick Selection Guide
|
||||||
|
|
||||||
For **technology essays**: organic-digital hybrids
|
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||||
For **social commentary**: human elements in systems
|
For **social commentary**: network patterns with small human traces
|
||||||
For **philosophy pieces**: space and light
|
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||||
For **cultural topics**: layered traditions
|
For **practical guides**: clean editorial diagrams with warm accents
|
||||||
For **future themes**: transformation states
|
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
|
```bash
|
||||||
pip install -r scripts/requirements.txt
|
# Primary use — QA an AUTHORED dataset before the client sees it (Mode A)
|
||||||
python scripts/schema_validator.py --url https://example.com
|
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 |
|
## Legacy single-URL tool (kept for quick one-offs)
|
||||||
|--------|---------|
|
|
||||||
| `schema_validator.py` | Extract and validate structured data |
|
|
||||||
| `base_client.py` | Shared utilities |
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# Validate page schema
|
pip install -r scripts/requirements.txt # extruct, jsonschema, rdflib, lxml, requests
|
||||||
python scripts/schema_validator.py --url https://example.com
|
|
||||||
|
|
||||||
# JSON output
|
|
||||||
python scripts/schema_validator.py --url https://example.com --json
|
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 |
|
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
|
||||||
| JSON-LD | `<script type="application/ld+json">` |
|
`notion-writer` skill; use Notion MCP only for **properties** (Status, Category, etc.).
|
||||||
| 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
|
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
|
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
|
||||||
| URL | https://www.notion.so/dintelligence/2c8581e58a1e8035880be38cefc2f3ef |
|
| Category | `Schema/Structured Data` |
|
||||||
|
| Priority | map gate: FAIL→Critical/High, PASS-with-P1→Medium, PASS-clean→Low |
|
||||||
### Required Properties
|
| Audit ID | `SCHEMA-YYYYMMDD-NNN` |
|
||||||
|
|
||||||
| 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": {...}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
|
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
|
```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
|
# Mode 1 — existing site → claims register (URLs, or local .html / a directory offline)
|
||||||
python scripts/schema_generator.py --type organization --url https://example.com
|
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
|
# Hand off to the QA gate (must reach zero P0)
|
||||||
python scripts/schema_generator.py --template templates/article.json --data article_data.json
|
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 |
|
## Retired
|
||||||
|--------|---------|
|
|
||||||
| `schema_generator.py` | Generate schema markup |
|
|
||||||
| `base_client.py` | Shared utilities |
|
|
||||||
|
|
||||||
## 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 |
|
## Notion output (OurDigital SEO Audit Log)
|
||||||
|------|----------|----------|
|
|
||||||
| Organization | `organization.json` | Company/brand info |
|
|
||||||
| LocalBusiness | `local_business.json` | Physical locations |
|
|
||||||
| Article | `article.json` | Blog posts, news |
|
|
||||||
| Product | `product.json` | E-commerce items |
|
|
||||||
| FAQPage | `faq.json` | FAQ sections |
|
|
||||||
| BreadcrumbList | `breadcrumb.json` | Navigation path |
|
|
||||||
| WebSite | `website.json` | Site-level info |
|
|
||||||
|
|
||||||
## Usage Examples
|
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
|
||||||
### Organization
|
`notion-writer` skill; use Notion MCP only for **properties**.
|
||||||
```bash
|
|
||||||
python scripts/schema_generator.py --type organization \
|
|
||||||
--name "Company Name" \
|
|
||||||
--url "https://example.com" \
|
|
||||||
--logo "https://example.com/logo.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
### LocalBusiness
|
|
||||||
```bash
|
|
||||||
python scripts/schema_generator.py --type localbusiness \
|
|
||||||
--name "Restaurant Name" \
|
|
||||||
--address "123 Main St, City, State 12345" \
|
|
||||||
--phone "+1-555-123-4567" \
|
|
||||||
--hours "Mo-Fr 09:00-17:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Article
|
|
||||||
```bash
|
|
||||||
python scripts/schema_generator.py --type article \
|
|
||||||
--headline "Article Title" \
|
|
||||||
--author "Author Name" \
|
|
||||||
--published "2024-01-15" \
|
|
||||||
--image "https://example.com/image.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
### FAQPage
|
|
||||||
```bash
|
|
||||||
python scripts/schema_generator.py --type faq \
|
|
||||||
--questions questions.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Generated JSON-LD ready for insertion:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": "Company Name",
|
|
||||||
"url": "https://example.com",
|
|
||||||
"logo": "https://example.com/logo.png"
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template Customization
|
|
||||||
|
|
||||||
Templates in `templates/` can be modified. Required fields are marked:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Article",
|
|
||||||
"headline": "{{REQUIRED}}",
|
|
||||||
"author": {
|
|
||||||
"@type": "Person",
|
|
||||||
"name": "{{REQUIRED}}"
|
|
||||||
},
|
|
||||||
"datePublished": "{{REQUIRED}}",
|
|
||||||
"image": "{{RECOMMENDED}}"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Generated schemas are validated before output:
|
|
||||||
- Syntax correctness
|
|
||||||
- Required properties present
|
|
||||||
- Schema.org vocabulary compliance
|
|
||||||
|
|
||||||
Use skill 13 (schema-validator) for additional validation.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
```
|
|
||||||
jsonschema>=4.21.0
|
|
||||||
requests>=2.31.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notion Output (Required)
|
|
||||||
|
|
||||||
**IMPORTANT**: All audit reports MUST be saved to the OurDigital SEO Audit Log database.
|
|
||||||
|
|
||||||
### Database Configuration
|
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
|
| Database ID | `2c8581e5-8a1e-8035-880b-e38cefc2f3ef` |
|
||||||
| URL | https://www.notion.so/dintelligence/2c8581e58a1e8035880be38cefc2f3ef |
|
| Category | `Schema/Structured Data` |
|
||||||
|
| Audit ID | `SCHEMA-YYYYMMDD-NNN` |
|
||||||
### 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": {...}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
|
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
|
name: seo-schema-generator
|
||||||
description: |
|
description: |
|
||||||
JSON-LD structured data generator from templates for various content types.
|
Generates validation-ready JSON-LD for a site via a claims register — Mode 1 from
|
||||||
Triggers: generate schema, create JSON-LD, schema markup, structured data generator.
|
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
|
# 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
|
## 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
|
| | Mode 1 — existing site | Mode 2 — collected sources |
|
||||||
2. **LocalBusiness** - Physical location businesses
|
|---|---|---|
|
||||||
3. **Article** - Blog posts and news articles
|
| Source of truth | the live pages | scattered sources (DART, Wikidata, brochures) |
|
||||||
4. **Product** - E-commerce products
|
| Seed the register with | extract from pages | manual research |
|
||||||
5. **FAQPage** - FAQ sections
|
| Hard part | extraction & mapping | authority hierarchy + entity reconciliation |
|
||||||
6. **BreadcrumbList** - Navigation breadcrumbs
|
|
||||||
7. **WebSite** - Site-level with search action
|
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
|
## Workflow
|
||||||
|
|
||||||
1. Identify content type
|
1. Lock the entity→type map (scope first).
|
||||||
2. Gather required information
|
2. Seed the claims register (Mode 1: extract from pages · Mode 2: research → register).
|
||||||
3. Generate JSON-LD from template
|
3. Reconcile to CONFIRMED; clear conflicts.
|
||||||
4. Validate output
|
4. Build drafts from type templates (placeholders pruned).
|
||||||
5. Provide implementation instructions
|
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
|
## Notes
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
|
- 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
|
name: seo-schema-generator
|
||||||
description: |
|
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
|
# Optional fields
|
||||||
allowed-tools:
|
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
|
4. **Topic Clustering** - Group keywords into semantic clusters
|
||||||
5. **Gap Analysis** - Find competitor keywords missing from target site
|
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
|
```bash
|
||||||
our keywords volume "<keyword>" --location 2410 --language ko
|
our keywords volume "<keyword>" --location 2410 --language ko
|
||||||
our keywords ideas "<keyword>" --location 2410 --limit 50
|
our keywords ideas "<keyword>" --location 2410 --limit 50
|
||||||
@@ -34,48 +64,50 @@ our keywords intent "<kw1>" "<kw2>" "<kw3>"
|
|||||||
our keywords difficulty "<kw1>" "<kw2>"
|
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__ahrefs__keywords-explorer-overview(keyword="<seed>", country="us")
|
||||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_ideas
|
mcp__ahrefs__keywords-explorer-matching-terms(keyword="<seed>", country="us")
|
||||||
mcp__dfs-mcp__dataforseo_labs_google_keyword_suggestions
|
mcp__ahrefs__keywords-explorer-volume-by-country(keyword="<seed>")
|
||||||
mcp__dfs-mcp__dataforseo_labs_search_intent
|
mcp__ahrefs__gsc-keywords(...)
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Common Parameters
|
**OurSEO Agent MCP (Claude Desktop, KG/entity expansion):**
|
||||||
- **location_code**: 2410 (Korea), 2840 (US), 2392 (Japan)
|
```
|
||||||
- **language_code**: ko, en, ja
|
mcp__ourseo__search_knowledge_graph(query="<brand or entity>")
|
||||||
|
mcp__ourseo__crawl_website(url="<target>", max_pages=50)
|
||||||
|
```
|
||||||
|
|
||||||
### Web Search for Naver Discovery
|
### Common parameters across backends
|
||||||
```
|
|
||||||
WebSearch: Naver autocomplete and trend discovery
|
| 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
|
## Workflow
|
||||||
|
|
||||||
### 1. Seed Keyword Expansion
|
### 1. Seed Keyword Expansion
|
||||||
1. Input seed keyword (Korean or English)
|
1. Determine backend via **Data Source Selection** above.
|
||||||
2. Fetch search volume via `our keywords volume "<seed>" --location 2410 --language ko`
|
2. Fetch search volume for the seed.
|
||||||
3. Expand with `our keywords ideas "<seed>" --location 2410 --limit 50`
|
3. Expand via the chosen backend's "related" / "ideas" / "matching-terms" endpoint.
|
||||||
4. Get autocomplete suggestions via MCP: `mcp__dfs-mcp__dataforseo_labs_google_keyword_suggestions`
|
4. Apply Korean suffix expansion if Korean market (regardless of backend).
|
||||||
5. Apply Korean suffix expansion if Korean market
|
5. Deduplicate and merge.
|
||||||
6. Deduplicate and merge results
|
|
||||||
|
|
||||||
### 2. Intent Classification & Clustering
|
### 2. Intent Classification & Clustering
|
||||||
1. Classify each keyword by search intent
|
1. Classify each keyword by search intent (informational / navigational / commercial / transactional).
|
||||||
2. Group keywords into topic clusters
|
2. Group keywords into topic clusters.
|
||||||
3. Identify pillar topics and supporting terms
|
3. Identify pillar topics and supporting terms.
|
||||||
4. Calculate cluster-level metrics (total volume, avg KD)
|
4. Calculate cluster-level metrics (total volume, avg KD).
|
||||||
|
|
||||||
### 3. Gap Analysis
|
### 3. Gap Analysis
|
||||||
1. Pull organic keywords for target: `our keywords for-site <target.com> --location 2410 --limit 100`
|
1. Pull organic keywords for target via chosen backend.
|
||||||
2. Pull organic keywords for competitors: `our keywords for-site <competitor.com> --location 2410 --limit 100`
|
2. Pull organic keywords for competitors (parallel).
|
||||||
3. Identify keywords present in competitors but missing from target
|
3. Identify keywords present in competitors but missing from target.
|
||||||
4. Score opportunities by volume/difficulty ratio
|
4. Score opportunities by volume/difficulty ratio.
|
||||||
5. Prioritize by intent alignment with business goals
|
5. Prioritize by intent alignment with business goals.
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
@@ -83,26 +115,30 @@ WebSearch: Naver autocomplete and trend discovery
|
|||||||
## Keyword Strategy Report: [seed keyword]
|
## Keyword Strategy Report: [seed keyword]
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
- Data source: [Semrush | Ahrefs | OurSEO CLI | OurSEO MCP | GSC]
|
||||||
|
- Market: [database/location code]
|
||||||
- Total keywords discovered: [count]
|
- Total keywords discovered: [count]
|
||||||
- Topic clusters: [count]
|
- Topic clusters: [count]
|
||||||
- Total search volume: [sum]
|
- Total search volume: [sum]
|
||||||
|
|
||||||
### Top Clusters
|
### Top Clusters
|
||||||
| Cluster | Keywords | Total Volume | Avg KD |
|
| Cluster | Keywords | Total Volume | Avg KD |
|
||||||
|---------|----------|-------------|--------|
|
|---|---|---|---|
|
||||||
| ... | ... | ... | ... |
|
| ... | ... | ... | ... |
|
||||||
|
|
||||||
### Top Opportunities
|
### Top Opportunities
|
||||||
| Keyword | Volume | KD | Intent | Cluster |
|
| Keyword | Volume | KD | Intent | Cluster |
|
||||||
|---------|--------|-----|--------|---------|
|
|---|---|---|---|---|
|
||||||
| ... | ... | ... | ... | ... |
|
| ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
### Keyword Gaps (vs competitors)
|
### Keyword Gaps (vs competitors)
|
||||||
| Keyword | Volume | Competitor Position | Opportunity Score |
|
| 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)
|
## Notion Output (Required)
|
||||||
|
|
||||||
All audit reports MUST be saved to OurDigital SEO Audit Log:
|
All audit reports MUST be saved to OurDigital SEO Audit Log:
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ name: seo-keyword-strategy
|
|||||||
description: |
|
description: |
|
||||||
Keyword strategy and research for SEO campaigns. Triggers: keyword research, keyword analysis, keyword gap, search volume, keyword clustering, intent classification.
|
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:
|
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__*
|
- mcp__notion__*
|
||||||
- WebSearch
|
- WebSearch
|
||||||
- WebFetch
|
- WebFetch
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user