Compare commits
43 Commits
6ac547e78f
...
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 |
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
|
||||||
36
.claude/commands/jamie-copy-trimmer.md
Normal file
36
.claude/commands/jamie-copy-trimmer.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Trims and sharpens Korean plastic-surgery/aesthetic marketing copy against cliché & compliance corpus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jamie Copy Trimmer
|
||||||
|
|
||||||
|
Trim and sharpen Korean plastic-surgery / aesthetic-medical marketing copy against an industry expression corpus, within 의료광고 심의 limits. Guidance-only skill (no scripts).
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
- "카피 다듬어", "카피 트리밍", "네이밍 검토", "슬로건 다듬어"
|
||||||
|
- "심의 안전하게", "copy trim", "make it catchier"
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
- The corpus is a map for AVOIDING clichés, not a library to copy
|
||||||
|
- 의료광고 심의 is a gate, not a score -- one 🔴 risk expression fails the option outright
|
||||||
|
- Trim first, dazzle second
|
||||||
|
- Don't guess -- mark `[확인]`
|
||||||
|
|
||||||
|
## Workflow (5 steps)
|
||||||
|
1. **Diagnose** -- tag each phrase 🟢 effective / 🟡 cliché / 🔴 compliance risk / ⚪ flat / 🟦 brand asset
|
||||||
|
2. **Trim** -- remove all 🔴, replace 🟡, delete redundancy
|
||||||
|
3. **Elevate** -- 2-3 alternatives per element within 심의 limits, matched to channel tone
|
||||||
|
4. **Re-score** -- 5-axis rubric (감각/차별성/브랜드적합성/심의 PASS-FAIL/명료성)
|
||||||
|
5. **Recursive Improvement** -- propose feeding adopted/rejected expressions back into the corpus
|
||||||
|
|
||||||
|
## Output (Korean)
|
||||||
|
진단 → 트리밍 → 대안 → 재평가 → 추천안 → 준비 점검 사항 (risks/gaps consolidated at the end)
|
||||||
|
|
||||||
|
## References
|
||||||
|
- `references/corpus_compliance_risk.md` -- medical-ad risk expressions (most important)
|
||||||
|
- `references/corpus_cliche.md`, `corpus_effective.md`, `corpus_examples.md`
|
||||||
|
- `references/witty_within_limits.md`, `evaluation_rubric.md`, `recursive_protocol.md`
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
- Compliance judgment here is guidance, not legal advice -- always recommend pre-publication 의료광고 자율심의
|
||||||
|
- A specific brand's tone guide (e.g. jamie-brand-audit) overrides this skill's taste defaults
|
||||||
@@ -24,7 +24,7 @@ Push markdown content to Notion pages or databases via the Notion API.
|
|||||||
## Scripts
|
## 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
|
||||||
|
|||||||
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-*/
|
||||||
@@ -77,6 +77,7 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 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)
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ This is a Claude Skills collection repository containing:
|
|||||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
| 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)
|
||||||
@@ -253,6 +255,7 @@ our-claude-skills/
|
|||||||
│ ├── 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/
|
||||||
@@ -271,6 +274,7 @@ 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/
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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)
|
||||||
@@ -215,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
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 클리닉 메타포 | 설명 |
|
| 핵심 가치 | 설명 |
|
||||||
|------|---------|--------------|------|
|
|----------|------|
|
||||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 |
|
| **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
|
||||||
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 |
|
| **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
|
||||||
| 지속 성장 | Sustainable Growth | 체질 개선 | 일회성이 아닌 지속 가능한 성장 |
|
| **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
|
||||||
|
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
|
||||||
|
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
|
||||||
|
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
|
||||||
|
|
||||||
### 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 | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||||
|
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||||
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
| 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가 글을 지배해서는 안 된다 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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문장]
|
||||||
|
```
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -607,6 +607,16 @@ def _address_street(node):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _address_locality(node):
|
||||||
|
"""City/region key, used to keep distinct same-name locations of a chain apart."""
|
||||||
|
addr = node.get("address")
|
||||||
|
if isinstance(addr, list) and addr and isinstance(addr[0], dict):
|
||||||
|
addr = addr[0]
|
||||||
|
if isinstance(addr, dict):
|
||||||
|
return normalize_name(addr.get("addressLocality") or addr.get("addressRegion"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _walk_ids(obj, defined, referenced):
|
def _walk_ids(obj, defined, referenced):
|
||||||
"""Collect @id definitions vs pure references by walking the whole document.
|
"""Collect @id definitions vs pure references by walking the whole document.
|
||||||
|
|
||||||
@@ -645,21 +655,26 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
|||||||
break # one placeholder defect per node is enough signal
|
break # one placeholder defect per node is enough signal
|
||||||
|
|
||||||
# ---- NAP consistency (P0) ----
|
# ---- NAP consistency (P0) ----
|
||||||
|
# Group by (name, locality): a multi-location chain legitimately shares a name
|
||||||
|
# across cities (e.g. "더 파크뷰" in Seoul AND Jeju). A real NAP conflict is a
|
||||||
|
# SINGLE location with contradictory phone/street, so scope the check per city.
|
||||||
by_name = defaultdict(list)
|
by_name = defaultdict(list)
|
||||||
for entry, node in node_index:
|
for entry, node in node_index:
|
||||||
if type_of(node) in NAP_TYPES and node.get("name"):
|
if type_of(node) in NAP_TYPES and node.get("name"):
|
||||||
by_name[normalize_name(first_text(node.get("name")))].append((entry, node))
|
key = (normalize_name(first_text(node.get("name"))), _address_locality(node))
|
||||||
for name, group in by_name.items():
|
by_name[key].append((entry, node))
|
||||||
|
for (name, locality), group in by_name.items():
|
||||||
|
loc = f" ({locality})" if locality else ""
|
||||||
phones = {str(first_text(n.get("telephone"))).strip()
|
phones = {str(first_text(n.get("telephone"))).strip()
|
||||||
for _, n in group if n.get("telephone")}
|
for _, n in group if n.get("telephone")}
|
||||||
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
streets = {_address_street(n) for _, n in group if _address_street(n)}
|
||||||
if len(phones) > 1:
|
if len(phones) > 1:
|
||||||
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
|
||||||
f"Business '{name}' has conflicting telephone values across "
|
f"Business '{name}'{loc} has conflicting telephone values across "
|
||||||
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
f"entries: {sorted(phones)}.", entry_id="(dataset)")
|
||||||
if len(streets) > 1:
|
if len(streets) > 1:
|
||||||
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
|
||||||
f"Business '{name}' has conflicting streetAddress values across "
|
f"Business '{name}'{loc} has conflicting streetAddress values across "
|
||||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
||||||
|
|
||||||
# ---- @id duplicates + dangling references (P1) ----
|
# ---- @id duplicates + dangling references (P1) ----
|
||||||
@@ -719,10 +734,147 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
|
|||||||
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}…", entry_id="(dataset)")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Layer R — Reference-URL integrity (sameAs / external identity links)
|
||||||
|
# Hardened after the Shilla incident (2026-05-29): LLM-fabricated Wikidata IDs
|
||||||
|
# (Q-numbers pointing to unrelated entities) and google.com/search reference
|
||||||
|
# URLs shipped undetected. Offline: forbid search-result URLs (P0) and flag any
|
||||||
|
# external identity ref as REFERENCE_UNVERIFIED (P1). Online (--verify-refs):
|
||||||
|
# resolve every ref; for Wikidata, fetch the label and compare to the entity
|
||||||
|
# name — a mismatch is a FALSE_REFERENCE (P0).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _http_get(url, timeout=12, accept=None):
|
||||||
|
import urllib.request, urllib.parse
|
||||||
|
# percent-encode non-ASCII path/query (e.g. ko.wikipedia.org/wiki/호텔신라)
|
||||||
|
p = urllib.parse.urlsplit(url)
|
||||||
|
url = urllib.parse.urlunsplit((p.scheme, p.netloc,
|
||||||
|
urllib.parse.quote(p.path),
|
||||||
|
urllib.parse.quote(p.query, safe="=&?"),
|
||||||
|
p.fragment))
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"User-Agent": "schema-ref-validator/1.0 (+offline-qa)",
|
||||||
|
**({"Accept": accept} if accept else {})})
|
||||||
|
return urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _wikidata_labels(qid, timeout=12):
|
||||||
|
import urllib.request, json as _json
|
||||||
|
url = f"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={qid}&props=labels&format=json"
|
||||||
|
with _http_get(url, timeout=timeout, accept="application/json") as r:
|
||||||
|
data = _json.loads(r.read().decode("utf-8"))
|
||||||
|
ent = data.get("entities", {}).get(qid, {})
|
||||||
|
if "missing" in ent:
|
||||||
|
return None
|
||||||
|
return {lang: v.get("value", "") for lang, v in ent.get("labels", {}).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def layer_references(node_index, rules, defects, verify_refs=False):
|
||||||
|
policy = rules.get("reference_policy")
|
||||||
|
if not policy:
|
||||||
|
return
|
||||||
|
forbidden = policy.get("forbidden_url_substrings", [])
|
||||||
|
ref_props = set(policy.get("identity_ref_props", ["sameAs"]))
|
||||||
|
import re as _re
|
||||||
|
qid_re = _re.compile(r"wikidata\.org/(?:wiki|entity)/(Q\d+)")
|
||||||
|
def every_string(obj):
|
||||||
|
# Unlike all_strings(), this also yields strings nested inside lists
|
||||||
|
# (e.g. each URL in a sameAs array) — the exact case missed before.
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for v in obj.values():
|
||||||
|
yield from every_string(v)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for v in obj:
|
||||||
|
yield from every_string(v)
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
yield obj
|
||||||
|
|
||||||
|
for entry, node in node_index:
|
||||||
|
eid, url, ntype = entry["entry_id"], entry["url"], type_of(node)
|
||||||
|
# (1) forbidden search-result URLs anywhere in the node (incl. list items) -> P0
|
||||||
|
for val in every_string(node):
|
||||||
|
low = val.lower()
|
||||||
|
hit = next((s for s in forbidden if s in low), None)
|
||||||
|
if hit:
|
||||||
|
defects.add("P0", "LR", "FORBIDDEN_REFERENCE",
|
||||||
|
f"Search-result URL used as reference (contains {hit!r}); "
|
||||||
|
f"not a valid entity reference: {val[:80]!r}.", eid, url, ntype)
|
||||||
|
# (2) external identity references (sameAs)
|
||||||
|
refs = []
|
||||||
|
for prop in ref_props:
|
||||||
|
v = node.get(prop)
|
||||||
|
if isinstance(v, str):
|
||||||
|
refs.append(v)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
refs += [x for x in v if isinstance(x, str)]
|
||||||
|
if not refs:
|
||||||
|
continue
|
||||||
|
# (2a) discouraged reference sources (policy: prefer Wikipedia over Wikidata)
|
||||||
|
for dd in policy.get("discouraged_ref_domains", []):
|
||||||
|
for ref in refs:
|
||||||
|
if dd in ref:
|
||||||
|
defects.add("P1", "LR", "DISCOURAGED_REFERENCE",
|
||||||
|
f"{ref} uses a discouraged source ({dd}). Policy: prefer "
|
||||||
|
"Wikipedia; if none verified, omit — never fabricate.",
|
||||||
|
eid, url, ntype)
|
||||||
|
if not verify_refs:
|
||||||
|
defects.add("P1", "LR", "REFERENCE_UNVERIFIED",
|
||||||
|
f"{len(refs)} external reference(s) on '{ntype}' not machine-verified "
|
||||||
|
f"(run with --verify-refs / confirm online): {refs}.", eid, url, ntype)
|
||||||
|
continue
|
||||||
|
# online verification
|
||||||
|
name = normalize_name(first_text(node.get("name")))
|
||||||
|
alts = node.get("alternateName") or []
|
||||||
|
if isinstance(alts, str):
|
||||||
|
alts = [alts]
|
||||||
|
names = {name} | {normalize_name(a) for a in alts if isinstance(a, str)}
|
||||||
|
for ref in refs:
|
||||||
|
m = qid_re.search(ref)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
labels = _wikidata_labels(m.group(1))
|
||||||
|
except Exception as e:
|
||||||
|
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
|
||||||
|
f"Could not fetch Wikidata {m.group(1)} ({e}).", eid, url, ntype)
|
||||||
|
continue
|
||||||
|
if labels is None:
|
||||||
|
defects.add("P0", "LR", "FALSE_REFERENCE",
|
||||||
|
f"sameAs {ref} → Wikidata item is missing/deleted.", eid, url, ntype)
|
||||||
|
continue
|
||||||
|
lab = {normalize_name(v) for v in labels.values()}
|
||||||
|
# match if any entity name appears in any label or vice-versa
|
||||||
|
ok = any(n and (n in l or l in n) for n in names for l in lab)
|
||||||
|
if not ok:
|
||||||
|
defects.add("P0", "LR", "FALSE_REFERENCE",
|
||||||
|
f"sameAs {ref} label {sorted(lab)[:3]} does NOT match entity "
|
||||||
|
f"name {sorted(n for n in names if n)[:3]} — fabricated/incorrect ID.",
|
||||||
|
eid, url, ntype)
|
||||||
|
else:
|
||||||
|
is_social = any(d in ref for d in policy.get("social_profile_domains", []))
|
||||||
|
try:
|
||||||
|
code = _http_get(ref).status
|
||||||
|
except Exception as e:
|
||||||
|
code = f"error: {e}"
|
||||||
|
if is_social:
|
||||||
|
# HTTP 200 does NOT prove official ownership, and platforms
|
||||||
|
# often bot-block live pages (e.g. Facebook 400). Always hand
|
||||||
|
# social/profile refs to a human. (Shilla: a 200 YouTube
|
||||||
|
# channel was not the official one; FB page was closed.)
|
||||||
|
defects.add("P1", "LR", "SOCIAL_UNVERIFIED",
|
||||||
|
f"sameAs {ref} is a social/profile URL (HTTP {code}). "
|
||||||
|
"Confirm official ownership AND active status manually — "
|
||||||
|
"a 200 is not proof of ownership.", eid, url, ntype)
|
||||||
|
elif isinstance(code, int) and code >= 400:
|
||||||
|
defects.add("P0", "LR", "BROKEN_REFERENCE",
|
||||||
|
f"sameAs {ref} returned HTTP {code}.", eid, url, ntype)
|
||||||
|
elif not isinstance(code, int):
|
||||||
|
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
|
||||||
|
f"sameAs {ref} not reachable ({code}).", eid, url, ntype)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Orchestration + output
|
# Orchestration + output
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def run(entries, rules, inventory, strict, no_recommended):
|
def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
|
||||||
defects = DefectLog()
|
defects = DefectLog()
|
||||||
if inventory is not None:
|
if inventory is not None:
|
||||||
layer0_coverage(entries, inventory, defects)
|
layer0_coverage(entries, inventory, defects)
|
||||||
@@ -743,6 +895,7 @@ def run(entries, rules, inventory, strict, no_recommended):
|
|||||||
node_index.append((entry, node))
|
node_index.append((entry, node))
|
||||||
|
|
||||||
layer4_consistency(node_index, parsed_docs, rules, defects)
|
layer4_consistency(node_index, parsed_docs, rules, defects)
|
||||||
|
layer_references(node_index, rules, defects, verify_refs=verify_refs)
|
||||||
return defects, valid_entries, len(node_index)
|
return defects, valid_entries, len(node_index)
|
||||||
|
|
||||||
|
|
||||||
@@ -822,6 +975,9 @@ def main(argv=None):
|
|||||||
ap.add_argument("--live", nargs="+", metavar="URL",
|
ap.add_argument("--live", nargs="+", metavar="URL",
|
||||||
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
help="Mode B: validate live URLs (extract embedded JSON-LD)")
|
||||||
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
|
||||||
|
ap.add_argument("--verify-refs", action="store_true",
|
||||||
|
help="online: resolve every sameAs and verify Wikidata labels match the "
|
||||||
|
"entity name (catches fabricated/incorrect reference IDs). Needs network.")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
if not args.dataset and not args.live:
|
if not args.dataset and not args.live:
|
||||||
@@ -838,7 +994,8 @@ def main(argv=None):
|
|||||||
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
inventory = load_url_inventory(args.url_list) if args.url_list else None
|
||||||
|
|
||||||
defects, valid_entries, nodes = run(entries, rules, inventory,
|
defects, valid_entries, nodes = run(entries, rules, inventory,
|
||||||
args.strict, args.no_recommended)
|
args.strict, args.no_recommended,
|
||||||
|
verify_refs=args.verify_refs)
|
||||||
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
|
||||||
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
||||||
"coverage": inventory is not None}
|
"coverage": inventory is not None}
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
||||||
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
||||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,6 +139,19 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
|||||||
| `---` | Divider |
|
| `---` | Divider |
|
||||||
| Paragraphs | Paragraph |
|
| Paragraphs | Paragraph |
|
||||||
|
|
||||||
|
### Engines and image uploads
|
||||||
|
|
||||||
|
Two write engines via `--engine {blocks,markdown}` (default: `blocks`).
|
||||||
|
|
||||||
|
The **blocks engine** (default) converts markdown locally to Notion block objects. Local images (``) are auto-uploaded via the `ntn` CLI and embedded at their original position in the page. Requires `ntn` installed and `ntn login`.
|
||||||
|
|
||||||
|
The **markdown engine** (`--engine markdown`) posts the document through Notion's native enhanced-markdown API (`Notion-Version: 2026-03-11`, set automatically; override with `--notion-version`). The skill's authoring dialect — GitHub alerts (`[!NOTE]`), Pandoc columns (`::: columns`), `<details>` toggles, and `@[mention]` — is auto-translated before posting. Note: local images are appended at the end of the page rather than inline with this engine; use `--engine blocks` when image position matters. Pass `--allow-deleting-content` when `--replace` needs to remove child pages or databases.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Markdown engine — create a DB row from a doc with callouts or columns
|
||||||
|
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
||||||
|
```
|
||||||
|
|
||||||
## Workflow Example
|
## Workflow Example
|
||||||
|
|
||||||
Integrate with Jamie YouTube Manager to log video info:
|
Integrate with Jamie YouTube Manager to log video info:
|
||||||
|
|||||||
@@ -189,6 +189,52 @@ print("Hello")
|
|||||||
|
|
||||||
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
||||||
|
|
||||||
|
### File uploads
|
||||||
|
|
||||||
|
Standalone local-image lines (``) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (``) are left as external links unchanged.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- `ntn` CLI installed: `curl -fsSL https://ntn.dev | bash`
|
||||||
|
|
||||||
|
Uploads run as the **same integration** that writes the page (`NOTION_API_KEY`). The script injects `NOTION_API_TOKEN=$NOTION_API_KEY` into every `ntn` subprocess, so the file upload and the page share one identity — no separate `ntn login` or workspace matching is needed. `ntn` only needs to be installed, not logged in.
|
||||||
|
|
||||||
|
### Engines
|
||||||
|
|
||||||
|
Two write engines, selected with `--engine {blocks,markdown}`. Default is `blocks`.
|
||||||
|
|
||||||
|
| Engine | Flag | When to use |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **blocks** (default) | `--engine blocks` | General use; images embed at their exact position; full table + container support |
|
||||||
|
| **markdown** | `--engine markdown` | Richer Notion-native formatting via enhanced-markdown endpoints |
|
||||||
|
|
||||||
|
**blocks engine** converts markdown to Notion block objects locally (via `markdown_to_notion_blocks`). Local images are uploaded via `ntn` and embedded at their exact position in the document.
|
||||||
|
|
||||||
|
**markdown engine** posts the document through Notion's native enhanced-markdown endpoints (`Notion-Version: 2026-03-11`, set automatically). The skill's authoring dialect is auto-translated before posting:
|
||||||
|
|
||||||
|
| Skill dialect | Translated to |
|
||||||
|
|---------------|---------------|
|
||||||
|
| `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | `<callout icon="..." color="...">` |
|
||||||
|
| `::: columns` / `::: column` / `:::` | `<columns><column>...</column></columns>` |
|
||||||
|
| `<details><summary>...</summary>` | `<details>` (Notion toggle) |
|
||||||
|
| `@[Title](id-or-url)` | `<mention-page url="...">` |
|
||||||
|
|
||||||
|
**Local image limitation with `--engine markdown`**: local images cannot be placed inline via the enhanced-markdown API. They are appended at the end of the page as a second write pass. Use `--engine blocks` when image placement matters.
|
||||||
|
|
||||||
|
**`--notion-version`**: Override the API version for the markdown engine (default: `2026-03-11`).
|
||||||
|
|
||||||
|
**`--allow-deleting-content`**: Required when `--replace` needs to delete child pages or databases under the target page; the Notion API refuses such deletions unless this flag is present. Applies only to the markdown engine's `--replace` path (`--engine markdown --replace`); has no effect with `--engine blocks`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Markdown engine, create a row from a doc with callouts/columns
|
||||||
|
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
||||||
|
|
||||||
|
# Blocks engine with a local image (auto-uploaded via ntn)
|
||||||
|
python notion_writer.py -p PAGE_URL -f post.md # post.md contains 
|
||||||
|
|
||||||
|
# Markdown engine, replace page allowing child deletion
|
||||||
|
python notion_writer.py -p PAGE_URL -f doc.md --replace --engine markdown --allow-deleting-content
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -398,9 +444,10 @@ python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Version 1.2.0 | Claude Code | 2026-04-27*
|
*Version 1.3.0 | Claude Code | 2026-06-27*
|
||||||
|
|
||||||
Changelog:
|
Changelog:
|
||||||
|
- 1.3.0 — Local file/image uploads via the `ntn` CLI (`` → file_upload image blocks). New `--engine markdown` path writing through Notion's native enhanced-markdown endpoints with a dialect translator. Added `--notion-version` and `--allow-deleting-content`.
|
||||||
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
||||||
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
||||||
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIErrorCode, APIResponseError
|
from notion_client.errors import APIErrorCode, APIResponseError
|
||||||
|
|
||||||
|
|
||||||
def make_client(api_key: str) -> Client:
|
def make_client(api_key: str, notion_version: str = None) -> Client:
|
||||||
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
|
"""Build a sync Notion client. Pass notion_version to override the SDK
|
||||||
|
default (needed: 2026-03-11 for the markdown content endpoints)."""
|
||||||
|
if notion_version:
|
||||||
|
return Client(auth=api_key, notion_version=notion_version)
|
||||||
return Client(auth=api_key)
|
return Client(auth=api_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +213,10 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
|
|||||||
"https://www.notion.so/my-integrations."
|
"https://www.notion.so/my-integrations."
|
||||||
)
|
)
|
||||||
if code == APIErrorCode.ValidationError:
|
if code == APIErrorCode.ValidationError:
|
||||||
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}"
|
msg = exc.body.get('message', str(exc))
|
||||||
|
if 'delet' in msg.lower():
|
||||||
|
msg += " — re-run with --allow-deleting-content to permit this."
|
||||||
|
return f"Validation error{suffix}: {msg}"
|
||||||
if code == APIErrorCode.RateLimited:
|
if code == APIErrorCode.RateLimited:
|
||||||
return f"Rate limited{suffix}. Back off and retry."
|
return f"Rate limited{suffix}. Back off and retry."
|
||||||
return f"Notion API error [{code}]{suffix}: {exc}"
|
return f"Notion API error [{code}]{suffix}: {exc}"
|
||||||
@@ -253,3 +259,33 @@ def find_existing_page(
|
|||||||
)
|
)
|
||||||
results = response.get("results") or []
|
results = response.get("results") or []
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_page_markdown(client, parent, properties, markdown):
|
||||||
|
"""POST /v1/pages with the enhanced-markdown body param.
|
||||||
|
`markdown` is only included in the body when non-empty to avoid sending
|
||||||
|
a blank markdown field when no --file/--stdin was supplied."""
|
||||||
|
body: Dict[str, Any] = {"parent": parent, "properties": properties}
|
||||||
|
if markdown:
|
||||||
|
body["markdown"] = markdown
|
||||||
|
return client.request(path="pages", method="POST", body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def append_markdown(client, page_id, markdown):
|
||||||
|
"""PATCH /v1/pages/:id/markdown — append at end (insert_content)."""
|
||||||
|
return client.request(
|
||||||
|
path=f"pages/{page_id}/markdown", method="PATCH",
|
||||||
|
body={"type": "insert_content",
|
||||||
|
"insert_content": {"content": markdown,
|
||||||
|
"position": {"type": "end"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_markdown(client, page_id, markdown, allow_deleting=False):
|
||||||
|
"""PATCH /v1/pages/:id/markdown — replace all content."""
|
||||||
|
return client.request(
|
||||||
|
path=f"pages/{page_id}/markdown", method="PATCH",
|
||||||
|
body={"type": "replace_content",
|
||||||
|
"replace_content": {"new_str": markdown,
|
||||||
|
"allow_deleting_content": allow_deleting}},
|
||||||
|
)
|
||||||
|
|||||||
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Translate the notion-writer markdown dialect into Notion enhanced
|
||||||
|
markdown for the markdown write engine. Pure functions, no I/O."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# NOTE: `notion_writer` is imported lazily inside _translate_mentions to avoid
|
||||||
|
# a circular import (notion_writer imports this module at its top level).
|
||||||
|
|
||||||
|
# Skill alert type -> (emoji, Notion enhanced-markdown background color)
|
||||||
|
CALLOUT_MAP = {
|
||||||
|
"NOTE": ("ℹ️", "blue_bg"),
|
||||||
|
"TIP": ("💡", "green_bg"),
|
||||||
|
"IMPORTANT": ("☝️", "purple_bg"),
|
||||||
|
"WARNING": ("⚠️", "yellow_bg"),
|
||||||
|
"CAUTION": ("🚨", "red_bg"),
|
||||||
|
}
|
||||||
|
_ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
|
||||||
|
_MENTION_RE = re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_mentions(text: str) -> str:
|
||||||
|
from notion_writer import extract_notion_id, format_id_with_dashes # lazy: breaks import cycle
|
||||||
|
|
||||||
|
def repl(m: "re.Match") -> str:
|
||||||
|
title, target = m.group(1), m.group(2)
|
||||||
|
page_id = extract_notion_id(target)
|
||||||
|
if page_id:
|
||||||
|
return (f'<mention-page url="{format_id_with_dashes(page_id)}">'
|
||||||
|
f'{title}</mention-page>')
|
||||||
|
return f"@{title}"
|
||||||
|
return _MENTION_RE.sub(repl, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(lines: List[str]) -> List[str]:
|
||||||
|
return ["\t" + ln if ln.strip() else ln for ln in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def translate(content: str) -> str:
|
||||||
|
lines = content.split("\n")
|
||||||
|
out: List[str] = []
|
||||||
|
i = 0
|
||||||
|
in_fence = False
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
|
||||||
|
# Fence tracking: pass through unchanged, toggle state
|
||||||
|
if line.strip().startswith("```"):
|
||||||
|
in_fence = not in_fence
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inside fence: pass through verbatim, no transformation
|
||||||
|
if in_fence:
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Callout: > [!TYPE] then contiguous > body lines
|
||||||
|
if line.lstrip().startswith(">"):
|
||||||
|
body_first = line.lstrip()[1:].strip()
|
||||||
|
alert = _ALERT_RE.match(body_first)
|
||||||
|
if alert:
|
||||||
|
emoji, color = CALLOUT_MAP[alert.group(1)]
|
||||||
|
i += 1
|
||||||
|
body: List[str] = []
|
||||||
|
while i < len(lines) and lines[i].lstrip().startswith(">"):
|
||||||
|
body.append(lines[i].lstrip()[1:].lstrip())
|
||||||
|
i += 1
|
||||||
|
out.append(f'<callout icon="{emoji}" color="{color}">')
|
||||||
|
out.extend(_indent([_translate_mentions(b) for b in body]))
|
||||||
|
out.append("</callout>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Columns: ::: columns / ::: column / :::
|
||||||
|
# State machine: "::: column" opens a column; ":::" closes a column
|
||||||
|
# when one is open, otherwise closes the wrapper. The Pandoc layout
|
||||||
|
# emits N "::: column" opens, N ":::" column-closes, then one final
|
||||||
|
# ":::" wrapper-close.
|
||||||
|
if line.strip() == "::: columns":
|
||||||
|
i += 1
|
||||||
|
cols: List[List[str]] = []
|
||||||
|
cur: List[str] = None
|
||||||
|
while i < len(lines):
|
||||||
|
s = lines[i].strip()
|
||||||
|
if s == "::: column":
|
||||||
|
if cur is not None:
|
||||||
|
cols.append(cur)
|
||||||
|
cur = []
|
||||||
|
i += 1
|
||||||
|
elif s == ":::":
|
||||||
|
if cur is not None: # close the open column
|
||||||
|
cols.append(cur)
|
||||||
|
cur = None
|
||||||
|
i += 1
|
||||||
|
else: # close the wrapper
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if cur is not None:
|
||||||
|
cur.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
out.append("<columns>")
|
||||||
|
for col in cols:
|
||||||
|
out.append("\t<column>")
|
||||||
|
out.extend(_indent(_indent(
|
||||||
|
[_translate_mentions(c) for c in col])))
|
||||||
|
out.append("\t</column>")
|
||||||
|
out.append("</columns>")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Toggle: <details><summary>..</summary> body </details>
|
||||||
|
if line.strip() == "<details>":
|
||||||
|
out.append("<details>")
|
||||||
|
i += 1
|
||||||
|
if i < len(lines) and lines[i].lstrip().startswith("<summary>"):
|
||||||
|
out.append(lines[i].strip())
|
||||||
|
i += 1
|
||||||
|
body = []
|
||||||
|
while i < len(lines) and lines[i].strip() != "</details>":
|
||||||
|
body.append(_translate_mentions(lines[i]))
|
||||||
|
i += 1
|
||||||
|
out.extend(_indent(body))
|
||||||
|
out.append("</details>")
|
||||||
|
if i < len(lines): # skip closing </details>
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.append(_translate_mentions(line))
|
||||||
|
i += 1
|
||||||
|
return "\n".join(out)
|
||||||
@@ -17,6 +17,11 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIResponseError
|
from notion_client.errors import APIResponseError
|
||||||
|
|
||||||
import _notion_compat as compat
|
import _notion_compat as compat
|
||||||
|
import ntn_files
|
||||||
|
from ntn_files import NtnUploadError
|
||||||
|
import md_translate
|
||||||
|
|
||||||
|
MARKDOWN_NOTION_VERSION = "2026-03-11"
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv(Path(__file__).parent / '.env')
|
load_dotenv(Path(__file__).parent / '.env')
|
||||||
@@ -56,6 +61,7 @@ def format_id_with_dashes(raw_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
||||||
|
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
||||||
|
|
||||||
|
|
||||||
def _is_table_row(line: str) -> bool:
|
def _is_table_row(line: str) -> bool:
|
||||||
@@ -210,6 +216,13 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
|||||||
blocks.append(create_column_list_block(column_blocks))
|
blocks.append(create_column_list_block(column_blocks))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
image_match = IMAGE_RE.match(line)
|
||||||
|
if image_match:
|
||||||
|
blocks.append(create_image_block(
|
||||||
|
image_match.group(1), image_match.group(2)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
||||||
header_cells = _split_table_row(line)
|
header_cells = _split_table_row(line)
|
||||||
i += 2
|
i += 2
|
||||||
@@ -507,6 +520,55 @@ def create_divider_block() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(alt: str, target: str) -> Dict[str, Any]:
|
||||||
|
"""Image block in external shape. Local targets are converted to
|
||||||
|
file_upload shape later by ntn_files.materialize_local_media."""
|
||||||
|
block: Dict[str, Any] = {
|
||||||
|
"object": "block",
|
||||||
|
"type": "image",
|
||||||
|
"image": {"type": "external", "external": {"url": target}},
|
||||||
|
}
|
||||||
|
if alt:
|
||||||
|
block["image"]["caption"] = parse_rich_text(alt)
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def _content_has_local_images(content: str) -> bool:
|
||||||
|
in_fence = False
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip().startswith('```'):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue # fence delimiter lines are never image lines
|
||||||
|
if in_fence:
|
||||||
|
continue
|
||||||
|
m = IMAGE_RE.match(line)
|
||||||
|
if m and not m.group(2).startswith(('http://', 'https://')):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_local_images(content: str):
|
||||||
|
"""Remove standalone LOCAL image lines; keep remote ones inline.
|
||||||
|
Returns (content_without_local_images, [(alt, target), ...]).
|
||||||
|
Lines inside fenced code blocks are passed through unchanged."""
|
||||||
|
kept, imgs = [], []
|
||||||
|
in_fence = False
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip().startswith('```'):
|
||||||
|
in_fence = not in_fence
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
if in_fence:
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
m = IMAGE_RE.match(line)
|
||||||
|
if m and not m.group(2).startswith(('http://', 'https://')):
|
||||||
|
imgs.append((m.group(1), m.group(2)))
|
||||||
|
continue
|
||||||
|
kept.append(line)
|
||||||
|
return "\n".join(kept), imgs
|
||||||
|
|
||||||
|
|
||||||
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]:
|
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]:
|
||||||
"""Build a Notion `table` block with header + body rows.
|
"""Build a Notion `table` block with header + body rows.
|
||||||
|
|
||||||
@@ -746,21 +808,6 @@ def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
|
|
||||||
"""Write markdown content to a Notion page."""
|
|
||||||
blocks = markdown_to_notion_blocks(markdown_content)
|
|
||||||
|
|
||||||
if not blocks:
|
|
||||||
print("No content to write")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if mode == 'replace':
|
|
||||||
if not clear_page_content(notion, page_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return append_to_page(notion, page_id, blocks)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Push markdown content to Notion pages or databases',
|
description='Push markdown content to Notion pages or databases',
|
||||||
@@ -796,9 +843,28 @@ Examples:
|
|||||||
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
||||||
help='List accessible pages and/or databases (default: all)')
|
help='List accessible pages and/or databases (default: all)')
|
||||||
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
||||||
|
parser.add_argument('--engine', choices=['blocks', 'markdown'],
|
||||||
|
default='blocks',
|
||||||
|
help='Write engine: blocks (default) or markdown')
|
||||||
|
parser.add_argument('--notion-version', dest='notion_version',
|
||||||
|
help='Override Notion API version')
|
||||||
|
parser.add_argument('--allow-deleting-content', dest='allow_deleting',
|
||||||
|
action='store_true',
|
||||||
|
help='Markdown replace may delete child pages/dbs')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_main_body(parser, args)
|
||||||
|
except NtnUploadError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
if e.stderr:
|
||||||
|
print(e.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _main_body(parser, args):
|
||||||
|
"""Body of main() after argparse; separated so NtnUploadError can be caught cleanly."""
|
||||||
if not NOTION_TOKEN:
|
if not NOTION_TOKEN:
|
||||||
print("Error: NOTION_API_KEY not set in environment.")
|
print("Error: NOTION_API_KEY not set in environment.")
|
||||||
print("Preferred: fetch from 1Password at runtime —")
|
print("Preferred: fetch from 1Password at runtime —")
|
||||||
@@ -885,23 +951,64 @@ Examples:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
content = file_path.read_text(encoding='utf-8')
|
content = file_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
base_dir = Path(args.file).parent if args.file else Path.cwd()
|
||||||
|
|
||||||
|
def _md_client():
|
||||||
|
version = args.notion_version or MARKDOWN_NOTION_VERSION
|
||||||
|
return compat.make_client(NOTION_TOKEN, notion_version=version)
|
||||||
|
|
||||||
|
def _upload_blocks_for(images):
|
||||||
|
"""Build + materialize image blocks for two-phase markdown writes."""
|
||||||
|
blocks = [create_image_block(alt, target) for alt, target in images]
|
||||||
|
return ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
||||||
|
|
||||||
# Write to page
|
# Write to page
|
||||||
if args.page:
|
if args.page:
|
||||||
if not content:
|
if not content:
|
||||||
print("Error: No content provided. Use --file or --stdin")
|
print("Error: No content provided. Use --file or --stdin")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
page_id = extract_notion_id(args.page)
|
page_id = extract_notion_id(args.page)
|
||||||
if not page_id:
|
if not page_id:
|
||||||
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
mode = 'replace' if args.replace else 'append'
|
|
||||||
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
|
||||||
|
|
||||||
if write_to_page(notion, page_id, content, mode):
|
|
||||||
print(f"✅ Successfully wrote content to page")
|
|
||||||
formatted_id = format_id_with_dashes(page_id)
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
|
||||||
|
if args.engine == 'markdown':
|
||||||
|
if _content_has_local_images(content):
|
||||||
|
ntn_files.preflight()
|
||||||
|
md_body, local_imgs = extract_local_images(content)
|
||||||
|
md_body = md_translate.translate(md_body)
|
||||||
|
mc = _md_client()
|
||||||
|
try:
|
||||||
|
if args.replace:
|
||||||
|
compat.replace_markdown(mc, formatted_id, md_body,
|
||||||
|
allow_deleting=args.allow_deleting)
|
||||||
|
else:
|
||||||
|
compat.append_markdown(mc, formatted_id, md_body)
|
||||||
|
except APIResponseError as exc:
|
||||||
|
print(f"Error: {compat.explain_api_error(exc, formatted_id)}")
|
||||||
|
sys.exit(1)
|
||||||
|
if local_imgs and not append_to_page(notion, page_id, _upload_blocks_for(local_imgs)):
|
||||||
|
print("❌ Text was written but image append failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Successfully wrote content to page (markdown engine)")
|
||||||
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# blocks engine (default)
|
||||||
|
blocks = markdown_to_notion_blocks(content)
|
||||||
|
if _content_has_local_images(content):
|
||||||
|
ntn_files.preflight()
|
||||||
|
blocks = ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
||||||
|
if not blocks:
|
||||||
|
print("No content to write")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"{'Replacing' if args.replace else 'Appending'} content to page...")
|
||||||
|
if args.replace and not clear_page_content(notion, page_id):
|
||||||
|
print("❌ Failed to write content")
|
||||||
|
sys.exit(1)
|
||||||
|
if append_to_page(notion, page_id, blocks):
|
||||||
|
print("✅ Successfully wrote content to page")
|
||||||
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
else:
|
else:
|
||||||
print("❌ Failed to write content")
|
print("❌ Failed to write content")
|
||||||
@@ -960,6 +1067,8 @@ Examples:
|
|||||||
|
|
||||||
content_blocks = markdown_to_notion_blocks(content) if content else None
|
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||||
|
|
||||||
|
existing = None
|
||||||
|
|
||||||
# Upsert path: look for existing row by the named property
|
# Upsert path: look for existing row by the named property
|
||||||
if args.upsert_by:
|
if args.upsert_by:
|
||||||
if args.upsert_by not in schema_props:
|
if args.upsert_by not in schema_props:
|
||||||
@@ -980,6 +1089,39 @@ Examples:
|
|||||||
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.engine == 'markdown':
|
||||||
|
md_content = content or ""
|
||||||
|
if _content_has_local_images(md_content):
|
||||||
|
ntn_files.preflight()
|
||||||
|
md_body, local_imgs = extract_local_images(md_content)
|
||||||
|
md_body = md_translate.translate(md_body)
|
||||||
|
mc = _md_client()
|
||||||
|
try:
|
||||||
|
if args.upsert_by and existing:
|
||||||
|
compat.replace_markdown(mc, existing['id'], md_body,
|
||||||
|
allow_deleting=args.allow_deleting)
|
||||||
|
if not update_page_properties(notion, existing['id'], properties):
|
||||||
|
sys.exit(1)
|
||||||
|
new_id = existing['id']
|
||||||
|
else:
|
||||||
|
parent = compat.build_data_source_parent(data_source_id)
|
||||||
|
result = compat.create_page_markdown(mc, parent, properties, md_body)
|
||||||
|
new_id = result['id']
|
||||||
|
except APIResponseError as exc:
|
||||||
|
print(f"Error: {compat.explain_api_error(exc)}")
|
||||||
|
sys.exit(1)
|
||||||
|
if local_imgs and not append_to_page(notion, new_id, _upload_blocks_for(local_imgs)):
|
||||||
|
print("❌ Text was written but image append failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Successfully wrote database row (markdown engine)")
|
||||||
|
print(f" https://notion.so/{new_id.replace('-', '')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if content_blocks and _content_has_local_images(content or ""):
|
||||||
|
ntn_files.preflight()
|
||||||
|
content_blocks = ntn_files.materialize_local_media(
|
||||||
|
content_blocks, base_dir, ntn_files.upload)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
page_id = existing['id']
|
page_id = existing['id']
|
||||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||||
|
|||||||
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local file uploads to Notion via the `ntn` CLI.
|
||||||
|
|
||||||
|
Owns all subprocess I/O for the skill. The CLI handles the full File Upload
|
||||||
|
lifecycle (create -> send bytes -> complete) and prints the upload ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Callable, Any
|
||||||
|
|
||||||
|
_WORKSPACE: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ntn_env():
|
||||||
|
"""Env for ntn subprocesses: force ntn to authenticate as the SAME
|
||||||
|
integration the script uses (NOTION_API_KEY/NOTION_TOKEN), so an uploaded
|
||||||
|
file and the page that references it share one identity. Notion scopes
|
||||||
|
file uploads to the creating integration, so a mismatch makes the upload
|
||||||
|
un-attachable."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
token = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_TOKEN")
|
||||||
|
if token:
|
||||||
|
env["NOTION_API_TOKEN"] = token
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
class NtnUploadError(Exception):
|
||||||
|
"""Raised when `ntn` is unavailable or a file upload fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, path: str = "", stderr: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.path = path
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def preflight() -> Dict[str, str]:
|
||||||
|
"""Verify `ntn` is installed and logged in; return its target workspace.
|
||||||
|
|
||||||
|
Cached for the process. Raises NtnUploadError with an actionable hint
|
||||||
|
on failure. Prints one informational line naming the workspace `ntn`
|
||||||
|
targets (file uploads are workspace-scoped).
|
||||||
|
"""
|
||||||
|
global _WORKSPACE
|
||||||
|
if _WORKSPACE is not None:
|
||||||
|
return _WORKSPACE
|
||||||
|
|
||||||
|
if shutil.which("ntn") is None:
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn CLI not found. Install it with: "
|
||||||
|
"curl -fsSL https://ntn.dev | bash"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["ntn", "api", "v1/users/me"],
|
||||||
|
capture_output=True, text=True, env=_ntn_env(),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn is not logged in. Run: ntn login\n" + result.stderr.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
me = json.loads(result.stdout)
|
||||||
|
bot = me.get("bot", {})
|
||||||
|
info = {
|
||||||
|
"workspace_name": bot.get("workspace_name", "unknown"),
|
||||||
|
"workspace_id": bot.get("workspace_id", "unknown"),
|
||||||
|
}
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
info = {"workspace_name": "unknown", "workspace_id": "unknown"}
|
||||||
|
|
||||||
|
print(f'ntn -> workspace "{info["workspace_name"]}"', file=sys.stderr)
|
||||||
|
_WORKSPACE = info
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def upload(path: Path) -> str:
|
||||||
|
"""Upload a local file via `ntn files create --plain`; return its id."""
|
||||||
|
path = Path(path)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ntn", "files", "create", "--plain"],
|
||||||
|
stdin=fh, capture_output=True, text=True, env=_ntn_env(),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise NtnUploadError(
|
||||||
|
f"ntn upload failed for {path}", path=str(path),
|
||||||
|
stderr=result.stderr.strip(),
|
||||||
|
)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn returned no output", path=str(path), stderr=result.stderr.strip()
|
||||||
|
)
|
||||||
|
first_line = result.stdout.strip().splitlines()[0]
|
||||||
|
upload_id = first_line.split("\t")[0].strip()
|
||||||
|
return upload_id
|
||||||
|
|
||||||
|
|
||||||
|
_REMOTE_PREFIXES = ("http://", "https://")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_local(url: str, base_dir: Path) -> Optional[Path]:
|
||||||
|
"""Return the resolved path for a local media url, or None if remote."""
|
||||||
|
if url.startswith(_REMOTE_PREFIXES):
|
||||||
|
return None
|
||||||
|
p = Path(url)
|
||||||
|
return p if p.is_absolute() else Path(base_dir) / p
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_local_media(
|
||||||
|
blocks: List[Dict[str, Any]],
|
||||||
|
base_dir: Path,
|
||||||
|
upload_fn: Callable[[Path], str] = upload,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Walk blocks (and nested children); upload local image files and
|
||||||
|
rewrite them to file_upload shape. Remote images are left as-is."""
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("type") == "image":
|
||||||
|
img = block["image"]
|
||||||
|
if img.get("type") == "external":
|
||||||
|
url = img.get("external", {}).get("url", "")
|
||||||
|
resolved = _resolve_local(url, base_dir)
|
||||||
|
if resolved is not None:
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise NtnUploadError(
|
||||||
|
f"image references missing file: {url}",
|
||||||
|
path=str(resolved),
|
||||||
|
)
|
||||||
|
upload_id = upload_fn(resolved)
|
||||||
|
caption = img.get("caption")
|
||||||
|
new_img: Dict[str, Any] = {
|
||||||
|
"type": "file_upload",
|
||||||
|
"file_upload": {"id": upload_id},
|
||||||
|
}
|
||||||
|
if caption:
|
||||||
|
new_img["caption"] = caption
|
||||||
|
block["image"] = new_img
|
||||||
|
# Recurse into any nested children list.
|
||||||
|
btype = block.get("type")
|
||||||
|
nested = block.get(btype, {}) if isinstance(block.get(btype), dict) else {}
|
||||||
|
if isinstance(nested.get("children"), list):
|
||||||
|
materialize_local_media(nested["children"], base_dir, upload_fn)
|
||||||
|
if btype == "column_list":
|
||||||
|
for col in nested.get("children", []):
|
||||||
|
col_children = col.get("column", {}).get("children", [])
|
||||||
|
materialize_local_media(col_children, base_dir, upload_fn)
|
||||||
|
return blocks
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for markdown transport helpers — run with `python test_engine_routing.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import _notion_compat as compat
|
||||||
|
import notion_writer
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_client():
|
||||||
|
c = mock.MagicMock()
|
||||||
|
c.request.return_value = {"object": "page", "id": "p1"}
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_page_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
parent = {"type": "data_source_id", "data_source_id": "ds1"}
|
||||||
|
compat.create_page_markdown(c, parent, {"Name": {"title": []}}, "# Hi")
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages"
|
||||||
|
assert kwargs["method"] == "POST"
|
||||||
|
assert kwargs["body"]["markdown"] == "# Hi"
|
||||||
|
assert kwargs["body"]["parent"] == parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
compat.append_markdown(c, "page-123", "## More")
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages/page-123/markdown"
|
||||||
|
assert kwargs["method"] == "PATCH"
|
||||||
|
assert kwargs["body"]["type"] == "insert_content"
|
||||||
|
assert kwargs["body"]["insert_content"]["content"] == "## More"
|
||||||
|
assert kwargs["body"]["insert_content"]["position"] == {"type": "end"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
compat.replace_markdown(c, "page-123", "# Fresh", allow_deleting=True)
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages/page-123/markdown"
|
||||||
|
assert kwargs["body"]["type"] == "replace_content"
|
||||||
|
assert kwargs["body"]["replace_content"]["new_str"] == "# Fresh"
|
||||||
|
assert kwargs["body"]["replace_content"]["allow_deleting_content"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_local_images_splits():
|
||||||
|
content = ("# Title\n\n\n\n"
|
||||||
|
"\n\nbody")
|
||||||
|
without, imgs = notion_writer.extract_local_images(content)
|
||||||
|
assert imgs == [("local", "./b.png")]
|
||||||
|
assert "" not in without
|
||||||
|
assert "" in without # remote stays
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_has_local_images():
|
||||||
|
assert notion_writer._content_has_local_images("") is True
|
||||||
|
assert notion_writer._content_has_local_images("") is False
|
||||||
|
assert notion_writer._content_has_local_images("no images") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_image_inside_fence_ignored():
|
||||||
|
"""A local image reference inside a fenced code block must NOT be detected
|
||||||
|
as a real image by _content_has_local_images or extracted by extract_local_images."""
|
||||||
|
fenced = "```markdown\n\n```"
|
||||||
|
assert notion_writer._content_has_local_images(fenced) is False, \
|
||||||
|
"_content_has_local_images should return False for image inside fence"
|
||||||
|
without, imgs = notion_writer.extract_local_images(fenced)
|
||||||
|
assert len(imgs) == 0, "extract_local_images should not extract image inside fence"
|
||||||
|
# The fenced lines (including the image line) should be preserved verbatim
|
||||||
|
assert "" in without, "fence content must be preserved in output"
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_create_page_markdown,
|
||||||
|
test_append_markdown,
|
||||||
|
test_replace_markdown,
|
||||||
|
test_extract_local_images_splits,
|
||||||
|
test_content_has_local_images,
|
||||||
|
test_local_image_inside_fence_ignored,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for md_translate.py — run with `python test_md_translate.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import md_translate
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_basics():
|
||||||
|
src = "# Heading\n\n- item\n\n```python\nx=1\n```"
|
||||||
|
assert md_translate.translate(src) == src
|
||||||
|
|
||||||
|
|
||||||
|
def test_callout_note():
|
||||||
|
out = md_translate.translate("> [!NOTE]\n> Be careful")
|
||||||
|
assert '<callout icon="ℹ️" color="blue_bg">' in out
|
||||||
|
assert "\tBe careful" in out
|
||||||
|
assert "</callout>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_callout_warning_color():
|
||||||
|
out = md_translate.translate("> [!WARNING]\n> Danger")
|
||||||
|
assert 'color="yellow_bg"' in out
|
||||||
|
assert 'icon="⚠️"' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns():
|
||||||
|
src = "::: columns\n::: column\nLeft\n:::\n::: column\nRight\n:::\n:::"
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
assert "<columns>" in out
|
||||||
|
assert out.count("<column>") == 2
|
||||||
|
assert "</columns>" in out
|
||||||
|
assert "\tLeft" in out or "\t\tLeft" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_children_indented():
|
||||||
|
src = "<details>\n<summary>More</summary>\nBody line\n</details>"
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
assert "<summary>More</summary>" in out
|
||||||
|
assert "\tBody line" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_url():
|
||||||
|
out = md_translate.translate(
|
||||||
|
"See @[ADR](https://notion.so/X-abcdef0123456789abcdef0123456789).")
|
||||||
|
assert "<mention-page url=" in out
|
||||||
|
assert ">ADR</mention-page>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_invalid_plain():
|
||||||
|
out = md_translate.translate("ping @[Bob](not-an-id)")
|
||||||
|
assert "@Bob" in out
|
||||||
|
assert "mention-page" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_fence_passthrough_no_transform():
|
||||||
|
"""Lines inside fenced code blocks must pass through verbatim — no callout,
|
||||||
|
columns, or mention transformation applied."""
|
||||||
|
raw_id = "abcdef0123456789abcdef0123456789"
|
||||||
|
src = (
|
||||||
|
"```\n"
|
||||||
|
"> [!NOTE]\n"
|
||||||
|
"::: columns\n"
|
||||||
|
f"@[Page]({raw_id})\n"
|
||||||
|
"```"
|
||||||
|
)
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
# No transformation should have occurred
|
||||||
|
assert "<callout" not in out, "callout must not be emitted inside fence"
|
||||||
|
assert "<columns>" not in out, "columns must not be emitted inside fence"
|
||||||
|
assert "<mention-page" not in out, "mention must not be emitted inside fence"
|
||||||
|
# Original lines must be present verbatim
|
||||||
|
assert "> [!NOTE]" in out
|
||||||
|
assert "::: columns" in out
|
||||||
|
assert f"@[Page]({raw_id})" in out
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_passthrough_basics,
|
||||||
|
test_callout_note,
|
||||||
|
test_callout_warning_color,
|
||||||
|
test_columns,
|
||||||
|
test_toggle_children_indented,
|
||||||
|
test_mention_url,
|
||||||
|
test_mention_invalid_plain,
|
||||||
|
test_fence_passthrough_no_transform,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for ntn_files.py — run with `python test_ntn_files.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import ntn_files
|
||||||
|
from ntn_files import NtnUploadError
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_cache():
|
||||||
|
ntn_files._WORKSPACE = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_missing_ntn():
|
||||||
|
_reset_cache()
|
||||||
|
with mock.patch("shutil.which", return_value=None):
|
||||||
|
try:
|
||||||
|
ntn_files.preflight()
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert "ntn" in str(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_returns_workspace():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout='{"bot":{"workspace_name":"D.Intelligence",'
|
||||||
|
'"workspace_id":"ws-123"}}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
with mock.patch("shutil.which", return_value="/usr/bin/ntn"), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
info = ntn_files.preflight()
|
||||||
|
assert info["workspace_name"] == "D.Intelligence"
|
||||||
|
assert info["workspace_id"] == "ws-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_returns_id():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout="43833259-72ae-404e-8441-b6577f3159b4\tphoto.png\tuploaded\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
# upload() opens the file before subprocess.run; mock open so the file
|
||||||
|
# need not exist (subprocess.run is mocked and never reads the handle).
|
||||||
|
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
upload_id = ntn_files.upload(Path("/tmp/photo.png"))
|
||||||
|
assert upload_id == "43833259-72ae-404e-8441-b6577f3159b4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_failure_raises():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="boom: invalid file",
|
||||||
|
)
|
||||||
|
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
try:
|
||||||
|
ntn_files.upload(Path("/tmp/bad.png"))
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert e.path == "/tmp/bad.png"
|
||||||
|
assert "boom" in e.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _img(url):
|
||||||
|
return {"object": "block", "type": "image",
|
||||||
|
"image": {"type": "external", "external": {"url": url}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_remote_untouched():
|
||||||
|
blocks = [_img("https://ex.com/a.png")]
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
blocks, Path("/tmp"), upload_fn=lambda p: "SHOULD_NOT_RUN")
|
||||||
|
assert out[0]["image"]["type"] == "external"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_local_uploaded(tmp_path=None):
|
||||||
|
import tempfile, os
|
||||||
|
d = Path(tempfile.mkdtemp())
|
||||||
|
(d / "x.png").write_bytes(b"fake")
|
||||||
|
blocks = [_img("x.png")]
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
blocks, d, upload_fn=lambda p: "UP123")
|
||||||
|
assert out[0]["image"]["type"] == "file_upload"
|
||||||
|
assert out[0]["image"]["file_upload"]["id"] == "UP123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_nested_children():
|
||||||
|
import tempfile
|
||||||
|
d = Path(tempfile.mkdtemp())
|
||||||
|
(d / "y.png").write_bytes(b"fake")
|
||||||
|
toggle = {"object": "block", "type": "toggle",
|
||||||
|
"toggle": {"rich_text": [], "children": [_img("y.png")]}}
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
[toggle], d, upload_fn=lambda p: "UPNESTED")
|
||||||
|
child = out[0]["toggle"]["children"][0]
|
||||||
|
assert child["image"]["file_upload"]["id"] == "UPNESTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_missing_file_raises():
|
||||||
|
blocks = [_img("nope.png")]
|
||||||
|
try:
|
||||||
|
ntn_files.materialize_local_media(
|
||||||
|
blocks, Path("/tmp"), upload_fn=lambda p: "x")
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert "nope.png" in str(e) or "nope.png" in e.path
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_passes_token_env():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(args=[], returncode=0,
|
||||||
|
stdout="ID123\tphoto.png\tuploaded\n", stderr="")
|
||||||
|
with mock.patch.dict("os.environ", {"NOTION_API_KEY": "tok-abc"}, clear=False), \
|
||||||
|
mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake) as m:
|
||||||
|
ntn_files.upload(Path("/tmp/p.png"))
|
||||||
|
passed_env = m.call_args.kwargs["env"]
|
||||||
|
assert passed_env["NOTION_API_TOKEN"] == "tok-abc"
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_preflight_missing_ntn,
|
||||||
|
test_preflight_returns_workspace,
|
||||||
|
test_upload_returns_id,
|
||||||
|
test_upload_failure_raises,
|
||||||
|
test_materialize_remote_untouched,
|
||||||
|
test_materialize_local_uploaded,
|
||||||
|
test_materialize_nested_children,
|
||||||
|
test_materialize_missing_file_raises,
|
||||||
|
test_upload_passes_token_env,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
@@ -413,6 +413,28 @@ def test_no_literal_markers_leak():
|
|||||||
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_remote_external():
|
||||||
|
blocks = markdown_to_notion_blocks("")
|
||||||
|
assert len(blocks) == 1
|
||||||
|
b = blocks[0]
|
||||||
|
assert b["type"] == "image"
|
||||||
|
assert b["image"]["type"] == "external"
|
||||||
|
assert b["image"]["external"]["url"] == "https://ex.com/c.png"
|
||||||
|
assert b["image"]["caption"][0]["text"]["content"] == "a chart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_local_external_shape_preupload():
|
||||||
|
blocks = markdown_to_notion_blocks("")
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0]["image"]["external"]["url"] == "./pics/x.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_only_when_standalone():
|
||||||
|
# An inline bang-bracket inside prose is NOT an image block.
|
||||||
|
blocks = markdown_to_notion_blocks("see  inline")
|
||||||
|
assert blocks[0]["type"] == "paragraph"
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
def run_all():
|
||||||
tests = [
|
tests = [
|
||||||
test_rich_text_plain,
|
test_rich_text_plain,
|
||||||
@@ -447,6 +469,9 @@ def run_all():
|
|||||||
test_rich_text_relative_link_becomes_plain,
|
test_rich_text_relative_link_becomes_plain,
|
||||||
test_rich_text_absolute_link_preserved,
|
test_rich_text_absolute_link_preserved,
|
||||||
test_no_literal_markers_leak,
|
test_no_literal_markers_leak,
|
||||||
|
test_image_remote_external,
|
||||||
|
test_image_local_external_shape_preupload,
|
||||||
|
test_image_only_when_standalone,
|
||||||
]
|
]
|
||||||
for t in tests:
|
for t in tests:
|
||||||
print(f"\n{t.__name__}")
|
print(f"\n{t.__name__}")
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv`
|
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
|
||||||
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
|
||||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts
|
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
142
custom-skills/35-seo-signal-validation/DESIGN.md
Normal file
142
custom-skills/35-seo-signal-validation/DESIGN.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Design Spec — `35-seo-signal-validation`
|
||||||
|
|
||||||
|
- **Status:** Draft for review
|
||||||
|
- **Date:** 2026-06-26
|
||||||
|
- **Author:** Andrew Yim (andrew.yim@ourdigital.org) + Claude Code
|
||||||
|
- **Genesis:** JHR josunhotel.com — SEMrush reported an organic "surge" attributed to "호텔" 16→3. Cross-checking GSC/GA4/live-SERP proved it a modeling artifact (real position ~12, ~5 clicks/mo; growth was all brand/seasonal). See workspace memory `feedback-semrush-serp-signal-validation`.
|
||||||
|
- **Related skills:** delegates to `20-seo-serp-analysis`, `21-seo-position-tracking`, `28-seo-knowledge-graph`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Given a `(term/intent, entity)` pair — and optionally a *claim* (a third-party tool's reported movement) or a *baseline* (a prior state to compare against) — return an **evidence-backed verdict** on whether SERP and Knowledge-Graph impact is **real**, **misattributed**, an **artifact**, or **unprovable with available data**.
|
||||||
|
|
||||||
|
The skill exists because OurDigital/clients repeatedly face *modeled* third-party signals (SEMrush/Ahrefs estimated organic traffic, position snapshots) that are easy to over-trust. This skill makes the validation cascade — measured → live → entity → attribution — a single repeatable procedure that ends in a defensible verdict and a client-safe narrative.
|
||||||
|
|
||||||
|
It generalizes the genesis case to **any term/intent and any entity** (a brand, a company, or a person), and to two additional jobs beyond refuting external claims: proving our own work's impact, and standalone "where do we really stand" checks.
|
||||||
|
|
||||||
|
## 2. Boundary — how this differs from neighbors
|
||||||
|
|
||||||
|
| Skill | Owns | This skill instead |
|
||||||
|
|---|---|---|
|
||||||
|
| `20-seo-serp-analysis` | What the SERP *looks like* (features, competitor positions, intent) | …calls it for the live-SERP layer |
|
||||||
|
| `21-seo-position-tracking` | Rank *over time*, change detection, visibility | …calls it for GSC-as-ground-truth |
|
||||||
|
| `28-seo-knowledge-graph` | Entity presence audit (KG panel, Wikidata, Naver) | …calls it for the entity layer |
|
||||||
|
|
||||||
|
None of the three **adjudicates the truth of a claimed cross-layer movement**. This skill is the *conductor*: signal/claim in → verdict + evidence ledger out. It duplicates none of their measurement logic; it sequences and synthesizes them.
|
||||||
|
|
||||||
|
## 3. Engine — the validation loop
|
||||||
|
|
||||||
|
A **cost-ordered evidence cascade** that short-circuits when a cheap layer is already decisive (this is exactly how the JHR "호텔" claim was refuted before any expensive step). The "loop" is the cascade, not a scheduler.
|
||||||
|
|
||||||
|
### 3.0 Pre-step — classify the entity (gates which layers are available)
|
||||||
|
|
||||||
|
- **First-party entity** — a site/property the user owns or has GSC/GA4 access to (e.g., JHR `sc-domain:josunhotel.com`, GA4 `258308769`). → **L1 measured ground truth available.**
|
||||||
|
- **Third-party entity** — a competitor brand or a person the user does NOT control. → **L1 unavailable**; rely on L2 + L3 + clearly-tiered third-party estimates; cap confidence lower and prefer INCONCLUSIVE over guessing.
|
||||||
|
|
||||||
|
The skill detects this from whether a verified GSC property / GA4 property is supplied or resolvable; if ambiguous, ask once.
|
||||||
|
|
||||||
|
### 3.1 L1 — Measured (first-party, native history) → delegates to `21-seo-position-tracking`
|
||||||
|
|
||||||
|
- **GSC** via `mcp__dda__gsc_fetch_performance`:
|
||||||
|
- term query-level (exact match) AND site-wide, **recent vs prior** window.
|
||||||
|
- report real avg position, clicks, impressions, CTR; **day-normalize** (compare periods often differ in calendar-day count).
|
||||||
|
- note **~43% query-level anonymization** — query-sum ≠ aggregate; never treat the disclosed subset as the whole.
|
||||||
|
- **query-clicks delta** (recent − prior) to name which terms actually moved (brand/seasonal vs the claimed term).
|
||||||
|
- **GA4** via `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend (dimensions `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`); GA4 captures **all engines incl. Naver**, so use it to test whether a "surge" exceeds normal month-to-month variance.
|
||||||
|
- **Short-circuit:** if the claimed keyword has trivial clicks and a real position nowhere near the claim → **ARTIFACT**, stop (skip L2/L3 unless caller wants the full picture).
|
||||||
|
|
||||||
|
### 3.2 L2 — Live SERP (third-party measured, point-in-time) → delegates to `20-seo-serp-analysis`
|
||||||
|
|
||||||
|
- **Live geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`/`computer`): force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts** (privacy). Confirm whether the domain actually holds the claimed position; capture the feature landscape (ads, local map-pack, PAA, knowledge panel, image/video) that explains *why* a brand site can't hold a head term.
|
||||||
|
- **Cheap rank spot-check** via `mcp__ourseo__check_serp(keyword, domain)` when a full render is unnecessary.
|
||||||
|
- **[KR market]** Naver SERP composition via `our research naver serp` (blog/cafe/지식iN/Smart Store/brand zone) — required for Korean entities since Semrush/Ahrefs don't model Naver.
|
||||||
|
|
||||||
|
### 3.3 L3 — Entity / Knowledge Graph (the differentiator) → delegates to `28-seo-knowledge-graph`
|
||||||
|
|
||||||
|
A real impact event should leave corroborating traces in the **entity layer**, not just a rank number. Five checks:
|
||||||
|
|
||||||
|
1. **Google KG API** entity match + `resultScore` — `mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
||||||
|
2. **Wikidata** QID presence + key claims — **verify the QID against `Special:EntityData/{Q}.json` labels** before trusting it (bakes in the JHR false-match guard: Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
||||||
|
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
||||||
|
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
||||||
|
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
||||||
|
|
||||||
|
`mcp__ourseo__monitor_brand` supplements with brand-mention/brand-SERP ownership signal.
|
||||||
|
|
||||||
|
### 3.4 L4 — Attribution synthesis → verdict
|
||||||
|
|
||||||
|
Cross-check: does the **measured delta (L1)** corroborate the **live reality (L2)**, and does the **entity layer (L3)** show consistent movement? The query-clicks delta names the true drivers. Output a verdict (§5) with an evidence ledger.
|
||||||
|
|
||||||
|
## 4. Entry modes (thin wrappers over the engine)
|
||||||
|
|
||||||
|
| Mode | Input contract | Engine use |
|
||||||
|
|---|---|---|
|
||||||
|
| **`adjudicate(claim)`** | `{term, entity, claim:{source, metric, from→to}}` e.g. `SEMrush: 호텔 pos 16→3, organic surge` | Full cascade; verdict confirms/refutes the claim |
|
||||||
|
| **`prove(baseline)`** | `{term, entity, change:{what, when}}` | Measured before/after from GSC/GA4 history; entity baseline = most recent existing Notion KG-audit archive — **if none exists, report current entity state only and mark the entity-layer delta INCONCLUSIVE** (the change pre-dates any captured baseline); live captured now |
|
||||||
|
| **`snapshot()`** | `{term, entity}` | Cascade with no claim; "where do we really stand" across all four layers |
|
||||||
|
|
||||||
|
All three call the *same* engine; they differ only in what they compare against.
|
||||||
|
|
||||||
|
## 5. Verdict logic
|
||||||
|
|
||||||
|
| Verdict | Condition |
|
||||||
|
|---|---|
|
||||||
|
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
||||||
|
| **PARTIAL** | Real movement, but misattributed (e.g., growth is brand/seasonal, not the claimed head term) or only some layers agree |
|
||||||
|
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it (the JHR 호텔 case) |
|
||||||
|
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — names exactly what's missing + how to resolve |
|
||||||
|
|
||||||
|
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic claims — at most PARTIAL, and ARTIFACT only when live+entity reality clearly contradicts the claim.
|
||||||
|
|
||||||
|
**Standing skepticism rules** (baked in from `feedback-semrush-serp-signal-validation`):
|
||||||
|
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
||||||
|
- **Head-term over-fire**: one high-volume keyword caught at an estimated high rank inflates the whole modeled number.
|
||||||
|
- **KR Naver blind spot**: Semrush models Google only; misses a large share of Korean organic.
|
||||||
|
- **Single-geo/device snapshot** diverges from GSC's national average.
|
||||||
|
- **Data-trust hierarchy**: 1st-party measured (GA4/GSC) > 3rd-party measured (backlinks, crawled rank) > 3rd-party modeled (estimated traffic).
|
||||||
|
|
||||||
|
### Output of the verdict
|
||||||
|
- **Evidence ledger** — per layer: finding + its data-trust tier + whether it corroborates or contradicts the claim.
|
||||||
|
- **Client-safe narrative** — the defensible story (e.g., "summer brand/long-tail demand lifted impressions +18%, clicks modest" — NOT "ranked #3 for 호텔").
|
||||||
|
|
||||||
|
## 6. Output
|
||||||
|
|
||||||
|
- **Always:** inline structured report (verdict + ledger + narrative + "what would raise confidence").
|
||||||
|
- **Optional:** archive to Notion **Working with AI DB** (`data_source_id f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (per global policy — never Notion MCP write tools). Properties follow the DB schema (Type=Memo/Research, Account Code, Topic=SEO, etc.).
|
||||||
|
- **Optional:** if the run surfaces a new *generalizable* gotcha, append a memory entry to the active workspace's memory dir.
|
||||||
|
|
||||||
|
## 7. Repo layout & conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
35-seo-signal-validation/
|
||||||
|
SKILL.md self-contained: classification, 4-layer cascade,
|
||||||
|
5 KG checks, 4-way verdict, skepticism rules, output
|
||||||
|
DESIGN.md PLAN.md spec + plan (live with the skill; no new top-level dir)
|
||||||
|
code/
|
||||||
|
CLAUDE.md code-environment notes (env, export→script flow)
|
||||||
|
scripts/
|
||||||
|
gsc_signal_delta.py deterministic L1/L4 GSC delta + mover ranking
|
||||||
|
test_gsc_signal_delta.py
|
||||||
|
requirements.txt (stdlib only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Target environment: Claude Code only (no desktop/ variant — matches precedent 95/96). Registered in .claude-plugin/marketplace.json under ourdigital-seo.
|
||||||
|
|
||||||
|
**Triggers:** `validate serp signal`, `is this ranking real`, `prove SEO impact`, `SEMrush surge real?`, `signal validation`, `신호 검증`, `순위 변화 진짜?`, `오가닉 급증 검증`.
|
||||||
|
|
||||||
|
**Conventions honored:** no new output directories beyond this approved folder; Notion writes via notion-writer script only; never crawl/audit Marriott for JHR (sameAs reference only); KR deliverables in Korean, English internal notes OK.
|
||||||
|
|
||||||
|
## 8. Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- No cron/scheduler and no snapshot DB (stateless, on-demand). A snapshot store + watchlist monitor is a documented **future option**, built only if proven needed.
|
||||||
|
- Does **not** replace the three instrument skills — it sequences them.
|
||||||
|
- Does **not** fabricate a verdict when data is thin — returns INCONCLUSIVE with a remediation list.
|
||||||
|
- Not a general SEO audit; scoped to validating a specific `(term, entity)` impact question.
|
||||||
|
|
||||||
|
## 9. Future options (explicitly out of v1)
|
||||||
|
|
||||||
|
- Lightweight snapshot store in the existing `dda` SQLite workspace to enable true over-time entity-layer deltas.
|
||||||
|
- Optional scheduled monitor over a `(term, entity)` watchlist that flags anomalies for `adjudicate`.
|
||||||
|
- Multi-engine claim intake (parse a pasted SEMrush/Ahrefs export directly).
|
||||||
729
custom-skills/35-seo-signal-validation/PLAN.md
Normal file
729
custom-skills/35-seo-signal-validation/PLAN.md
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
# SEO Signal Validation — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the `35-seo-signal-validation` Claude Skill — a conductor that adjudicates whether a claimed SERP / Knowledge-Graph movement for a `(term, entity)` pair is real, misattributed, an artifact, or unprovable.
|
||||||
|
|
||||||
|
**Architecture:** A self-contained `SKILL.md` carries the decision procedure (entity classification → 4-layer evidence cascade → 4-way verdict). One stdlib Python helper (`gsc_signal_delta.py`) makes the L1/L4 GSC delta + mover-ranking deterministic (the part that was ad-hoc and overflowed context in the genesis case). The skill delegates measurement to existing skills (`20-seo-serp-analysis`, `21-seo-position-tracking`, `28-seo-knowledge-graph`) and is registered in the repo's marketplace manifest.
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown skill (Claude Code format), Python 3 stdlib (`json`, `argparse`, `csv`), repo `.claude-plugin/marketplace.json`. No third-party deps. Code-only skill (no `desktop/` variant — matches precedent `95-ourdigital-presales-seo`, `96-ourdigital-estimate-engine`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Skill structure** = root `SKILL.md` (self-contained, ~180–220 lines, content NOT split into `references/`) + `code/` (CLAUDE.md + scripts). Code-only; **no `desktop/` variant**.
|
||||||
|
- **Register** the skill in `.claude-plugin/marketplace.json` under the `ourdigital-seo` plugin's `skills` array as `./custom-skills/35-seo-signal-validation`.
|
||||||
|
- **No new output directories** beyond the approved `custom-skills/35-seo-signal-validation/` folder (and its `code/scripts/fixtures/`).
|
||||||
|
- **Stateless, on-demand**: no cron/scheduler, no snapshot DB.
|
||||||
|
- **Notion writes via the notion-writer script only** — never Notion MCP write tools.
|
||||||
|
- **Never crawl/audit Marriott** for JHR — `sameAs` reference only.
|
||||||
|
- **Verify any Wikidata QID** against `Special:EntityData/{Q}.json` labels before trusting it (false-match guard: Q109455878 ≠ hotel, Q490787 ≠ Shinsegae Group).
|
||||||
|
- **Data-trust hierarchy**: 1st-party measured (GSC/GA4) > 3rd-party measured (backlinks, crawled rank) > 3rd-party modeled (estimated traffic).
|
||||||
|
- **Confidence cap**: third-party entities (no GSC/GA4 access) cannot reach `CONFIRMED` on traffic claims — at most `PARTIAL`, `ARTIFACT` only when live+entity reality clearly contradicts.
|
||||||
|
- **Verdict taxonomy**: `CONFIRMED | PARTIAL | ARTIFACT | INCONCLUSIVE`.
|
||||||
|
- **Branch**: all work commits to `feat/seo-signal-validation-skill` (already created; `DESIGN.md` already committed there).
|
||||||
|
- **Any client deliverable the skill emits** uses naming `{CODE}-{desc}-{class}-{YYYYMMDD}.{ext}`; KR client-facing content in Korean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `SKILL.md` — measurement half (frontmatter, classification, cascade L1–L2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/SKILL.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing (first task).
|
||||||
|
- Produces: the `SKILL.md` file with frontmatter `name: seo-signal-validation`; section anchors `## Step 0`, `## The validation loop` with layers `L1`/`L2`; references the helper script path `code/scripts/gsc_signal_delta.py` (implemented in Task 3).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `SKILL.md` frontmatter + measurement sections**
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
name: seo-signal-validation
|
||||||
|
description: |
|
||||||
|
Validate whether a claimed SERP / Knowledge-Graph movement for a (term, entity)
|
||||||
|
is real, misattributed, an artifact, or unprovable — before reporting impact.
|
||||||
|
Triggers: validate serp signal, is this ranking real, prove SEO impact,
|
||||||
|
SEMrush surge real, signal validation, real impact check,
|
||||||
|
신호 검증, 순위 변화 진짜, 오가닉 급증 검증, 임팩트 검증.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SEO Signal Validation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Given a `(term/intent, entity)` pair — and optionally a **claim** (a third-party
|
||||||
|
tool's reported movement) or a **baseline** (a prior state) — return an
|
||||||
|
evidence-backed verdict on whether SERP and Knowledge-Graph impact is real.
|
||||||
|
Built because modeled third-party signals (SEMrush/Ahrefs estimated organic
|
||||||
|
traffic, position snapshots) are easy to over-trust. This skill makes the
|
||||||
|
measured → live → entity → attribution cascade a single repeatable procedure
|
||||||
|
ending in a defensible verdict and a client-safe narrative.
|
||||||
|
|
||||||
|
## When to use (boundary)
|
||||||
|
|
||||||
|
This is the **conductor**, not an instrument. It sequences and synthesizes the
|
||||||
|
three measurement skills — it does not duplicate them.
|
||||||
|
|
||||||
|
| Use instead | When |
|
||||||
|
|---|---|
|
||||||
|
| `20-seo-serp-analysis` | You only need SERP composition / features |
|
||||||
|
| `21-seo-position-tracking` | You only need rank over time |
|
||||||
|
| `28-seo-knowledge-graph` | You only need an entity-presence audit |
|
||||||
|
| **this skill** | You must adjudicate whether a *claimed movement* is real across layers |
|
||||||
|
|
||||||
|
## Step 0 — Classify entity + pick mode
|
||||||
|
|
||||||
|
1. **Entity ownership** (gates which layers exist):
|
||||||
|
- **First-party** — a site/property you own or have GSC/GA4 access to (e.g. JHR
|
||||||
|
`sc-domain:josunhotel.com`, GA4 `258308769`) → **L1 measured available**.
|
||||||
|
- **Third-party** — a competitor brand or a person you do not control →
|
||||||
|
**L1 unavailable**; lean on L2 + L3 + clearly-tiered estimates; apply the
|
||||||
|
confidence cap (see Verdict). If unclear, ask once.
|
||||||
|
2. **Mode** (thin wrappers over the same cascade):
|
||||||
|
- `adjudicate(claim)` — a 3rd-party tool reports a move; confirm/refute.
|
||||||
|
- `prove(baseline)` — after our change; before/after from GSC/GA4 history.
|
||||||
|
- `snapshot()` — no claim; "where do we really stand."
|
||||||
|
|
||||||
|
## The validation loop (cost-ordered cascade, short-circuiting)
|
||||||
|
|
||||||
|
Run cheapest-first; stop early when a layer is already decisive.
|
||||||
|
|
||||||
|
### L1 — Measured (first-party ground truth) → via `21-seo-position-tracking`
|
||||||
|
|
||||||
|
- **GSC** `mcp__dda__gsc_fetch_performance`: the term at **query level** (exact)
|
||||||
|
AND **site-wide**, for **recent vs prior** windows. Pull clicks / impressions /
|
||||||
|
position / CTR. **Day-normalize** (compare windows differ in calendar-day count).
|
||||||
|
Note **~43% query-level anonymization** — the disclosed subset ≠ the whole.
|
||||||
|
- **GA4** `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend
|
||||||
|
(dims `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`). GA4
|
||||||
|
includes Naver + all engines — use it to test whether a "surge" exceeds normal
|
||||||
|
month-to-month variance.
|
||||||
|
- **Compute deltas with the helper** (deterministic, avoids ad-hoc parsing):
|
||||||
|
save each GSC pull, then run
|
||||||
|
`python3 code/scripts/gsc_signal_delta.py --recent <recent.tsv> --prior <prior.tsv> --recent-days N --prior-days M --claim-term "<term>"`.
|
||||||
|
It returns day-normalized site totals, top gainers/decliners, and whether the
|
||||||
|
claimed term is among the real movers.
|
||||||
|
- **SHORT-CIRCUIT:** if the claimed keyword has trivial clicks and a real
|
||||||
|
position nowhere near the claim → **ARTIFACT**; stop unless the caller wants
|
||||||
|
the full picture.
|
||||||
|
|
||||||
|
### L2 — Live SERP (3rd-party measured, point-in-time) → via `20-seo-serp-analysis`
|
||||||
|
|
||||||
|
- **Geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`):
|
||||||
|
force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts**.
|
||||||
|
Confirm whether the domain actually holds the claimed position; capture the
|
||||||
|
feature landscape (ads, local map-pack, PAA, knowledge panel) that explains why
|
||||||
|
a brand site can't own a head term.
|
||||||
|
- **Cheap rank spot-check**: `mcp__ourseo__check_serp(keyword, domain)`.
|
||||||
|
- **[KR market]** Naver SERP composition: `our research naver serp` (blog / cafe /
|
||||||
|
지식iN / Smart Store / brand zone) — Semrush/Ahrefs don't model Naver.
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify frontmatter parses and required anchors exist**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sys, pathlib
|
||||||
|
p = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md")
|
||||||
|
t = p.read_text(encoding="utf-8")
|
||||||
|
assert t.startswith("---\n"), "missing frontmatter"
|
||||||
|
fm = t.split("---\n",2)[1]
|
||||||
|
assert "name: seo-signal-validation" in fm, "bad name"
|
||||||
|
assert "Triggers:" in fm, "missing triggers"
|
||||||
|
for anchor in ["## Step 0", "## The validation loop", "### L1", "### L2",
|
||||||
|
"gsc_signal_delta.py"]:
|
||||||
|
assert anchor in t, f"missing: {anchor}"
|
||||||
|
print("OK SKILL.md measurement half")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK SKILL.md measurement half`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
git add custom-skills/35-seo-signal-validation/SKILL.md
|
||||||
|
git commit -m "feat(skill): seo-signal-validation SKILL.md measurement half (L1-L2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `SKILL.md` — decision half (L3 KG, L4 synthesis, verdict, output)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `custom-skills/35-seo-signal-validation/SKILL.md` (append after L2)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the `SKILL.md` from Task 1 (appends to it).
|
||||||
|
- Produces: sections `### L3`, `### L4`, `## Verdict`, `## Standing skepticism rules`, `## Output`, `## Non-goals` with the four verdict labels verbatim.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append the decision sections to `SKILL.md`**
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
### L3 — Entity / Knowledge Graph → via `28-seo-knowledge-graph`
|
||||||
|
|
||||||
|
A real impact event should leave corroborating traces in the entity layer, not
|
||||||
|
just a rank number. Five checks:
|
||||||
|
|
||||||
|
1. **Google KG API** entity match + `resultScore` —
|
||||||
|
`mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
||||||
|
2. **Wikidata** QID presence + key claims — **verify the QID against
|
||||||
|
`Special:EntityData/{Q}.json` labels before trusting it** (false-match guard:
|
||||||
|
Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
||||||
|
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
||||||
|
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
||||||
|
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
||||||
|
|
||||||
|
`mcp__ourseo__monitor_brand` supplements with brand-mention / brand-SERP ownership.
|
||||||
|
|
||||||
|
### L4 — Attribution synthesis
|
||||||
|
|
||||||
|
Cross-check: does the **measured delta (L1)** corroborate the **live reality
|
||||||
|
(L2)**, and does the **entity layer (L3)** move consistently? The query-clicks
|
||||||
|
delta names the true drivers (brand/seasonal vs the claimed term).
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
| Verdict | Condition |
|
||||||
|
|---|---|
|
||||||
|
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
||||||
|
| **PARTIAL** | Real movement, but misattributed, or only some layers agree |
|
||||||
|
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it |
|
||||||
|
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — name what's missing + how to resolve |
|
||||||
|
|
||||||
|
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic
|
||||||
|
claims — at most PARTIAL; ARTIFACT only when live+entity clearly contradict.
|
||||||
|
|
||||||
|
Every verdict ships an **evidence ledger** (per layer: finding + data-trust tier +
|
||||||
|
corroborates/contradicts) and a **client-safe narrative** (the defensible story).
|
||||||
|
|
||||||
|
## Standing skepticism rules
|
||||||
|
|
||||||
|
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
||||||
|
- **Head-term over-fire**: one high-volume keyword at an estimated high rank inflates the whole modeled number.
|
||||||
|
- **KR Naver blind spot**: Semrush models Google only; misses much of Korean organic.
|
||||||
|
- **Single-geo/device snapshot** diverges from GSC's national average.
|
||||||
|
- **Trust hierarchy**: 1st-party measured > 3rd-party measured > 3rd-party modeled.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- **Always**: inline report — verdict + evidence ledger + client-safe narrative +
|
||||||
|
"what would raise confidence."
|
||||||
|
- **Optional**: archive to Notion *Working with AI DB* (`data_source_id
|
||||||
|
f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (never
|
||||||
|
Notion MCP write). Type=Memo/Research, Topic=SEO, Account Code as relevant.
|
||||||
|
- **Optional**: if a new generalizable gotcha emerges, append a memory entry to
|
||||||
|
the active workspace's memory dir.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
No cron/scheduler, no snapshot DB, no new directories. Does not replace the three
|
||||||
|
instrument skills. Returns INCONCLUSIVE rather than fabricating when data is thin.
|
||||||
|
**Never crawls/audits Marriott for JHR** (sameAs only).
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the four verdicts, confidence cap, and skepticism rules are present**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
t = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md").read_text(encoding="utf-8")
|
||||||
|
for s in ["### L3", "### L4", "**CONFIRMED**", "**PARTIAL**", "**ARTIFACT**",
|
||||||
|
"**INCONCLUSIVE**", "Confidence cap", "smoke-detector, not scale",
|
||||||
|
"Special:EntityData", "## Output", "## Non-goals"]:
|
||||||
|
assert s in t, f"missing: {s}"
|
||||||
|
n = t.count("\n")
|
||||||
|
assert 130 <= n <= 320, f"SKILL.md length {n} lines outside expected band"
|
||||||
|
print(f"OK SKILL.md decision half ({n} lines)")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK SKILL.md decision half (… lines)`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
git add custom-skills/35-seo-signal-validation/SKILL.md
|
||||||
|
git commit -m "feat(skill): seo-signal-validation SKILL.md decision half (L3-L4, verdict, output)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `gsc_signal_delta.py` helper + tests (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create test: `custom-skills/35-seo-signal-validation/code/scripts/test_gsc_signal_delta.py`
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/code/scripts/gsc_signal_delta.py`
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/code/scripts/requirements.txt`
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/code/CLAUDE.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing at runtime.
|
||||||
|
- Produces: `compute_delta(recent: list[dict], prior: list[dict], recent_days: int, prior_days: int, claim_term: str|None=None, top_n: int=10) -> dict` and `load_gsc(path: str) -> list[dict]`; CLI `python3 gsc_signal_delta.py --recent --prior --recent-days --prior-days [--claim-term] [--top-n]`. Output dict keys: `site_totals`, `top_gainers`, `top_decliners`, `claim_term`, `verdict_hint`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/scripts/test_gsc_signal_delta.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for gsc_signal_delta. Run: `python3 test_gsc_signal_delta.py`
|
||||||
|
(also pytest-compatible). Stdlib only."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from gsc_signal_delta import compute_delta # noqa: E402
|
||||||
|
|
||||||
|
# Genesis fixture: JHR "호텔" — flat head term, growth all brand (2026-06 case).
|
||||||
|
RECENT = [
|
||||||
|
{"query": "호텔", "clicks": 5, "impressions": 572, "position": 11.6},
|
||||||
|
{"query": "grand josun busan", "clicks": 250, "impressions": 4000, "position": 1.2},
|
||||||
|
{"query": "조선호텔", "clicks": 300, "impressions": 6000, "position": 1.1},
|
||||||
|
]
|
||||||
|
PRIOR = [
|
||||||
|
{"query": "호텔", "clicks": 9, "impressions": 371, "position": 18.1},
|
||||||
|
{"query": "grand josun busan", "clicks": 49, "impressions": 1500, "position": 3.4},
|
||||||
|
{"query": "조선호텔", "clicks": 150, "impressions": 5000, "position": 1.3},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_term_flagged_artifact():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
||||||
|
ct = out["claim_term"]
|
||||||
|
assert ct["found"] is True
|
||||||
|
assert ct["in_top_movers"] is False
|
||||||
|
assert ct["click_share_pct"] < 1.0
|
||||||
|
assert "ARTIFACT" in out["verdict_hint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_gainer_is_brand_term():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
||||||
|
assert out["top_gainers"][0]["query"] == "grand josun busan"
|
||||||
|
assert out["top_gainers"][0]["delta_clicks"] == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_normalization():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30)
|
||||||
|
assert out["site_totals"]["recent"]["clicks_per_day"] == 19.82 # 555/28
|
||||||
|
assert out["site_totals"]["prior"]["clicks_per_day"] == 6.93 # 208/30
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive_days_required():
|
||||||
|
try:
|
||||||
|
compute_delta(RECENT, PRIOR, 0, 30)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
raise AssertionError("expected ValueError for non-positive days")
|
||||||
|
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
for fn in fns:
|
||||||
|
fn(); print(f"PASS {fn.__name__}")
|
||||||
|
print(f"\n{len(fns)} passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_run()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
||||||
|
python3 test_gsc_signal_delta.py
|
||||||
|
```
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'gsc_signal_delta'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/scripts/gsc_signal_delta.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Day-normalized GSC query delta + mover ranking for signal validation.
|
||||||
|
|
||||||
|
Reads two GSC query exports (recent, prior) — JSON list or TSV with a header row
|
||||||
|
containing query / clicks / impressions / position — and reports day-normalized
|
||||||
|
site totals, top gainers/decliners, and whether a claimed term is a real mover.
|
||||||
|
This is the deterministic L1/L4 core of the 35-seo-signal-validation skill.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_row(r: dict) -> dict:
|
||||||
|
def num(*keys, default=0.0):
|
||||||
|
for k in keys:
|
||||||
|
if k in r and r[k] not in (None, ""):
|
||||||
|
try:
|
||||||
|
return float(str(r[k]).replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
query = (r.get("query") or r.get("term") or "")
|
||||||
|
if isinstance(r.get("keys"), list) and r["keys"]:
|
||||||
|
query = str(r["keys"][0])
|
||||||
|
return {
|
||||||
|
"query": str(query).strip(),
|
||||||
|
"clicks": num("clicks"),
|
||||||
|
"impressions": num("impressions", "impr"),
|
||||||
|
"position": num("position", "pos", default=0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_gsc(path: str) -> list[dict]:
|
||||||
|
"""Parse a GSC export (JSON list/{rows:[...]} or TSV-with-header)."""
|
||||||
|
text = Path(path).read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if text[0] in "[{":
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("rows", [])
|
||||||
|
return [_norm_row(r) for r in data]
|
||||||
|
lines = text.splitlines()
|
||||||
|
header = [h.strip().lower() for h in lines[0].split("\t")]
|
||||||
|
rows = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.strip():
|
||||||
|
rows.append(_norm_row(dict(zip(header, line.split("\t")))))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _by_query(rows: list[dict]) -> dict:
|
||||||
|
return {r["query"]: r for r in rows if r["query"]}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_delta(recent, prior, recent_days, prior_days,
|
||||||
|
claim_term=None, top_n=10) -> dict:
|
||||||
|
if recent_days <= 0 or prior_days <= 0:
|
||||||
|
raise ValueError("recent_days and prior_days must be positive")
|
||||||
|
r_by, p_by = _by_query(recent), _by_query(prior)
|
||||||
|
|
||||||
|
def totals(rows):
|
||||||
|
return {"clicks": sum(r["clicks"] for r in rows),
|
||||||
|
"impressions": sum(r["impressions"] for r in rows)}
|
||||||
|
rt, pt = totals(recent), totals(prior)
|
||||||
|
|
||||||
|
def per_day(total, days):
|
||||||
|
return round(total / days, 2)
|
||||||
|
|
||||||
|
def pct(new, old):
|
||||||
|
return round((new - old) / old * 100, 1) if old else None
|
||||||
|
|
||||||
|
r_cpd, p_cpd = per_day(rt["clicks"], recent_days), per_day(pt["clicks"], prior_days)
|
||||||
|
r_ipd, p_ipd = per_day(rt["impressions"], recent_days), per_day(pt["impressions"], prior_days)
|
||||||
|
|
||||||
|
deltas = []
|
||||||
|
for q in set(r_by) | set(p_by):
|
||||||
|
rc = r_by.get(q, {}).get("clicks", 0.0)
|
||||||
|
pc = p_by.get(q, {}).get("clicks", 0.0)
|
||||||
|
deltas.append({"query": q, "delta_clicks": rc - pc,
|
||||||
|
"recent_clicks": rc, "prior_clicks": pc})
|
||||||
|
deltas.sort(key=lambda d: d["delta_clicks"], reverse=True)
|
||||||
|
gainers = [d for d in deltas if d["delta_clicks"] > 0][:top_n]
|
||||||
|
decliners = sorted([d for d in deltas if d["delta_clicks"] < 0],
|
||||||
|
key=lambda d: d["delta_clicks"])[:top_n]
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"site_totals": {
|
||||||
|
"recent": {**rt, "clicks_per_day": r_cpd,
|
||||||
|
"impressions_per_day": r_ipd, "days": recent_days},
|
||||||
|
"prior": {**pt, "clicks_per_day": p_cpd,
|
||||||
|
"impressions_per_day": p_ipd, "days": prior_days},
|
||||||
|
"clicks_per_day_pct": pct(r_cpd, p_cpd),
|
||||||
|
"impressions_per_day_pct": pct(r_ipd, p_ipd),
|
||||||
|
},
|
||||||
|
"top_gainers": gainers,
|
||||||
|
"top_decliners": decliners,
|
||||||
|
"claim_term": None,
|
||||||
|
"verdict_hint": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if claim_term:
|
||||||
|
gainer_terms = {g["query"] for g in gainers}
|
||||||
|
rc, pc = r_by.get(claim_term, {}), p_by.get(claim_term, {})
|
||||||
|
in_movers = claim_term in gainer_terms
|
||||||
|
share = (rc.get("clicks", 0.0) / rt["clicks"] * 100) if rt["clicks"] else 0.0
|
||||||
|
out["claim_term"] = {
|
||||||
|
"term": claim_term, "found": bool(rc or pc),
|
||||||
|
"recent": {"clicks": rc.get("clicks", 0.0),
|
||||||
|
"impressions": rc.get("impressions", 0.0),
|
||||||
|
"position": rc.get("position")},
|
||||||
|
"prior": {"clicks": pc.get("clicks", 0.0),
|
||||||
|
"impressions": pc.get("impressions", 0.0),
|
||||||
|
"position": pc.get("position")},
|
||||||
|
"in_top_movers": in_movers,
|
||||||
|
"click_share_pct": round(share, 2),
|
||||||
|
}
|
||||||
|
if not in_movers and share < 1.0:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' contributes {share:.2f}% of recent clicks and is "
|
||||||
|
f"absent from top movers -> claimed impact likely ARTIFACT; real "
|
||||||
|
f"movement is elsewhere (see top_gainers).")
|
||||||
|
elif in_movers:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' is among top movers -> claim plausibly CONFIRMED/"
|
||||||
|
f"PARTIAL; corroborate with live SERP + entity layer.")
|
||||||
|
else:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' has non-trivial share ({share:.2f}%) but is not a "
|
||||||
|
f"top mover -> PARTIAL; inspect attribution.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="GSC signal delta for signal validation")
|
||||||
|
ap.add_argument("--recent", required=True)
|
||||||
|
ap.add_argument("--prior", required=True)
|
||||||
|
ap.add_argument("--recent-days", type=int, required=True)
|
||||||
|
ap.add_argument("--prior-days", type=int, required=True)
|
||||||
|
ap.add_argument("--claim-term", default=None)
|
||||||
|
ap.add_argument("--top-n", type=int, default=10)
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
out = compute_delta(load_gsc(a.recent), load_gsc(a.prior),
|
||||||
|
a.recent_days, a.prior_days, a.claim_term, a.top_n)
|
||||||
|
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
||||||
|
python3 test_gsc_signal_delta.py
|
||||||
|
```
|
||||||
|
Expected: `PASS test_claim_term_flagged_artifact` … `4 passed`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `requirements.txt` and `code/CLAUDE.md`**
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/scripts/requirements.txt`:
|
||||||
|
```text
|
||||||
|
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/CLAUDE.md`:
|
||||||
|
```markdown
|
||||||
|
# seo-signal-validation — code environment notes
|
||||||
|
|
||||||
|
## Helper: scripts/gsc_signal_delta.py
|
||||||
|
Deterministic L1/L4 GSC delta. Feed it two saved GSC query exports (recent,
|
||||||
|
prior) as JSON or TSV (columns: query, clicks, impressions, position).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gsc_signal_delta.py \
|
||||||
|
--recent recent.tsv --prior prior.tsv \
|
||||||
|
--recent-days 28 --prior-days 30 --claim-term "호텔"
|
||||||
|
```
|
||||||
|
Returns day-normalized site totals, top gainers/decliners, and a `verdict_hint`
|
||||||
|
(heuristic only — the final verdict is the skill's job, after L2/L3).
|
||||||
|
|
||||||
|
## Getting the exports
|
||||||
|
`mcp__dda__gsc_fetch_performance` (property pinned per workspace, e.g. JHR
|
||||||
|
`sc-domain:josunhotel.com`) → save the query-dimension rows to a file → run the
|
||||||
|
script. GSC anonymizes ~43% of query clicks; the disclosed subset ≠ the whole.
|
||||||
|
|
||||||
|
## Env / access
|
||||||
|
- `GOOGLE_KG_API_KEY` for `mcp__ourseo__search_knowledge_graph` (L3).
|
||||||
|
- GSC/GA4 only exist for first-party properties — third-party entities skip L1.
|
||||||
|
- Never crawl/audit Marriott for JHR (sameAs only).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
git add custom-skills/35-seo-signal-validation/code
|
||||||
|
git commit -m "feat(skill): gsc_signal_delta helper + tests + code notes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Register in marketplace + reconcile DESIGN.md structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude-plugin/marketplace.json` (add to `ourdigital-seo` → `skills`)
|
||||||
|
- Modify: `custom-skills/35-seo-signal-validation/DESIGN.md:§7` (replace `references/` layout with actual `code/` layout; mark Code-only)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the skill folder from Tasks 1–3.
|
||||||
|
- Produces: a registered, discoverable skill; a spec whose §7 matches the built structure.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the skill path to the manifest**
|
||||||
|
|
||||||
|
In `.claude-plugin/marketplace.json`, inside the `ourdigital-seo` plugin's
|
||||||
|
`skills` array, add (keep numeric order; insert after the `34-seo-reporting-dashboard` entry):
|
||||||
|
```json
|
||||||
|
"./custom-skills/35-seo-signal-validation",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the manifest still parses and contains the entry**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, pathlib
|
||||||
|
m = json.loads(pathlib.Path(".claude-plugin/marketplace.json").read_text())
|
||||||
|
seo = next(p for p in m["plugins"] if p["name"] == "ourdigital-seo")
|
||||||
|
assert "./custom-skills/35-seo-signal-validation" in seo["skills"], "not registered"
|
||||||
|
print("OK manifest valid + skill registered")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK manifest valid + skill registered`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reconcile `DESIGN.md` §7 with the real structure**
|
||||||
|
|
||||||
|
In `custom-skills/35-seo-signal-validation/DESIGN.md`, replace the §7 "Repo
|
||||||
|
layout & conventions" code block (the `references/...` sketch) with:
|
||||||
|
```text
|
||||||
|
35-seo-signal-validation/
|
||||||
|
SKILL.md self-contained: classification, 4-layer cascade,
|
||||||
|
5 KG checks, 4-way verdict, skepticism rules, output
|
||||||
|
DESIGN.md PLAN.md spec + plan (live with the skill; no new top-level dir)
|
||||||
|
code/
|
||||||
|
CLAUDE.md code-environment notes (env, export→script flow)
|
||||||
|
scripts/
|
||||||
|
gsc_signal_delta.py deterministic L1/L4 GSC delta + mover ranking
|
||||||
|
test_gsc_signal_delta.py
|
||||||
|
requirements.txt (stdlib only)
|
||||||
|
```
|
||||||
|
And add one line under it: `Target environment: Claude Code only (no desktop/ variant — matches precedent 95/96). Registered in .claude-plugin/marketplace.json under ourdigital-seo.`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the spec no longer references the old layout**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
t = pathlib.Path("custom-skills/35-seo-signal-validation/DESIGN.md").read_text(encoding="utf-8")
|
||||||
|
assert "references/\n evidence-cascade.md" not in t, "old layout still present"
|
||||||
|
assert "gsc_signal_delta.py" in t and "marketplace.json" in t, "structure not reconciled"
|
||||||
|
print("OK DESIGN.md §7 reconciled")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK DESIGN.md §7 reconciled`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
git add .claude-plugin/marketplace.json custom-skills/35-seo-signal-validation/DESIGN.md
|
||||||
|
git commit -m "feat(skill): register seo-signal-validation in marketplace; reconcile spec layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Smoke test — genesis case end-to-end + consistency gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-recent.tsv`
|
||||||
|
- Create: `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-prior.tsv`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `gsc_signal_delta.py` CLI (Task 3) and the full `SKILL.md` (Tasks 1–2).
|
||||||
|
- Produces: a reproducible CLI smoke run proving the genesis "호텔" case yields an ARTIFACT-leaning hint; a final spec↔skill consistency check.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the genesis fixtures (TSV)**
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-recent.tsv`:
|
||||||
|
```text
|
||||||
|
query clicks impressions position
|
||||||
|
호텔 5 572 11.6
|
||||||
|
grand josun busan 250 4000 1.2
|
||||||
|
조선호텔 300 6000 1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `custom-skills/35-seo-signal-validation/code/scripts/fixtures/jhr-hotel-prior.tsv`:
|
||||||
|
```text
|
||||||
|
query clicks impressions position
|
||||||
|
호텔 9 371 18.1
|
||||||
|
grand josun busan 49 1500 3.4
|
||||||
|
조선호텔 150 5000 1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the CLI end-to-end and verify the verdict hint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills/custom-skills/35-seo-signal-validation/code/scripts
|
||||||
|
python3 gsc_signal_delta.py --recent fixtures/jhr-hotel-recent.tsv \
|
||||||
|
--prior fixtures/jhr-hotel-prior.tsv --recent-days 28 --prior-days 30 \
|
||||||
|
--claim-term "호텔" | python3 - <<'PY'
|
||||||
|
import json, sys
|
||||||
|
o = json.load(sys.stdin)
|
||||||
|
assert o["claim_term"]["in_top_movers"] is False
|
||||||
|
assert o["claim_term"]["click_share_pct"] < 1.0
|
||||||
|
assert "ARTIFACT" in o["verdict_hint"]
|
||||||
|
assert o["top_gainers"][0]["query"] == "grand josun busan"
|
||||||
|
print("OK smoke: 호텔 → ARTIFACT-leaning; top mover = brand term")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK smoke: 호텔 → ARTIFACT-leaning; top mover = brand term`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Consistency gate — every spec default maps to skill content**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
python3 - <<'PY'
|
||||||
|
import pathlib
|
||||||
|
sk = pathlib.Path("custom-skills/35-seo-signal-validation/SKILL.md").read_text(encoding="utf-8")
|
||||||
|
# Default 1: 5 KG checks Default 2: 4 verdicts Default 3: output triple
|
||||||
|
for s in ["Google KG API", "Wikidata", "Knowledge Panel", "sameAs", "지식iN"]:
|
||||||
|
assert s in sk, f"KG check missing: {s}"
|
||||||
|
for s in ["CONFIRMED", "PARTIAL", "ARTIFACT", "INCONCLUSIVE"]:
|
||||||
|
assert s in sk, f"verdict missing: {s}"
|
||||||
|
for s in ["notion-writer", "evidence ledger", "client-safe narrative"]:
|
||||||
|
assert s.lower() in sk.lower(), f"output element missing: {s}"
|
||||||
|
# Default 5: triggers (KR + EN)
|
||||||
|
assert "신호 검증" in sk and "validate serp signal" in sk, "triggers missing"
|
||||||
|
print("OK all five approved defaults present in SKILL.md")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `OK all five approved defaults present in SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Project/our-claude-skills
|
||||||
|
git add custom-skills/35-seo-signal-validation/code/scripts/fixtures
|
||||||
|
git commit -m "test(skill): genesis 호텔 smoke fixtures + end-to-end ARTIFACT check"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage** (each DESIGN.md section → task):
|
||||||
|
- §1 Purpose, §2 Boundary → Task 1 (SKILL.md Purpose/boundary). ✓
|
||||||
|
- §3 Engine (entity classification, L1–L4, short-circuit) → Tasks 1 (L1–L2) + 2 (L3–L4); L1/L4 delta computation → Task 3 script. ✓
|
||||||
|
- §4 Modes → Task 1 (Step 0 mode dispatch). ✓
|
||||||
|
- §5 Verdict + skepticism + confidence cap → Task 2. ✓
|
||||||
|
- §6 Output → Task 2. ✓
|
||||||
|
- §7 Repo layout → corrected in Task 4 (was wrong in spec); registration in Task 4. ✓
|
||||||
|
- §8 Non-goals → Task 2. ✓
|
||||||
|
- §9 Future options → intentionally not implemented (YAGNI). ✓
|
||||||
|
- Genesis verification → Task 5 smoke. ✓
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No TBD/TODO; all code blocks complete; every test shows real assertions; commands show expected output. ✓
|
||||||
|
|
||||||
|
**3. Type consistency:** `compute_delta(recent, prior, recent_days, prior_days, claim_term=None, top_n=10)` and `load_gsc(path)` are referenced identically in Task 3 (definition + test) and Task 5 (CLI). Output keys (`site_totals`, `top_gainers`, `claim_term.in_top_movers`, `claim_term.click_share_pct`, `verdict_hint`) match across the implementation, the tests, and both smoke checks. Day-normalization fixtures (555/28=19.82, 208/30=6.93; gainer delta 201) are arithmetically consistent. ✓
|
||||||
|
|
||||||
|
**No gaps found.**
|
||||||
141
custom-skills/35-seo-signal-validation/SKILL.md
Normal file
141
custom-skills/35-seo-signal-validation/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: seo-signal-validation
|
||||||
|
description: |
|
||||||
|
Validate whether a claimed SERP / Knowledge-Graph movement for a (term, entity)
|
||||||
|
is real, misattributed, an artifact, or unprovable — before reporting impact.
|
||||||
|
Triggers: validate serp signal, is this ranking real, prove SEO impact,
|
||||||
|
SEMrush surge real, signal validation, real impact check,
|
||||||
|
신호 검증, 순위 변화 진짜, 오가닉 급증 검증, 임팩트 검증.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SEO Signal Validation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Given a `(term/intent, entity)` pair — and optionally a **claim** (a third-party
|
||||||
|
tool's reported movement) or a **baseline** (a prior state) — return an
|
||||||
|
evidence-backed verdict on whether SERP and Knowledge-Graph impact is real.
|
||||||
|
Built because modeled third-party signals (SEMrush/Ahrefs estimated organic
|
||||||
|
traffic, position snapshots) are easy to over-trust. This skill makes the
|
||||||
|
measured → live → entity → attribution cascade a single repeatable procedure
|
||||||
|
ending in a defensible verdict and a client-safe narrative.
|
||||||
|
|
||||||
|
## When to use (boundary)
|
||||||
|
|
||||||
|
This is the **conductor**, not an instrument. It sequences and synthesizes the
|
||||||
|
three measurement skills — it does not duplicate them.
|
||||||
|
|
||||||
|
| Use instead | When |
|
||||||
|
|---|---|
|
||||||
|
| `20-seo-serp-analysis` | You only need SERP composition / features |
|
||||||
|
| `21-seo-position-tracking` | You only need rank over time |
|
||||||
|
| `28-seo-knowledge-graph` | You only need an entity-presence audit |
|
||||||
|
| **this skill** | You must adjudicate whether a *claimed movement* is real across layers |
|
||||||
|
|
||||||
|
## Step 0 — Classify entity + pick mode
|
||||||
|
|
||||||
|
1. **Entity ownership** (gates which layers exist):
|
||||||
|
- **First-party** — a site/property you own or have GSC/GA4 access to (e.g. JHR
|
||||||
|
`sc-domain:josunhotel.com`, GA4 `258308769`) → **L1 measured available**.
|
||||||
|
- **Third-party** — a competitor brand or a person you do not control →
|
||||||
|
**L1 unavailable**; lean on L2 + L3 + clearly-tiered estimates; apply the
|
||||||
|
confidence cap (see Verdict). If unclear, ask once.
|
||||||
|
2. **Mode** (thin wrappers over the same cascade):
|
||||||
|
- `adjudicate(claim)` — a 3rd-party tool reports a move; confirm/refute.
|
||||||
|
- `prove(baseline)` — after our change; before/after from GSC/GA4 history.
|
||||||
|
- `snapshot()` — no claim; "where do we really stand."
|
||||||
|
|
||||||
|
## The validation loop (cost-ordered cascade, short-circuiting)
|
||||||
|
|
||||||
|
Run cheapest-first; stop early when a layer is already decisive.
|
||||||
|
|
||||||
|
### L1 — Measured (first-party ground truth) → via `21-seo-position-tracking`
|
||||||
|
|
||||||
|
- **GSC** `mcp__dda__gsc_fetch_performance`: the term at **query level** (exact)
|
||||||
|
AND **site-wide**, for **recent vs prior** windows. Pull clicks / impressions /
|
||||||
|
position / CTR. **Day-normalize** (compare windows differ in calendar-day count).
|
||||||
|
Note **~43% query-level anonymization** — the disclosed subset ≠ the whole.
|
||||||
|
- **GA4** `mcp__dda__ga4_run_report`: `Organic Search` sessions monthly trend
|
||||||
|
(dims `yearMonth` + `sessionDefaultChannelGroup`, metric `sessions`). GA4
|
||||||
|
includes Naver + all engines — use it to test whether a "surge" exceeds normal
|
||||||
|
month-to-month variance.
|
||||||
|
- **Compute deltas with the helper** (deterministic, avoids ad-hoc parsing):
|
||||||
|
save each GSC pull, then run
|
||||||
|
`python3 code/scripts/gsc_signal_delta.py --recent <recent.tsv> --prior <prior.tsv> --recent-days N --prior-days M --claim-term "<term>"`.
|
||||||
|
It returns day-normalized site totals, top gainers/decliners, and whether the
|
||||||
|
claimed term is among the real movers.
|
||||||
|
- **SHORT-CIRCUIT:** if the claimed keyword has trivial clicks and a real
|
||||||
|
position nowhere near the claim → **ARTIFACT**; stop unless the caller wants
|
||||||
|
the full picture.
|
||||||
|
|
||||||
|
### L2 — Live SERP (3rd-party measured, point-in-time) → via `20-seo-serp-analysis`
|
||||||
|
|
||||||
|
- **Geo-correct Google render** via `claude-in-chrome` (`navigate` → `read_page`):
|
||||||
|
force `gl`/`hl` + correct geo, `pws=0`; **decline precise-location prompts**.
|
||||||
|
Confirm whether the domain actually holds the claimed position; capture the
|
||||||
|
feature landscape (ads, local map-pack, PAA, knowledge panel) that explains why
|
||||||
|
a brand site can't own a head term.
|
||||||
|
- **Cheap rank spot-check**: `mcp__ourseo__check_serp(keyword, domain)`.
|
||||||
|
- **[KR market]** Naver SERP composition: `our research naver serp` (blog / cafe /
|
||||||
|
지식iN / Smart Store / brand zone) — Semrush/Ahrefs don't model Naver.
|
||||||
|
|
||||||
|
### L3 — Entity / Knowledge Graph → via `28-seo-knowledge-graph`
|
||||||
|
|
||||||
|
A real impact event should leave corroborating traces in the entity layer, not
|
||||||
|
just a rank number. Five checks:
|
||||||
|
|
||||||
|
1. **Google KG API** entity match + `resultScore` —
|
||||||
|
`mcp__ourseo__search_knowledge_graph(query)` (uses `GOOGLE_KG_API_KEY`).
|
||||||
|
2. **Wikidata** QID presence + key claims — **verify the QID against
|
||||||
|
`Special:EntityData/{Q}.json` labels before trusting it** (false-match guard:
|
||||||
|
Q109455878 = office tower ≠ hotel; Q490787 = Shinsegae Inc. ≠ Group).
|
||||||
|
3. **Knowledge Panel** presence/attributes on the live entity-name SERP (Chrome).
|
||||||
|
4. **sameAs** consistency on the entity's `Organization`/`Person` JSON-LD.
|
||||||
|
5. **[KR]** Naver 백과사전 / 지식iN presence.
|
||||||
|
|
||||||
|
`mcp__ourseo__monitor_brand` supplements with brand-mention / brand-SERP ownership.
|
||||||
|
|
||||||
|
### L4 — Attribution synthesis
|
||||||
|
|
||||||
|
Cross-check: does the **measured delta (L1)** corroborate the **live reality
|
||||||
|
(L2)**, and does the **entity layer (L3)** move consistently? The query-clicks
|
||||||
|
delta names the true drivers (brand/seasonal vs the claimed term).
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
| Verdict | Condition |
|
||||||
|
|---|---|
|
||||||
|
| **CONFIRMED** | Measured + live + (where relevant) entity all corroborate movement attributable to the term/intent |
|
||||||
|
| **PARTIAL** | Real movement, but misattributed, or only some layers agree |
|
||||||
|
| **ARTIFACT** | Modeling/snapshot artifact — measured + live reality don't support it |
|
||||||
|
| **INCONCLUSIVE** | Insufficient data (query anonymized, GSC lag, no entity baseline, third-party entity with no measured access) — name what's missing + how to resolve |
|
||||||
|
|
||||||
|
**Confidence cap:** third-party entities (no L1) cannot reach CONFIRMED on traffic
|
||||||
|
claims — at most PARTIAL; ARTIFACT only when live+entity clearly contradict.
|
||||||
|
|
||||||
|
Every verdict ships an **evidence ledger** (per layer: finding + data-trust tier +
|
||||||
|
corroborates/contradicts) and a **client-safe narrative** (the defensible story).
|
||||||
|
|
||||||
|
## Standing skepticism rules
|
||||||
|
|
||||||
|
- Estimated organic traffic = **smoke-detector, not scale** (Σ est-volume × position-CTR curve).
|
||||||
|
- **Head-term over-fire**: one high-volume keyword at an estimated high rank inflates the whole modeled number.
|
||||||
|
- **KR Naver blind spot**: Semrush models Google only; misses much of Korean organic.
|
||||||
|
- **Single-geo/device snapshot** diverges from GSC's national average.
|
||||||
|
- **Trust hierarchy**: 1st-party measured > 3rd-party measured > 3rd-party modeled.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- **Always**: inline report — verdict + evidence ledger + client-safe narrative +
|
||||||
|
"what would raise confidence."
|
||||||
|
- **Optional**: archive to Notion *Working with AI DB* (`data_source_id
|
||||||
|
f8f19ede-32bd-43ac-9f60-0651f6f40afe`) via the **notion-writer script** (never
|
||||||
|
Notion MCP write). Type=Memo/Research, Topic=SEO, Account Code as relevant.
|
||||||
|
- **Optional**: if a new generalizable gotcha emerges, append a memory entry to
|
||||||
|
the active workspace's memory dir.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
No cron/scheduler, no snapshot DB, no new directories. Does not replace the three
|
||||||
|
instrument skills. Returns INCONCLUSIVE rather than fabricating when data is thin.
|
||||||
|
**Never crawls/audits Marriott for JHR** (sameAs only).
|
||||||
25
custom-skills/35-seo-signal-validation/code/CLAUDE.md
Normal file
25
custom-skills/35-seo-signal-validation/code/CLAUDE.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# seo-signal-validation — code environment notes
|
||||||
|
|
||||||
|
## Helper: scripts/gsc_signal_delta.py
|
||||||
|
Deterministic L1/L4 GSC delta. Feed it two saved GSC query exports (recent,
|
||||||
|
prior) as JSON or TSV (columns: query, clicks, impressions, position).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gsc_signal_delta.py \
|
||||||
|
--recent recent.tsv --prior prior.tsv \
|
||||||
|
--recent-days 28 --prior-days 30 --claim-term "호텔"
|
||||||
|
```
|
||||||
|
Returns day-normalized site totals, top gainers/decliners, and a `verdict_hint`
|
||||||
|
(heuristic only — the final verdict is the skill's job, after L2/L3).
|
||||||
|
|
||||||
|
**Surge-tuning note**: `verdict_hint` and `in_top_movers` are calibrated for upward "surge" claims (movers ranked by click gain). For a claimed *drop*, inspect `top_decliners` directly rather than relying on the hint.
|
||||||
|
|
||||||
|
## Getting the exports
|
||||||
|
`mcp__dda__gsc_fetch_performance` (property pinned per workspace, e.g. JHR
|
||||||
|
`sc-domain:josunhotel.com`) → save the query-dimension rows to a file → run the
|
||||||
|
script. GSC anonymizes ~43% of query clicks; the disclosed subset ≠ the whole.
|
||||||
|
|
||||||
|
## Env / access
|
||||||
|
- `GOOGLE_KG_API_KEY` for `mcp__ourseo__search_knowledge_graph` (L3).
|
||||||
|
- GSC/GA4 only exist for first-party properties — third-party entities skip L1.
|
||||||
|
- Never crawl/audit Marriott for JHR (sameAs only).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
query clicks impressions position
|
||||||
|
호텔 9 371 18.1
|
||||||
|
grand josun busan 49 1500 3.4
|
||||||
|
조선호텔 150 5000 1.3
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
query clicks impressions position
|
||||||
|
호텔 5 572 11.6
|
||||||
|
grand josun busan 250 4000 1.2
|
||||||
|
조선호텔 300 6000 1.1
|
||||||
|
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Day-normalized GSC query delta + mover ranking for signal validation.
|
||||||
|
|
||||||
|
Reads two GSC query exports (recent, prior) — JSON list or TSV with a header row
|
||||||
|
containing query / clicks / impressions / position — and reports day-normalized
|
||||||
|
site totals, top gainers/decliners, and whether a claimed term is a real mover.
|
||||||
|
This is the deterministic L1/L4 core of the 35-seo-signal-validation skill.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_row(r: dict) -> dict:
|
||||||
|
def num(*keys, default=0.0):
|
||||||
|
for k in keys:
|
||||||
|
if k in r and r[k] not in (None, ""):
|
||||||
|
try:
|
||||||
|
return float(str(r[k]).replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
query = (r.get("query") or r.get("term") or "")
|
||||||
|
if isinstance(r.get("keys"), list) and r["keys"]:
|
||||||
|
query = str(r["keys"][0])
|
||||||
|
return {
|
||||||
|
"query": str(query).strip(),
|
||||||
|
"clicks": num("clicks"),
|
||||||
|
"impressions": num("impressions", "impr"),
|
||||||
|
"position": num("position", "pos", default=0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_gsc(path: str) -> list[dict]:
|
||||||
|
"""Parse a GSC export (JSON list/{rows:[...]} or TSV-with-header)."""
|
||||||
|
text = Path(path).read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if text[0] in "[{":
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("rows", [])
|
||||||
|
return [_norm_row(r) for r in data]
|
||||||
|
lines = text.splitlines()
|
||||||
|
header = [h.strip().lower() for h in lines[0].split("\t")]
|
||||||
|
rows = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if line.strip():
|
||||||
|
rows.append(_norm_row(dict(zip(header, line.split("\t")))))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _by_query(rows: list[dict]) -> dict:
|
||||||
|
return {r["query"]: r for r in rows if r["query"]}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_delta(recent, prior, recent_days, prior_days,
|
||||||
|
claim_term=None, top_n=10) -> dict:
|
||||||
|
if recent_days <= 0 or prior_days <= 0:
|
||||||
|
raise ValueError("recent_days and prior_days must be positive")
|
||||||
|
r_by, p_by = _by_query(recent), _by_query(prior)
|
||||||
|
|
||||||
|
def totals(rows):
|
||||||
|
return {"clicks": sum(r["clicks"] for r in rows),
|
||||||
|
"impressions": sum(r["impressions"] for r in rows)}
|
||||||
|
rt, pt = totals(recent), totals(prior)
|
||||||
|
|
||||||
|
def per_day(total, days):
|
||||||
|
return round(total / days, 2)
|
||||||
|
|
||||||
|
def pct(new, old):
|
||||||
|
return round((new - old) / old * 100, 1) if old else None
|
||||||
|
|
||||||
|
r_cpd, p_cpd = per_day(rt["clicks"], recent_days), per_day(pt["clicks"], prior_days)
|
||||||
|
r_ipd, p_ipd = per_day(rt["impressions"], recent_days), per_day(pt["impressions"], prior_days)
|
||||||
|
|
||||||
|
deltas = []
|
||||||
|
for q in set(r_by) | set(p_by):
|
||||||
|
rc = r_by.get(q, {}).get("clicks", 0.0)
|
||||||
|
pc = p_by.get(q, {}).get("clicks", 0.0)
|
||||||
|
deltas.append({"query": q, "delta_clicks": rc - pc,
|
||||||
|
"recent_clicks": rc, "prior_clicks": pc})
|
||||||
|
deltas.sort(key=lambda d: d["delta_clicks"], reverse=True)
|
||||||
|
gainers = [d for d in deltas if d["delta_clicks"] > 0][:top_n]
|
||||||
|
decliners = sorted([d for d in deltas if d["delta_clicks"] < 0],
|
||||||
|
key=lambda d: d["delta_clicks"])[:top_n]
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"site_totals": {
|
||||||
|
"recent": {**rt, "clicks_per_day": r_cpd,
|
||||||
|
"impressions_per_day": r_ipd, "days": recent_days},
|
||||||
|
"prior": {**pt, "clicks_per_day": p_cpd,
|
||||||
|
"impressions_per_day": p_ipd, "days": prior_days},
|
||||||
|
"clicks_per_day_pct": pct(r_cpd, p_cpd),
|
||||||
|
"impressions_per_day_pct": pct(r_ipd, p_ipd),
|
||||||
|
},
|
||||||
|
"top_gainers": gainers,
|
||||||
|
"top_decliners": decliners,
|
||||||
|
"claim_term": None,
|
||||||
|
"verdict_hint": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if claim_term:
|
||||||
|
gainer_terms = {g["query"] for g in gainers}
|
||||||
|
rc, pc = r_by.get(claim_term, {}), p_by.get(claim_term, {})
|
||||||
|
in_movers = claim_term in gainer_terms
|
||||||
|
found = bool(rc or pc)
|
||||||
|
share = (rc.get("clicks", 0.0) / rt["clicks"] * 100) if rt["clicks"] else 0.0
|
||||||
|
out["claim_term"] = {
|
||||||
|
"term": claim_term, "found": found,
|
||||||
|
"recent": {"clicks": rc.get("clicks", 0.0),
|
||||||
|
"impressions": rc.get("impressions", 0.0),
|
||||||
|
"position": rc.get("position")},
|
||||||
|
"prior": {"clicks": pc.get("clicks", 0.0),
|
||||||
|
"impressions": pc.get("impressions", 0.0),
|
||||||
|
"position": pc.get("position")},
|
||||||
|
"in_top_movers": in_movers,
|
||||||
|
"click_share_pct": round(share, 2),
|
||||||
|
}
|
||||||
|
if not found:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' is absent from both GSC windows (no impressions / "
|
||||||
|
f"likely anonymized) -> INCONCLUSIVE, not refuted; confirm via live "
|
||||||
|
f"SERP + entity layer.")
|
||||||
|
elif not in_movers and share < 1.0:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' contributes {share:.2f}% of recent clicks and is "
|
||||||
|
f"absent from top movers -> claimed impact likely ARTIFACT; real "
|
||||||
|
f"movement is elsewhere (see top_gainers).")
|
||||||
|
elif in_movers:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' is among top movers -> claim plausibly CONFIRMED/"
|
||||||
|
f"PARTIAL; corroborate with live SERP + entity layer.")
|
||||||
|
else:
|
||||||
|
out["verdict_hint"] = (
|
||||||
|
f"'{claim_term}' has non-trivial share ({share:.2f}%) but is not a "
|
||||||
|
f"top mover -> PARTIAL; inspect attribution.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="GSC signal delta for signal validation")
|
||||||
|
ap.add_argument("--recent", required=True)
|
||||||
|
ap.add_argument("--prior", required=True)
|
||||||
|
ap.add_argument("--recent-days", type=int, required=True)
|
||||||
|
ap.add_argument("--prior-days", type=int, required=True)
|
||||||
|
ap.add_argument("--claim-term", default=None)
|
||||||
|
ap.add_argument("--top-n", type=int, default=10)
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
out = compute_delta(load_gsc(a.recent), load_gsc(a.prior),
|
||||||
|
a.recent_days, a.prior_days, a.claim_term, a.top_n)
|
||||||
|
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for gsc_signal_delta. Run: `python3 test_gsc_signal_delta.py`
|
||||||
|
(also pytest-compatible). Stdlib only."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from gsc_signal_delta import compute_delta # noqa: E402
|
||||||
|
|
||||||
|
# Genesis fixture: JHR "호텔" — flat head term, growth all brand (2026-06 case).
|
||||||
|
RECENT = [
|
||||||
|
{"query": "호텔", "clicks": 5, "impressions": 572, "position": 11.6},
|
||||||
|
{"query": "grand josun busan", "clicks": 250, "impressions": 4000, "position": 1.2},
|
||||||
|
{"query": "조선호텔", "clicks": 300, "impressions": 6000, "position": 1.1},
|
||||||
|
]
|
||||||
|
PRIOR = [
|
||||||
|
{"query": "호텔", "clicks": 9, "impressions": 371, "position": 18.1},
|
||||||
|
{"query": "grand josun busan", "clicks": 49, "impressions": 1500, "position": 3.4},
|
||||||
|
{"query": "조선호텔", "clicks": 150, "impressions": 5000, "position": 1.3},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_term_flagged_artifact():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
||||||
|
ct = out["claim_term"]
|
||||||
|
assert ct["found"] is True
|
||||||
|
assert ct["in_top_movers"] is False
|
||||||
|
assert ct["click_share_pct"] < 1.0
|
||||||
|
assert "ARTIFACT" in out["verdict_hint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_gainer_is_brand_term():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="호텔")
|
||||||
|
assert out["top_gainers"][0]["query"] == "grand josun busan"
|
||||||
|
assert out["top_gainers"][0]["delta_clicks"] == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_normalization():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30)
|
||||||
|
assert out["site_totals"]["recent"]["clicks_per_day"] == 19.82 # 555/28
|
||||||
|
assert out["site_totals"]["prior"]["clicks_per_day"] == 6.93 # 208/30
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_claim_term_inconclusive():
|
||||||
|
out = compute_delta(RECENT, PRIOR, 28, 30, claim_term="존재하지않는검색어")
|
||||||
|
assert out["claim_term"]["found"] is False
|
||||||
|
assert "INCONCLUSIVE" in out["verdict_hint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive_days_required():
|
||||||
|
try:
|
||||||
|
compute_delta(RECENT, PRIOR, 0, 30)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
raise AssertionError("expected ValueError for non-positive days")
|
||||||
|
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
for fn in fns:
|
||||||
|
fn(); print(f"PASS {fn.__name__}")
|
||||||
|
print(f"\n{len(fns)} passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_run()
|
||||||
100
custom-skills/48-jamie-copy-trimmer/SKILL.md
Normal file
100
custom-skills/48-jamie-copy-trimmer/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
name: jamie-copy-trimmer
|
||||||
|
description: >-
|
||||||
|
Trims and sharpens Korean plastic-surgery / aesthetic marketing copy — headlines, body,
|
||||||
|
CTAs, names, slogans. Removes clichés and medical-ad compliance risks, suggests trendy,
|
||||||
|
catchy alternatives within 의료광고 심의 limits, and re-scores them. Use when copy feels
|
||||||
|
awkward or old, or to refine, name, or compliance-check any copy. Triggers: 카피 다듬어,
|
||||||
|
카피 트리밍, 네이밍 검토, 슬로건 다듬어, 심의 안전하게, copy trim, make it catchier, 제이미 카피.
|
||||||
|
For Jamie work, use with jamie-brand-guardian.
|
||||||
|
license: Proprietary (OurDigital internal)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jamie Copy Trimmer
|
||||||
|
|
||||||
|
Trim and sharpen Korean plastic-surgery / aesthetic-medical marketing copy against an industry expression corpus, within medical-advertising limits.
|
||||||
|
|
||||||
|
**Working language.** These directives are in English, but the copy you evaluate and produce is Korean — the language of the target market. Write all deliverable copy, alternatives, and the final report in Korean unless the user asks otherwise.
|
||||||
|
|
||||||
|
## Core philosophy (internalize this)
|
||||||
|
|
||||||
|
1. **The corpus is a map for AVOIDING clichés, not a library to COPY.** Frequently used means probably already stale. Use it to spot what everyone says and go elsewhere. Copying corpus phrases defeats the purpose, which is differentiation.
|
||||||
|
2. **The ceiling on wit is set by 의료광고 심의.** Korean plastic-surgery copy is constrained by rules against exaggeration, superlatives, and patient inducement. Raise the appeal, but anything past the compliance line is void. Compliance is a **gate, not a score** — if a single 🔴 risk expression remains, that option fails outright.
|
||||||
|
3. **Trim first, dazzle second.** Cutting redundancy, clichés, and risk is the primary job. Add restrained flair into the space you cleared. "Say less, land harder."
|
||||||
|
4. **Don't guess — mark [확인].** If procedure facts, target, channel, or brand tone are missing, ask the user or leave a `[확인]` note and move on. Never fill gaps with invention.
|
||||||
|
|
||||||
|
## Before you start (inputs)
|
||||||
|
|
||||||
|
Ask, or mark `[확인]`, if any of these are missing:
|
||||||
|
- **Copy text** and its type (headline / body / CTA / name / slogan)
|
||||||
|
- **Channel** (카카오플러스친구 / Instagram / blog / Naver / in-clinic POP / search ad) — tone and 심의 strictness differ by channel
|
||||||
|
- **Procedure & target** (standard procedure name, patient concern)
|
||||||
|
- **Brand tone** — for a specific brand (e.g., Jamie), that brand's guide (`jamie-brand-guardian`) overrides this skill's taste defaults
|
||||||
|
|
||||||
|
## Workflow (5 steps)
|
||||||
|
|
||||||
|
### 1) Diagnose — tag the original
|
||||||
|
Tag each phrase/expression. When unsure, read the matching corpus file.
|
||||||
|
|
||||||
|
| Tag | Meaning | Action |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| 🟢 effective | Works, resonates | Keep |
|
||||||
|
| 🟡 cliché | Industry-stale, tired | Trim / replace (→ `corpus_cliche.md`) |
|
||||||
|
| 🔴 compliance risk | Exaggeration, superlative, inducement, comparison | Must remove/replace (→ `corpus_compliance_risk.md`) |
|
||||||
|
| ⚪ flat | Not wrong, but no hook | Sharpen (→ `witty_within_limits.md`) |
|
||||||
|
| 🟦 brand asset | Brand-owned phrase | Do not misjudge as cliché (e.g., Jamie's "티 안 나게") |
|
||||||
|
|
||||||
|
### 2) Trim
|
||||||
|
Remove all 🔴, replace 🟡, delete redundancy. Output of this step is a "safe, plain" version.
|
||||||
|
|
||||||
|
### 3) Elevate — add appeal within 심의 limits
|
||||||
|
Using techniques in `witty_within_limits.md`, give **2–3 alternatives per element**, each with a rationale (why it lands harder), matched to the channel's tone. Consult `corpus_examples.md` for technique coordinates — never copy the examples verbatim.
|
||||||
|
|
||||||
|
### 4) Re-score — 5-axis rubric
|
||||||
|
Score original vs. improved with `evaluation_rubric.md`. 심의 is a PASS/FAIL gate. In the brand-fit axis, state in one line **which brand attribute / association / asset** the copy strengthens (keeps qualitative judgment concrete rather than vague).
|
||||||
|
|
||||||
|
### 5) Recursive Improvement — evolve the corpus
|
||||||
|
Per `recursive_protocol.md` (Recursive Improvement Protocol), propose feeding this session's adopted/rejected expressions back into the corpus. Demote once-effective phrases that have become common to the cliché list.
|
||||||
|
|
||||||
|
## Output format (produce the report in Korean, using this structure)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 카피 트리밍 결과 — [대상/채널]
|
||||||
|
|
||||||
|
### 1) 진단
|
||||||
|
- "원문 문구" → 🔴/🟡/⚪/🟢/🟦 사유
|
||||||
|
|
||||||
|
### 2) 트리밍 (담백한 안)
|
||||||
|
[군더더기·위험 제거 버전]
|
||||||
|
|
||||||
|
### 3) 대안 (심의 한도 내 감각안)
|
||||||
|
| 요소 | 원문 | 문제 | 개선안 2~3개 | 근거 |
|
||||||
|
|
||||||
|
### 4) 재평가 (5축, 1~5점 · 심의=게이트)
|
||||||
|
| 축 | 원문 | 추천안 | 코멘트 |
|
||||||
|
| 감각 / 차별성 / 브랜드적합성 / 심의(P·F) / 명료성 |
|
||||||
|
→ 강화되는 브랜드 자산: [속성/연상 명시]
|
||||||
|
|
||||||
|
### 5) 추천안 + 이유
|
||||||
|
|
||||||
|
### 6) 준비 점검 사항
|
||||||
|
- [확인] 심의 필요 / 정보 부재 / 사실 검증 필요 항목을 여기 모아 정리
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep risk items and open questions out of the body; collect them at the end under **준비 점검 사항** (brevity principle).
|
||||||
|
|
||||||
|
## Reference files (read as needed)
|
||||||
|
|
||||||
|
- `references/corpus_compliance_risk.md` — medical-ad risk expressions + safe replacements (**most important**)
|
||||||
|
- `references/corpus_cliche.md` — stale/old expressions to avoid (avoidance coordinates)
|
||||||
|
- `references/corpus_effective.md` — working patterns (application coordinates; don't copy verbatim)
|
||||||
|
- `references/corpus_examples.md` — analyzed real-world exemplar copy (technique coordinates; no imitation)
|
||||||
|
- `references/witty_within_limits.md` — making copy trendy/catchy inside 심의 limits, and the boundaries
|
||||||
|
- `references/evaluation_rubric.md` — the 5-axis re-scoring rubric and gate rules
|
||||||
|
- `references/recursive_protocol.md` — Recursive Improvement Protocol and corpus tagging schema
|
||||||
|
|
||||||
|
## Reminders
|
||||||
|
|
||||||
|
- Compliance judgment here is guidance, not legal advice. Always leave a `[확인]` recommending pre-publication 의료광고 자율심의.
|
||||||
|
- For a specific brand, that brand's tone guide wins over this skill's defaults.
|
||||||
|
- The corpus is a living document — even effective phrases go stale. Refresh it periodically via `recursive_protocol.md`.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Cliché / Stale Expressions (🟡) — Avoidance Coordinates
|
||||||
|
|
||||||
|
> **This list is for avoiding, not copying.** Frequently used = already tired.
|
||||||
|
> If a listed expression shows up in the copy, consider replacing it. The "refresh direction" is a lead for finding an alternative, not a ready-made line.
|
||||||
|
|
||||||
|
## A. Beauty/result show-off (stale)
|
||||||
|
| Cliché (Korean) | Why it's old | Refresh direction |
|
||||||
|
|-----------------|--------------|-------------------|
|
||||||
|
| 여신, 인형 같은, 리즈 갱신, 인생 리즈 | Ranks looks, unrealistic, 10-years-ago tone | Replace with a concrete everyday-change scene |
|
||||||
|
| 물광, 꿀광, 동안 미모 | Spent beauty buzzwords | Use the customer's concern language, not glow words |
|
||||||
|
| 확 달라진, 몰라보게, 환골탈태 | Exaggeration; borders on 심의 risk | Restrain and specify the degree of change |
|
||||||
|
|
||||||
|
## B. Self-flattering filler (empty)
|
||||||
|
| Cliché | Why it's old | Refresh direction |
|
||||||
|
|--------|--------------|-------------------|
|
||||||
|
| 특별한 당신을 위한, 당신만의 | Said to everyone = says nothing | Name a truly specific concern/situation |
|
||||||
|
| 아름다움을 완성하다, 당신의 아름다움 | Abstract, boilerplate | Make it concrete with verbs/scenes |
|
||||||
|
| 프리미엄, 명품, 하이엔드 | Unbacked elevation words | Replace with facts (technique, track record) |
|
||||||
|
|
||||||
|
## C. Pressure/urgency (also inducement risk)
|
||||||
|
| Cliché | Why it's old | Refresh direction |
|
||||||
|
|--------|--------------|-------------------|
|
||||||
|
| 지금 바로, 서두르세요, 놓치지 마세요 | Pressure cliché, lowers trust | Informational CTA ("편하게 문의해 주세요") |
|
||||||
|
| 마감 임박, 선착순, 단 O명 | Inducement/pressure, 심의 risk | Keep any time-limited notice plain, strip inducement |
|
||||||
|
| 후회 없는 선택, 결심만 하세요 | Decision-coercion tone | Leave the decision with the customer |
|
||||||
|
|
||||||
|
## D. Region/class clichés
|
||||||
|
| Cliché | Why it's old | Refresh direction |
|
||||||
|
|--------|--------------|-------------------|
|
||||||
|
| 강남 언니, 청담동 스타일, 강남 미인 | Regional cliché, fatigue | Replace region with specific result/experience |
|
||||||
|
|
||||||
|
## E. Over-used "safe" words (the irony)
|
||||||
|
| Expression | Caution | Refresh direction |
|
||||||
|
|------------|---------|-------------------|
|
||||||
|
| 자연스러운 / 자연스럽게 | The whole industry overuses it → loses distinctiveness | Keep the concept, vary the expression (scene/metaphor). ※ If a brand owns it as a slogan, treat as 🟦 |
|
||||||
|
| 1:1 맞춤, 나만의 디자인 | Now a standard phrase | Make the "what" of the customization concrete |
|
||||||
|
|
||||||
|
## F. Form habits (hurt readability)
|
||||||
|
- Exclamation spam (!!!), emoji overload, question-mark spam → one breath per sentence, restrained emoji
|
||||||
|
- All-caps / all-bold → emphasize in one place only
|
||||||
|
|
||||||
|
---
|
||||||
|
*Update rule: when you spot a newly-stale expression, add it here per `recursive_protocol.md`, and demote over-common "effective" phrases into this file.*
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Compliance Risk Dictionary (🔴) + Safe Replacements
|
||||||
|
|
||||||
|
> Based on Korean medical law and 의료광고 심의 (medical-ad review) standards. **This is not legal advice** — recommend pre-publication self-regulatory review. If any 🔴 remains, the option FAILS the rubric gate.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
1. Absolute / superlative expressions
|
||||||
|
2. Effect-guarantee / definitive expressions
|
||||||
|
3. Patient-inducement expressions
|
||||||
|
4. Comparative / exclusivity expressions
|
||||||
|
5. Testimonials / reviews / before-after photos
|
||||||
|
6. Neologisms / non-standard procedure names
|
||||||
|
7. Missing mandatory disclosures
|
||||||
|
8. Safe-replacement technique
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Absolute / superlative (prohibited)
|
||||||
|
Overstating or misleading beyond objective fact is prohibited.
|
||||||
|
- Risk terms: `100%`, `완벽`, `완전`, `전혀`, `모든`, `유일`, `최고`, `국내 1위`, `NO.1`, `확실히`, `반드시`, `무조건` (and English `best`, `only`, `perfect`)
|
||||||
|
- Replace with: "대부분의 경우", "개인에 따라 차이가 있습니다", "~를 기대할 수 있습니다"; use numbers only with a cited basis.
|
||||||
|
|
||||||
|
## 2. Effect-guarantee / definitive (prohibited / caution)
|
||||||
|
- Risk terms: `부작용 없는`, `안전한` (as an absolute), `반영구`, `평생`, `재발 없음`, `100% 자연스러움`, `반드시 예뻐지는`
|
||||||
|
- Replace with: "부작용은 극히 드뭅니다", "오래 유지되는 편입니다", "자연스러운 결과를 기대할 수 있습니다" + **attach a side-effect disclosure**.
|
||||||
|
|
||||||
|
## 3. Patient inducement (Medical Act Art. 27(3) risk)
|
||||||
|
Offering economic benefit to induce/solicit patients carries strong illegality risk.
|
||||||
|
- Risk terms: `무료`, `공짜`, `1+1`, `이벤트가`, `할인 이벤트`, `선착순 할인`, `○○원` (price front-loaded as a lure), `후기 작성 시 할인/적립/사은품`, `친구 소개하면`
|
||||||
|
- Replace with: quote price only as "상담 시 안내"; keep any discount **decoupled** from reviews/referrals as a plain time-limited notice; request reviews only voluntarily with no compensation.
|
||||||
|
- Note: even phrasing like "얼마일까" has been flagged as inducement.
|
||||||
|
|
||||||
|
## 4. Comparative / exclusivity (prohibited)
|
||||||
|
- Risk terms: `타 병원보다`, `다른 곳과 다르게` (disparaging tone), `국내 유일`, `업계 최초` (unverified), any direct/indirect disparagement of competitors
|
||||||
|
- Replace with: "저희만의 방법으로"; state own strengths factually (uniqueness instead of comparison).
|
||||||
|
|
||||||
|
## 5. Testimonials / reviews / before-after photos (review-subject, caution)
|
||||||
|
- Risk: using patient reviews or before/after photos in ads; implying effect via testimonial
|
||||||
|
- Handle: advertising use is subject to 심의; before/after must meet rules (same conditions, no misleading) → `[확인]` for review.
|
||||||
|
|
||||||
|
## 6. Neologisms / non-standard procedure names (caution)
|
||||||
|
Neologisms and gimmicky phrasing are a top cause of 심의 rejection.
|
||||||
|
- Risk: unverified proprietary procedure names, trendy slang, jargon
|
||||||
|
- Replace with: standard procedure names first; pair a brand procedure name with its standard name.
|
||||||
|
|
||||||
|
## 7. Missing mandatory disclosures (formal requirement)
|
||||||
|
- Required: **advertiser (clinic name)**, **side-effect / caution disclosure**, and on large online platforms the **review-approval number and validity period**
|
||||||
|
- Missing these risks unreviewed-ad / labeling violations → confirm with a checklist.
|
||||||
|
|
||||||
|
## 8. Safe-replacement technique
|
||||||
|
- Absolute → qualified ("대부분", "개인차")
|
||||||
|
- Guarantee → expectation ("기대할 수 있습니다")
|
||||||
|
- Inducement → information ("상담 시 안내")
|
||||||
|
- Comparison → uniqueness ("저희만의")
|
||||||
|
- Always pair with a side-effect disclosure and the advertiser name.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Sources: 대한의사협회 의료광고 심의기준; 의료광고 심의 금지 문구 안내; 강남언니 광고 가이드; related legal columns. Confirm the latest standard with the self-regulatory body.*
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Effective Patterns (🟢) — Application Coordinates
|
||||||
|
|
||||||
|
> **These are patterns, not finished lines.** Adapt the structure to the situation; pasting an example verbatim quickly turns it into a cliché.
|
||||||
|
|
||||||
|
## 1. Customer's own words (mirror the concern)
|
||||||
|
Naming the concern in the words a customer actually uses beats abstract praise.
|
||||||
|
- Pattern: [specific moment] + [discomfort]
|
||||||
|
- Ex: "웃을 때 잡히는 이마 주름", "눈뜨는 게 무거운 아침", "화장이 자꾸 접히는 눈가"
|
||||||
|
- Why: people who recognize themselves stop and read.
|
||||||
|
|
||||||
|
## 2. Scene & verb driven (restrain adjectives)
|
||||||
|
A scene or verb that reveals the result beats an adjective like "아름다운."
|
||||||
|
- Ex: "거울 앞에 서는 시간이 짧아졌다", "사진 찍을 때 앞줄에 선다"
|
||||||
|
- Why: show, don't tell.
|
||||||
|
|
||||||
|
## 3. Restrained numbers & facts (trust)
|
||||||
|
Verifiable facts are 심의-safe and persuasive, unlike exaggeration.
|
||||||
|
- Ex: "2008년부터", "회복 3일", "제가 직접 집도합니다"
|
||||||
|
- Caution: do not slide into superlatives/guarantees.
|
||||||
|
|
||||||
|
## 4. Honesty/humility as differentiation
|
||||||
|
When the category is full of hype, candor stands out.
|
||||||
|
- Ex: "안 되는 것도 말씀드립니다", "개선에 한계가 있을 수 있습니다"
|
||||||
|
- Why: trust is the hook.
|
||||||
|
|
||||||
|
## 5. Question hook (not misleading)
|
||||||
|
- Ex: "표정 주름, 한 번으로 될까요?", "왜 4개월마다일까요?"
|
||||||
|
- Caution: 심의 risk if the answer becomes an exaggeration/guarantee.
|
||||||
|
|
||||||
|
## 6. Rhythm / parallelism
|
||||||
|
- Ex (brand-asset example): "티 안 나게 수술하고, 티 나게 예뻐지는" — parallel/rhyme sticks in memory.
|
||||||
|
- Why: it must read cleanly aloud, with no stumble.
|
||||||
|
|
||||||
|
## 7. Channel-fit tone
|
||||||
|
- 카플친 / 알림톡: warm, thank-you tone, short
|
||||||
|
- Instagram: first-line hook, sensory and concise
|
||||||
|
- Blog / homepage: educational, evidence-led
|
||||||
|
- In-clinic POP: one line understood at a glance + a support line
|
||||||
|
|
||||||
|
---
|
||||||
|
*These coordinates only tell you which direction to aim. The actual line must be forged fresh each time.*
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Analyzed Exemplar Copy — Technique Coordinates (No Imitation)
|
||||||
|
|
||||||
|
> A distilled bank of **real** Korean (and a few global) plastic-surgery / aesthetic copy examples, curated from web research and filtered by this skill's rubric (fresh · compliance-safe · catchy).
|
||||||
|
> **Use these to learn the technique and then write something new.** Do not paste them into client copy — several are brand-owned, and copying defeats differentiation.
|
||||||
|
> Each entry: the line · the technique to steal · a compliance note.
|
||||||
|
|
||||||
|
## How to read this file
|
||||||
|
Organized by **technique** (not by clinic), because the transferable asset is the technique. Compliance notes flag where a line would be risky if used directly in a Korean medical ad (superlatives, effect-guarantees, inducement, comparison). "🌐" marks a non-Korean example kept for inspiration only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Metaphor + brand-name fusion
|
||||||
|
Fuse the brand name with a metaphor so the name itself carries meaning. Highest-value, low-risk technique.
|
||||||
|
- **"예쁨이 자란다, 나무성형외과"** — brand name (나무/tree) + growth metaphor + rhythm. Compliance: safe (metaphor, no guarantee).
|
||||||
|
- **"I am Detailist" (바노바기)** — first-person identity claim ("detail = us"); differentiates on *attitude*, not effect. Safe.
|
||||||
|
- **"진화하는 피부질환, 연구하는 피부주치의" (차앤박)** — clean parallelism + "주치의 (personal doctor)" metaphor = expertise + intimacy. Safe.
|
||||||
|
- **"REWRITE YOUR STORY" (리쥬란)** — regeneration reframed as authoring your own story; strong emotion, no effect claim. Safe.
|
||||||
|
- **Steal this:** let the brand/procedure name do double duty; anchor an abstract benefit to a concrete metaphor.
|
||||||
|
|
||||||
|
## 2. Contrarian reframe (flip the category default)
|
||||||
|
Turn an industry assumption on its head.
|
||||||
|
- **"Slow Banobagi / 느린 만큼 더 안전한 성형" (바노바기)** — flips "fast & flashy"; the slowness *is* the trust. Compliance: safe (but "더" leans comparative — keep it about self, not others).
|
||||||
|
- **"줄였다는 느낌 말고, 맞춘 느낌!" (아이디병원, 콧볼)** — "not A, but B" contrast + rhythm; conveys philosophy without superlatives. Relatively safe.
|
||||||
|
- **"누군가를 위해 예뻐지지 않아" (낫포유)** — value flip toward self-determination; fresh MZ tone. Safe.
|
||||||
|
- **Steal this:** name the default the category shouts, then stake the opposite ground.
|
||||||
|
|
||||||
|
## 3. Everyday scene / customer language
|
||||||
|
Make the need self-evident with a concrete life moment in the customer's own words.
|
||||||
|
- **"오늘부로 보정 어플 삭제" (아이디병원, 윤곽)** — everyday detail (photo-retouch app) implies the result. Compliance: caution — it edges toward a result claim; soften.
|
||||||
|
- **"무더운 여름에 반팔티 한장만 입자" (아이디병원, 여유증)** — season + scene generate the need naturally. Relatively safe.
|
||||||
|
- **"심술보, 불독살, 처진볼살" (아이디병원, 중안부)** — lists the concern in exact customer words. Relatively safe (avoid shaming register).
|
||||||
|
- **Steal this:** open on the mirror/photo/clothing moment where the concern actually bites.
|
||||||
|
|
||||||
|
## 4. Subjecthood / self-determination
|
||||||
|
Make the customer the subject; the clinic is the helper.
|
||||||
|
- **"예쁘게 나답게" (AB성형외과)** — parallel + self-acceptance. Safe.
|
||||||
|
- 🌐 **"You... redefined." (Centra)** — ellipsis whitespace + "redefined" in one word; ultra-concise, adapts well to Korean. Safe.
|
||||||
|
- 🌐 **"Own Your Look" (BOTOX/Allergan)** — imperative agency. Safe in spirit.
|
||||||
|
- **Steal this:** put 당신/나 as the grammatical subject; frame surgery as the customer's decision, not the clinic's promise.
|
||||||
|
|
||||||
|
## 5. Indirect emotion (result → feeling; dodges effect-claims)
|
||||||
|
Point at how life feels afterward, not at the physical result — the safest way to be moving.
|
||||||
|
- **"세상이 나에게 친절해졌다" (본 아이템)** — change framed as how the world *treats* you. Safe, long resonance.
|
||||||
|
- **"당신의 피부에 자신감을" (RNME, 슈링크)** — abstract value (confidence). Safe.
|
||||||
|
- 🌐 **"Recapture the beauty of self-confidence."** — beauty = confidence; avoids appearance claims, matches Korea's 2026 trust-first shift. Safe.
|
||||||
|
- **Steal this:** shift the object from the face to the feeling/relationship; this both moves people and clears 심의.
|
||||||
|
|
||||||
|
## 6. Wordplay / rhyme mnemonic
|
||||||
|
Sound-based memorability tied to the brand.
|
||||||
|
- **"예쁘면 DA야! / 잘생기면 DA야!" (디에이)** — brand name + interjection pun, gendered variants for reach. Relatively safe (rhyme-led).
|
||||||
|
- **"당신의 뷰티메이트" (Beauty+Medical+Mate, 리앤영)** — coined word compresses a "companion" position. Safe.
|
||||||
|
- **Steal this:** find the pun that lives inside the brand name; make it repeatable.
|
||||||
|
|
||||||
|
## 7. Location / positioning anchor
|
||||||
|
Compress positioning into a place or association.
|
||||||
|
- **"신사역에 있는 쥬얼리 / 가슴 성형을 잘 하는 쥬얼리" (쥬얼리)** — place anchor + drives associated search. Compliance: caution — "잘 하는" implies superiority; soften.
|
||||||
|
- **Steal this:** anchor to a place/association the customer already navigates by.
|
||||||
|
|
||||||
|
## 8. Question hook / curiosity
|
||||||
|
Open a loop the reader wants closed.
|
||||||
|
- **"한 장의 시트가 피부를 얼마나 바꿀 수 있을까" (더우주)** — curiosity question, easy to transplant to procedure content openers. Safe.
|
||||||
|
- **Steal this:** ask the exact question the hesitant customer is already asking — but don't answer it with a guarantee.
|
||||||
|
|
||||||
|
## 9. Contrast / triad structure
|
||||||
|
Structural rhythm carries the message.
|
||||||
|
- 🌐 **"Look Better. Breathe Better. Sleep Better."** — triad; sells *function* (e.g., rhinoplasty) — functional benefit is comparatively 심의-safe. Safe.
|
||||||
|
- **"다시, 원인을 정확히 분석하다" (아이디병원, 재수술)** — process/trust framing for anxious re-op customers; fits the 2026 trend. Safe.
|
||||||
|
- **Steal this:** triads and "process over promise" reassure without claiming results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AVOID cluster — frequent clichés that overlap with 심의 risk
|
||||||
|
Seen repeatedly in the wild; low distinctiveness and usually risky. Details in `corpus_cliche.md` / `corpus_compliance_risk.md`.
|
||||||
|
- Price/inducement: 특가 · 파격 · 초특가 · "최대 OO% 할인" · 선착순 O명 · 후기 작성 시 할인
|
||||||
|
- Superlative/exclusivity: 최고 · 1위 · 유일 · 국내최초 · (EN) best / only / perfect
|
||||||
|
- Effect-guarantee: 예뻐진다(단정) · 흉터/부작용 없는 · 책임진료
|
||||||
|
- Shaming/objectifying: 넙데데 · 코끼리 다리 · "SIZE MATTERS" (전형적 성상품화 논란)
|
||||||
|
- Tired beauty words: 여신 · 리즈 갱신 · 인형 같은
|
||||||
|
|
||||||
|
## Market context (why the above matters)
|
||||||
|
Korean aesthetic marketing is shifting from **price competition → trust competition**: event/discount lines are shrinking, and story-led, indirect emotional appeals ("변화보다 자신감") both pass 심의 and perform better. English-market lines built on `best / only / results / perfect` should **not** be translated literally — they become superlative/effect-guarantee risks in Korea.
|
||||||
|
|
||||||
|
## Distilled principle
|
||||||
|
The safest *and* catchiest formula = **(a) metaphor/brand-name fusion + (b) subjecthood framing + (c) process/emotion instead of result claims** — and never superlatives, discount inducement, or comparison.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (traceability)
|
||||||
|
- 바노바기 공식 — https://www.banobagi.com/page/sub07_00
|
||||||
|
- 쥬얼리성형외과 인터뷰(채널톡) — https://channel.io/ko/blog/articles/cs-case-jewerly-e75ca530
|
||||||
|
- 리쥬란(나무위키) — https://namu.wiki/w/%EB%A6%AC%EC%A5%AC%EB%9E%80
|
||||||
|
- 차앤박(CNP) 브랜드스토리 — https://www.cnpskin.com/pc/cnp/about-us/brand-story.html
|
||||||
|
- 나무성형외과 공모전(위비티) — https://www.wevity.com/index_university.php?c=find&s=_university&gbn=viewok&gp=71&ix=55321
|
||||||
|
- 아이디병원 프로모션 — https://www.idhospital.com/promotion/onsale
|
||||||
|
- 디에이성형외과 — https://daprs.com/board/event/list
|
||||||
|
- 카피 모음(채널톡) — https://channel.io/ko/blog/articles/copy222-ffa64ebe
|
||||||
|
- 성형외과 광고 인사이트(신뢰 중심 전환, AMPM) — https://inside.ampm.co.kr/insight/13055
|
||||||
|
- 강남언니 광고 가이드 — https://blog.gangnamunni.com/post/ads-guide
|
||||||
|
- 글로벌 클리닉 슬로건 DB — http://www.textart.ru/advertising/slogans/plastic-surgery.html
|
||||||
|
- BOTOX "The One & Only" (AbbVie) — https://news.abbvie.com/2025-09-09-BOTOX-R-Cosmetic-onabotulinumtoxinA-Unveils-The-One-Only-Campaign
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 5-Axis Re-scoring Rubric
|
||||||
|
|
||||||
|
Score the original and the improved copy on the same basis to make "did it actually get better?" objective.
|
||||||
|
|
||||||
|
## Axes and scale (1–5 each; compliance is a gate)
|
||||||
|
|
||||||
|
| Axis | Question | 1 | 5 |
|
||||||
|
|------|----------|---|---|
|
||||||
|
| **Freshness / Hook** | Non-stale and eye-catching? | tired, boilerplate | makes you stop and read |
|
||||||
|
| **Distinctiveness** | Avoids the clichés everyone uses? | seen-it-before | only this clinic |
|
||||||
|
| **Brand fit** | Matches tone & brand assets? | off-tone | reinforces brand-ness |
|
||||||
|
| **Compliance (심의)** | Free of risk expressions? | — | — (PASS/FAIL gate) |
|
||||||
|
| **Clarity** | Understood instantly? | what does it say | one-pass clear |
|
||||||
|
|
||||||
|
## Compliance gate rule
|
||||||
|
- If any 🔴 (risk expression) exists → **FAIL** → that option cannot be adopted, no matter how high the other scores.
|
||||||
|
- Missing mandatory disclosure (advertiser / side-effects / review number) is also a FAIL → then `[확인]`.
|
||||||
|
|
||||||
|
## Brand-asset line (required)
|
||||||
|
When scoring brand fit, avoid stopping at a vague "feel." Write one line:
|
||||||
|
> Brand **attribute / association / asset** this copy strengthens: (e.g., a "naturalness" association, an "honest expert" attribute, a slogan asset)
|
||||||
|
|
||||||
|
This anchors qualitative judgment in a measurable direction instead of vague sentiment.
|
||||||
|
|
||||||
|
## Judgment / improvement priority
|
||||||
|
1. Compliance gate first (fix immediately if FAIL)
|
||||||
|
2. Clarity (if it doesn't read, appeal is moot)
|
||||||
|
3. Distinctiveness & freshness (remove clichés → strengthen hook)
|
||||||
|
4. Brand-fit fine-tuning
|
||||||
|
|
||||||
|
## Scoring example
|
||||||
|
```
|
||||||
|
| Axis | Original | Recommended |
|
||||||
|
| Freshness | 2 | 4 |
|
||||||
|
| Distinctiveness | 2 | 4 |
|
||||||
|
| Brand fit | 3 | 5 |
|
||||||
|
| Compliance | PASS | PASS |
|
||||||
|
| Clarity | 4 | 4 |
|
||||||
|
→ Strengthened asset: "표정 습관" reframe reinforces the "honest educator" attribute
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Recursive Improvement Protocol — Evolving the Corpus
|
||||||
|
|
||||||
|
This skill's taste standard is not fixed; it **learns every campaign**. That keeps pace with staleness and keeps the differentiation coordinates current.
|
||||||
|
|
||||||
|
## After each trimming session
|
||||||
|
1. **Adopted expressions** → if a newly-working pattern, add to `corpus_effective.md` (generalize the structure/principle; do not store the whole line).
|
||||||
|
2. **Rejected / trimmed expressions** → if stale, add to `corpus_cliche.md`; if a compliance risk, add to `corpus_compliance_risk.md`.
|
||||||
|
3. **Demotion**: move once-effective but now-common expressions from `corpus_effective` → `corpus_cliche`.
|
||||||
|
|
||||||
|
## Tagging schema (record on each addition)
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| expression | expression/pattern (prefer a generalized form over a single line) |
|
||||||
|
| class | effective / cliché / risk |
|
||||||
|
| reason | why this class (one line) |
|
||||||
|
| channel | channel it was mainly used in |
|
||||||
|
| date | added/updated date |
|
||||||
|
| source | campaign / client source |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
| "headline it with the customer's own concern" | effective | instant empathy, stops the scroll | Instagram/blog | 2026-07 | Jamie 표정케어 |
|
||||||
|
| "인생 리즈 갱신" | cliché | 10-yr-old beauty buzzword, no distinctiveness | all | 2026-07 | — |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Periodic cleanup (quarterly recommended)
|
||||||
|
- Merge duplicates, delete dead entries
|
||||||
|
- If `corpus_effective` grows too large, review candidates for demotion
|
||||||
|
- Reflect changes in 심의 standards (check the self-regulatory body's notices → `[확인]`)
|
||||||
|
|
||||||
|
## Principle recap
|
||||||
|
- The corpus is for **avoidance / coordinates, not imitation**. Even `effective` entries mean "borrow the structure, write anew," not "reuse."
|
||||||
|
- Always confirm currency of compliance items. Judgments here are guidance, not legal advice.
|
||||||
|
|
||||||
|
## Optional user feedback loop
|
||||||
|
After a campaign ends, if the user shares which copy performed well/poorly (reactions, conversions, reviews), use that signal to re-tag effective/cliché. Performance data is the corpus's final arbiter.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Making Copy Trendy / Catchy Within 심의 Limits
|
||||||
|
|
||||||
|
> Premise: **the ceiling on wit is set by 의료광고 심의.** If the fun relies on exaggeration, superlatives, inducement, or comparison, it is void.
|
||||||
|
> Goal: restrained appeal that still catches the eye inside the rules.
|
||||||
|
|
||||||
|
## Six techniques that work
|
||||||
|
|
||||||
|
### 1. Win with specificity
|
||||||
|
Drop abstractions ("아름다움") for a concrete scene or object — the concreteness itself feels fresh.
|
||||||
|
- Flat: "자신감을 드립니다" → Sharp: "거울 보는 시간이 즐거워집니다"
|
||||||
|
|
||||||
|
### 2. Borrow the customer's words
|
||||||
|
Use the exact phrase the target types into a search box or community post as the headline.
|
||||||
|
- Ex: "표정 관리, 매번 큰맘 먹지 않아도"
|
||||||
|
|
||||||
|
### 3. Unexpected frame (reframe/twist)
|
||||||
|
Bend a familiar idea slightly, without creating a misunderstanding.
|
||||||
|
- Ex: "주름은 나이가 아니라 표정 습관" (educational reframe)
|
||||||
|
|
||||||
|
### 4. Rhythm / parallelism / rhyme
|
||||||
|
Make it stick through the pleasure of sound: parallel structure, triads, alliteration.
|
||||||
|
- Caution: never sacrifice meaning for rhyme.
|
||||||
|
|
||||||
|
### 5. Restrained humor (keep dignity)
|
||||||
|
Light but not cheap; never at the cost of the clinic's trust.
|
||||||
|
- Safe: situational empathy humor ("월요일 아침 눈꺼풀처럼 무거운")
|
||||||
|
- Risky: appearance-shaming, anxiety-baiting, self-deprecation
|
||||||
|
|
||||||
|
### 6. Whitespace and brevity
|
||||||
|
Don't say everything. Stop at one line and leave the rest to consultation.
|
||||||
|
|
||||||
|
## Boundaries — wit that does NOT work
|
||||||
|
- Appearance-shaming / anxiety-baiting ("이대로 괜찮으세요?" pressure)
|
||||||
|
- Superlatives dressed as humor ("완벽 변신 실화")
|
||||||
|
- Inducement-as-fun ("친구 데려오면 개이득") → 심의 / inducement violation
|
||||||
|
- Neologism / meme overuse → ages fast and is a top 심의-rejection cause
|
||||||
|
- Dignity-damaging or provocative gags
|
||||||
|
|
||||||
|
## Wit intensity by channel
|
||||||
|
| Channel | Wit allowance | Note |
|
||||||
|
|---------|---------------|------|
|
||||||
|
| Instagram | High (still 심의) | Focus on the first-line hook |
|
||||||
|
| Blog / homepage | Medium | Educational tone first; wit at the subhead level |
|
||||||
|
| 카플친 / 알림톡 | Low | Warm, thank-you tone; no heavy gags |
|
||||||
|
| In-clinic POP | Medium | One catchy line, understood instantly |
|
||||||
|
| Search ad | Low | Clarity and compliance first |
|
||||||
|
|
||||||
|
## Self-check questions
|
||||||
|
- Does this joke create a misunderstanding (effect / safety)?
|
||||||
|
- Is any inducement (economic-benefit emphasis) mixed in?
|
||||||
|
- Will it still be fresh in 6 months (not meme-dependent)?
|
||||||
|
- Does it read cleanly aloud, with no stumble?
|
||||||
@@ -4,7 +4,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "70"
|
agent-id: "70"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "70"
|
agent-id: "70"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "71"
|
agent-id: "71"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ version: 1.2.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "71"
|
agent-id: "71"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: auto
|
autonomy: auto
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "72"
|
agent-id: "72"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "72"
|
agent-id: "72"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "73"
|
agent-id: "73"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Quotation Manager for D.intelligence. Generates professional quotations
|
Quotation Manager for D.intelligence. Generates professional quotations
|
||||||
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "73"
|
agent-id: "73"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Quotation Manager for D.intelligence. Generates professional quotations
|
Quotation Manager for D.intelligence. Generates professional quotations
|
||||||
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
and estimates using a multi-agent sub-system (Scope, Resource, Pricing, Output).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "74"
|
agent-id: "74"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Service Architect for D.intelligence. Designs service scope and recommends
|
Service Architect for D.intelligence. Designs service scope and recommends
|
||||||
optimal module combinations through structured inquiry.
|
optimal module combinations through structured inquiry.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "74"
|
agent-id: "74"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Service Architect for D.intelligence. Designs service scope and recommends
|
Service Architect for D.intelligence. Designs service scope and recommends
|
||||||
optimal module combinations through structured inquiry.
|
optimal module combinations through structured inquiry.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "75"
|
agent-id: "75"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "75"
|
agent-id: "75"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: draft-and-wait
|
autonomy: draft-and-wait
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "76"
|
agent-id: "76"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||||
NDA, employment contracts, billing, HR operations, and compliance.
|
NDA, employment contracts, billing, HR operations, and compliance.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "76"
|
agent-id: "76"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
Back Office & HR Manager for D.intelligence. Handles invoicing, contracts,
|
||||||
NDA, employment contracts, billing, HR operations, and compliance.
|
NDA, employment contracts, billing, HR operations, and compliance.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "77"
|
agent-id: "77"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "77"
|
agent-id: "77"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
description: |
|
description: |
|
||||||
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
Account Manager for D.intelligence. Andrew's copilot for client relationship
|
||||||
management — project monitoring, meeting prep, status reports, issue escalation.
|
management — project monitoring, meeting prep, status reports, issue escalation.
|
||||||
|
|||||||
58
custom-skills/78-dintel-campaign-designer/README.md
Normal file
58
custom-skills/78-dintel-campaign-designer/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 78 — D.intelligence Campaign Designer
|
||||||
|
|
||||||
|
**Agent #78** in the [D.intelligence Agent Corps](../_dintel-shared/README.md).
|
||||||
|
Plans marketing campaigns, promotions, events, and launches as a disciplined 3-gate process, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
1. **Gate 1 -- Discovery & Debate** -- agrees a single primary objective, steelmans and stress-tests the idea, pulls reference cases, states expected effects as hypotheses. Produces a 1-page Decision Log.
|
||||||
|
2. **Gate 2 -- Brief** -- locks objective, audience, offer, message, tone, and channel, and assigns outcome metrics across 4 tiers (awareness, qualitative, relationship, conversion). Produces a 1-page Campaign Brief.
|
||||||
|
3. **Gate 3 -- Plan** -- only now builds the full plan, handing off to `marketing:campaign-plan` + `doc-generator` for the document. Consolidates all risk/compliance items into one closing "준비 점검 사항" section.
|
||||||
|
|
||||||
|
Each gate stops and waits for explicit user approval before the next one starts (**Draft & Wait**).
|
||||||
|
|
||||||
|
## Agent Corps Context
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Agent # | 78 |
|
||||||
|
| Skill Name | `dintel-campaign-designer` |
|
||||||
|
| Version | 1.0.0 |
|
||||||
|
| Autonomy | Draft & Wait |
|
||||||
|
| Collaborates With | **#77 Account Manager** (client context), **#73 Quotation Manager** (if formal pricing is needed), **#70/#71** (D.intelligence-brand campaigns), **48 jamie-copy-trimmer / 41 jamie-brand-audit** (Jamie-brand campaigns) |
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- "campaign plan", "plan a promotion", "캠페인 설계"
|
||||||
|
- 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획
|
||||||
|
|
||||||
|
## Cross-Brand Routing
|
||||||
|
|
||||||
|
This agent is not D.intelligence-exclusive -- the gates are brand-agnostic. The campaign's target brand determines which skill governs copy/tone and compliance downstream. See the routing table in `SKILL.md`.
|
||||||
|
|
||||||
|
## Universal Guardrails
|
||||||
|
|
||||||
|
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off.
|
||||||
|
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
||||||
|
3. **Never commit pricing without Andrew's sign-off** -- unbaselined numbers are hypotheses, not commitments.
|
||||||
|
4. **Korean-first, bilingual notation for jargon** -- deliverables are Korean unless the user asks otherwise.
|
||||||
|
5. **Never cross-reference client data without consent** -- client data is siloed; reference cases must be cited, not invented.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
78-dintel-campaign-designer/
|
||||||
|
├── SKILL.md # Canonical directive (root, loadable)
|
||||||
|
├── code/
|
||||||
|
│ └── CLAUDE.md # Claude Code pointer + quick reference
|
||||||
|
├── desktop/
|
||||||
|
│ └── SKILL.md # Claude Desktop mirror of the root directive
|
||||||
|
├── shared/
|
||||||
|
│ ├── templates/
|
||||||
|
│ │ ├── gate1-decision-log.md
|
||||||
|
│ │ ├── gate2-campaign-brief.md
|
||||||
|
│ │ └── gate3-plan-outline.md
|
||||||
|
│ └── references/
|
||||||
|
│ └── debate-and-outcomes.md # Facilitation prompts + 4-tier outcome framework
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
147
custom-skills/78-dintel-campaign-designer/SKILL.md
Normal file
147
custom-skills/78-dintel-campaign-designer/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
name: dintel-campaign-designer
|
||||||
|
version: 1.0.0
|
||||||
|
last_updated: 2026-07-01
|
||||||
|
canon_compliance: v1.3
|
||||||
|
agent-id: "78"
|
||||||
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
|
description: |
|
||||||
|
Campaign Designer for D.intelligence. Plans marketing campaigns, promotions,
|
||||||
|
events, and launches as a 3-stage gate process -- Discovery & Debate -> Brief
|
||||||
|
-> Plan -- instead of jumping straight to a finished document. Use whenever
|
||||||
|
the user wants to plan a campaign, promotion, event, launch, or 기획안; start
|
||||||
|
here first, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
||||||
|
Triggers: 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획, campaign plan,
|
||||||
|
plan a promotion, 캠페인 설계. Produces a 1-page Decision Log (Gate 1) and
|
||||||
|
1-page Brief (Gate 2), each needing explicit user approval, before the full
|
||||||
|
plan (Gate 3).
|
||||||
|
autonomy: draft-and-wait
|
||||||
|
---
|
||||||
|
|
||||||
|
# D.intelligence Campaign Designer
|
||||||
|
|
||||||
|
> Agent #78 | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
||||||
|
|
||||||
|
A disciplined way to plan campaigns: **debate and agree direction before producing any document.** Autonomy level: **Draft & Wait** -- each gate stops for explicit user approval before the next gate starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ v1.3 정합성 — 단일 진실 (Single Source of Truth)
|
||||||
|
|
||||||
|
> **갱신일**: 2026-07-01 (v1.3 정합 적용) | **기준**: `knowledge-base/canon/` v1.0 + BRAND-GUIDE v1.3
|
||||||
|
|
||||||
|
**참조 의무 1순위** (충돌 시 canon이 우선) — *when the campaign is FOR D.intelligence itself*:
|
||||||
|
|
||||||
|
| Canon 문서 | 사용 시점 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `knowledge-base/canon/brand-canon.md` v1.0 | 캠페인 톤 & 메시지 프레임 |
|
||||||
|
| `knowledge-base/canon/fact-sheet.md` v1.0 | 법인·연락처 표기 (기획서 표지/각주) |
|
||||||
|
| `knowledge-base/canon/service-architecture.md` v1.0 | 캠페인이 특정 모듈/패키지를 프로모션할 경우 |
|
||||||
|
| `knowledge-base/canon/naming-conventions.md` v1.0 | 산출물 파일명 |
|
||||||
|
| `knowledge-base/gotcha/01_outdated-facts.md` | 회피 대상 (주소·이메일·CEO 직함) |
|
||||||
|
|
||||||
|
### Cross-brand routing (this agent is NOT D.intelligence-exclusive)
|
||||||
|
|
||||||
|
Unlike most Agent Corps members, Campaign Designer runs the same 3-gate process regardless of which brand the campaign is for. The brand determines which skill governs tone/copy/compliance downstream -- confirm the target brand at Gate 1 if it isn't obvious:
|
||||||
|
|
||||||
|
| Campaign is for | Copy & tone | Compliance / brand review |
|
||||||
|
|------------------|-------------|---------------------------|
|
||||||
|
| D.intelligence | `dintel-brand-editor` (#71) | `dintel-brand-guardian` (#70) |
|
||||||
|
| Jamie Clinic | `jamie-copy-trimmer` (48) | `jamie-brand-audit` / "jamie-brand-guardian" (41) |
|
||||||
|
| OurDigital | `ourdigital-ad-manager` (07) | `ourdigital-brand-guide` (01) |
|
||||||
|
| Other client | Ask which brand guide applies -- do not assume D.intelligence defaults | Same |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Corps Context
|
||||||
|
|
||||||
|
- **Agent #78** -- Campaign Designer
|
||||||
|
- **Downstream (Gate 3 handoff)**: the `marketing:campaign-plan` skill + `doc-generator` skill turn the approved outline into the finished document
|
||||||
|
- **Collaborates with**: Agent #77 (Account Manager) for client context, Agent #73 (Quotation Manager) if the plan needs formal pricing, Agent #70/#71 for D.intelligence-brand campaigns
|
||||||
|
- **Shared constants**: `_dintel-shared/src/dintel/brand.py` (colors, terminology) -- only relevant when the campaign is for D.intelligence itself
|
||||||
|
|
||||||
|
## Universal Guardrails
|
||||||
|
|
||||||
|
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off; never advance a gate on the agent's own initiative.
|
||||||
|
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
||||||
|
3. **Never commit pricing without Andrew's sign-off** -- any budget/quantitative target in Gate 2/3 without a baseline is a hypothesis, not a commitment.
|
||||||
|
4. **Korean-first, bilingual notation** -- these directives are in English; deliverables (Decision Log, Brief, Plan) are written in Korean unless the user asks otherwise.
|
||||||
|
5. **Never cross-reference client data without consent** -- reference cases and precedents must be sourced/cited, not invented, and client data stays siloed by account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The recurring failure mode is going straight from a request to a finished plan. When direction isn't agreed first, plans come out bloated (everything crammed in), quant-skewed (arbitrary conversion targets), and tonally off. Gates fix this by forcing debate, then agreement, then documentation -- in that order.
|
||||||
|
|
||||||
|
## The three gates
|
||||||
|
|
||||||
|
Each gate is a checkpoint that **requires explicit user approval before advancing.** Do not skip ahead. At every gate, record rejected alternatives and dissenting views so the decision trail survives.
|
||||||
|
|
||||||
|
| Gate | Purpose | Output | Advance when |
|
||||||
|
|------|---------|--------|--------------|
|
||||||
|
| 1. Discovery & Debate | Agree the ONE primary objective; pressure-test the idea | Decision Log (1 page) | User confirms objective & decision |
|
||||||
|
| 2. Brief | Lock objective, audience, offer, outcomes, tone, message | Campaign Brief (1 page) | User approves the brief |
|
||||||
|
| 3. Plan | Build the full plan/document | Full plan (docx) | -- (deliverable) |
|
||||||
|
|
||||||
|
### Gate 1 -- Discovery & Debate
|
||||||
|
|
||||||
|
Goal: reach agreement on a single primary objective and stress-test the idea **before any document exists.**
|
||||||
|
|
||||||
|
Do:
|
||||||
|
- **Objective priority** -- pick ONE primary objective; everything else is secondary. Force the trade-off.
|
||||||
|
- **Debate both sides** -- steelman the idea, then argue against it as devil's advocate; respond to the strongest objection. Run a quick pre-mortem ("if this fails in 90 days, why?").
|
||||||
|
- **Reference cases** -- bring 1-3 real examples of what worked/failed elsewhere (use web research or connected tools); cite sources.
|
||||||
|
- **Expected effects as HYPOTHESES** -- not targets yet. State what you'd observe to know.
|
||||||
|
- **Gaps -> [확인]** -- don't invent missing facts.
|
||||||
|
- **Confirm the target brand** if it isn't obvious (see Cross-brand routing above) -- it determines which skills govern Gate 3.
|
||||||
|
|
||||||
|
Output: fill `shared/templates/gate1-decision-log.md`. Present it and ask the user to confirm/adjust before Gate 2. See `shared/references/debate-and-outcomes.md` for facilitation prompts.
|
||||||
|
|
||||||
|
### Gate 2 -- Brief
|
||||||
|
|
||||||
|
Goal: a 1-page agreement that will govern the plan.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
- Convert the agreed decision into a tight brief: objective, audience, offer, core + supporting messages, tone, channels.
|
||||||
|
- **Assign outcome metrics across 4 tiers** (see below). Label any number without a baseline as a *hypothesis*. For each qualitative goal, name the brand **attribute / association / asset** it touches.
|
||||||
|
|
||||||
|
Output: fill `shared/templates/gate2-campaign-brief.md`. Gate: user approves before Gate 3.
|
||||||
|
|
||||||
|
### Gate 3 -- Plan
|
||||||
|
|
||||||
|
Only now build the full plan. Hand off to the `marketing:campaign-plan` skill and `doc-generator` for the document.
|
||||||
|
|
||||||
|
Enforce:
|
||||||
|
- **Brevity / one-topic-one-place** -- keep the body decision-relevant; put **all** risks, compliance, and due-diligence in ONE section at the very end: **"준비 점검 사항"**. Don't scatter them.
|
||||||
|
- **[확인] convention** -- mark any gap instead of guessing.
|
||||||
|
- **Copy & compliance** -- route through the skills named in Cross-brand routing above for the campaign's target brand.
|
||||||
|
|
||||||
|
Output: fill/expand `shared/templates/gate3-plan-outline.md`, then produce the document.
|
||||||
|
|
||||||
|
## The 4-tier outcome framework (used at Gate 2 & 3)
|
||||||
|
|
||||||
|
Diversify beyond conversion so goals are credible and not arbitrarily quantitative:
|
||||||
|
|
||||||
|
1. **Awareness / cognitive** -- reach, recall, branded-search lift
|
||||||
|
2. **Qualitative** -- message reaction, tone fit, consult quality -- *and name the brand asset it strengthens*
|
||||||
|
3. **Relationship / advocacy** -- revisit intent, referral/recommendation, voluntary reviews
|
||||||
|
4. **Quantitative conversion** -- inquiries, purchases -- *label as hypothesis if there's no baseline*
|
||||||
|
|
||||||
|
## Principles baked in (from recurring feedback)
|
||||||
|
|
||||||
|
- **Debate before document.** Gates 1-2 exist so Gate 3 is tight.
|
||||||
|
- **Brevity.** Risks/compliance consolidated in one end section, not repeated throughout.
|
||||||
|
- **Ask/confirm over completeness-anxiety.** Use `[확인]`.
|
||||||
|
- **Diversified, brand-equity-anchored outcomes.** No lonely conversion targets.
|
||||||
|
|
||||||
|
## Templates & references
|
||||||
|
|
||||||
|
- `shared/templates/gate1-decision-log.md` -- Gate 1 Decision Log (1p)
|
||||||
|
- `shared/templates/gate2-campaign-brief.md` -- Gate 2 Campaign Brief (1p)
|
||||||
|
- `shared/templates/gate3-plan-outline.md` -- Gate 3 Plan outline (maps to `marketing:campaign-plan`)
|
||||||
|
- `shared/references/debate-and-outcomes.md` -- facilitation prompts + the 4-tier outcome framework in depth
|
||||||
|
|
||||||
|
## Reminder
|
||||||
|
|
||||||
|
The gates are about sequencing decisions, not adding bureaucracy. If the user explicitly wants to skip to a draft, do so -- but tell them which gate you're bypassing and what risk that carries.
|
||||||
31
custom-skills/78-dintel-campaign-designer/code/CLAUDE.md
Normal file
31
custom-skills/78-dintel-campaign-designer/code/CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# D.intelligence Campaign Designer
|
||||||
|
|
||||||
|
> **Agent #78** | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
||||||
|
> canon_compliance: v1.3 | last_updated: 2026-07-01
|
||||||
|
|
||||||
|
> Canonical directive: **`../SKILL.md`** (this file is a pointer, not a duplicate).
|
||||||
|
|
||||||
|
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: each gate stops for Andrew's (or the requesting user's) explicit approval before the next gate starts.
|
||||||
|
|
||||||
|
## Agent Corps Context
|
||||||
|
|
||||||
|
- **Agent #78** -- Campaign Designer
|
||||||
|
- **Downstream**: `marketing:campaign-plan` + `doc-generator` produce the finished document after Gate 3 is approved
|
||||||
|
- **Cross-brand**: this agent is not D.intelligence-exclusive -- see the Cross-brand routing table in `../SKILL.md` for which brand skill governs copy/compliance
|
||||||
|
|
||||||
|
## Universal Guardrails
|
||||||
|
|
||||||
|
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off.
|
||||||
|
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive.
|
||||||
|
3. **Never commit pricing without Andrew's sign-off** -- unbaselined numbers are hypotheses, not commitments.
|
||||||
|
4. **Korean-first, bilingual notation** -- deliverables are Korean unless asked otherwise.
|
||||||
|
5. **Never cross-reference client data without consent** -- client data stays siloed by account.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
- **Gates**: 1 Discovery & Debate → 2 Brief → 3 Plan (each needs explicit approval to advance)
|
||||||
|
- **Templates**: `../shared/templates/gate1-decision-log.md`, `gate2-campaign-brief.md`, `gate3-plan-outline.md`
|
||||||
|
- **Facilitation guide**: `../shared/references/debate-and-outcomes.md`
|
||||||
|
- **Canon (authoritative, when campaign is for D.intelligence)**: `knowledge-base/canon/brand-canon.md` v1.0
|
||||||
|
|
||||||
|
See `../SKILL.md` for the full gate-by-gate workflow and the 4-tier outcome framework.
|
||||||
147
custom-skills/78-dintel-campaign-designer/desktop/SKILL.md
Normal file
147
custom-skills/78-dintel-campaign-designer/desktop/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
name: dintel-campaign-designer
|
||||||
|
version: 1.0.0
|
||||||
|
last_updated: 2026-07-01
|
||||||
|
canon_compliance: v1.3
|
||||||
|
agent-id: "78"
|
||||||
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
|
description: |
|
||||||
|
Campaign Designer for D.intelligence. Plans marketing campaigns, promotions,
|
||||||
|
events, and launches as a 3-stage gate process -- Discovery & Debate -> Brief
|
||||||
|
-> Plan -- instead of jumping straight to a finished document. Use whenever
|
||||||
|
the user wants to plan a campaign, promotion, event, launch, or 기획안; start
|
||||||
|
here first, for any brand (D.intelligence, OurDigital, Jamie, or a client).
|
||||||
|
Triggers: 캠페인 기획, 프로모션 기획, 기획안 만들어, 이벤트 기획, campaign plan,
|
||||||
|
plan a promotion, 캠페인 설계. Produces a 1-page Decision Log (Gate 1) and
|
||||||
|
1-page Brief (Gate 2), each needing explicit user approval, before the full
|
||||||
|
plan (Gate 3).
|
||||||
|
autonomy: draft-and-wait
|
||||||
|
---
|
||||||
|
|
||||||
|
# D.intelligence Campaign Designer
|
||||||
|
|
||||||
|
> Agent #78 | `dintel-campaign-designer` v1.0.0 | D.intelligence Agent Corps
|
||||||
|
|
||||||
|
A disciplined way to plan campaigns: **debate and agree direction before producing any document.** Autonomy level: **Draft & Wait** -- each gate stops for explicit user approval before the next gate starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ v1.3 정합성 — 단일 진실 (Single Source of Truth)
|
||||||
|
|
||||||
|
> **갱신일**: 2026-07-01 (v1.3 정합 적용) | **기준**: `knowledge-base/canon/` v1.0 + BRAND-GUIDE v1.3
|
||||||
|
|
||||||
|
**참조 의무 1순위** (충돌 시 canon이 우선) — *when the campaign is FOR D.intelligence itself*:
|
||||||
|
|
||||||
|
| Canon 문서 | 사용 시점 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `knowledge-base/canon/brand-canon.md` v1.0 | 캠페인 톤 & 메시지 프레임 |
|
||||||
|
| `knowledge-base/canon/fact-sheet.md` v1.0 | 법인·연락처 표기 (기획서 표지/각주) |
|
||||||
|
| `knowledge-base/canon/service-architecture.md` v1.0 | 캠페인이 특정 모듈/패키지를 프로모션할 경우 |
|
||||||
|
| `knowledge-base/canon/naming-conventions.md` v1.0 | 산출물 파일명 |
|
||||||
|
| `knowledge-base/gotcha/01_outdated-facts.md` | 회피 대상 (주소·이메일·CEO 직함) |
|
||||||
|
|
||||||
|
### Cross-brand routing (this agent is NOT D.intelligence-exclusive)
|
||||||
|
|
||||||
|
Unlike most Agent Corps members, Campaign Designer runs the same 3-gate process regardless of which brand the campaign is for. The brand determines which skill governs tone/copy/compliance downstream -- confirm the target brand at Gate 1 if it isn't obvious:
|
||||||
|
|
||||||
|
| Campaign is for | Copy & tone | Compliance / brand review |
|
||||||
|
|------------------|-------------|---------------------------|
|
||||||
|
| D.intelligence | `dintel-brand-editor` (#71) | `dintel-brand-guardian` (#70) |
|
||||||
|
| Jamie Clinic | `jamie-copy-trimmer` (48) | `jamie-brand-audit` / "jamie-brand-guardian" (41) |
|
||||||
|
| OurDigital | `ourdigital-ad-manager` (07) | `ourdigital-brand-guide` (01) |
|
||||||
|
| Other client | Ask which brand guide applies -- do not assume D.intelligence defaults | Same |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Corps Context
|
||||||
|
|
||||||
|
- **Agent #78** -- Campaign Designer
|
||||||
|
- **Downstream (Gate 3 handoff)**: the `marketing:campaign-plan` skill + `doc-generator` skill turn the approved outline into the finished document
|
||||||
|
- **Collaborates with**: Agent #77 (Account Manager) for client context, Agent #73 (Quotation Manager) if the plan needs formal pricing, Agent #70/#71 for D.intelligence-brand campaigns
|
||||||
|
- **Shared constants**: `_dintel-shared/src/dintel/brand.py` (colors, terminology) -- only relevant when the campaign is for D.intelligence itself
|
||||||
|
|
||||||
|
## Universal Guardrails
|
||||||
|
|
||||||
|
1. **Never send to clients without Andrew's approval** -- all three gates require explicit sign-off; never advance a gate on the agent's own initiative.
|
||||||
|
2. **Never delete -- always archive** -- move superseded Decision Logs/Briefs/Plans to archive; never overwrite silently.
|
||||||
|
3. **Never commit pricing without Andrew's sign-off** -- any budget/quantitative target in Gate 2/3 without a baseline is a hypothesis, not a commitment.
|
||||||
|
4. **Korean-first, bilingual notation** -- these directives are in English; deliverables (Decision Log, Brief, Plan) are written in Korean unless the user asks otherwise.
|
||||||
|
5. **Never cross-reference client data without consent** -- reference cases and precedents must be sourced/cited, not invented, and client data stays siloed by account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The recurring failure mode is going straight from a request to a finished plan. When direction isn't agreed first, plans come out bloated (everything crammed in), quant-skewed (arbitrary conversion targets), and tonally off. Gates fix this by forcing debate, then agreement, then documentation -- in that order.
|
||||||
|
|
||||||
|
## The three gates
|
||||||
|
|
||||||
|
Each gate is a checkpoint that **requires explicit user approval before advancing.** Do not skip ahead. At every gate, record rejected alternatives and dissenting views so the decision trail survives.
|
||||||
|
|
||||||
|
| Gate | Purpose | Output | Advance when |
|
||||||
|
|------|---------|--------|--------------|
|
||||||
|
| 1. Discovery & Debate | Agree the ONE primary objective; pressure-test the idea | Decision Log (1 page) | User confirms objective & decision |
|
||||||
|
| 2. Brief | Lock objective, audience, offer, outcomes, tone, message | Campaign Brief (1 page) | User approves the brief |
|
||||||
|
| 3. Plan | Build the full plan/document | Full plan (docx) | -- (deliverable) |
|
||||||
|
|
||||||
|
### Gate 1 -- Discovery & Debate
|
||||||
|
|
||||||
|
Goal: reach agreement on a single primary objective and stress-test the idea **before any document exists.**
|
||||||
|
|
||||||
|
Do:
|
||||||
|
- **Objective priority** -- pick ONE primary objective; everything else is secondary. Force the trade-off.
|
||||||
|
- **Debate both sides** -- steelman the idea, then argue against it as devil's advocate; respond to the strongest objection. Run a quick pre-mortem ("if this fails in 90 days, why?").
|
||||||
|
- **Reference cases** -- bring 1-3 real examples of what worked/failed elsewhere (use web research or connected tools); cite sources.
|
||||||
|
- **Expected effects as HYPOTHESES** -- not targets yet. State what you'd observe to know.
|
||||||
|
- **Gaps -> [확인]** -- don't invent missing facts.
|
||||||
|
- **Confirm the target brand** if it isn't obvious (see Cross-brand routing above) -- it determines which skills govern Gate 3.
|
||||||
|
|
||||||
|
Output: fill `shared/templates/gate1-decision-log.md`. Present it and ask the user to confirm/adjust before Gate 2. See `shared/references/debate-and-outcomes.md` for facilitation prompts.
|
||||||
|
|
||||||
|
### Gate 2 -- Brief
|
||||||
|
|
||||||
|
Goal: a 1-page agreement that will govern the plan.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
- Convert the agreed decision into a tight brief: objective, audience, offer, core + supporting messages, tone, channels.
|
||||||
|
- **Assign outcome metrics across 4 tiers** (see below). Label any number without a baseline as a *hypothesis*. For each qualitative goal, name the brand **attribute / association / asset** it touches.
|
||||||
|
|
||||||
|
Output: fill `shared/templates/gate2-campaign-brief.md`. Gate: user approves before Gate 3.
|
||||||
|
|
||||||
|
### Gate 3 -- Plan
|
||||||
|
|
||||||
|
Only now build the full plan. Hand off to the `marketing:campaign-plan` skill and `doc-generator` for the document.
|
||||||
|
|
||||||
|
Enforce:
|
||||||
|
- **Brevity / one-topic-one-place** -- keep the body decision-relevant; put **all** risks, compliance, and due-diligence in ONE section at the very end: **"준비 점검 사항"**. Don't scatter them.
|
||||||
|
- **[확인] convention** -- mark any gap instead of guessing.
|
||||||
|
- **Copy & compliance** -- route through the skills named in Cross-brand routing above for the campaign's target brand.
|
||||||
|
|
||||||
|
Output: fill/expand `shared/templates/gate3-plan-outline.md`, then produce the document.
|
||||||
|
|
||||||
|
## The 4-tier outcome framework (used at Gate 2 & 3)
|
||||||
|
|
||||||
|
Diversify beyond conversion so goals are credible and not arbitrarily quantitative:
|
||||||
|
|
||||||
|
1. **Awareness / cognitive** -- reach, recall, branded-search lift
|
||||||
|
2. **Qualitative** -- message reaction, tone fit, consult quality -- *and name the brand asset it strengthens*
|
||||||
|
3. **Relationship / advocacy** -- revisit intent, referral/recommendation, voluntary reviews
|
||||||
|
4. **Quantitative conversion** -- inquiries, purchases -- *label as hypothesis if there's no baseline*
|
||||||
|
|
||||||
|
## Principles baked in (from recurring feedback)
|
||||||
|
|
||||||
|
- **Debate before document.** Gates 1-2 exist so Gate 3 is tight.
|
||||||
|
- **Brevity.** Risks/compliance consolidated in one end section, not repeated throughout.
|
||||||
|
- **Ask/confirm over completeness-anxiety.** Use `[확인]`.
|
||||||
|
- **Diversified, brand-equity-anchored outcomes.** No lonely conversion targets.
|
||||||
|
|
||||||
|
## Templates & references
|
||||||
|
|
||||||
|
- `shared/templates/gate1-decision-log.md` -- Gate 1 Decision Log (1p)
|
||||||
|
- `shared/templates/gate2-campaign-brief.md` -- Gate 2 Campaign Brief (1p)
|
||||||
|
- `shared/templates/gate3-plan-outline.md` -- Gate 3 Plan outline (maps to `marketing:campaign-plan`)
|
||||||
|
- `shared/references/debate-and-outcomes.md` -- facilitation prompts + the 4-tier outcome framework in depth
|
||||||
|
|
||||||
|
## Reminder
|
||||||
|
|
||||||
|
The gates are about sequencing decisions, not adding bureaucracy. If the user explicitly wants to skip to a draft, do so -- but tell them which gate you're bypassing and what risk that carries.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Facilitation Guide — Debate & Outcomes
|
||||||
|
|
||||||
|
Deeper guidance for running Gate 1's debate and setting Gate 2's outcome metrics.
|
||||||
|
|
||||||
|
## Running the Gate 1 debate (don't rubber-stamp)
|
||||||
|
|
||||||
|
**Objective priority — force one.** If the user lists several goals (retention, cash, awareness), make them rank. Multiple co-equal objectives are the root of bloated, contradictory plans. Ask: "If we could only achieve one, which one?"
|
||||||
|
|
||||||
|
**Steelman, then devil's advocate.**
|
||||||
|
- Steelman: state the strongest possible case FOR the idea, better than the user did.
|
||||||
|
- Devil's advocate: argue AGAINST it — where it wastes money, annoys customers, cannibalizes, or breaks compliance.
|
||||||
|
- Then answer the single strongest objection. If it can't be answered, that's a finding.
|
||||||
|
|
||||||
|
**Pre-mortem.** "It's 90 days later and this flopped. Write the reason." Common failure seeds: wrong audience, offer too strong/weak, no follow-through, channel fatigue, compliance block.
|
||||||
|
|
||||||
|
**Reference cases.** Pull 1–3 concrete precedents (competitors, other categories, past campaigns). Cite sources. Ask what specifically transfers and what doesn't. Avoid "best practices" hand-waving.
|
||||||
|
|
||||||
|
**Hypotheses, not targets (yet).** At Gate 1, expected effects are hypotheses with an observation method: "We believe X will move Y; we'll know by watching Z." Numbers get committed only at Gate 2, and only with a baseline.
|
||||||
|
|
||||||
|
## The 4-tier outcome framework (Gate 2)
|
||||||
|
|
||||||
|
Brand marketing lives partly in perception, which is hard to measure — but "hard to measure" must not become "vague direction." Anchor each qualitative goal to a **brand-equity** element: an **attribute** (what the brand is), an **association** (what it evokes), or an **asset** (an owned phrase/symbol). That keeps it concrete.
|
||||||
|
|
||||||
|
| Tier | Examples of metrics | Note |
|
||||||
|
|------|--------------------|------|
|
||||||
|
| Awareness / cognitive | reach, impressions, branded-search lift, recall | leading indicators |
|
||||||
|
| Qualitative | message reaction, tone-fit, consult quality, sentiment | **name the brand attribute/association/asset it strengthens** |
|
||||||
|
| Relationship / advocacy | revisit intent, referral/recommendation, voluntary reviews | trust signals |
|
||||||
|
| Quantitative conversion | inquiries, bookings, purchases, revenue | **no baseline → label as hypothesis, not target** |
|
||||||
|
|
||||||
|
**Baseline rule.** A number without a baseline is a guess wearing a target's clothes. Either supply a baseline (past data, connected analytics) or explicitly label it a hypothesis / observation metric. This is what makes targets credible.
|
||||||
|
|
||||||
|
**Measurement method per qualitative goal.** For each soft metric, write how you'd observe it (survey item, review sentiment, consult-note tag). If you can't name a method, it's a direction, not a metric — say so.
|
||||||
|
|
||||||
|
## Quick gate checklist
|
||||||
|
- Gate 1 pass: one primary objective agreed; strongest objection answered; ≥1 reference case; effects framed as hypotheses; user approved.
|
||||||
|
- Gate 2 pass: brief fits one page; outcomes span the 4 tiers; baseline-less numbers labeled hypotheses; qualitative goals tied to brand assets; user approved.
|
||||||
|
- Gate 3: body decision-relevant; risks/compliance consolidated in "준비 점검 사항"; gaps marked [확인]; copy through jamie-copy-trimmer; compliance via jamie-brand-guardian.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 결정 로그 — Gate 1: Discovery & Debate
|
||||||
|
|
||||||
|
<!-- One page. Produced BEFORE any plan document. Fill it, present it, then get user approval to pass Gate 1.
|
||||||
|
Language: Korean deliverable. Keep it to one page. -->
|
||||||
|
|
||||||
|
## 0. 개요
|
||||||
|
- 캠페인/과제:
|
||||||
|
- 작성일 / 참여자:
|
||||||
|
|
||||||
|
## 1. 목적 우선순위 <!-- pick ONE primary; force the trade-off -->
|
||||||
|
- **주 목적 (1개):**
|
||||||
|
- 부 목적:
|
||||||
|
- 왜 이것이 1순위인가:
|
||||||
|
|
||||||
|
## 2. 디베이트 (찬 / 반) <!-- steelman both sides honestly -->
|
||||||
|
- 찬성 논거 (통하는 이유):
|
||||||
|
- 반대 논거 (악마의 변호인):
|
||||||
|
- 프리모템 — "90일 뒤 실패했다면 이유는?":
|
||||||
|
- 반론에 대한 대응:
|
||||||
|
|
||||||
|
## 3. 레퍼런스 사례 <!-- 1-3 real cases, cite source -->
|
||||||
|
- 사례 1 (출처): — 시사점:
|
||||||
|
- 사례 2 (출처): — 시사점:
|
||||||
|
|
||||||
|
## 4. 기대효과 (가설) <!-- hypotheses, NOT targets. baseline unknown = hypothesis -->
|
||||||
|
- 가설:
|
||||||
|
- 검증 방법 (무엇을 보면 아는가):
|
||||||
|
|
||||||
|
## 5. 기각된 대안 / 반대 의견 <!-- preserve the decision trail -->
|
||||||
|
- 검토했으나 채택 안 함: — 이유:
|
||||||
|
|
||||||
|
## 6. 결정
|
||||||
|
- 결정 사항:
|
||||||
|
|
||||||
|
## 7. 준비 점검 사항 <!-- consolidate gaps & risks here, not scattered -->
|
||||||
|
- [확인] (정보 부재 / 사실 검증 필요):
|
||||||
|
|
||||||
|
---
|
||||||
|
## 게이트 승인
|
||||||
|
- [ ] 의사결정자(원장 등) 승인 → **Gate 2 (브리프)** 진행
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 캠페인 브리프 — Gate 2
|
||||||
|
|
||||||
|
<!-- One page. This is the agreement that governs the plan. Approve before Gate 3.
|
||||||
|
Carry forward the objective decided at Gate 1. Korean deliverable. -->
|
||||||
|
|
||||||
|
## 1. 목적 <!-- the single primary objective locked at Gate 1 -->
|
||||||
|
|
||||||
|
## 2. 타겟
|
||||||
|
- 1차 / 2차:
|
||||||
|
- 오디언스 프로파일 (한 문장):
|
||||||
|
|
||||||
|
## 3. 오퍼 / 핵심 제안
|
||||||
|
|
||||||
|
## 4. 메시지
|
||||||
|
- 핵심 메시지 (한 문장):
|
||||||
|
- 보조 메시지 (2~3):
|
||||||
|
|
||||||
|
## 5. 성과 지표 — 4층위 <!-- diversify; label baseline-less numbers as 가설; name the brand asset each qualitative goal touches -->
|
||||||
|
- 인지적 (도달 / 상기 / 브랜드 검색량):
|
||||||
|
- 정성적 (반응 / 톤 적합성) — **강화 브랜드 자산(속성·연상):**
|
||||||
|
- 관계·추천 (재방문 / 추천 의사 / 자발적 후기):
|
||||||
|
- 정량 전환 (문의 / 결제) — **[가설? baseline 유무]:**
|
||||||
|
|
||||||
|
## 6. 톤 & 채널 방향
|
||||||
|
- 톤:
|
||||||
|
- 채널 (우선순위):
|
||||||
|
|
||||||
|
## 7. 제약 / 컴플라이언스 (요약) <!-- summary only; full detail lives in the plan's 준비 점검 사항 -->
|
||||||
|
|
||||||
|
---
|
||||||
|
## 게이트 승인
|
||||||
|
- [ ] 승인 → **Gate 3 (기획서화)** 진행
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 기획서 아웃라인 — Gate 3
|
||||||
|
|
||||||
|
<!-- Build ONLY after Gate 2 approval. Hand off to the marketing campaign-plan skill + docx.
|
||||||
|
Two hard rules:
|
||||||
|
(1) Keep the body decision-relevant. Put ALL risks/compliance/due-diligence in ONE section at the END: "준비 점검 사항".
|
||||||
|
(2) Use [확인] for any gap instead of inventing.
|
||||||
|
Korean deliverable. -->
|
||||||
|
|
||||||
|
0. 한눈에 보기 (Executive summary)
|
||||||
|
1. 캠페인 개요 <!-- from the Brief -->
|
||||||
|
2. 프로그램 / 제품 구조 & 경제성
|
||||||
|
3. 타겟 & 메시지
|
||||||
|
4. 채널 전략
|
||||||
|
5. 주차별 액션플랜 / 콘텐츠 캘린더
|
||||||
|
6. 필요 콘텐츠 자산 <!-- copy runs through jamie-copy-trimmer -->
|
||||||
|
7. 성과지표 & 트래킹 <!-- 4층위, carried from the Brief -->
|
||||||
|
8. 리스크 & 대응
|
||||||
|
9. 다음 단계 (Next steps)
|
||||||
|
|
||||||
|
────────────────────────
|
||||||
|
▶ **준비 점검 사항** <!-- consolidate here: 의료광고 심의, 주의의무, 위험요인, 부가세/고지 표기, 그리고 모든 [확인] 항목 -->
|
||||||
|
- [확인] …
|
||||||
@@ -10,7 +10,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "79"
|
agent-id: "79"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: triggered
|
autonomy: triggered
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ version: 1.1.0
|
|||||||
last_updated: 2026-05-18
|
last_updated: 2026-05-18
|
||||||
canon_compliance: v1.3
|
canon_compliance: v1.3
|
||||||
agent-id: "79"
|
agent-id: "79"
|
||||||
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent)
|
agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
|
||||||
autonomy: triggered
|
autonomy: triggered
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,31 +15,5 @@
|
|||||||
"packages",
|
"packages",
|
||||||
"disk-space"
|
"disk-space"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"name": "mac-doctor",
|
|
||||||
"description": "Full macOS system health check — runs all 5 audit modules and presents unified findings"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac-packages",
|
|
||||||
"description": "Audit package managers (Homebrew, npm, pip, pyenv) for outdated packages and issues"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac-environment",
|
|
||||||
"description": "Audit shell environment — PATH, symlinks, shell configs, and startup time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac-security",
|
|
||||||
"description": "Security posture assessment — SIP, Gatekeeper, Firewall, FileVault, SSH, ports"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac-cleanup",
|
|
||||||
"description": "Scan and clean caches, logs, and clutter — shows sizes first, cleans only with consent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mac-resources",
|
|
||||||
"description": "Monitor CPU, memory, disk, battery, and identify resource-hungry processes"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,5 @@
|
|||||||
"gemini",
|
"gemini",
|
||||||
"codex"
|
"codex"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"name": "multi-agent-setup",
|
|
||||||
"description": "Quick setup for multi-agent collaboration"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "setup-agents",
|
|
||||||
"description": "Full interactive multi-agent setup"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,17 +55,18 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
|
|||||||
→ write `03_presales-opportunity-brief.md`.
|
→ write `03_presales-opportunity-brief.md`.
|
||||||
|
|
||||||
## Stage 5 — Estimate (견적) — REVIEW GATE
|
## Stage 5 — Estimate (견적) — REVIEW GATE
|
||||||
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`); this skill only maps findings → scope, then calls the engine:
|
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`). Estimates are archived **centrally in OurDigital's space**, not in the engagement bundle:
|
||||||
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out <out>/data/scope.json --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
|
- `EST=~/Workspaces/ourdigital-space/estimates/<YYYY-MM-DD>_<account_code>_seo` — create it (central 견적 archive).
|
||||||
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope <out>/data/scope.json --out-dir <out>`
|
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out "$EST/data/scope.json" --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
|
||||||
|
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope "$EST/data/scope.json" --out-dir "$EST"`
|
||||||
- Engine (effort method, `seo` catalog): auto-tier (smb/basic/treatment) by portfolio + premium-vertical floor; On-page hours scale by `subbrands_total` (cap ×2.0); 제안가 = 합계 절사. Reproduces Basic ₩10.5M / Treatment ₩25.0M; SMB ~₩3M; 25-property chain ~₩29.5M.
|
- Engine (effort method, `seo` catalog): auto-tier (smb/basic/treatment) by portfolio + premium-vertical floor; On-page hours scale by `subbrands_total` (cap ×2.0); 제안가 = 합계 절사. Reproduces Basic ₩10.5M / Treatment ₩25.0M; SMB ~₩3M; 25-property chain ~₩29.5M.
|
||||||
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` (effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
|
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` in `$EST` (the central quote archive; effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
|
||||||
- SEO findings→scope/tier mapping lives here (`findings_to_service.md`); **rates/hours/tiers live in the engine** (`rate_card.yaml` + `catalog/seo.yaml`) — edit pricing there, not here.
|
- SEO findings→scope/tier mapping lives here (`findings_to_service.md`); **rates/hours/tiers live in the engine** (`rate_card.yaml` + `catalog/seo.yaml`) — edit pricing there, not here.
|
||||||
|
|
||||||
## Stage 6 — Deliverables — REVIEW GATE before send
|
## Stage 6 — Deliverables — REVIEW GATE before send
|
||||||
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),
|
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),
|
||||||
then `bash scripts/render_pdf.sh <brief>.html` → PDF. Verify Korean renders (Read the PDF).
|
then `bash scripts/render_pdf.sh <brief>.html` → PDF. Verify Korean renders (Read the PDF).
|
||||||
- **Sales deck**: `python scripts/build_deck.py --findings <out>/data/findings.json --estimate <out>/data/estimate.json --out <out>/sales-deck.pptx`
|
- **Sales deck**: `python scripts/build_deck.py --findings <out>/data/findings.json --estimate "$EST/data/estimate.json" --out <out>/sales-deck.pptx` (deck stays in the engagement bundle; reads the archived estimate.json from `$EST`)
|
||||||
- Sanitize the client-facing pieces: no internal pricing strategy beyond the 견적; tasteful competitor benchmark only.
|
- Sanitize the client-facing pieces: no internal pricing strategy beyond the 견적; tasteful competitor benchmark only.
|
||||||
|
|
||||||
## Stage 7 — Archive (standard)
|
## Stage 7 — Archive (standard)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ourdigital-estimate-engine
|
name: ourdigital-estimate-engine
|
||||||
description: Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).
|
description: 'Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).'
|
||||||
---
|
---
|
||||||
|
|
||||||
# OurDigital Estimate Engine
|
# OurDigital Estimate Engine
|
||||||
@@ -54,6 +54,13 @@ Example consumer: `ourdigital-presales-seo` (`findings_to_scope.py` → engine
|
|||||||
real quote data. All current catalogs are real.
|
real quote data. All current catalogs are real.
|
||||||
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
|
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
|
||||||
|
|
||||||
|
## Output location
|
||||||
|
Estimates are archived centrally in OurDigital's space:
|
||||||
|
`~/Workspaces/ourdigital-space/estimates/<YYYY-MM-DD>_<prospect|account>_<service>/`
|
||||||
|
(e.g. `…/estimates/2026-05-27_shr_seo/`). The engine stays `--out-dir`-agnostic — the caller
|
||||||
|
passes that path. Client engagements keep their audit docs/deck in their own workspace but the
|
||||||
|
견적 lives here (single quote archive).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
Korean-first output · 부가세 별도 · 유효기간 14d · 현금 · `OD-YYYY-NNN`. Don't invent rates —
|
Korean-first output · 부가세 별도 · 유효기간 14d · 현금 · `OD-YYYY-NNN`. Don't invent rates —
|
||||||
stub and flag instead. Legal entity (주)디인텔리전스 / info@ourdigital.org.
|
stub and flag instead. Legal entity (주)디인텔리전스 / info@ourdigital.org.
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ method: coaching
|
|||||||
courses:
|
courses:
|
||||||
|
|
||||||
# Real quote: GA4/GTM 중급 과정 1:1 코칭 → ₩1,570,000 (7 대면×100k + 9 화상×80k + 1 실습×150k).
|
# Real quote: GA4/GTM 중급 과정 1:1 코칭 → ₩1,570,000 (7 대면×100k + 9 화상×80k + 1 실습×150k).
|
||||||
|
# = the "중급 기본구성" standard course (identical to 숨고 GA4/GTM 중급 기본견적; sent to 코코네·45번가 등).
|
||||||
ga4_gtm_intermediate:
|
ga4_gtm_intermediate:
|
||||||
title: "GA4/GTM 중급 과정 1:1 코칭"
|
title: "GA4/GTM 중급 과정 1:1 코칭 (기본구성)"
|
||||||
|
aliases: ["기본구성", "숨고 GA4/GTM 중급 기본견적"]
|
||||||
lessons:
|
lessons:
|
||||||
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
|
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
|
||||||
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
|
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
|
||||||
@@ -27,5 +29,33 @@ courses:
|
|||||||
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
|
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
|
||||||
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
|
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
|
||||||
|
|
||||||
|
# Real menu: GA4/GTM 마케팅 애널리틱스 (중급 기본구성 + 메타/구글 마케팅 솔루션 + 워크숍/트리트먼트).
|
||||||
|
# Full list price = ₩4,070,000. (참고: 디하이브 외 발송 견적은 선택/협상으로 ₩2,360,000에 체결된 사례 있음.)
|
||||||
|
ga4_gtm_marketing_analytics:
|
||||||
|
title: "GA4/GTM 마케팅 애널리틱스 과정 1:1 코칭"
|
||||||
|
lessons:
|
||||||
|
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "기본 리포트 설정", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "맞춤 리포트 구성", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "획득 보고서의 이해", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "참여도 보고서 해석", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "수익창출 보고서 관리", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "주요 이벤트와 전환", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "탐색 분석 활용", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "이벤트 태깅 관리 준비", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "GTM 설정 분석과 검수", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "이벤트 태깅 실습", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "마케팅 태그 설정", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "e-Commerce 태그 설정", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "잠재 고객 설정과 활용", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
|
||||||
|
- {subject: "Digital Marketing Strategy", title: "메타 마케팅 솔루션 활용", type: 대면, hours: 1}
|
||||||
|
- {subject: "Digital Marketing Strategy", title: "Facebook Insight·Pixel·전환 최적화", type: 대면, hours: 1}
|
||||||
|
- {subject: "Digital Marketing Strategy", title: "인스타그램 인사이트·비즈니스 API", type: 대면, hours: 1}
|
||||||
|
- {subject: "Digital Marketing Strategy", title: "구글 마케팅 솔루션(Google Ads·YouTube Analytics)", type: 워크숍, hours: 4}
|
||||||
|
- {subject: "Digital Marketing Strategy", title: "광고 데이터 통합 자동화(아웃코드·Kakao·네이버 API)", type: 트리트먼트, hours: 2}
|
||||||
|
|
||||||
# Add more courses (workshops, SEO/Content Marketing coaching, etc.) as real lesson plans arrive.
|
# Add more courses (workshops, SEO/Content Marketing coaching, etc.) as real lesson plans arrive.
|
||||||
# For an ad-hoc plan, pass scope.lessons directly instead of a named course.
|
# For an ad-hoc plan, pass scope.lessons directly instead of a named course.
|
||||||
|
|||||||
139
custom-skills/97-ourdigital-okf/DESIGN.md
Normal file
139
custom-skills/97-ourdigital-okf/DESIGN.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# ourdigital-okf — Claude Skill Design Spec
|
||||||
|
|
||||||
|
> **Status:** Approved (decisions resolved 2026-06-16) · **Author:** Claude Code (brainstorming skill)
|
||||||
|
> **Notion spec of record:** https://app.notion.com/p/381581e58a1e81128280f43839902dc8
|
||||||
|
> **Related:** OKF Reference Capture (Notion) · Local reference library at `~/Documents/reference-library/open-knowledge-format/`
|
||||||
|
|
||||||
|
A custom Claude skill, triggered by `/ourdigital-okf`, that **produces**, **visualizes**, and **validates**
|
||||||
|
Google Open Knowledge Format (OKF) v0.1 knowledge bundles. It puts Claude where it is strongest —
|
||||||
|
drafting and enriching concept documents — the work Google's reference implementation needs a full
|
||||||
|
Python ADK + Gemini agent to do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope (finalized)
|
||||||
|
|
||||||
|
- **In scope:** produce · visualize · validate.
|
||||||
|
- **Dropped:** query/consume mode (decision 1 — largely native to Claude; not worth the surface area).
|
||||||
|
- **Out of scope:** wrapping Google's Python `enrichment_agent`; live BigQuery pulls.
|
||||||
|
|
||||||
|
## 2. Architecture — "Single skill + bundled utilities" (Approach A)
|
||||||
|
|
||||||
|
One coherent skill with three modes. Production *composes existing tooling* rather than reimplementing it,
|
||||||
|
and stays **MCP-agnostic** (decision 4):
|
||||||
|
|
||||||
|
| Need | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Data-source schemas | **Pasted or exported schema** — BigQuery DDL / `information_schema` dump, GA4 export schema, CSV/list of columns, JSON Schema, OpenAPI. No live MCP dependency. |
|
||||||
|
| Existing docs & markdown | direct file reads |
|
||||||
|
| Web-research topics | the `/reference-curator` pipeline (or Firecrawl) |
|
||||||
|
| Conformance + graph viz | two small bundled Python scripts (zero pip deps) |
|
||||||
|
|
||||||
|
## 3. Identity & install
|
||||||
|
|
||||||
|
- **Trigger:** `/ourdigital-okf` (decision 2 — part of the OurDigital skill family).
|
||||||
|
- **Source dir:** `/Users/ourdigital/Project/our-claude-skills/custom-skills/97-ourdigital-okf/`
|
||||||
|
- **Installs to:** `~/.claude/skills/ourdigital-okf/` via `install.sh` (symlink — existing pattern).
|
||||||
|
- **Conventions:** must follow OurDigital `ourdigital-*` skill rules (`_ourdigital-shared`, brand guide);
|
||||||
|
verified by `ourdigital-skill-creator` at the end (decision 6).
|
||||||
|
- **Activates on:** "OKF", "Open Knowledge Format", "knowledge bundle", "concept docs with YAML frontmatter",
|
||||||
|
produce/validate/visualize a bundle.
|
||||||
|
|
||||||
|
## 4. File layout (OurDigital `ourdigital-*` convention)
|
||||||
|
|
||||||
|
Follows the same structure as existing numbered OurDigital skills (e.g. `04-ourdigital-research`):
|
||||||
|
top-level `SKILL.md` + `README.md`, a `code/` variant (Claude Code), a `desktop/` variant
|
||||||
|
(Claude Desktop), and `docs/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
97-ourdigital-okf/
|
||||||
|
├── SKILL.md # top-level canonical (YAML frontmatter: name, triggers, version, author, environment)
|
||||||
|
├── README.md # overview
|
||||||
|
├── DESIGN.md # this spec (repo copy — decision 5)
|
||||||
|
├── install.sh # symlink top-level SKILL.md → ~/.claude/skills/ourdigital-okf
|
||||||
|
├── code/
|
||||||
|
│ ├── SKILL.md # Claude Code variant: detailed produce/visualize/validate mode flows
|
||||||
|
│ ├── CLAUDE.md # code-pattern pointer
|
||||||
|
│ ├── references/
|
||||||
|
│ │ ├── okf-spec-v0.1.md # SPEC.md distilled into actionable authoring rules + conformance checklist
|
||||||
|
│ │ └── frontmatter-fields.md
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── concept.md index.md log.md # templates
|
||||||
|
│ └── scripts/
|
||||||
|
│ ├── okf_common.py # shared frontmatter/link parsing (stdlib only)
|
||||||
|
│ ├── okf_validate.py # conformance + broken-link linter
|
||||||
|
│ ├── okf_viz.py # minimal viz.html generator (Cytoscape+marked via CDN)
|
||||||
|
│ ├── requirements.txt # documents zero runtime deps
|
||||||
|
│ └── tests/ # stdlib unittest + a mini fixture bundle
|
||||||
|
├── desktop/
|
||||||
|
│ ├── SKILL.md # Claude Desktop variant (leaner)
|
||||||
|
│ └── skill.yaml
|
||||||
|
└── docs/
|
||||||
|
├── CHANGELOG.md
|
||||||
|
└── IMPLEMENTATION-PLAN.md # the build plan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zero-dependency choice:** OKF frontmatter uses a tiny YAML subset (`key: value`, `[a, b]` lists),
|
||||||
|
so `okf_common.py` ships a minimal built-in parser — no PyYAML / pip install. Tests use stdlib
|
||||||
|
`unittest` (no pytest), keeping the whole skill installable-free.
|
||||||
|
|
||||||
|
## 5. Mode: produce (Claude-native)
|
||||||
|
|
||||||
|
1. Pick input adapter (schema / docs / research) and **confirm the target bundle directory before
|
||||||
|
creating anything** (honors the no-directory-without-consent rule).
|
||||||
|
2. Gather raw material per §2 — for the data adapter, ingest a pasted/exported schema (no live MCP call).
|
||||||
|
3. Plan a concept hierarchy (`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/` — as fits).
|
||||||
|
4. Draft one concept `.md` per concept: **required** `type` + recommended fields
|
||||||
|
(`title`, `description`, `resource`, `tags`, `timestamp`), bundle-relative cross-links
|
||||||
|
(`/path/concept.md`), and a `# Citations` section.
|
||||||
|
5. Auto-generate `index.md` per directory + root; optional `log.md`.
|
||||||
|
6. **Self-validate** with `okf_validate.py`; fix all errors before reporting done.
|
||||||
|
|
||||||
|
## 6. Mode: visualize (minimal first — decision 3)
|
||||||
|
|
||||||
|
`okf_viz.py --bundle <dir> [--out viz.html] [--name X]` → one self-contained HTML.
|
||||||
|
**v1 (minimal):** force-directed concept graph (Cytoscape), type-colored nodes, click a node to see its
|
||||||
|
rendered markdown + frontmatter. **Later iterations:** "cited by" backlinks, search box, type filter,
|
||||||
|
layout switch (parity with Google's viewer).
|
||||||
|
|
||||||
|
## 7. Validation (supporting)
|
||||||
|
|
||||||
|
`okf_validate.py <bundle>` checks: parseable frontmatter on every non-reserved `.md`; non-empty `type`;
|
||||||
|
`index.md` / `log.md` structure; **broken cross-link report** (warning, not failure). Text + JSON output,
|
||||||
|
meaningful exit code. Invoked automatically by produce, available standalone.
|
||||||
|
|
||||||
|
## 8. Built-in fixtures / regression test
|
||||||
|
|
||||||
|
Validator and visualizer are verified against **Google's own three sample bundles** already mirrored:
|
||||||
|
`~/Documents/reference-library/open-knowledge-format/okf/bundles/{crypto_bitcoin, ga4, stackoverflow}`.
|
||||||
|
Authoritative, conformant fixtures — no invented test data.
|
||||||
|
|
||||||
|
## 9. Success criteria
|
||||||
|
|
||||||
|
- `/ourdigital-okf produce` from a pasted schema, a docs folder, or a research topic emits a bundle that
|
||||||
|
passes `okf_validate.py` with **0 conformance errors**.
|
||||||
|
- `okf_viz.py` on any bundle opens a working graph in the browser.
|
||||||
|
- Both scripts validate/visualize Google's 3 reference bundles cleanly.
|
||||||
|
- Skill passes `ourdigital-skill-creator` consistency/rules check.
|
||||||
|
|
||||||
|
## 10. Build sequence
|
||||||
|
|
||||||
|
1. Scaffold `97-ourdigital-okf/` (SKILL.md, README, USER-GUIDE, install.sh, templates) to OurDigital conventions.
|
||||||
|
2. Distill `SPEC.md` → `reference/okf-spec-v0.1.md` authoring rules + checklist.
|
||||||
|
3. Write `okf_validate.py`; verify against the 3 Google sample bundles.
|
||||||
|
4. Write `okf_viz.py` (minimal); verify a working graph for those bundles.
|
||||||
|
5. Write the produce/visualize/validate mode flows in SKILL.md.
|
||||||
|
6. End-to-end test: produce a small bundle from a docs folder → validate → visualize.
|
||||||
|
7. `install.sh` into `~/.claude/skills/ourdigital-okf/`; smoke-test `/ourdigital-okf`.
|
||||||
|
8. Run `ourdigital-skill-creator` consistency check; fix any rule violations.
|
||||||
|
|
||||||
|
## 11. Resolved decisions (2026-06-16)
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
|---|----------|----------|
|
||||||
|
| 1 | Keep query mode? | **No** — produce + visualize + validate only |
|
||||||
|
| 2 | Naming | **`ourdigital-okf`** |
|
||||||
|
| 3 | Viz scope | **Minimal graph first**, iterate |
|
||||||
|
| 4 | Data adapter | **Accept pasted/exported schema** (MCP-agnostic) |
|
||||||
|
| 5 | Spec home | Notion (of record) **+ repo `DESIGN.md` copy** |
|
||||||
|
| 6 | Build method | **writing-plans → implementation**, then `ourdigital-skill-creator` validity check |
|
||||||
51
custom-skills/97-ourdigital-okf/README.md
Normal file
51
custom-skills/97-ourdigital-okf/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# ourdigital-okf
|
||||||
|
|
||||||
|
A custom OurDigital Claude skill for **Google Open Knowledge Format (OKF) v0.1** — produce,
|
||||||
|
validate, and visualize knowledge bundles (directories of markdown concept files with YAML
|
||||||
|
frontmatter).
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
| Mode | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| **produce** | Claude drafts a conformant OKF bundle from a pasted/exported schema, a docs folder, or a `/reference-curator` research topic. |
|
||||||
|
| **validate** | Lints a bundle for OKF v0.1 conformance (parseable frontmatter + non-empty `type`); reports broken cross-links as warnings. |
|
||||||
|
| **visualize** | Renders a bundle as a self-contained interactive Cytoscape graph (`viz.html`). |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Symlinks the skill into `~/.claude/skills/ourdigital-okf`. Invoke with `/ourdigital-okf`.
|
||||||
|
|
||||||
|
## Scripts (Python standard library only — no pip install)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 code/scripts/okf_validate.py <bundle> [--json]
|
||||||
|
python3 code/scripts/okf_viz.py --bundle <bundle> [--out viz.html] [--name "Name"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the tests from `code/scripts/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite is verified against Google's three reference bundles mirrored at
|
||||||
|
`~/Documents/reference-library/open-knowledge-format/okf/bundles/` (crypto_bitcoin, ga4,
|
||||||
|
stackoverflow).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `SKILL.md` — canonical entry; `code/SKILL.md` — detailed Claude Code flows; `desktop/` — Claude Desktop variant.
|
||||||
|
- `code/references/` — distilled OKF spec + frontmatter reference.
|
||||||
|
- `code/assets/` — concept/index/log templates.
|
||||||
|
- `code/scripts/` — `okf_common.py`, `okf_validate.py`, `okf_viz.py` + tests.
|
||||||
|
- `DESIGN.md` — approved design spec. `docs/IMPLEMENTATION-PLAN.md` — build plan.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
OKF spec and reference implementation:
|
||||||
|
<https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf>
|
||||||
65
custom-skills/97-ourdigital-okf/SKILL.md
Normal file
65
custom-skills/97-ourdigital-okf/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: ourdigital-okf
|
||||||
|
description: |
|
||||||
|
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1
|
||||||
|
knowledge bundles. Activated with the "ourdigital" or "our" keyword for OKF work.
|
||||||
|
|
||||||
|
Triggers (ourdigital or our prefix):
|
||||||
|
- "ourdigital okf", "our okf"
|
||||||
|
- "ourdigital open knowledge format", "our knowledge bundle"
|
||||||
|
- "ourdigital okf 만들기", "our okf 검증", "our okf 시각화"
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Produce conformant OKF bundles from a pasted/exported schema, a docs folder, or a research topic
|
||||||
|
- Validate a bundle for OKF v0.1 conformance + broken-link report
|
||||||
|
- Visualize a bundle as a self-contained interactive graph (viz.html)
|
||||||
|
version: "1.0"
|
||||||
|
author: OurDigital
|
||||||
|
environment: Both
|
||||||
|
---
|
||||||
|
|
||||||
|
# OurDigital OKF
|
||||||
|
|
||||||
|
Work with **Open Knowledge Format (OKF) v0.1** — Google's open, vendor-neutral standard
|
||||||
|
for representing knowledge as a directory of markdown files with YAML frontmatter. Each
|
||||||
|
file is a *concept* (table, dataset, metric, playbook, API, reference); the file path is
|
||||||
|
its identity; markdown links make the directory a graph. The only required field is
|
||||||
|
`type`.
|
||||||
|
|
||||||
|
This skill has three modes:
|
||||||
|
|
||||||
|
- **produce** — Claude drafts a conformant OKF bundle from one of three inputs: a
|
||||||
|
pasted/exported **schema** (BigQuery DDL, GA4 export schema, CSV/JSON-Schema/OpenAPI), a
|
||||||
|
**docs/markdown** folder, or a **research topic** (via `/reference-curator`). It plans a
|
||||||
|
concept hierarchy, writes one `type`-bearing concept per file, cross-links them, and
|
||||||
|
generates `index.md` files — then self-validates.
|
||||||
|
- **validate** — lint a bundle for OKF v0.1 conformance (parseable frontmatter + non-empty
|
||||||
|
`type`) and report broken cross-links as warnings.
|
||||||
|
- **visualize** — render a bundle as a self-contained interactive graph in one HTML file.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate
|
||||||
|
python3 code/scripts/okf_validate.py <bundle> [--json]
|
||||||
|
|
||||||
|
# Visualize → writes <bundle>/viz.html (or --out PATH)
|
||||||
|
python3 code/scripts/okf_viz.py --bundle <bundle> [--name "Display Name"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts are Python standard-library only — no install needed.
|
||||||
|
|
||||||
|
## Where to look
|
||||||
|
|
||||||
|
- **Detailed mode flows:** `code/SKILL.md` (the Claude Code variant).
|
||||||
|
- **Authoring authority:** `code/references/okf-spec-v0.1.md` — read before producing.
|
||||||
|
- **Field guidance:** `code/references/frontmatter-fields.md`.
|
||||||
|
- **Templates:** `code/assets/{concept,index,log}.md`.
|
||||||
|
- **Design + plan:** `DESIGN.md`, `docs/IMPLEMENTATION-PLAN.md`.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Confirm the output directory with the user before creating it.
|
||||||
|
- A bundle is "done" only after the validator reports CONFORMANT with zero errors.
|
||||||
|
- Verified against Google's reference bundles at
|
||||||
|
`~/Documents/reference-library/open-knowledge-format/okf/bundles/`.
|
||||||
11
custom-skills/97-ourdigital-okf/code/CLAUDE.md
Normal file
11
custom-skills/97-ourdigital-okf/code/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# ourdigital-okf (Claude Code)
|
||||||
|
|
||||||
|
Use `SKILL.md` in this directory as the instruction set for producing, validating, and
|
||||||
|
visualizing Open Knowledge Format (OKF) v0.1 bundles.
|
||||||
|
|
||||||
|
- Scripts live in `scripts/` and use the Python standard library only (no pip install).
|
||||||
|
- Read `references/okf-spec-v0.1.md` before producing a bundle.
|
||||||
|
- Templates are in `assets/`.
|
||||||
|
- Always confirm the output directory with the user before creating it.
|
||||||
|
- A bundle is done only when `python3 scripts/okf_validate.py <bundle>` reports CONFORMANT
|
||||||
|
with zero errors.
|
||||||
149
custom-skills/97-ourdigital-okf/code/SKILL.md
Normal file
149
custom-skills/97-ourdigital-okf/code/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: ourdigital-okf
|
||||||
|
description: |
|
||||||
|
Produce, visualize, and validate Google Open Knowledge Format (OKF) v0.1
|
||||||
|
knowledge bundles. Activated with the "ourdigital" or "our" keyword for OKF work.
|
||||||
|
|
||||||
|
Triggers (ourdigital or our prefix):
|
||||||
|
- "ourdigital okf", "our okf"
|
||||||
|
- "ourdigital open knowledge format", "our knowledge bundle"
|
||||||
|
- "ourdigital okf 만들기", "our okf 검증", "our okf 시각화"
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Produce conformant OKF bundles from a pasted/exported schema, a docs folder, or a research topic
|
||||||
|
- Validate a bundle for OKF v0.1 conformance + broken-link report
|
||||||
|
- Visualize a bundle as a self-contained interactive graph (viz.html)
|
||||||
|
version: "1.0"
|
||||||
|
author: OurDigital
|
||||||
|
environment: Both
|
||||||
|
---
|
||||||
|
|
||||||
|
# OurDigital OKF
|
||||||
|
|
||||||
|
Produce, validate, and visualize **Open Knowledge Format (OKF) v0.1** bundles. OKF is an
|
||||||
|
open, vendor-neutral standard that represents knowledge as a directory of markdown files
|
||||||
|
with YAML frontmatter — each file is a *concept* (a table, dataset, metric, playbook, API,
|
||||||
|
reference), the file path is its identity, and ordinary markdown links turn the directory
|
||||||
|
into a graph. The only hard rule is a `type` field on every concept; everything else is
|
||||||
|
producer-defined and consumers tolerate the unknown.
|
||||||
|
|
||||||
|
**Before producing anything, read `references/okf-spec-v0.1.md`** — it is the authoring
|
||||||
|
authority (reserved filenames, frontmatter fields, cross-linking, conformance). Use
|
||||||
|
`references/frontmatter-fields.md` for per-field guidance and `assets/` for templates.
|
||||||
|
|
||||||
|
## Mode dispatch
|
||||||
|
|
||||||
|
Decide the mode from the request:
|
||||||
|
|
||||||
|
- **produce** — "make/build/generate an OKF bundle from …"
|
||||||
|
- **validate** — "check/validate/lint this bundle"
|
||||||
|
- **visualize** — "visualize/graph this bundle", "make a viz"
|
||||||
|
|
||||||
|
The scripts live in `scripts/` and use the Python standard library only (no pip install).
|
||||||
|
|
||||||
|
## Mode: produce
|
||||||
|
|
||||||
|
Claude drafts the concept documents directly — this is where the skill adds the most value.
|
||||||
|
|
||||||
|
1. **Pick the input adapter** and **confirm the output directory with the user before
|
||||||
|
creating it** (OurDigital rule: never create a directory without explicit consent —
|
||||||
|
show the full path and wait for approval). Input adapters:
|
||||||
|
- **Schema (pasted/exported)** — the user pastes or points to an exported schema:
|
||||||
|
BigQuery DDL or `information_schema` dump, a GA4 export schema, a CSV/JSON-Schema/
|
||||||
|
OpenAPI file, or a column list. Do **not** call a live MCP; work from the supplied
|
||||||
|
text so the skill stays portable.
|
||||||
|
- **Docs & markdown** — read a provided file or folder and reorganize its knowledge
|
||||||
|
into concepts.
|
||||||
|
- **Research topic** — invoke `/reference-curator` (or Firecrawl) to gather sources,
|
||||||
|
then distill them into concepts with citations.
|
||||||
|
2. **Plan the hierarchy.** Choose directories that fit the domain — typically
|
||||||
|
`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/`. One concept per file.
|
||||||
|
3. **Draft each concept** using `assets/concept.md` as the skeleton. Every concept MUST
|
||||||
|
have a non-empty `type`. Add `title` and a one-sentence `description`; add `resource`
|
||||||
|
when the concept maps to a real asset; add `tags` and `timestamp`. Favor structural
|
||||||
|
markdown (`# Schema` tables, `# Examples`, `# Citations`) over prose.
|
||||||
|
4. **Cross-link** related concepts with bundle-relative links (`/tables/customers.md`).
|
||||||
|
Express foreign keys, joins, and dependencies in prose next to the link.
|
||||||
|
5. **Generate `index.md`** for each directory and the bundle root (use `assets/index.md`),
|
||||||
|
listing children with their descriptions for progressive disclosure. Optionally add a
|
||||||
|
`log.md` (use `assets/log.md`).
|
||||||
|
6. **Self-validate and fix.** Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/okf_validate.py <bundle>
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve every conformance error before reporting the bundle done. Broken links are
|
||||||
|
warnings, not errors.
|
||||||
|
|
||||||
|
7. Offer to visualize the result (see below).
|
||||||
|
|
||||||
|
## Mode: validate
|
||||||
|
|
||||||
|
Run the linter and interpret the report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/okf_validate.py <bundle> # human-readable
|
||||||
|
python3 scripts/okf_validate.py <bundle> --json # machine-readable
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Errors** (block conformance): missing/unparseable frontmatter, missing or empty
|
||||||
|
`type`. Exit code is `1` when any error exists, `0` when conformant.
|
||||||
|
- **Warnings** (informational): broken cross-links — a link whose target `.md` is not in
|
||||||
|
the bundle. Per the spec these are tolerated (not-yet-written knowledge), so report them
|
||||||
|
but do not treat them as failures.
|
||||||
|
|
||||||
|
Summarize the result for the user (concepts count, status, errors, warnings) and, if there
|
||||||
|
are errors, point to the exact concept and rule.
|
||||||
|
|
||||||
|
## Mode: visualize
|
||||||
|
|
||||||
|
Generate a self-contained interactive graph (one HTML file, Cytoscape + marked from a CDN,
|
||||||
|
no backend, no data leaves the page):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/okf_viz.py --bundle <bundle> [--out viz.html] [--name "Display Name"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Nodes are concepts colored by `type`; edges are cross-links; clicking a node renders its
|
||||||
|
markdown body and frontmatter in a side panel. This is the *minimal* viewer (graph +
|
||||||
|
detail panel) — search, type filters, and backlinks are deliberate future iterations.
|
||||||
|
Tell the user the output path and that they open it in any browser.
|
||||||
|
|
||||||
|
## Example: produce from a pasted schema
|
||||||
|
|
||||||
|
The user pastes a BigQuery DDL for `acme.sales.orders` and `acme.sales.customers` and
|
||||||
|
asks for a bundle. After confirming the output directory (e.g. `/tmp/sales-okf/`, with
|
||||||
|
the user's approval), the producer:
|
||||||
|
|
||||||
|
- Creates `datasets/sales.md` (`type: BigQuery Dataset`) describing the dataset and
|
||||||
|
linking to its tables.
|
||||||
|
- Creates `tables/orders.md` and `tables/customers.md` (`type: BigQuery Table`), each with
|
||||||
|
a `# Schema` table built from the DDL columns. In `orders.md`, the `customer_id` row
|
||||||
|
links to `[customers](/tables/customers.md)`, and a sentence notes the join key.
|
||||||
|
- Adds a root `index.md` and a `tables/index.md` listing each concept with its
|
||||||
|
`description`.
|
||||||
|
- Runs `python3 scripts/okf_validate.py /tmp/sales-okf`; on `CONFORMANT`, offers to
|
||||||
|
generate `viz.html`.
|
||||||
|
|
||||||
|
Nothing here requires a live database connection — the producer works entirely from the
|
||||||
|
pasted DDL, which keeps the skill portable across machines and accounts.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- `references/okf-spec-v0.1.md` — distilled authoring rules + conformance checklist (read first).
|
||||||
|
- `references/frontmatter-fields.md` — per-field guidance and example `type` values.
|
||||||
|
- `assets/concept.md`, `assets/index.md`, `assets/log.md` — templates.
|
||||||
|
- `scripts/okf_common.py` — shared frontmatter/link parser (stdlib).
|
||||||
|
- `scripts/okf_validate.py` — conformance + broken-link linter.
|
||||||
|
- `scripts/okf_viz.py` — minimal graph visualizer.
|
||||||
|
- `scripts/tests/` — stdlib `unittest` suite; run `python3 -m unittest discover -s tests`
|
||||||
|
from `scripts/`. The suite is verified against Google's reference bundles under
|
||||||
|
`~/Documents/reference-library/open-knowledge-format/okf/bundles/`.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Never create an output directory without explicit user confirmation of the path.
|
||||||
|
- Keep the scripts dependency-free; if a real bundle uses YAML the parser cannot handle,
|
||||||
|
extend `okf_common.py` minimally and re-run the test suite.
|
||||||
|
- A bundle is "done" only after `okf_validate.py` reports `CONFORMANT` with zero errors.
|
||||||
18
custom-skills/97-ourdigital-okf/code/assets/concept.md
Normal file
18
custom-skills/97-ourdigital-okf/code/assets/concept.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
type: <Concept type, e.g. BigQuery Table>
|
||||||
|
title: <Human-readable display name>
|
||||||
|
description: <One-sentence summary>
|
||||||
|
resource: <Canonical URI, omit for abstract concepts>
|
||||||
|
tags: [<tag>, <tag>]
|
||||||
|
timestamp: <ISO 8601, e.g. 2026-06-16T00:00:00Z>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `col` | TYPE | What it is. FK to [other](/tables/other.md). |
|
||||||
|
|
||||||
|
# Citations
|
||||||
|
|
||||||
|
[1] [Source title](https://example.com)
|
||||||
8
custom-skills/97-ourdigital-okf/code/assets/index.md
Normal file
8
custom-skills/97-ourdigital-okf/code/assets/index.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Group Heading
|
||||||
|
|
||||||
|
* [Title](relative-or-bundle-relative-path) - short description from the concept's frontmatter
|
||||||
|
* [Another concept](/tables/orders.md) - one row per completed order
|
||||||
|
|
||||||
|
# Subdirectories
|
||||||
|
|
||||||
|
* [datasets/](datasets/) - dataset-level concepts
|
||||||
5
custom-skills/97-ourdigital-okf/code/assets/log.md
Normal file
5
custom-skills/97-ourdigital-okf/code/assets/log.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Update Log
|
||||||
|
|
||||||
|
## 2026-06-16
|
||||||
|
* **Initialization**: Created the bundle structure.
|
||||||
|
* **Creation**: Added the [orders table](/tables/orders.md).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# OKF Frontmatter Field Reference
|
||||||
|
|
||||||
|
Per-field guidance for authoring OKF v0.1 concept frontmatter. Only `type` is required;
|
||||||
|
everything else is recommended or optional. Producers may add arbitrary extra keys —
|
||||||
|
consumers preserve them and never reject on unknown keys.
|
||||||
|
|
||||||
|
| Field | Required | Format | Example |
|
||||||
|
|-------|----------|--------|---------|
|
||||||
|
| `type` | **Yes** | short string | `BigQuery Table` |
|
||||||
|
| `title` | Recommended | string | `Customer Orders` |
|
||||||
|
| `description` | Recommended | one sentence | `One row per completed customer order.` |
|
||||||
|
| `resource` | Recommended | URI | `https://console.cloud.google.com/bigquery?p=acme&d=sales&t=orders` |
|
||||||
|
| `tags` | Optional | list of strings | `[sales, revenue]` or block `- sales` |
|
||||||
|
| `timestamp` | Optional | ISO 8601 datetime | `2026-05-28T14:30:00Z` |
|
||||||
|
|
||||||
|
## `type` — the only required field
|
||||||
|
|
||||||
|
A short string identifying the kind of concept. Consumers route, filter, and present on
|
||||||
|
it. Type values are **not** registered centrally; pick descriptive, self-explanatory
|
||||||
|
values. Common examples:
|
||||||
|
|
||||||
|
- `BigQuery Table`, `BigQuery Dataset` — data assets
|
||||||
|
- `Metric` — a derived/calculated measure
|
||||||
|
- `Reference` — a standalone external doc captured as a concept (often under `references/`)
|
||||||
|
- `Playbook`, `Runbook` — operational procedures
|
||||||
|
- `API Endpoint` — an API surface
|
||||||
|
|
||||||
|
Consumers MUST tolerate unknown `type` values (treat as generic concepts).
|
||||||
|
|
||||||
|
## Recommended fields (priority order)
|
||||||
|
|
||||||
|
- **`title`** — human-readable display name. If omitted, consumers derive one from the
|
||||||
|
filename.
|
||||||
|
- **`description`** — a single summarizing sentence. Used by `index.md` generators,
|
||||||
|
search snippets, and previews — keep it crisp.
|
||||||
|
- **`resource`** — a URI uniquely identifying the underlying asset. Omit for concepts
|
||||||
|
that describe abstract ideas (a metric, a business process) rather than a physical
|
||||||
|
resource.
|
||||||
|
- **`tags`** — cross-cutting categorization. Accepts inline (`[a, b]`) or block list
|
||||||
|
(`- a` on following lines) form.
|
||||||
|
- **`timestamp`** — ISO 8601 datetime of the last meaningful change.
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
Add any additional keys your producer needs (e.g. `owner`, `sensitivity`, `okf_version`).
|
||||||
|
Round-tripping consumers SHOULD preserve unknown keys and SHOULD NOT reject documents
|
||||||
|
that carry them.
|
||||||
|
|
||||||
|
## Notes for this skill's parser (`okf_common.py`)
|
||||||
|
|
||||||
|
The bundled validator/visualizer parse a small YAML subset that covers real OKF bundles:
|
||||||
|
`key: value`, inline lists `[a, b]`, block lists (`key:` then `- item`), folded
|
||||||
|
multi-line scalars (a value continued on indented lines), `>`/`|` block scalars, and
|
||||||
|
quoted scalars. It is not a full YAML engine — keep frontmatter to these shapes.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# OKF v0.1 — Authoring Rules & Conformance Checklist
|
||||||
|
|
||||||
|
Distilled, actionable reference for producing and validating Open Knowledge Format
|
||||||
|
bundles. Authority: the full spec at
|
||||||
|
`~/Documents/reference-library/open-knowledge-format/okf/SPEC.md`. **Read this file
|
||||||
|
before producing a bundle.**
|
||||||
|
|
||||||
|
## Core model
|
||||||
|
|
||||||
|
- **Bundle** — a directory tree of markdown files. The unit of distribution.
|
||||||
|
- **Concept** — one markdown file = one unit of knowledge (a table, dataset, metric,
|
||||||
|
playbook, API, reference…).
|
||||||
|
- **Concept ID** — the bundle-relative file path with `.md` removed
|
||||||
|
(`tables/users.md` → `tables/users`). The path *is* the identity.
|
||||||
|
- Concepts form a **graph**, linked by ordinary markdown links — richer than the
|
||||||
|
parent/child implied by the directory tree.
|
||||||
|
|
||||||
|
## Reserved filenames (not concepts)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `index.md` | Directory listing for **progressive disclosure** (§6). No frontmatter — except an optional bundle-root `index.md` may carry `okf_version: "0.1"`. |
|
||||||
|
| `log.md` | Update history: date-grouped (`YYYY-MM-DD`, newest first), entries lead with a bold verb (`**Update**`, `**Creation**`…). |
|
||||||
|
|
||||||
|
All other `.md` files are concept documents.
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
YAML block delimited by `---` at the very top of the file.
|
||||||
|
|
||||||
|
| Field | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `type` | **REQUIRED** | Short string, e.g. `BigQuery Table`, `BigQuery Dataset`, `Metric`, `Playbook`, `Reference`, `API Endpoint`. Not centrally registered; consumers tolerate unknown types. |
|
||||||
|
| `title` | Recommended | Display name; else derived from filename. |
|
||||||
|
| `description` | Recommended | One sentence; used in index snippets/previews. |
|
||||||
|
| `resource` | Recommended | Canonical URI of the underlying asset; omit for abstract concepts. |
|
||||||
|
| `tags` | Optional | List of short strings (inline `[a, b]` or block `- a`). |
|
||||||
|
| `timestamp` | Optional | ISO 8601 last-modified time. |
|
||||||
|
| *(extensions)* | Optional | Any extra producer keys; preserve, never reject. |
|
||||||
|
|
||||||
|
## Body
|
||||||
|
|
||||||
|
Standard markdown. Prefer structural markdown (headings, tables, lists, fenced code)
|
||||||
|
over prose. Conventional section headings (use when applicable):
|
||||||
|
|
||||||
|
| Heading | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `# Schema` | Columns/fields of an asset. |
|
||||||
|
| `# Examples` | Concrete usage, usually fenced code. |
|
||||||
|
| `# Citations` | Numbered external sources backing body claims. |
|
||||||
|
|
||||||
|
## Cross-linking
|
||||||
|
|
||||||
|
- **Bundle-relative (recommended):** leading `/`, from the bundle root —
|
||||||
|
`[customers](/tables/customers.md)`. Stable when files move within a subdirectory.
|
||||||
|
- **Relative:** `[other](./other.md)`.
|
||||||
|
- A link asserts an *untyped* relationship; the kind is conveyed by surrounding prose.
|
||||||
|
- **Broken links are tolerated** — they may represent not-yet-written knowledge. Never
|
||||||
|
an error.
|
||||||
|
|
||||||
|
## Conformance (§9) — what the validator enforces
|
||||||
|
|
||||||
|
A bundle is conformant if:
|
||||||
|
1. Every non-reserved `.md` has a parseable YAML frontmatter block.
|
||||||
|
2. Every frontmatter block has a non-empty `type`.
|
||||||
|
3. `index.md` / `log.md` follow their structure when present.
|
||||||
|
|
||||||
|
Consumers MUST NOT reject for: missing optional fields · unknown `type` values ·
|
||||||
|
unknown extra keys · broken cross-links · missing `index.md`.
|
||||||
|
|
||||||
|
Run: `python3 ../scripts/okf_validate.py <bundle>` (exit 0 = conformant; broken links
|
||||||
|
are warnings).
|
||||||
|
|
||||||
|
## Producer authoring rules
|
||||||
|
|
||||||
|
1. **One concept per file.** Choose a directory layout that fits the domain
|
||||||
|
(`datasets/`, `tables/`, `metrics/`, `references/`, `playbooks/`…).
|
||||||
|
2. **Always set `type`** — descriptive and self-explanatory.
|
||||||
|
3. Add `title` + a one-sentence `description` to every concept (drives indexes).
|
||||||
|
4. Set `resource` for concepts bound to a real asset; omit for abstract ones.
|
||||||
|
5. **Cross-link** related concepts with bundle-relative links; reference FKs/joins in
|
||||||
|
prose.
|
||||||
|
6. Add a `# Citations` section (numbered) whenever the body makes externally-sourced
|
||||||
|
claims; cite as URLs, bundle paths, or `references/<slug>` concepts.
|
||||||
|
7. Generate an `index.md` per directory (and the root) listing children with their
|
||||||
|
descriptions, for progressive disclosure.
|
||||||
|
8. **Self-validate** before declaring done; fix every conformance error.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
`<major>.<minor>`. Minor = backward-compatible additions; major = breaking changes.
|
||||||
|
Bundles may declare `okf_version: "0.1"` in the root `index.md` frontmatter.
|
||||||
129
custom-skills/97-ourdigital-okf/code/scripts/okf_common.py
Normal file
129
custom-skills/97-ourdigital-okf/code/scripts/okf_common.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Shared OKF v0.1 parsing utilities (Python standard library only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RESERVED = {"index.md", "log.md"}
|
||||||
|
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||||||
|
_BLOCK_INDICATORS = {">", "|", ">-", "|-", ">+", "|+"}
|
||||||
|
|
||||||
|
|
||||||
|
class FrontmatterError(ValueError):
|
||||||
|
"""Raised when a frontmatter block is present but cannot be parsed."""
|
||||||
|
|
||||||
|
|
||||||
|
def split_frontmatter(text):
|
||||||
|
"""Return (raw_frontmatter, body). raw is None if there is no leading '---' block."""
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return None, text
|
||||||
|
lines = text.splitlines()
|
||||||
|
if lines[0].strip() != "---":
|
||||||
|
return None, text
|
||||||
|
for i in range(1, len(lines)):
|
||||||
|
if lines[i].strip() == "---":
|
||||||
|
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:])
|
||||||
|
raise FrontmatterError("opening '---' without a closing '---'")
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(value):
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml_subset(raw):
|
||||||
|
"""Parse the small YAML subset OKF bundles use in practice.
|
||||||
|
|
||||||
|
Handles: ``key: value``; inline lists ``key: [a, b]``; block lists
|
||||||
|
(``key:`` followed by ``- item`` lines); folded multi-line scalars
|
||||||
|
(a value continued on following indented lines); and ``>``/``|`` block
|
||||||
|
scalars. Surrounding quotes are stripped from scalar values. This is a
|
||||||
|
deliberately small parser — not a full YAML implementation.
|
||||||
|
"""
|
||||||
|
lines = raw.splitlines()
|
||||||
|
n = len(lines)
|
||||||
|
meta = {}
|
||||||
|
i = 0
|
||||||
|
while i < n:
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ":" not in line:
|
||||||
|
raise FrontmatterError("unparseable frontmatter line: %r" % line)
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
key, value = key.strip(), value.strip()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Inline list: key: [a, b]
|
||||||
|
if value.startswith("[") and value.endswith("]"):
|
||||||
|
inner = value[1:-1].strip()
|
||||||
|
meta[key] = [s for s in (_scalar(x) for x in inner.split(",")) if s] if inner else []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Empty value: either a block list (- item lines) or a continued scalar.
|
||||||
|
if value == "":
|
||||||
|
items = []
|
||||||
|
while i < n and lines[i].strip().startswith("-"):
|
||||||
|
items.append(_scalar(lines[i].strip()[1:].strip()))
|
||||||
|
i += 1
|
||||||
|
if items:
|
||||||
|
meta[key] = items
|
||||||
|
else:
|
||||||
|
cont = []
|
||||||
|
i = _collect_indented(lines, i, n, cont)
|
||||||
|
meta[key] = " ".join(cont)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Explicit block scalar indicator (> folded, | literal).
|
||||||
|
if value in _BLOCK_INDICATORS:
|
||||||
|
cont = []
|
||||||
|
i = _collect_indented(lines, i, n, cont)
|
||||||
|
meta[key] = ("\n" if value[0] == "|" else " ").join(cont)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Plain scalar, possibly folded across following indented lines.
|
||||||
|
parts = [value]
|
||||||
|
i = _collect_indented(lines, i, n, parts, skip_list_items=True)
|
||||||
|
meta[key] = _scalar(" ".join(parts))
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_indented(lines, i, n, out, skip_list_items=False):
|
||||||
|
"""Append stripped indented continuation lines to ``out``; return new index."""
|
||||||
|
while i < n and lines[i][:1].isspace() and lines[i].strip():
|
||||||
|
if skip_list_items and lines[i].strip().startswith("-"):
|
||||||
|
break
|
||||||
|
out.append(lines[i].strip())
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(text):
|
||||||
|
"""Return (meta, body). meta is None when no frontmatter block is present."""
|
||||||
|
raw, body = split_frontmatter(text)
|
||||||
|
if raw is None:
|
||||||
|
return None, text
|
||||||
|
return parse_yaml_subset(raw), body
|
||||||
|
|
||||||
|
|
||||||
|
def iter_concepts(bundle_dir):
|
||||||
|
"""Yield Path for every non-reserved .md file under bundle_dir, sorted."""
|
||||||
|
root = Path(bundle_dir)
|
||||||
|
for path in sorted(root.rglob("*.md")):
|
||||||
|
if path.name not in RESERVED:
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def concept_id(bundle_dir, path):
|
||||||
|
"""Concept ID = bundle-relative path with the .md suffix removed."""
|
||||||
|
rel = Path(path).relative_to(Path(bundle_dir)).as_posix()
|
||||||
|
return rel[:-3] if rel.endswith(".md") else rel
|
||||||
|
|
||||||
|
|
||||||
|
def extract_links(body):
|
||||||
|
"""Return the list of link targets from markdown links in body."""
|
||||||
|
return _LINK_RE.findall(body)
|
||||||
89
custom-skills/97-ourdigital-okf/code/scripts/okf_validate.py
Normal file
89
custom-skills/97-ourdigital-okf/code/scripts/okf_validate.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OKF v0.1 conformance + broken-link validator (Python standard library only).
|
||||||
|
|
||||||
|
Conformance (SPEC.md section 9): every non-reserved .md has a parseable
|
||||||
|
frontmatter block with a non-empty `type`. Broken cross-links are reported as
|
||||||
|
warnings, never errors (consumers MUST tolerate them).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from okf_common import (FrontmatterError, concept_id, extract_links,
|
||||||
|
iter_concepts, parse_frontmatter)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_link(bundle, path, target):
|
||||||
|
"""Return a Path for an internal .md link, or None for external/anchor/non-md."""
|
||||||
|
t = target.split("#", 1)[0].strip()
|
||||||
|
if not t or "://" in t or t.startswith("mailto:") or not t.endswith(".md"):
|
||||||
|
return None
|
||||||
|
if t.startswith("/"):
|
||||||
|
return bundle / t.lstrip("/")
|
||||||
|
return path.parent / t
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bundle(bundle_dir):
|
||||||
|
bundle = Path(bundle_dir)
|
||||||
|
errors, warnings = [], []
|
||||||
|
concept_files = list(iter_concepts(bundle))
|
||||||
|
existing = {p.resolve() for p in bundle.rglob("*.md")}
|
||||||
|
for path in concept_files:
|
||||||
|
cid = concept_id(bundle, path)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
meta, body = parse_frontmatter(text)
|
||||||
|
except FrontmatterError as exc:
|
||||||
|
errors.append({"concept": cid, "rule": "frontmatter", "message": str(exc)})
|
||||||
|
continue
|
||||||
|
if meta is None:
|
||||||
|
errors.append({"concept": cid, "rule": "frontmatter",
|
||||||
|
"message": "missing YAML frontmatter block"})
|
||||||
|
continue
|
||||||
|
if not str(meta.get("type", "")).strip():
|
||||||
|
errors.append({"concept": cid, "rule": "type",
|
||||||
|
"message": "missing or empty required 'type' field"})
|
||||||
|
for target in extract_links(body):
|
||||||
|
resolved = _resolve_link(bundle, path, target)
|
||||||
|
if resolved is not None and resolved.resolve() not in existing:
|
||||||
|
warnings.append({"concept": cid, "rule": "broken_link",
|
||||||
|
"message": "link target not found: %s" % target})
|
||||||
|
return {
|
||||||
|
"bundle": str(bundle),
|
||||||
|
"concepts": len(concept_files),
|
||||||
|
"conformant": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_report(report):
|
||||||
|
status = "CONFORMANT" if report["conformant"] else "NON-CONFORMANT"
|
||||||
|
lines = [
|
||||||
|
"OKF v0.1 validation: %s" % report["bundle"],
|
||||||
|
" concepts: %d status: %s" % (report["concepts"], status),
|
||||||
|
" errors: %d warnings: %d" % (len(report["errors"]), len(report["warnings"])),
|
||||||
|
]
|
||||||
|
for e in report["errors"]:
|
||||||
|
lines.append(" ERROR [%s] %s: %s" % (e["concept"], e["rule"], e["message"]))
|
||||||
|
for w in report["warnings"]:
|
||||||
|
lines.append(" WARN [%s] %s: %s" % (w["concept"], w["rule"], w["message"]))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Validate an OKF v0.1 bundle.")
|
||||||
|
ap.add_argument("bundle", help="Path to the bundle directory")
|
||||||
|
ap.add_argument("--json", action="store_true", help="Emit a JSON report")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
report = validate_bundle(args.bundle)
|
||||||
|
print(json.dumps(report, indent=2) if args.json else format_report(report))
|
||||||
|
return 0 if report["conformant"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a minimal, self-contained OKF bundle visualizer (stdlib only).
|
||||||
|
|
||||||
|
Emits one HTML file: a Cytoscape.js force-directed graph of the bundle's
|
||||||
|
concepts (nodes colored by `type`), with a side panel that renders the
|
||||||
|
selected concept's markdown body via marked. Both libraries load from a CDN;
|
||||||
|
the bundle is embedded as JSON, so no backend and no data leaves the page.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from okf_common import (concept_id, extract_links, iter_concepts,
|
||||||
|
parse_frontmatter)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_id(bundle, path, target, ids):
|
||||||
|
t = target.split("#", 1)[0].strip()
|
||||||
|
if not t or "://" in t or not t.endswith(".md"):
|
||||||
|
return None
|
||||||
|
if t.startswith("/"):
|
||||||
|
cid = t.lstrip("/")[:-3]
|
||||||
|
else:
|
||||||
|
rel = (path.parent / t).resolve().relative_to(Path(bundle).resolve())
|
||||||
|
cid = rel.as_posix()[:-3]
|
||||||
|
return cid if cid in ids else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph(bundle_dir):
|
||||||
|
bundle = Path(bundle_dir)
|
||||||
|
concepts = list(iter_concepts(bundle))
|
||||||
|
ids = {concept_id(bundle, p) for p in concepts}
|
||||||
|
nodes, edges = [], []
|
||||||
|
for path in concepts:
|
||||||
|
cid = concept_id(bundle, path)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
meta, body = parse_frontmatter(text)
|
||||||
|
except Exception:
|
||||||
|
meta, body = None, text
|
||||||
|
meta = meta or {}
|
||||||
|
nodes.append({
|
||||||
|
"id": cid,
|
||||||
|
"type": str(meta.get("type", "Concept")),
|
||||||
|
"title": str(meta.get("title", Path(cid).name)),
|
||||||
|
"description": str(meta.get("description", "")),
|
||||||
|
"body": body,
|
||||||
|
})
|
||||||
|
for target in extract_links(body):
|
||||||
|
tid = _target_id(bundle, path, target, ids)
|
||||||
|
if tid and tid != cid:
|
||||||
|
edges.append({"source": cid, "target": tid})
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
|
_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<title>__NAME__ — OKF Viewer</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.2/cytoscape.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body{margin:0;font-family:system-ui,sans-serif;display:flex;height:100vh}
|
||||||
|
#cy{flex:1;background:#fafafa}
|
||||||
|
#panel{width:380px;overflow:auto;padding:16px;border-left:1px solid #ddd;box-sizing:border-box}
|
||||||
|
#panel h2{margin:.2em 0;font-size:16px} table{border-collapse:collapse} td,th{border:1px solid #ddd;padding:2px 6px}
|
||||||
|
.type{display:inline-block;font-size:11px;background:#eef;color:#225;padding:2px 6px;border-radius:4px}
|
||||||
|
header{position:absolute;top:8px;left:12px;font-weight:600;color:#333}
|
||||||
|
</style></head><body>
|
||||||
|
<header>__NAME__</header><div id="cy"></div>
|
||||||
|
<div id="panel"><p>Click a concept node to view it.</p></div>
|
||||||
|
<script>
|
||||||
|
const DATA = __DATA__;
|
||||||
|
const colors=["#4e79a7","#f28e2b","#e15759","#76b7b2","#59a14f","#edc948","#b07aa1","#ff9da7","#9c755f","#bab0ac"];
|
||||||
|
const types=[...new Set(DATA.nodes.map(n=>n.type))];
|
||||||
|
const colorOf={}; types.forEach((t,i)=>colorOf[t]=colors[i%colors.length]);
|
||||||
|
const byId={}; DATA.nodes.forEach(n=>byId[n.id]=n);
|
||||||
|
const elements=[];
|
||||||
|
DATA.nodes.forEach(n=>elements.push({data:{id:n.id,label:n.title,color:colorOf[n.type]}}));
|
||||||
|
DATA.edges.forEach(e=>elements.push({data:{source:e.source,target:e.target}}));
|
||||||
|
const cy=cytoscape({container:document.getElementById('cy'),elements,
|
||||||
|
style:[{selector:'node',style:{'label':'data(label)','font-size':'9px','background-color':'data(color)','width':14,'height':14,'color':'#333'}},
|
||||||
|
{selector:'edge',style:{'width':1,'line-color':'#bbb','target-arrow-color':'#bbb','target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':0.7}}],
|
||||||
|
layout:{name:'cose',animate:false}});
|
||||||
|
cy.on('tap','node',evt=>{const n=byId[evt.target.id()];
|
||||||
|
document.getElementById('panel').innerHTML='<span class="type">'+n.type+'</span><h2>'+n.title+'</h2>'+
|
||||||
|
(n.description?'<p><em>'+n.description+'</em></p>':'')+'<hr>'+marked.parse(n.body||'');});
|
||||||
|
</script></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(graph, name="OKF Bundle"):
|
||||||
|
data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/")
|
||||||
|
return _HTML.replace("__NAME__", html.escape(name)).replace("__DATA__", data)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Visualize an OKF bundle as self-contained HTML.")
|
||||||
|
ap.add_argument("--bundle", required=True)
|
||||||
|
ap.add_argument("--out")
|
||||||
|
ap.add_argument("--name")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
bundle = Path(args.bundle)
|
||||||
|
graph = build_graph(bundle)
|
||||||
|
out = Path(args.out) if args.out else bundle / "viz.html"
|
||||||
|
out.write_text(render_html(graph, args.name or bundle.name), encoding="utf-8")
|
||||||
|
print("Wrote %s (%d nodes, %d edges)" % (out, len(graph["nodes"]), len(graph["edges"])))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# ourdigital-okf scripts use the Python standard library only.
|
||||||
|
# No third-party runtime dependencies. Tests use stdlib `unittest`.
|
||||||
10
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/datasets/sales.md
vendored
Normal file
10
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/datasets/sales.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
type: BigQuery Dataset
|
||||||
|
title: Sales
|
||||||
|
description: All sales-related tables for the retail business.
|
||||||
|
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales
|
||||||
|
tags: [sales]
|
||||||
|
timestamp: 2026-05-28T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
The sales dataset contains [orders](/tables/orders.md) and [customers](/tables/customers.md).
|
||||||
8
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/index.md
vendored
Normal file
8
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/index.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Datasets
|
||||||
|
|
||||||
|
* [Sales](datasets/sales.md) - All sales-related tables.
|
||||||
|
|
||||||
|
# Tables
|
||||||
|
|
||||||
|
* [Orders](tables/orders.md) - One row per completed order.
|
||||||
|
* [Customers](tables/customers.md) - One row per customer.
|
||||||
16
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/customers.md
vendored
Normal file
16
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/customers.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
type: BigQuery Table
|
||||||
|
title: Customers
|
||||||
|
description: One row per customer.
|
||||||
|
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=customers
|
||||||
|
tags: [sales, customers]
|
||||||
|
timestamp: 2026-05-28T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---------------|--------|-----------------------|
|
||||||
|
| `customer_id` | STRING | Unique customer id. |
|
||||||
|
|
||||||
|
Referenced by [orders](/tables/orders.md).
|
||||||
17
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/orders.md
vendored
Normal file
17
custom-skills/97-ourdigital-okf/code/scripts/tests/fixtures/mini_bundle/tables/orders.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: BigQuery Table
|
||||||
|
title: Orders
|
||||||
|
description: One row per completed customer order.
|
||||||
|
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=orders
|
||||||
|
tags: [sales, orders]
|
||||||
|
timestamp: 2026-05-28T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---------------|---------|------------------------------------------|
|
||||||
|
| `order_id` | STRING | Unique order identifier. |
|
||||||
|
| `customer_id` | STRING | FK to [customers](/tables/customers.md). |
|
||||||
|
|
||||||
|
Part of the [sales dataset](/datasets/sales.md).
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import sys, unittest
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from okf_common import (parse_frontmatter, parse_yaml_subset,
|
||||||
|
extract_links, concept_id, FrontmatterError)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrontmatter(unittest.TestCase):
|
||||||
|
def test_parse_basic(self):
|
||||||
|
text = "---\ntype: BigQuery Table\ntitle: Orders\ntags: [sales, revenue]\n---\n\n# Body\n"
|
||||||
|
meta, body = parse_frontmatter(text)
|
||||||
|
self.assertEqual(meta["type"], "BigQuery Table")
|
||||||
|
self.assertEqual(meta["title"], "Orders")
|
||||||
|
self.assertEqual(meta["tags"], ["sales", "revenue"])
|
||||||
|
self.assertIn("# Body", body)
|
||||||
|
|
||||||
|
def test_url_value_with_colons(self):
|
||||||
|
meta, _ = parse_frontmatter("---\ntype: X\nresource: https://e.com/a?b=c\n---\nbody")
|
||||||
|
self.assertEqual(meta["resource"], "https://e.com/a?b=c")
|
||||||
|
|
||||||
|
def test_quoted_value(self):
|
||||||
|
self.assertEqual(parse_yaml_subset('okf_version: "0.1"')["okf_version"], "0.1")
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
self.assertEqual(parse_yaml_subset("tags: []")["tags"], [])
|
||||||
|
|
||||||
|
def test_block_list(self):
|
||||||
|
raw = "type: BigQuery Dataset\ntags:\n- cryptocurrency\n- bitcoin\n- public data\ntimestamp: '2026-05-28T22:44:47+00:00'"
|
||||||
|
meta = parse_yaml_subset(raw)
|
||||||
|
self.assertEqual(meta["tags"], ["cryptocurrency", "bitcoin", "public data"])
|
||||||
|
self.assertEqual(meta["timestamp"], "2026-05-28T22:44:47+00:00")
|
||||||
|
|
||||||
|
def test_folded_multiline_scalar(self):
|
||||||
|
raw = "description: This dataset contains a complete history of the Bitcoin\n blockchain and updates every 10 minutes.\ntype: BigQuery Dataset"
|
||||||
|
meta = parse_yaml_subset(raw)
|
||||||
|
self.assertEqual(
|
||||||
|
meta["description"],
|
||||||
|
"This dataset contains a complete history of the Bitcoin blockchain and updates every 10 minutes.",
|
||||||
|
)
|
||||||
|
self.assertEqual(meta["type"], "BigQuery Dataset")
|
||||||
|
|
||||||
|
def test_no_frontmatter(self):
|
||||||
|
meta, body = parse_frontmatter("# Just markdown\n")
|
||||||
|
self.assertIsNone(meta)
|
||||||
|
self.assertEqual(body, "# Just markdown\n")
|
||||||
|
|
||||||
|
def test_unclosed_raises(self):
|
||||||
|
with self.assertRaises(FrontmatterError):
|
||||||
|
parse_frontmatter("---\ntype: X\n\nbody without close")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinksAndIds(unittest.TestCase):
|
||||||
|
def test_extract_links(self):
|
||||||
|
body = "See [a](/tables/a.md) and [b](./b.md) and [ext](https://x.com)."
|
||||||
|
self.assertEqual(extract_links(body), ["/tables/a.md", "./b.md", "https://x.com"])
|
||||||
|
|
||||||
|
def test_concept_id(self):
|
||||||
|
self.assertEqual(concept_id("/bundle", "/bundle/tables/users.md"), "tables/users")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import sys, tempfile, unittest
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from okf_validate import validate_bundle
|
||||||
|
|
||||||
|
FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
|
||||||
|
GOOGLE = Path.home() / "Documents/reference-library/open-knowledge-format/okf/bundles"
|
||||||
|
|
||||||
|
|
||||||
|
def write_bundle(root, files):
|
||||||
|
for rel, content in files.items():
|
||||||
|
p = Path(root) / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate(unittest.TestCase):
|
||||||
|
def test_mini_fixture_conformant(self):
|
||||||
|
report = validate_bundle(FIX)
|
||||||
|
self.assertTrue(report["conformant"], report["errors"])
|
||||||
|
self.assertGreaterEqual(report["concepts"], 3)
|
||||||
|
|
||||||
|
def test_missing_type_is_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
write_bundle(d, {"tables/x.md": "---\ntitle: X\n---\nbody"})
|
||||||
|
report = validate_bundle(d)
|
||||||
|
self.assertFalse(report["conformant"])
|
||||||
|
self.assertTrue(any(e["rule"] == "type" for e in report["errors"]))
|
||||||
|
|
||||||
|
def test_missing_frontmatter_is_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
write_bundle(d, {"tables/x.md": "# no frontmatter\n"})
|
||||||
|
self.assertFalse(validate_bundle(d)["conformant"])
|
||||||
|
|
||||||
|
def test_reserved_files_not_required_to_have_type(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
write_bundle(d, {"index.md": "# Index\n* [x](/tables/x.md)\n",
|
||||||
|
"tables/x.md": "---\ntype: T\n---\nb"})
|
||||||
|
self.assertTrue(validate_bundle(d)["conformant"])
|
||||||
|
|
||||||
|
def test_broken_link_is_warning_not_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
write_bundle(d, {"tables/x.md": "---\ntype: T\n---\nSee [y](/tables/y.md)."})
|
||||||
|
report = validate_bundle(d)
|
||||||
|
self.assertTrue(report["conformant"])
|
||||||
|
self.assertTrue(any(w["rule"] == "broken_link" for w in report["warnings"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoogleBundles(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(GOOGLE.exists(), "Google reference bundles not present")
|
||||||
|
def test_google_sample_bundles_conformant(self):
|
||||||
|
for name in ("crypto_bitcoin", "ga4", "stackoverflow"):
|
||||||
|
bundle = GOOGLE / name
|
||||||
|
if not bundle.exists():
|
||||||
|
continue
|
||||||
|
report = validate_bundle(bundle)
|
||||||
|
self.assertTrue(
|
||||||
|
report["conformant"],
|
||||||
|
"%s non-conformant; first errors: %s" % (name, report["errors"][:3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sys, tempfile, unittest
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from okf_viz import build_graph, render_html, main
|
||||||
|
|
||||||
|
FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
|
||||||
|
|
||||||
|
|
||||||
|
class TestViz(unittest.TestCase):
|
||||||
|
def test_build_graph_nodes_and_edges(self):
|
||||||
|
g = build_graph(FIX)
|
||||||
|
ids = {n["id"] for n in g["nodes"]}
|
||||||
|
self.assertIn("tables/orders", ids)
|
||||||
|
self.assertTrue(any(e["source"] == "tables/orders" and e["target"] == "tables/customers"
|
||||||
|
for e in g["edges"]))
|
||||||
|
|
||||||
|
def test_render_html_embeds_data_and_cdn(self):
|
||||||
|
html = render_html(build_graph(FIX), "Mini")
|
||||||
|
self.assertIn("cytoscape", html)
|
||||||
|
self.assertIn('"nodes"', html)
|
||||||
|
|
||||||
|
def test_render_html_escapes_script_close(self):
|
||||||
|
g = {"nodes": [{"id": "x", "type": "T", "title": "x",
|
||||||
|
"description": "", "body": "</script><b>hi</b>"}], "edges": []}
|
||||||
|
html = render_html(g, "X")
|
||||||
|
self.assertNotIn("</script><b>hi", html)
|
||||||
|
|
||||||
|
def test_main_writes_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = Path(d) / "v.html"
|
||||||
|
self.assertEqual(main(["--bundle", str(FIX), "--out", str(out)]), 0)
|
||||||
|
self.assertTrue(out.exists() and out.stat().st_size > 500)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user