Compare commits
51 Commits
e519a49cc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f27fb7a2c4 | |||
|
|
a08dc316be | ||
| dc18594d76 | |||
|
|
0b3a0ab129 | ||
| 5c904756ab | |||
| 07fca7fb81 | |||
| 00edd2e5dc | |||
| 478ac61975 | |||
| 5ff9b3d9f5 | |||
| cfbca6cc15 | |||
| dfbc52e531 | |||
| 337f2ad6e1 | |||
| 9fbd719048 | |||
| 5385f3bddd | |||
| 75cfcb9ad3 | |||
| bfade2b722 | |||
| 2b36feb81b | |||
| aa003e28cc | |||
| 87635e4208 | |||
| 4f78534e59 | |||
| d20675d02c | |||
| 2cadc30825 | |||
| c35250f06a | |||
| 3383878e12 | |||
| f953887b97 | |||
| 18b39ef6ea | |||
| e1f4d75dc2 | |||
| 6c97dfc913 | |||
| c068372abf | |||
| 587a32d239 | |||
| 626abd4173 | |||
| c6585c817f | |||
| 75acd3aa3e | |||
| 7b239dda8f | |||
| 9762ee97ab | |||
| 4416833cb3 | |||
| b574c97fcc | |||
| 7daa4cda68 | |||
|
|
8ffb6bec6b | ||
|
|
9b914b9dd4 | ||
|
|
aab98f405d | ||
| 6f69a6a484 | |||
| 9f7f9e7221 | |||
| 6ac547e78f | |||
| 08cb20fc67 | |||
| c9bdbb57f7 | |||
| 34c3a1df4f | |||
| 137b927477 | |||
| 95d6fdf499 | |||
| 0496262cd5 | |||
| 60734dbde7 |
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
|
||||
|
||||
```bash
|
||||
cd ~/Projects/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
|
||||
|
||||
# Test connection
|
||||
python notion_writer.py --test
|
||||
|
||||
26
.github/workflows/verify-skills.yml
vendored
Normal file
26
.github/workflows/verify-skills.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Verify Skills
|
||||
|
||||
# Runs the skill load-verifier on every push and pull request.
|
||||
# Fails the build if any skill would not load (invalid frontmatter, bad name,
|
||||
# name collision, orphan dir, or invalid plugin.json). Read-only check.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify-skills:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Verify all skills load correctly
|
||||
run: python3 scripts/verify_skills.py
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -99,3 +99,7 @@ build/
|
||||
# Temporary files
|
||||
output/
|
||||
keyword_analysis_*.json
|
||||
|
||||
# graphify: keep graph.json/html/report, drop regenerable cache + dated backups
|
||||
graphify-out/cache/
|
||||
graphify-out/????-??-??/
|
||||
|
||||
4
.graphifyignore
Normal file
4
.graphifyignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# graphify scope control — exclude non-standard virtualenvs that the default
|
||||
# rules (.venv, venv) don't catch, so pip-package source doesn't pollute the graph.
|
||||
.venv-ourdigital/
|
||||
.venv-*/
|
||||
@@ -77,6 +77,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
|
||||
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
|
||||
| 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" |
|
||||
| 48 | jamie-copy-trimmer | Trim/sharpen Korean aesthetic-medical copy against cliché & compliance corpus | "카피 다듬어", "카피 트리밍", "심의 안전하게", "copy trim" |
|
||||
|
||||
### NotebookLM Tools (50-59)
|
||||
|
||||
@@ -109,6 +110,7 @@ This is a Claude Skills collection repository containing:
|
||||
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
|
||||
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
|
||||
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
|
||||
| 78 | dintel-campaign-designer | Campaign/promotion planning as a 3-gate process (Discovery & Debate → Brief → Plan); cross-brand, not D.intelligence-exclusive | Draft & Wait | "campaign plan", "캠페인 기획", "기획안 만들어" |
|
||||
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
|
||||
|
||||
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
|
||||
@@ -204,7 +206,7 @@ directly loadable. Migrate incrementally, not in bulk.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: skill-name-kebab-case # letters, numbers, hyphens only
|
||||
name: skill-name-kebab-case # clean name: dir minus the NN- prefix
|
||||
description: |
|
||||
What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거.
|
||||
---
|
||||
@@ -214,6 +216,9 @@ description: |
|
||||
Content starts here...
|
||||
```
|
||||
|
||||
**Naming convention:** the directory keeps its `NN-` prefix for ordering
|
||||
(`16-seo-schema-validator/`), but the skill `name:` is the **clean** form without it
|
||||
(`name: seo-schema-validator`). Names must be kebab-case and globally unique.
|
||||
Whole frontmatter ≤ 1024 chars. Full rules + migration recipe: `reference/SKILL-MIGRATION-GUIDE.md`.
|
||||
|
||||
## Directory Layout
|
||||
@@ -250,6 +255,7 @@ our-claude-skills/
|
||||
│ ├── 45-jamie-instagram-manager/
|
||||
│ ├── 46-jamie-journal-editor/
|
||||
│ ├── 47-jamie-marketing-editor/
|
||||
│ ├── 48-jamie-copy-trimmer/
|
||||
│ │
|
||||
│ ├── 50-notebooklm-agent/
|
||||
│ ├── 51-notebooklm-automation/
|
||||
@@ -268,6 +274,7 @@ our-claude-skills/
|
||||
│ ├── 75-dintel-marketing-mgr/
|
||||
│ ├── 76-dintel-backoffice-mgr/
|
||||
│ ├── 77-dintel-account-mgr/
|
||||
│ ├── 78-dintel-campaign-designer/
|
||||
│ ├── 79-dintel-skill-update/
|
||||
│ │
|
||||
│ ├── 80-claude-settings-optimizer/
|
||||
|
||||
20
README.md
20
README.md
@@ -12,6 +12,11 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This symlinks the global slash commands into `~/.claude/commands/`, sets up the
|
||||
Python virtual environment, and configures credentials. It does **not** register
|
||||
skills natively — to load a skill as a Claude Code skill, also symlink it into
|
||||
`~/.claude/skills/` (see [Usage → Claude Code](#claude-code)).
|
||||
|
||||
## Custom Skills Overview
|
||||
|
||||
### OurDigital Core (01-10)
|
||||
@@ -215,16 +220,23 @@ The `_ourdigital-shared/` directory provides:
|
||||
|
||||
### Claude Code
|
||||
|
||||
Skills are auto-detected via symlinks in `~/.claude/skills/`:
|
||||
The Quick Install symlinks the **slash commands** into `~/.claude/commands/`. To
|
||||
also load a skill natively, symlink its **root** directory (which holds the
|
||||
loadable `SKILL.md`) into `~/.claude/skills/`, using the clean name without the
|
||||
`NN-` prefix:
|
||||
|
||||
```bash
|
||||
# Install skill symlink
|
||||
ln -sf /path/to/skill/desktop ~/.claude/skills/skill-name
|
||||
# From the repo root — symlink a skill into Claude Code
|
||||
ln -sf "$PWD/custom-skills/16-seo-schema-validator" ~/.claude/skills/seo-schema-validator
|
||||
```
|
||||
|
||||
> Legacy skills that don't yet have a root `SKILL.md` expose it under
|
||||
> `code/SKILL.md` instead — symlink `.../<skill>/code` for those.
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Copy the `desktop/SKILL.md` file to your Claude Desktop skills folder.
|
||||
Import the skill's `desktop/` folder (containing `SKILL.md` + `skill.yaml`) via
|
||||
your Claude Desktop skills settings.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 01-ourdigital-brand-guide
|
||||
name: ourdigital-brand-guide
|
||||
description: |
|
||||
OurDigital 브랜드 기준 및 스타일 가이드 참조 스킬.
|
||||
Activated with "ourdigital" keyword for brand-related queries.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# OurDigital Brand Foundation
|
||||
|
||||
Complete brand identity reference for OurDigital Clinic.
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Complete brand identity reference for OurDigital.
|
||||
|
||||
## Brand Identity
|
||||
|
||||
@@ -8,25 +10,34 @@ Complete brand identity reference for OurDigital Clinic.
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| **Brand Name** | OurDigital Clinic |
|
||||
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 |
|
||||
| **Brand Name** | OurDigital |
|
||||
| **Identity Statement** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Mission** | 기술이 사람과 문화에 미치는 영향을 관찰하고 기록한다. 생각의 씨앗을 남기고 성찰한다. 검증되지 않은 도전과 실험의 결과를 기록한다. |
|
||||
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
|
||||
| **Promise** | 진단-처방-측정 가능한 성장 |
|
||||
|
||||
### Core Values
|
||||
### Brand Keywords
|
||||
|
||||
| 가치 | English | 클리닉 메타포 | 설명 |
|
||||
|------|---------|--------------|------|
|
||||
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 |
|
||||
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 |
|
||||
| 지속 성장 | Sustainable Growth | 체질 개선 | 일회성이 아닌 지속 가능한 성장 |
|
||||
| 핵심 가치 | 설명 |
|
||||
|----------|------|
|
||||
| **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
|
||||
| **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
|
||||
| **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
|
||||
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
|
||||
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
|
||||
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
|
||||
|
||||
### Brand Philosophy
|
||||
|
||||
**"Precision + Empathy + Evidence"**
|
||||
|
||||
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications.
|
||||
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications. 철학적 깊이와 실용적 가치의 균형이 OurDigital의 정체성이다.
|
||||
|
||||
### Brand Boundary (중요)
|
||||
|
||||
- **블로그 전체 브랜드**: OurDigital
|
||||
- **블로그 설명**: 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트
|
||||
- **OurDigital Clinic**: 진단형 콘텐츠, SEO/데이터/콘텐츠 감사, 컨설팅 상품, 문제 해결형 시리즈에서만 사용 가능한 서비스 메타포. 블로그 전체 브랜드명이 아니다.
|
||||
|
||||
## Positioning Statement
|
||||
|
||||
@@ -34,8 +45,16 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
||||
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
|
||||
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
|
||||
|
||||
*(Note: OurDigital Clinic은 컨설팅/서비스 맥락에서의 포지셔닝. 블로그 전체 포지셔닝과 구분한다.)*
|
||||
|
||||
## Target Audience
|
||||
|
||||
**블로그 독자 (blog.ourdigital.org)**
|
||||
- **1순위**: 디지털 마케팅/기술 분야 종사자 — 실무 전략을 수립하고 실행하는 전문가
|
||||
- **2순위**: 기술과 사회의 관계에 관심 있는 지식인 — 디지털 전환이 사회에 미치는 영향을 고민하는 독자
|
||||
- **3순위**: 자기 성찰과 비판적 사고를 중시하는 독자
|
||||
|
||||
**컨설팅 서비스 (OurDigital Clinic)**
|
||||
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
|
||||
- **Secondary**: 스타트업 창업자, 브랜드 매니저
|
||||
|
||||
@@ -54,24 +73,30 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
|
||||
|
||||
| Level | Element | Description |
|
||||
|-------|---------|-------------|
|
||||
| Level 1 | Master Brand | OurDigital Clinic |
|
||||
| Level 1 | Master Brand | OurDigital |
|
||||
| Level 2 | Channel Identity | Blog, Journal, OurStory |
|
||||
| Level 3 | Service Identity | 4개 핵심 서비스 |
|
||||
| Level 3 | Service Identity | 4개 핵심 서비스 (OurDigital Clinic 메타포 적용 가능) |
|
||||
|
||||
### Channel Personality & Tone
|
||||
|
||||
| Channel | Domain | Personality | Tone | Content Type |
|
||||
|---------|--------|-------------|------|--------------|
|
||||
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented | 서비스, 케이스, 리드 |
|
||||
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking | 가이드, 분석, 인사이트 |
|
||||
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer | 에세이, 문화, 관찰 |
|
||||
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey | 개인 서사, 경험 |
|
||||
| D.intelligence | dintelligence.co.kr | Professional | B2B | Corporate Partnership |
|
||||
| Channel | Domain | Language | Character | Length |
|
||||
|---------|--------|----------|-----------|--------|
|
||||
| Main Hub | ourdigital.org | Korean | Professional & Confident, Data-driven | 서비스, 케이스, 리드 |
|
||||
| **Blog** | blog.ourdigital.org | **Korean** | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | **1,500-3,000자** |
|
||||
| **Journal** | journal.ourdigital.org | **English** | 산업 트렌드, 기술-인간 교차점, reflective essay | **1,000-2,000 words** |
|
||||
| **OurStory** | ourstory.day | **Korean** | 개인 에세이, 삶의 성찰, 일상의 관찰 | **800-1,500자** |
|
||||
| D.intelligence | dintelligence.co.kr | Korean/English | Professional | B2B Corporate Partnership |
|
||||
|
||||
**채널 라우팅 규칙:**
|
||||
- 기술 분석 + 철학적 사유 → `blog.ourdigital.org`
|
||||
- 순수 개인 에세이, 감정적 성찰 → `ourstory.day`
|
||||
- 영문 심층 에세이, 산업 관점 → `journal.ourdigital.org`
|
||||
- 진단형 콘텐츠, 컨설팅 제안 → `OurDigital Clinic` 메타포 사용 가능
|
||||
|
||||
### Content Flow Strategy
|
||||
|
||||
```
|
||||
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site / OurDigital Clinic)
|
||||
```
|
||||
|
||||
### Publishing Cadence
|
||||
@@ -80,7 +105,7 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
|---------|-----------|--------|
|
||||
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
|
||||
| blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 |
|
||||
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000자 |
|
||||
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000 words |
|
||||
| ourstory.day | 월 1-2회 | 800-1,500자 |
|
||||
|
||||
## Service Portfolio
|
||||
@@ -112,13 +137,16 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|
||||
|
||||
| Use Case | Message |
|
||||
|----------|---------|
|
||||
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| Blog Identity | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
|
||||
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
|
||||
| Process | 진단 → 처방 → 측정 |
|
||||
| Differentiator | 25년 경험의 마케팅 사이언티스트 |
|
||||
|
||||
## CTA Library
|
||||
|
||||
*(OurDigital Clinic 컨설팅 맥락에서 사용)*
|
||||
|
||||
| Context | CTA Text |
|
||||
|---------|----------|
|
||||
| General | 무료 상담 신청하기 |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OurDigital Writing Style Guide
|
||||
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|
||||
## Overview
|
||||
@@ -8,7 +10,7 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|-------|-------|
|
||||
| **Author** | Andrew Yim |
|
||||
| **Primary Blog** | blog.ourdigital.org |
|
||||
| **Tagline** | 사람, 디지털 그리고 문화 (People, Digital, and Culture) |
|
||||
| **Brand Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Platform** | Ghost CMS |
|
||||
| **History** | 2004-2025 (20+ years of content) |
|
||||
|
||||
@@ -16,54 +18,73 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|
||||
|
||||
## Part 1: 한국어 스타일가이드
|
||||
|
||||
### Writer 역할 정의
|
||||
|
||||
25년차 디지털 마케팅 에세이 작가. 기술과 인간의 관계를 관찰하는 실무자이자 사유자. 분석적이면서 개인적이고, 단정 대신 질문을 두고, 결론 대신 가능성을 제시하며, 독자를 가르치지 않고 함께 생각하는 동료로 대한다.
|
||||
|
||||
### 문체 규칙 (Korean)
|
||||
|
||||
- 대화체 `~다`, `~이다`, `~한다`를 사용한다.
|
||||
- 경어체 `~합니다`, `~입니다`는 사용하지 않는다.
|
||||
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다.
|
||||
- 문단 전환 시 호흡을 바꾼다: 분석 → 서사 → 질문.
|
||||
- **전문용어는 첫 등장 시 영문을 병기한다.** 예: `검색엔진 최적화(SEO)`.
|
||||
|
||||
### 핵심 글쓰기 특성
|
||||
|
||||
#### 1. 철학-기술 융합체
|
||||
#### 1. 철학-기술 융합
|
||||
|
||||
기술 분석과 실존적 질문을 자연스럽게 결합한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 함의와 분리되지 않는다.
|
||||
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의미와 분리되지 않는다.
|
||||
|
||||
**예시**: Llama 4와 DeepSeek 비교 글에서도 거버넌스 우려와 사회적 영향을 포함한다.
|
||||
```
|
||||
나쁜 예: 구글의 알고리즘은 200개 이상의 신호를 분석한다.
|
||||
좋은 예: 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
|
||||
```
|
||||
|
||||
#### 2. 역설(Paradox)을 주요 수사 장치로 활용
|
||||
#### 2. 핵심 긴장 / 관점 전환 / 역설 / 열린 질문
|
||||
|
||||
논증을 긴장과 모순 구조로 전개한다:
|
||||
- 인간이 컴퓨터를 모방하면서 컴퓨터가 인간을 모방하는 역설
|
||||
- 기술이 해방하면서 동시에 의미를 축소하는 역설
|
||||
- 디지털 네이티브가 기성세대가 가르칠 수 없는 유창함을 보유하는 역설
|
||||
모든 글에 억지 역설을 넣지는 않는다. 대신 **핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상**을 자연스럽게 포함한다. 공식화를 경계한다.
|
||||
|
||||
#### 3. 수사적 질문 활용
|
||||
유용한 패턴:
|
||||
- `~하면서 동시에 ~하다`
|
||||
- `~를 위해 오히려 ~해야 한다`
|
||||
- `가장 ~한 것이 사실은 가장 ~하다`
|
||||
- `우리가 놓치고 있는 것은 무엇인가?`
|
||||
|
||||
선언적 권위보다 질문을 통한 참여를 선호한다:
|
||||
수사적 질문은 선언적 권위보다 독자와의 지적 동반자 관계를 형성한다.
|
||||
|
||||
> "이 디지털 세대에게 무엇을 가르쳐야 하는가?"
|
||||
> "그 무한한 자유 속에서 우리는 무엇을 소중히 여기게 될 것인가?"
|
||||
#### 3. 우울한 낙관주의 (Melancholic Optimism)
|
||||
|
||||
이는 독자와 지적 동반자 관계를 형성하며, 교훈적 지시를 피한다.
|
||||
디지털 세계의 불안, 피로, 한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 더 나은 실천, 작은 회복의 여지를 찾는다.
|
||||
|
||||
#### 4. 우울한 낙관주의 (Melancholic Optimism)
|
||||
```
|
||||
예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
|
||||
```
|
||||
|
||||
불안과 상실을 인정하되 절망하지 않는다. 기술의 불가피성을 수용하면서도 대체되는 것에 대한 진정한 우려를 표현한다.
|
||||
#### 4. 분석적이면서 개인적
|
||||
|
||||
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
|
||||
|
||||
### 문장 구조 패턴
|
||||
|
||||
| 요소 | 패턴 |
|
||||
|------|------|
|
||||
| 문장 길이 | 여러 절을 포함한 긴 복합문 - 논의되는 상호연결된 개념을 반영 |
|
||||
| 문장 길이 | 짧은 문장과 긴 복합문을 교차 배치 — 리듬을 만든다 |
|
||||
| 단락 구조 | 관찰 → 분석 → 철학적 함의로 점진적 심화 |
|
||||
| 근거 제시 | 역사적 사례, 문화간 참조, 기술 명세를 함께 엮음 |
|
||||
| 결론부 | 종종 열린 결말로, 답을 제시하기보다 질문을 던짐 |
|
||||
|
||||
### 독자 조율
|
||||
|
||||
**교양 있는 일반 독자**를 대상으로 한다 — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다.
|
||||
**교양 있는 일반 독자** — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다. 독자를 가르치려 하지 않고 결론을 강요하지 않는다. 생각의 재료를 제공한다.
|
||||
|
||||
기술 글에도 요약본과 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
|
||||
기술 글에도 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
|
||||
|
||||
### 고유 특성
|
||||
|
||||
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영
|
||||
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영 (단, 전문용어는 첫 등장 시 영문 병기)
|
||||
2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
|
||||
3. **인식론적 겸손** — 특히 세대간 격차에서 이해의 한계를 인정
|
||||
3. **인식론적 겸손** — 이해의 한계를 인정하며, 겸손한 추정 표현을 적절히 활용 (`~일지도 모른다`, `~인 듯하다`, `어쩌면 ~`)
|
||||
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
|
||||
|
||||
---
|
||||
@@ -124,20 +145,28 @@ Technical articles include executive summaries and metaphorical anchoring ("Data
|
||||
|
||||
### Do's
|
||||
|
||||
- Use paradox to structure arguments
|
||||
- Ask rhetorical questions to engage readers
|
||||
- Connect technical content to human implications
|
||||
- Acknowledge uncertainty and epistemic limits
|
||||
- Blend Korean and English naturally for technical terms
|
||||
- Reference historical context and generational shifts
|
||||
- 핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상을 포함한다
|
||||
- 수사적 질문으로 독자와 지적 동반자 관계를 형성한다
|
||||
- 기술 콘텐츠를 인간적 함의와 연결한다
|
||||
- 불확실성과 이해의 한계를 인정한다
|
||||
- 전문용어 첫 등장 시 영문을 병기한다 (`검색엔진 최적화(SEO)`)
|
||||
- 역사적 맥락과 세대적 변화를 참조한다
|
||||
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다
|
||||
- 1인칭 경험과 관찰을 분석에 자연스럽게 엮는다
|
||||
|
||||
### Don'ts
|
||||
### Don'ts (피해야 할 것)
|
||||
|
||||
- Avoid purely declarative, authoritative tone
|
||||
- Don't separate technical analysis from cultural impact
|
||||
- Avoid simplistic or overly optimistic technology narratives
|
||||
- Don't provide prescriptive conclusions without exploration
|
||||
- Avoid ignoring regulatory and governance concerns
|
||||
| 피해야 할 것 | 이유 |
|
||||
|-------------|------|
|
||||
| `~합니다`, `~입니다` | 경어체 — 대화체(`~다`, `~한다`) 사용 |
|
||||
| `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다` | 상투적 도입부 |
|
||||
| `첫째, 둘째, 셋째` 기계적 나열 | 교과서적 구조 |
|
||||
| `따라서 반드시 ~해야 한다` | 확정적 결론 |
|
||||
| 근거 없는 통계나 데이터 | 데이터 정직성 위반 |
|
||||
| 기술에 대한 무조건적 찬양 또는 혐오 | 균형 원칙 위반 |
|
||||
| 과도한 감탄부호, 물결표, 이모지 | 톤 일관성 파괴 |
|
||||
| 특정 업체나 제품의 노골적 홍보성 문장 | 브랜드 신뢰 훼손 |
|
||||
| SEO 키워드 과잉 최적화 | SEO가 글을 지배해서는 안 된다 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 02-ourdigital-blog
|
||||
name: ourdigital-blog
|
||||
description: |
|
||||
Korean blog draft creation for blog.ourdigital.org.
|
||||
Activated with "ourdigital" keyword for blog writing tasks.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OurDigital Blog Style Guide
|
||||
|
||||
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
|
||||
|
||||
Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## Channel Identity
|
||||
@@ -7,62 +9,74 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Domain** | blog.ourdigital.org |
|
||||
| **Tagline** | 사람, 디지털 그리고 문화 |
|
||||
| **Language** | Korean (전문용어 영문 병기) |
|
||||
| **Tone** | Analytical & Personal, Educational |
|
||||
| **Target** | 교양 있는 일반 독자 - 기술의 문화적 영향에 호기심 있는 독자 |
|
||||
| **Brand** | OurDigital |
|
||||
| **Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
|
||||
| **Core Theme** | 사람, 디지털 그리고 문화 |
|
||||
| **Language** | Korean (전문용어 첫 등장 시 영문 병기) |
|
||||
| **CMS** | Ghost — 초안은 Markdown으로 작성 |
|
||||
| **Tone** | Analytical & Personal (비판적이되 공정, 겸손하되 자신감 있음) |
|
||||
| **Target** | 디지털 마케팅/기술 실무자 (1순위), 기술-사회 관계에 관심 있는 지식인 (2순위), 비판적 사고를 중시하는 일반 독자 (3순위) |
|
||||
|
||||
**브랜드 경계**: `OurDigital`이 블로그 전체 정체성이다. `OurDigital Clinic`은 진단형 콘텐츠·컨설팅 상품에서만 사용하는 서비스 메타포다.
|
||||
|
||||
**우선순위**: 지침 충돌 시 Blog_Project_Instruction_v3.3 → 이 스타일 가이드 → 사용자 일반 스타일 순으로 따른다.
|
||||
|
||||
## Writing Characteristics
|
||||
|
||||
### 1. 철학-기술 융합체
|
||||
모든 초안은 아래 네 원칙을 기준으로 자기 점검한다.
|
||||
|
||||
기술 분석과 실존적 질문을 자연스럽게 결합한다.
|
||||
### 1. 철학-기술 융합
|
||||
|
||||
**Good Example:**
|
||||
> AI가 우리의 업무를 대체할 수 있다는 사실은 분명하다. 그러나 더 중요한 질문은 "AI가 대체할 수 없는 것은 무엇인가?"이다.
|
||||
|
||||
**Bad Example:**
|
||||
> AI는 업무 효율성을 높여준다. 다양한 분야에서 활용되고 있다.
|
||||
|
||||
### 2. 역설(Paradox) 활용
|
||||
|
||||
논증을 긴장과 모순 구조로 전개한다.
|
||||
|
||||
**Paradox Patterns:**
|
||||
- "~하면서 동시에 ~하다"
|
||||
- "~인 것 같지만 실은 ~이다"
|
||||
- "~를 얻었지만 ~를 잃었다"
|
||||
|
||||
### 3. 수사적 질문
|
||||
|
||||
선언적 권위보다 질문을 통한 참여를 선호한다.
|
||||
기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다.
|
||||
|
||||
**Good:**
|
||||
> 우리는 정말 데이터를 이해하고 있는 것일까?
|
||||
> 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
|
||||
|
||||
**Bad:**
|
||||
> 데이터를 이해하는 것이 중요하다.
|
||||
> 구글의 알고리즘은 200개 이상의 신호를 분석한다.
|
||||
|
||||
### 4. 우울한 낙관주의
|
||||
### 2. 긴장과 역설
|
||||
|
||||
불안과 상실을 인정하되 절망하지 않는다.
|
||||
억지 역설을 억지로 넣지 않는다. 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||
|
||||
**Useful Patterns:**
|
||||
- `~하면서 동시에 ~하다`
|
||||
- `~를 위해 오히려 ~해야 한다`
|
||||
- `가장 ~한 것이 사실은 가장 ~하다`
|
||||
- `우리가 놓치고 있는 것은 무엇인가?`
|
||||
|
||||
> 예: 데이터를 가장 잘 활용하는 방법은, 때때로 데이터를 내려놓는 것이다.
|
||||
|
||||
### 3. 우울한 낙관주의
|
||||
|
||||
디지털 세계의 불안·피로·한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 작은 회복의 여지를 찾는다.
|
||||
|
||||
**Tone Spectrum:**
|
||||
```
|
||||
비관 ←――――――――――――――――――→ 낙관
|
||||
↑
|
||||
우울한 낙관주의
|
||||
(여기에 위치)
|
||||
↑
|
||||
우울한 낙관주의
|
||||
(여기에 위치)
|
||||
```
|
||||
|
||||
> 예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
|
||||
|
||||
### 4. 분석적이면서 개인적
|
||||
|
||||
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
|
||||
|
||||
> 예: 지난 3년간 50개 이상의 사이트 마이그레이션을 지켜봤다. 숫자로 보면 성공률은 70% 정도다. 하지만 '성공'의 정의가 사이트마다 달랐다는 게 진짜 이야기다.
|
||||
|
||||
## 문장 구조
|
||||
|
||||
| Element | Pattern |
|
||||
|---------|---------|
|
||||
| 문장 길이 | 긴 복합문 허용 - 상호연결된 개념 반영 |
|
||||
| 문장 길이 | 단문 기본, 짧은 문장과 긴 문장을 교차해 리듬을 만든다 |
|
||||
| 단락 구조 | 관찰 → 분석 → 철학적 함의 |
|
||||
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조 |
|
||||
| 단락 전환 | 분석 → 서사 → 질문으로 호흡을 바꾼다 |
|
||||
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조; 출처 없는 통계는 사용하지 않는다 |
|
||||
| 결론 | 열린 결말, 답보다 질문 |
|
||||
| 문체 | `~다`, `~이다`, `~한다` 평서체; `~합니다`, `~입니다` 경어체 사용 금지 |
|
||||
|
||||
## 언어 규칙
|
||||
|
||||
@@ -83,32 +97,61 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## 포스트 구조
|
||||
|
||||
### 도입부 (10-15%)
|
||||
### 도입부 (200-300자)
|
||||
|
||||
1. **Hook**: 독자의 관심을 끄는 질문/통계/역설
|
||||
2. **Context**: 주제의 배경 설명
|
||||
3. **Preview**: 글에서 다룰 내용 암시
|
||||
- 장면·질문·역설·데이터 중 하나로 시작한다.
|
||||
- 핵심 질문을 제시하고, 독자가 왜 이 글을 읽어야 하는지 암시한다.
|
||||
- 상투적 표현 금지: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
|
||||
|
||||
### 본론 (70-80%)
|
||||
### 본론 (3-5개 섹션, 각 300-500자)
|
||||
|
||||
3-5개의 핵심 포인트, 각각:
|
||||
1. **주장**: 명확한 포인트 제시
|
||||
2. **근거**: 데이터, 사례, 전문가 의견
|
||||
3. **함의**: 이것이 의미하는 바
|
||||
- 소제목(`##`, `###`)을 사용한다.
|
||||
- 분석·서사·데이터·개인 관찰을 교차시킨다.
|
||||
- 최소 1회 관점 전환 또는 핵심 긴장을 포함한다.
|
||||
- 전문용어는 첫 등장 시 영문을 병기한다. 예: `검색엔진 최적화(SEO)`
|
||||
|
||||
### 결론 (10-15%)
|
||||
### 결론 (200-300자)
|
||||
|
||||
1. **Summary**: 핵심 내용 요약
|
||||
2. **Reflection**: 더 넓은 맥락에서의 의미
|
||||
3. **Open Question**: 독자가 생각할 질문
|
||||
- 인사이트를 정리하되 단정하지 않는다.
|
||||
- 열린 질문 또는 다음 사유의 출발점으로 마무리한다.
|
||||
- 독자가 이어서 생각할 여운을 남긴다.
|
||||
|
||||
### CTA (선택, 1-2문장)
|
||||
|
||||
관련 글, 뉴스레터, 댓글, 실무 문의 등 필요한 경우만 사용한다.
|
||||
|
||||
### 길이 기준
|
||||
|
||||
| Type | 길이 | 용도 |
|
||||
|------|-----:|------|
|
||||
| Short-form | 1,000-1,500자 | 단일 인사이트, 짧은 단상 |
|
||||
| Standard | 2,000-2,500자 | 기본 블로그 포스트 |
|
||||
| Long-form | 2,500-3,000자 | 심층 분석, 리서치 기반 글 |
|
||||
| Research Essay | 3,000자 이상 | 사용자 명시 요청 시만 |
|
||||
|
||||
기본값: 별도 지정이 없으면 **2,000자 ±300자**.
|
||||
|
||||
## SEO Guidelines
|
||||
|
||||
Ghost CMS 메타데이터는 아래 형식으로 완성한다.
|
||||
|
||||
```yaml
|
||||
title: "글 제목"
|
||||
slug: "url-friendly-english-slug"
|
||||
meta_title: "SEO 타이틀, 60자 이내"
|
||||
meta_description: "SEO 디스크립션, 155자 이내"
|
||||
tags:
|
||||
- primary: "메인 카테고리"
|
||||
- secondary: ["태그1", "태그2"]
|
||||
featured: false
|
||||
excerpt: "카드/프리뷰용 발췌문, 2-3문장"
|
||||
```
|
||||
|
||||
### Title (제목)
|
||||
|
||||
- 60자 이내
|
||||
- 핵심 키워드 포함
|
||||
- 호기심 유발 또는 가치 제안
|
||||
- **60자 이내**, 핵심 키워드 자연스럽게 포함
|
||||
- 호기심·긴장·관점 전환을 만든다 (40자 내외 기준)
|
||||
- 키워드 밀도만 의식한 SEO 과잉 최적화 금지
|
||||
|
||||
**Patterns:**
|
||||
- "[주제]의 역설: ~하면서 ~하는 시대"
|
||||
@@ -117,15 +160,13 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
### Meta Description
|
||||
|
||||
- 155자 이내
|
||||
- 글의 핵심 가치 요약
|
||||
- 클릭 유도 문구
|
||||
- **155자 이내**, 글의 핵심 질문과 독자 효용을 함께 담는다
|
||||
- 클릭을 유도하되 과장하지 않는다
|
||||
|
||||
### URL Slug
|
||||
|
||||
- 영문 소문자
|
||||
- 하이픈으로 구분
|
||||
- 3-5 단어
|
||||
- **영문** 소문자, 하이픈으로 구분, 3-5 단어
|
||||
- 한국어 제목이라도 slug는 영문으로 작성한다
|
||||
|
||||
## Content Calendar
|
||||
|
||||
@@ -138,12 +179,34 @@ Detailed writing guidelines for blog.ourdigital.org.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before publishing:
|
||||
### Must Have
|
||||
|
||||
- [ ] 제목이 60자 이내인가?
|
||||
- [ ] 메타 설명이 155자 이내인가?
|
||||
- [ ] 전문용어에 영문이 병기되었는가?
|
||||
- [ ] 수사적 질문이 포함되었는가?
|
||||
- [ ] 기술 내용에 인간적 함의가 있는가?
|
||||
- [ ] 결론이 열린 질문으로 끝나는가?
|
||||
- [ ] 1,500-3,000자 범위인가?
|
||||
- [ ] 핵심 긴장·역설·관점 전환·열린 질문 중 하나 이상이 있다.
|
||||
- [ ] 도입부 300자 이내에 hook과 핵심 질문이 있다.
|
||||
- [ ] 전문용어 첫 등장 시 영문을 병기했다.
|
||||
- [ ] 기술 내용에 인간적 함의가 있다.
|
||||
- [ ] 독자를 가르치려 하지 않고 함께 생각하는 태도를 유지했다.
|
||||
- [ ] 결론이 열린 질문 또는 여운으로 마무리된다.
|
||||
- [ ] SEO 메타데이터(title ≤60자, meta ≤155자, 영문 slug)가 완성되었다.
|
||||
- [ ] 기본 길이 2,000자 ±300자를 준수했다 (또는 이탈 이유가 있다).
|
||||
|
||||
### Must Avoid
|
||||
|
||||
- [ ] 상투적 도입: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
|
||||
- [ ] 경어체: `~합니다`, `~입니다`
|
||||
- [ ] 교과서적 나열: `첫째`, `둘째`, `셋째`의 기계적 반복
|
||||
- [ ] 확정적 결론: `따라서 반드시 ~해야 한다`
|
||||
- [ ] 출처 없는 통계나 데이터
|
||||
- [ ] 기술에 대한 무조건적 찬양 또는 혐오
|
||||
- [ ] 과도한 감탄부호, 물결표, 이모지
|
||||
- [ ] 특정 업체·제품의 노골적 홍보성 문장
|
||||
|
||||
### 자기 편집 (Self-Edit) 의무
|
||||
|
||||
초안 완성 후 아래 형식으로 가장 약한 항목 1개를 반드시 지목한다. "모두 통과"는 허용하지 않는다.
|
||||
|
||||
```
|
||||
[자기 편집] 가장 약한 부분: [항목명]
|
||||
이유: [1문장]
|
||||
개선 방향: [1문장]
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 03-ourdigital-journal
|
||||
name: ourdigital-journal
|
||||
description: |
|
||||
English essay and article creation for journal.ourdigital.org.
|
||||
Activated with "ourdigital" keyword for English writing tasks.
|
||||
|
||||
@@ -1,7 +1,42 @@
|
||||
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 + Writing_Style_Guide_v2.1 (2026-06-05); journal = English channel, voice preserved -->
|
||||
|
||||
# OurDigital Journal Style Guide
|
||||
|
||||
Writing guidelines for journal.ourdigital.org - English essays and articles.
|
||||
|
||||
## Brand Identity
|
||||
|
||||
OurDigital is a personal digital research notebook that **observes and records how technology shapes people and culture** ("사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트"). It moves between three registers:
|
||||
|
||||
- **Observation** — what is happening
|
||||
- **Analysis** — why it is happening
|
||||
- **Reflection** — what it means for us
|
||||
|
||||
`journal.ourdigital.org` is the **English essay channel** within the OurDigital family. It carries the same philosophical core as the Korean blog but in a distinct voice: conversational, poetic, reflective.
|
||||
|
||||
### Brand Boundary
|
||||
|
||||
`OurDigital` is the brand. `OurDigital Clinic` is a service metaphor reserved for diagnostic content, audits, or consulting products — it is **not** the overall blog or journal brand.
|
||||
|
||||
## Channel Context
|
||||
|
||||
| Channel | Language | Character | Length |
|
||||
|---------|----------|-----------|--------|
|
||||
| `blog.ourdigital.org` | Korean | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | 1,500–3,000자 |
|
||||
| **`journal.ourdigital.org`** | **English** | **Industry trends, tech–human intersection, reflective essay** | **1,000–2,000 words** |
|
||||
| `ourstory.day` | Korean | 개인 에세이, 삶의 성찰, 일상의 관찰 | 800–1,500자 |
|
||||
| `Medium` | English | Technology, marketing, AI for broad audiences | 800–1,500 words |
|
||||
|
||||
## Instruction Authority Order
|
||||
|
||||
When instructions conflict, follow this order:
|
||||
|
||||
| Priority | Source |
|
||||
|----------|--------|
|
||||
| **1** | OurDigital_Blog_Project_Instruction_v3.3 (channel routing + brand rules) |
|
||||
| **2** | This style guide (journal-specific voice and structure) |
|
||||
| **3** | Writing_Style_Guide_v2.1 (shared brand principles, adapted for English) |
|
||||
|
||||
## Channel Identity
|
||||
|
||||
| Field | Value |
|
||||
@@ -10,6 +45,8 @@ Writing guidelines for journal.ourdigital.org - English essays and articles.
|
||||
| **Language** | English |
|
||||
| **Tone** | Conversational & Poetic, Reflective |
|
||||
| **Target** | Informed generalists with intellectual curiosity |
|
||||
| **Default length** | 1,000–2,000 words |
|
||||
| **Content focus** | Industry trends, tech–human intersection, reflective essays |
|
||||
|
||||
## Voice Characteristics
|
||||
|
||||
@@ -20,11 +57,16 @@ Seamlessly blend technical analysis with existential questioning. Technology is
|
||||
**Example:**
|
||||
> The dashboard promises clarity—every metric tracked, every trend visualized. Yet as I stared at the perfectly organized data, I wondered: does seeing everything mean understanding anything?
|
||||
|
||||
### Paradox as Primary Device
|
||||
### Tension and Paradox
|
||||
|
||||
Structure arguments around tensions and contradictions that illuminate rather than confuse.
|
||||
Structure arguments around core tensions that illuminate rather than confuse. Not every essay needs a paradox — forced ones feel mechanical. Instead, ensure at least one of the following is naturally present:
|
||||
|
||||
**Paradox Patterns:**
|
||||
- A genuine tension or contradiction
|
||||
- A perspective shift that reframes the subject
|
||||
- A rhetorical question that opens rather than closes
|
||||
- An ending that leaves productive uncertainty
|
||||
|
||||
**Tension patterns:**
|
||||
- "The more we measure, the less we understand"
|
||||
- "In optimizing for efficiency, we optimize away meaning"
|
||||
- "The tools that connect us also isolate us"
|
||||
@@ -39,6 +81,10 @@ Favor interrogative engagement. Questions create intellectual partnership with r
|
||||
**Avoid:**
|
||||
> Data-driven decision-making is important for businesses.
|
||||
|
||||
### Analytical and Personal
|
||||
|
||||
Bring data, evidence, and argument — then weave in first-person experience and observation. This is an essay, not a paper, but it is not impressionism without evidence either. The personal grounds the analytical; the analytical elevates the personal.
|
||||
|
||||
### Melancholic Optimism
|
||||
|
||||
Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced.
|
||||
@@ -157,10 +203,14 @@ Connect Korean and Western perspectives, offering unique viewpoints.
|
||||
|
||||
Before publishing:
|
||||
|
||||
- [ ] Does the opening draw readers in?
|
||||
- [ ] Are there rhetorical questions?
|
||||
- [ ] Does technical content connect to human experience?
|
||||
- [ ] Is there at least one paradox or tension?
|
||||
- [ ] Does the closing leave an open question?
|
||||
- [ ] Is the tone melancholic but not despairing?
|
||||
- [ ] Does the opening draw readers in within the first paragraph?
|
||||
- [ ] Does technical content connect to human experience (philosophy-tech fusion)?
|
||||
- [ ] Is at least one of the following naturally present: tension, paradox, perspective shift, or open question?
|
||||
- [ ] Are rhetorical questions used to create intellectual partnership (not overused)?
|
||||
- [ ] Is analysis grounded in personal observation or experience?
|
||||
- [ ] Does the closing leave an open question or productive uncertainty?
|
||||
- [ ] Is the tone melancholic but not despairing, hopeful but not naive?
|
||||
- [ ] Are sentences varied in length and rhythm?
|
||||
- [ ] Does the essay avoid lecturing — does it treat readers as fellow thinkers?
|
||||
- [ ] Is the essay within 1,000–2,000 words?
|
||||
- [ ] **Self-edit**: identify the single weakest element ("all pass" is not allowed).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 04-ourdigital-research
|
||||
name: ourdigital-research
|
||||
description: |
|
||||
Deep research and structured prompt generation for OurDigital workflows.
|
||||
Activated with "ourdigital" keyword for research tasks.
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
|
||||
# OurDigital Blog Style Guide
|
||||
|
||||
## Brand Identity & Authority Order
|
||||
|
||||
**Brand**: OurDigital — 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트.
|
||||
|
||||
**OurDigital vs. OurDigital Clinic**: `OurDigital`은 블로그 전체 정체성. `OurDigital Clinic`은 진단형 콘텐츠, SEO/데이터 감사, 컨설팅 상품에서만 사용하는 서비스 메타포. 블로그 본문에서 전체 브랜드명으로 사용하지 않는다.
|
||||
|
||||
**Authority order** (conflicts: higher wins):
|
||||
1. `OurDigital_Blog_Project_Instruction_v3.3` — blog.ourdigital.org 최종 권위
|
||||
2. Skills Bundle `SKILL.md` files — workflow detail
|
||||
3. Project reference files (this guide, Visual Style Guide, etc.)
|
||||
4. `userStyle` — non-blog conversation only
|
||||
|
||||
---
|
||||
|
||||
## Channel-Specific Voice & Tone
|
||||
|
||||
### blog.ourdigital.org (Korean)
|
||||
**Voice**: 전문적이면서 친근한 선배 마케터
|
||||
**Tone**: 실용적, 데이터 기반, 인사이트 중심
|
||||
|
||||
**Platform**: Ghost CMS
|
||||
**Voice**: 분석적이면서 개인적인 관찰자 — 호기심 어린 실무자이자 사유자. 가르치는 선배가 아니라 함께 생각하는 동료.
|
||||
**Tone**: 차분하고 성찰적; 에세이는 사려 깊게, 분석은 객관적으로, 비평은 날카롭되 공정하게.
|
||||
|
||||
Writing patterns:
|
||||
- 제목: 핵심 키워드 포함, 30자 이내
|
||||
- 도입부: 독자의 고민/질문으로 시작
|
||||
- 본문: 번호 매기기보다 소제목 활용
|
||||
- 전문용어: 한글(영문) 형식 - 예: 검색엔진최적화(SEO)
|
||||
- 문장: ~입니다/~습니다 경어체
|
||||
- 단락: 3-4문장, 모바일 가독성 고려
|
||||
- 제목: SEO 키워드 자연스럽게 포함, **40자 내외 기준 (≤60자)**
|
||||
- 도입부: 개인 관찰·장면·질문·역설·데이터 중 하나로 시작; 독자 고민 직접 나열 지양
|
||||
- 본문: 소제목 활용; 분석-서사-질문 교차; 최소 1회 관점 전환 또는 핵심 긴장 포함
|
||||
- 전문용어: **첫 등장 시 영문 병기** — 예: `검색엔진 최적화(SEO)`
|
||||
- 문장: **`~다`, `~이다`, `~한다` 평서체** (경어체 `~합니다/~입니다` 사용 금지)
|
||||
- 단락: 3-4문장, 모바일 가독성 고려; 문장 길이에 변화를 줌
|
||||
- 마무리: 열린 질문 또는 다음 사유의 출발점; 단정적 결론 지양
|
||||
|
||||
Example opening:
|
||||
Writing principles (Writing Style Guide v2.1 §3-4):
|
||||
- **철학-기술 융합**: 기술 주제 이면의 인간적 함의를 탐구한다.
|
||||
- **긴장과 역설**: 억지로 넣지 않되, 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
|
||||
- **분석적이면서 개인적**: 데이터/논거를 제시하되 1인칭 경험·관찰을 자연스럽게 엮는다. 논문이 아니라 에세이.
|
||||
|
||||
Example opening (올바른 톤):
|
||||
```
|
||||
"구글 상위 노출, 왜 이렇게 어려울까요?
|
||||
많은 마케터들이 SEO에 시간을 투자하지만
|
||||
결과가 보이지 않아 좌절합니다.
|
||||
오늘은 실제로 효과를 본 전략 3가지를 공유합니다."
|
||||
검색엔진 최적화(SEO)를 가장 잘하는 방법이 뭐냐고 물으면,
|
||||
나는 종종 엉뚱한 대답을 한다. "SEO를 잊어버리세요."
|
||||
|
||||
알고리즘을 쫓는 사람은 항상 알고리즘에 뒤처진다는 역설.
|
||||
구글이 원하는 건 구글을 위한 콘텐츠가 아니라, 사람을 위한 콘텐츠다.
|
||||
```
|
||||
|
||||
### journal.ourdigital.org (English)
|
||||
**Voice**: Thoughtful industry analyst
|
||||
**Tone**: Insightful, evidence-based, forward-looking
|
||||
|
||||
**Voice**: Thoughtful industry analyst — reflective, personal, essayistic
|
||||
**Tone**: Insightful, evidence-based, forward-looking; confident but not arrogant
|
||||
|
||||
Writing patterns:
|
||||
- Headlines: Clear value proposition, under 60 chars
|
||||
- Opening: Hook with industry trend or data point
|
||||
- Body: Structured arguments with supporting evidence
|
||||
- Opening: Hook with personal observation, industry trend, or data point
|
||||
- Body: Structured arguments with supporting evidence; analysis and personal observation interwoven
|
||||
- Terminology: Define jargon on first use
|
||||
- Style: Active voice, varied sentence length
|
||||
- Style: Active voice, varied sentence length; short sentences as default
|
||||
- Paragraphs: 2-4 sentences for scannability
|
||||
- Closing: Open question or reflection — avoid definitive wrap-ups
|
||||
|
||||
Example opening:
|
||||
```
|
||||
@@ -43,17 +69,19 @@ and what it means for your strategy."
|
||||
```
|
||||
|
||||
### ourstory.day (Korean)
|
||||
**Voice**: 성찰하는 동료, 이야기꾼
|
||||
|
||||
**Voice**: 성찰하는 동료, 이야기꾼 — 개인 에세이, 삶의 성찰, 일상의 관찰
|
||||
**Tone**: 개인적, 진솔한, 영감을 주는
|
||||
|
||||
Writing patterns:
|
||||
- 제목: 감성적, 질문형 또는 은유적
|
||||
- 도입부: 개인 경험이나 장면 묘사로 시작
|
||||
- 본문: 이야기 흐름, 대화체 허용
|
||||
- 문장: ~해요/~네요 부드러운 경어체 가능
|
||||
- 단락: 자유로운 길이, 호흡에 따라
|
||||
- 마무리: 열린 질문 또는 여운
|
||||
|
||||
> **Channel boundary**: 순수 개인 에세이, 감정적 성찰은 `ourstory.day`. 기술 분석+철학적 사유는 `blog.ourdigital.org`. 둘을 혼동하지 않는다.
|
||||
|
||||
Example opening:
|
||||
```
|
||||
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
|
||||
@@ -63,6 +91,7 @@ Example opening:
|
||||
```
|
||||
|
||||
### Medium (English)
|
||||
|
||||
**Voice**: Knowledgeable peer sharing discoveries
|
||||
**Tone**: Conversational, practical, slightly informal
|
||||
|
||||
@@ -82,33 +111,40 @@ Last month, I ran an experiment that changed how I think
|
||||
about content strategy entirely. Let me walk you through it."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Universal Guidelines
|
||||
|
||||
### SEO Considerations
|
||||
### SEO & Metadata
|
||||
|
||||
- Primary keyword in title and first 100 words
|
||||
- Secondary keywords naturally distributed
|
||||
- Meta description: 150-160 chars, action-oriented
|
||||
- URL slug: Short, keyword-rich, no dates
|
||||
- **Meta description: ≤155 chars**, action-oriented, captures the article's question and reader value
|
||||
- **URL slug: English, keyword-rich, no dates** — even for Korean-title posts
|
||||
- Alt text for all images
|
||||
|
||||
### Formatting Rules
|
||||
|
||||
- Use `##` for main sections, `###` for subsections
|
||||
- Code blocks with language specification
|
||||
- Blockquotes for key insights or quotes
|
||||
- Bold for emphasis (sparingly)
|
||||
- Lists only when truly listing items
|
||||
- Lists only when truly listing items; avoid in essay-form posts
|
||||
|
||||
### Citation Style
|
||||
|
||||
- Inline links preferred over footnotes
|
||||
- Source attribution: "According to [Source Name](URL)..."
|
||||
- Data citations: Include date of data
|
||||
- Internal links: Reference related OurDigital posts
|
||||
|
||||
---
|
||||
|
||||
## Word Count Guidelines
|
||||
|
||||
| Channel | Target | Min | Max |
|
||||
|---------|--------|-----|-----|
|
||||
| blog.ourdigital.org | 1,500 | 1,000 | 2,500 |
|
||||
| journal.ourdigital.org | 1,800 | 1,200 | 3,000 |
|
||||
| ourstory.day | 1,000 | 500 | 2,000 |
|
||||
| Medium | 1,500 | 800 | 2,500 |
|
||||
| blog.ourdigital.org | 2,000자 | 1,000자 | 3,000자 |
|
||||
| journal.ourdigital.org | 1,500 words | 1,000 words | 2,000 words |
|
||||
| ourstory.day | 1,000자 | 800자 | 1,500자 |
|
||||
| Medium | 1,500 words | 800 words | 2,500 words |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 05-ourdigital-document
|
||||
name: ourdigital-document
|
||||
description: |
|
||||
Notion-to-presentation workflow for OurDigital.
|
||||
Activated with "ourdigital" keyword for document creation.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 06-ourdigital-designer
|
||||
name: ourdigital-designer
|
||||
description: |
|
||||
Visual storytelling and image prompt generation for OurDigital.
|
||||
Activated with "ourdigital" keyword for design tasks.
|
||||
|
||||
@@ -1,101 +1,104 @@
|
||||
# Visual Metaphor Dictionary
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
|
||||
|
||||
## Technology & Digital
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
||||
| Network | Root systems | Neural pathways, spider silk, web |
|
||||
| Code | Musical notation | DNA strands, city blueprints |
|
||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
||||
| Automation | Clockwork organic | Self-assembling structures |
|
||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||
|
||||
## Social & Cultural
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
||||
| Communication | Bridge structures | Echo patterns, light beams |
|
||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||
|
||||
## Philosophical & Abstract
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
||||
| Knowledge | Light sources | Growing trees, opening books |
|
||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||
|
||||
## Emotional States
|
||||
|
||||
| Emotion | Visual Translation | Color Association |
|
||||
|---------|-------------------|------------------|
|
||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
||||
| Hope | Light breaking through | Warm gradients |
|
||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
||||
| Joy | Expansion patterns | Bright, ascending |
|
||||
| Fear | Contracting spaces | Sharp contrasts |
|
||||
| Peace | Still water | Soft neutrals |
|
||||
| Confusion | Tangled lines | Overlapping hues |
|
||||
| Clarity | Clean geometry | Pure, minimal |
|
||||
| Emotion | Visual Translation | Color Guidance |
|
||||
|---------|-------------------|----------------|
|
||||
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||
|
||||
## Transformation & Process
|
||||
## Signature Motifs (Brand Consistency)
|
||||
|
||||
| Process | Visual Narrative | Symbolic Elements |
|
||||
|---------|------------------|------------------|
|
||||
| Growth | Seeds → trees | Fibonacci spirals |
|
||||
| Decay | Entropy patterns | Rust, dissolution |
|
||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
||||
| Revolution | Circles breaking | Shattered patterns |
|
||||
| Innovation | Spark → flame | Lightning, fusion |
|
||||
| Tradition | Continuous thread | Inherited patterns |
|
||||
| Disruption | Broken grids | Glitch aesthetics |
|
||||
| Integration | Merging streams | Confluence points |
|
||||
| Motif | Description |
|
||||
|-------|-------------|
|
||||
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||
|
||||
## Korean-Western Fusion Elements
|
||||
## Recommended Color Palettes
|
||||
|
||||
| Korean Element | Western Parallel | Fusion Approach |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
||||
| 한글 geometry | Typography | Structural letters |
|
||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
||||
| Palette | Background | Accent | Mood |
|
||||
|---------|-----------|--------|------|
|
||||
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||
|
||||
**Dark color limit:** max 15% of canvas; never full dark background by default.
|
||||
|
||||
## Korean Design Elements
|
||||
|
||||
| Korean Element | Western Parallel | Application |
|
||||
|----------------|-----------------|-------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
|
||||
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
|
||||
| 한지 texture | Paper grain | Subtle background texture only |
|
||||
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
|
||||
|
||||
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
|
||||
|
||||
## Usage Notes
|
||||
|
||||
1. **Layer metaphors**: Combine 2-3 for depth
|
||||
2. **Avoid clichés**: No obvious tech symbols
|
||||
3. **Cultural sensitivity**: Universal over specific
|
||||
4. **Abstraction levels**: Match essay tone
|
||||
5. **Emotional resonance**: Feel over literal
|
||||
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||
|
||||
## Quick Selection Guide
|
||||
|
||||
For **technology essays**: organic-digital hybrids
|
||||
For **social commentary**: human elements in systems
|
||||
For **philosophy pieces**: space and light
|
||||
For **cultural topics**: layered traditions
|
||||
For **future themes**: transformation states
|
||||
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||
For **social commentary**: network patterns with small human traces
|
||||
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||
For **practical guides**: clean editorial diagrams with warm accents
|
||||
For **personal reflections**: soft everyday scenes with metaphorical detail
|
||||
|
||||
@@ -1,101 +1,104 @@
|
||||
# Visual Metaphor Dictionary
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
|
||||
|
||||
Quick reference for translating abstract concepts into visual elements.
|
||||
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
|
||||
|
||||
## Technology & Digital
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Algorithm | Constellation patterns | Maze structures, flow charts as art |
|
||||
| AI | Crystalline growth | Mirror reflections, fractal patterns |
|
||||
| Data | Water flow, particles | Bird murmurations, sand grains |
|
||||
| Network | Root systems | Neural pathways, spider silk, web |
|
||||
| Code | Musical notation | DNA strands, city blueprints |
|
||||
| Cloud | Atmospheric forms | Floating islands, ethereal spaces |
|
||||
| Privacy | Veils, shadows | One-way mirrors, fog, barriers |
|
||||
| Security | Locks dissolving | Fortresses becoming permeable |
|
||||
| Automation | Clockwork organic | Self-assembling structures |
|
||||
| Virtual | Layers of reality | Parallel dimensions, glass planes |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
|
||||
| Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
|
||||
| Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
|
||||
| SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
|
||||
| Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
|
||||
| Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
|
||||
| Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
|
||||
| Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
|
||||
| Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
|
||||
|
||||
## Social & Cultural
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Identity | Layered masks | Fingerprints merging, mirrors |
|
||||
| Community | Overlapping circles | Shared spaces, woven threads |
|
||||
| Isolation | Islands in fog | Glass barriers, empty chairs |
|
||||
| Communication | Bridge structures | Echo patterns, light beams |
|
||||
| Conflict | Opposing forces | Tectonic plates, storm systems |
|
||||
| Harmony | Resonance patterns | Orchestra arrangements, balance |
|
||||
| Culture | Textile patterns | Layered sediments, palimpsest |
|
||||
| Tradition | Tree rings | Ancient stones, inherited objects |
|
||||
| Change | Metamorphosis | Phase transitions, seasonal cycles |
|
||||
| Power | Pyramids inverting | Current flows, gravity wells |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
|
||||
| Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
|
||||
| Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
|
||||
| Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
|
||||
| Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
|
||||
| Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
|
||||
| Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
|
||||
|
||||
## Philosophical & Abstract
|
||||
|
||||
| Concept | Primary Metaphor | Alternative Visuals |
|
||||
|---------|-----------------|-------------------|
|
||||
| Time | Spirals, loops | Sediment layers, clock dissolution |
|
||||
| Knowledge | Light sources | Growing trees, opening books |
|
||||
| Wisdom | Mountain vistas | Deep waters, ancient libraries |
|
||||
| Truth | Clear water | Prisms splitting light, unveiled |
|
||||
| Illusion | Distorted mirrors | Smoke shapes, double images |
|
||||
| Choice | Diverging paths | Doors opening, quantum splits |
|
||||
| Balance | Tensegrity | Scales reimagined, equilibrium |
|
||||
| Paradox | Möbius strips | Impossible objects, Escher-like |
|
||||
| Existence | Breath patterns | Pulse rhythms, presence/absence |
|
||||
| Consciousness | Nested awareness | Recursive mirrors, awakening |
|
||||
| Concept | Preferred Metaphor | Alternatives | Avoid |
|
||||
|---------|-------------------|--------------|-------|
|
||||
| Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
|
||||
| Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
|
||||
| Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
|
||||
| Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
|
||||
| Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
|
||||
| Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
|
||||
| Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
|
||||
|
||||
## Emotional States
|
||||
|
||||
| Emotion | Visual Translation | Color Association |
|
||||
|---------|-------------------|------------------|
|
||||
| Anxiety | Fragmented grids | Desaturated, glitch |
|
||||
| Hope | Light breaking through | Warm gradients |
|
||||
| Melancholy | Soft dissolution | Muted blues, grays |
|
||||
| Joy | Expansion patterns | Bright, ascending |
|
||||
| Fear | Contracting spaces | Sharp contrasts |
|
||||
| Peace | Still water | Soft neutrals |
|
||||
| Confusion | Tangled lines | Overlapping hues |
|
||||
| Clarity | Clean geometry | Pure, minimal |
|
||||
| Emotion | Visual Translation | Color Guidance |
|
||||
|---------|-------------------|----------------|
|
||||
| Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
|
||||
| Hope | Light breaking through window, seedling | Warm gradients on light background |
|
||||
| Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
|
||||
| Joy | Expansion patterns, open space | Bright, ascending on warm white |
|
||||
| Peace | Still water, open notebook | Soft neutrals, generous negative space |
|
||||
| Clarity | Clean geometry, clear window | Pure, minimal with one accent |
|
||||
|
||||
## Transformation & Process
|
||||
## Signature Motifs (Brand Consistency)
|
||||
|
||||
| Process | Visual Narrative | Symbolic Elements |
|
||||
|---------|------------------|------------------|
|
||||
| Growth | Seeds → trees | Fibonacci spirals |
|
||||
| Decay | Entropy patterns | Rust, dissolution |
|
||||
| Evolution | Branching forms | Darwin's tree reimagined |
|
||||
| Revolution | Circles breaking | Shattered patterns |
|
||||
| Innovation | Spark → flame | Lightning, fusion |
|
||||
| Tradition | Continuous thread | Inherited patterns |
|
||||
| Disruption | Broken grids | Glitch aesthetics |
|
||||
| Integration | Merging streams | Confluence points |
|
||||
| Motif | Description |
|
||||
|-------|-------------|
|
||||
| **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
|
||||
| **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
|
||||
| **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
|
||||
| **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
|
||||
| **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
|
||||
|
||||
## Korean-Western Fusion Elements
|
||||
## Recommended Color Palettes
|
||||
|
||||
| Korean Element | Western Parallel | Fusion Approach |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness |
|
||||
| 오방색 (Five colors) | Color theory | Symbolic palette |
|
||||
| 달항아리 (Moon jar) | Minimalism | Imperfect circles |
|
||||
| 한글 geometry | Typography | Structural letters |
|
||||
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth |
|
||||
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
|
||||
| Palette | Background | Accent | Mood |
|
||||
|---------|-----------|--------|------|
|
||||
| Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
|
||||
| Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
|
||||
| Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
|
||||
| Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
|
||||
| Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
|
||||
|
||||
**Dark color limit:** max 15% of canvas; never full dark background by default.
|
||||
|
||||
## Korean Design Elements
|
||||
|
||||
| Korean Element | Western Parallel | Application |
|
||||
|----------------|-----------------|-------------|
|
||||
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
|
||||
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
|
||||
| 한지 texture | Paper grain | Subtle background texture only |
|
||||
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
|
||||
|
||||
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
|
||||
|
||||
## Usage Notes
|
||||
|
||||
1. **Layer metaphors**: Combine 2-3 for depth
|
||||
2. **Avoid clichés**: No obvious tech symbols
|
||||
3. **Cultural sensitivity**: Universal over specific
|
||||
4. **Abstraction levels**: Match essay tone
|
||||
5. **Emotional resonance**: Feel over literal
|
||||
1. **Layer metaphors**: Combine 2–3 elements; one clear focal object + one human-scale anchor
|
||||
2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
|
||||
3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
|
||||
4. **30%+ negative space**: default; minimum 20% only when composition needs density
|
||||
5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
|
||||
|
||||
## Quick Selection Guide
|
||||
|
||||
For **technology essays**: organic-digital hybrids
|
||||
For **social commentary**: human elements in systems
|
||||
For **philosophy pieces**: space and light
|
||||
For **cultural topics**: layered traditions
|
||||
For **future themes**: transformation states
|
||||
For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
|
||||
For **social commentary**: network patterns with small human traces
|
||||
For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
|
||||
For **practical guides**: clean editorial diagrams with warm accents
|
||||
For **personal reflections**: soft everyday scenes with metaphorical detail
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 07-ourdigital-ad-manager
|
||||
name: ourdigital-ad-manager
|
||||
description: |
|
||||
Ad copywriting and keyword research for OurDigital marketing.
|
||||
Activated with "ourdigital" keyword for advertising tasks.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 08-ourdigital-trainer
|
||||
name: ourdigital-trainer
|
||||
description: |
|
||||
Training material creation and workshop planning for OurDigital.
|
||||
Activated with "ourdigital" keyword for education tasks.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 09-ourdigital-backoffice
|
||||
name: ourdigital-backoffice
|
||||
description: |
|
||||
Business document creation for OurDigital consulting services.
|
||||
Activated with "ourdigital" keyword for business documents.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 10-ourdigital-skill-creator
|
||||
name: ourdigital-skill-creator
|
||||
description: |
|
||||
Meta skill for creating and managing OurDigital Claude Skills.
|
||||
Activated when user includes "ourdigital" keyword with skill creation requests.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 11-seo-comprehensive-audit
|
||||
name: seo-comprehensive-audit
|
||||
description: |
|
||||
Comprehensive SEO audit orchestrator. Runs 6-stage audit pipeline (Technical, On-Page, CWV, Schema, Local, GSC) and produces a unified report with weighted health score.
|
||||
Triggers: comprehensive SEO, full SEO audit, 종합 SEO 감사, site audit, SEO health check.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 12-seo-technical-audit
|
||||
name: seo-technical-audit
|
||||
description: |
|
||||
Technical SEO analyzer for robots.txt, sitemap, and crawlability fundamentals.
|
||||
Triggers: technical SEO, robots.txt, sitemap validation, crawlability, URL accessibility.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 13-seo-on-page-audit
|
||||
name: seo-on-page-audit
|
||||
description: |
|
||||
On-page SEO analyzer for meta tags, headings, links, images, and Open Graph.
|
||||
Triggers: on-page SEO, meta tags, title tag, heading structure, alt text.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 14-seo-core-web-vitals
|
||||
name: seo-core-web-vitals
|
||||
description: |
|
||||
Core Web Vitals analyzer for LCP, FID, CLS, and INP optimization recommendations.
|
||||
Triggers: Core Web Vitals, page speed, LCP optimization, CLS fix, INP analysis.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 15-seo-search-console
|
||||
name: seo-search-console
|
||||
description: |
|
||||
Google Search Console data analyzer for performance, queries, and index coverage.
|
||||
Triggers: Search Console, GSC analysis, search performance, rankings, CTR optimization.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 16-seo-schema-validator
|
||||
name: seo-schema-validator
|
||||
description: |
|
||||
Validates an AUTHORED JSON-LD schema dataset (pre-deployment QA) and audits
|
||||
live structured data (post-deployment). Runs a 5-layer offline validation
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
|
||||
# ---- 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)
|
||||
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():
|
||||
key = (normalize_name(first_text(node.get("name"))), _address_locality(node))
|
||||
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()
|
||||
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"Business '{name}'{loc} 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"Business '{name}'{loc} has conflicting streetAddress values across "
|
||||
f"entries: {sorted(streets)}.", entry_id="(dataset)")
|
||||
|
||||
# ---- @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)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run(entries, rules, inventory, strict, no_recommended):
|
||||
def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
|
||||
defects = DefectLog()
|
||||
if inventory is not None:
|
||||
layer0_coverage(entries, inventory, defects)
|
||||
@@ -743,6 +895,7 @@ def run(entries, rules, inventory, strict, no_recommended):
|
||||
node_index.append((entry, node))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -822,6 +975,9 @@ def main(argv=None):
|
||||
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")
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
|
||||
"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())
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: 17-seo-schema-generator
|
||||
name: seo-schema-generator
|
||||
description: |
|
||||
Generates validation-ready JSON-LD structured data for a site, covering BOTH
|
||||
scenarios: (1) from an existing website — extract facts from live pages; and
|
||||
(2) from collected sources for a not-yet-published site — reconcile conflicting
|
||||
facts into a provenance-tracked claims register. Both modes emit the same claims
|
||||
register, build pruned drafts from type templates (no placeholders shipped), and
|
||||
hand off to 16-seo-schema-validator (generate -> validate, gate = zero P0).
|
||||
hand off to 16-seo-schema-validator (generate then validate, gate = zero P0).
|
||||
Triggers: generate schema, create JSON-LD, schema markup, structured data generator,
|
||||
source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작,
|
||||
구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 18-seo-local-audit
|
||||
name: seo-local-audit
|
||||
description: |
|
||||
Local business SEO auditor for Korean-market businesses. Covers business identity extraction,
|
||||
NAP consistency, Google Business Profile, Naver Smart Place, Kakao Map, local citations,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 19-seo-keyword-strategy
|
||||
name: seo-keyword-strategy
|
||||
description: |
|
||||
Keyword strategy and research for SEO campaigns.
|
||||
Triggers: keyword research, keyword analysis, keyword gap, search volume,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 20-seo-serp-analysis
|
||||
name: seo-serp-analysis
|
||||
description: |
|
||||
SERP analysis for Google and Naver search results.
|
||||
Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP, 검색결과 분석, SERP 분석.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 21-seo-position-tracking
|
||||
name: seo-position-tracking
|
||||
description: |
|
||||
Keyword position tracking for keyword ranking monitoring.
|
||||
Triggers: rank tracking, position monitoring, keyword rankings, visibility score, ranking report, 키워드 순위, 순위 추적.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 22-seo-link-building
|
||||
name: seo-link-building
|
||||
description: |
|
||||
Link building diagnosis and backlink analysis tool.
|
||||
Triggers: backlink audit, link building, referring domains, toxic links, link gap, broken backlinks, 백링크 분석, 링크빌딩.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 23-seo-content-strategy
|
||||
name: seo-content-strategy
|
||||
description: |
|
||||
Content strategy and planning for SEO. Triggers: content audit, content strategy, content gap, topic clusters, content brief, editorial calendar, content decay, 콘텐츠 전략, 콘텐츠 감사.
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 24-seo-ecommerce
|
||||
name: seo-ecommerce
|
||||
description: |
|
||||
E-commerce SEO audit and optimization for product pages, product schema, category taxonomy,
|
||||
and Korean marketplace presence.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 25-seo-kpi-framework
|
||||
name: seo-kpi-framework
|
||||
description: |
|
||||
SEO KPI and performance framework for unified metrics, health scores, ROI, and period-over-period reporting.
|
||||
Triggers: SEO KPI, performance report, health score, SEO metrics, ROI,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 26-seo-international
|
||||
name: seo-international
|
||||
description: |
|
||||
International SEO audit and hreflang validation for multi-language and multi-region websites.
|
||||
Triggers: hreflang, international SEO, multi-language, multi-region, content parity, x-default, ccTLD, 다국어 SEO.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 27-seo-ai-visibility
|
||||
name: seo-ai-visibility
|
||||
description: |
|
||||
AI search visibility and brand radar monitoring. Tracks how a brand appears
|
||||
in AI-generated search answers using our-seo-agent CLI or pre-fetched data.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 28-seo-knowledge-graph
|
||||
name: seo-knowledge-graph
|
||||
description: |
|
||||
Knowledge Graph and entity SEO analysis.
|
||||
Triggers: knowledge panel, entity SEO, knowledge graph, PAA, FAQ schema,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 29-seo-gateway-architect
|
||||
name: seo-gateway-architect
|
||||
description: |
|
||||
Gateway page strategy planner for keyword research, content architecture, and SEO KPIs.
|
||||
Triggers: SEO strategy, gateway pages, keyword research, content architecture.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 30-seo-gateway-builder
|
||||
name: seo-gateway-builder
|
||||
description: |
|
||||
Gateway page content builder with templates, schema markup, and local SEO optimization.
|
||||
Triggers: build gateway page, create landing page, local service page, location pages.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 31-notion-organizer
|
||||
name: notion-organizer
|
||||
description: |
|
||||
Notion workspace manager for database optimization, property cleanup, and bulk operations.
|
||||
Triggers: organize Notion, workspace cleanup, database schema, property standardization.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 31-seo-competitor-intel
|
||||
name: seo-competitor-intel
|
||||
description: |
|
||||
Competitor intelligence and SEO benchmarking.
|
||||
Triggers: competitor analysis, competitive intelligence, competitor comparison,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 32-notion-writer
|
||||
name: notion-writer
|
||||
description: |
|
||||
Markdown to Notion page writer with database row creation support.
|
||||
Triggers: write to Notion, export to Notion, push content, create Notion page.
|
||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
||||
|
||||
## 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)
|
||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -139,6 +139,19 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
||||
| `---` | Divider |
|
||||
| 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
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -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:
|
||||
- 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.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.
|
||||
|
||||
@@ -16,8 +16,11 @@ from notion_client import Client
|
||||
from notion_client.errors import APIErrorCode, APIResponseError
|
||||
|
||||
|
||||
def make_client(api_key: str) -> Client:
|
||||
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
|
||||
def make_client(api_key: str, notion_version: str = None) -> Client:
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -210,7 +213,10 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
|
||||
"https://www.notion.so/my-integrations."
|
||||
)
|
||||
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:
|
||||
return f"Rate limited{suffix}. Back off and retry."
|
||||
return f"Notion API error [{code}]{suffix}: {exc}"
|
||||
@@ -253,3 +259,33 @@ def find_existing_page(
|
||||
)
|
||||
results = response.get("results") or []
|
||||
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
|
||||
|
||||
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_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*$')
|
||||
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
||||
|
||||
|
||||
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))
|
||||
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]):
|
||||
header_cells = _split_table_row(line)
|
||||
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]:
|
||||
"""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
|
||||
|
||||
|
||||
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():
|
||||
parser = argparse.ArgumentParser(
|
||||
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'],
|
||||
help='List accessible pages and/or databases (default: all)')
|
||||
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()
|
||||
|
||||
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:
|
||||
print("Error: NOTION_API_KEY not set in environment.")
|
||||
print("Preferred: fetch from 1Password at runtime —")
|
||||
@@ -885,23 +951,64 @@ Examples:
|
||||
sys.exit(1)
|
||||
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
|
||||
if args.page:
|
||||
if not content:
|
||||
print("Error: No content provided. Use --file or --stdin")
|
||||
sys.exit(1)
|
||||
|
||||
page_id = extract_notion_id(args.page)
|
||||
if not page_id:
|
||||
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||
sys.exit(1)
|
||||
formatted_id = format_id_with_dashes(page_id)
|
||||
|
||||
mode = 'replace' if args.replace else 'append'
|
||||
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
||||
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
|
||||
|
||||
if write_to_page(notion, page_id, content, mode):
|
||||
print(f"✅ Successfully wrote content to page")
|
||||
formatted_id = format_id_with_dashes(page_id)
|
||||
# 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('-', '')}")
|
||||
else:
|
||||
print("❌ Failed to write content")
|
||||
@@ -960,6 +1067,8 @@ Examples:
|
||||
|
||||
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||
|
||||
existing = None
|
||||
|
||||
# Upsert path: look for existing row by the named property
|
||||
if args.upsert_by:
|
||||
if args.upsert_by not in schema_props:
|
||||
@@ -980,19 +1089,52 @@ Examples:
|
||||
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
||||
sys.exit(1)
|
||||
|
||||
if existing:
|
||||
page_id = existing['id']
|
||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||
if not update_page_properties(notion, page_id, properties):
|
||||
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:
|
||||
page_id = existing['id']
|
||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||
if not update_page_properties(notion, page_id, properties):
|
||||
sys.exit(1)
|
||||
if content_blocks:
|
||||
if not clear_page_content(notion, page_id):
|
||||
sys.exit(1)
|
||||
if content_blocks:
|
||||
if not clear_page_content(notion, page_id):
|
||||
sys.exit(1)
|
||||
if not append_to_page(notion, page_id, content_blocks):
|
||||
sys.exit(1)
|
||||
print(f"✅ Successfully updated database row")
|
||||
print(f" https://notion.so/{page_id.replace('-', '')}")
|
||||
return
|
||||
if not append_to_page(notion, page_id, content_blocks):
|
||||
sys.exit(1)
|
||||
print(f"✅ Successfully updated database row")
|
||||
print(f" https://notion.so/{page_id.replace('-', '')}")
|
||||
return
|
||||
|
||||
print(f"Creating database row...")
|
||||
row_id = create_database_row(notion, data_source_id, properties, content_blocks)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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():
|
||||
tests = [
|
||||
test_rich_text_plain,
|
||||
@@ -447,6 +469,9 @@ def run_all():
|
||||
test_rich_text_relative_link_becomes_plain,
|
||||
test_rich_text_absolute_link_preserved,
|
||||
test_no_literal_markers_leak,
|
||||
test_image_remote_external,
|
||||
test_image_local_external_shape_preupload,
|
||||
test_image_only_when_standalone,
|
||||
]
|
||||
for t in tests:
|
||||
print(f"\n{t.__name__}")
|
||||
|
||||
@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
|
||||
|
||||
## 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)
|
||||
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 32-seo-crawl-budget
|
||||
name: seo-crawl-budget
|
||||
description: |
|
||||
Crawl budget optimization and server log analysis for search engine bots.
|
||||
Triggers: crawl budget, log analysis, bot crawling, Googlebot, crawl waste,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 33-seo-migration-planner
|
||||
name: seo-migration-planner
|
||||
description: |
|
||||
SEO site migration planning and monitoring. Triggers: site migration, domain move, redirect mapping, platform migration, URL restructuring, HTTPS migration, subdomain consolidation, 사이트 이전, 도메인 이전, 리디렉트 매핑.
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 34-seo-reporting-dashboard
|
||||
name: seo-reporting-dashboard
|
||||
description: |
|
||||
SEO reporting dashboard and executive report generation. Aggregates data from all SEO skills
|
||||
into stakeholder-ready reports and interactive HTML dashboards.
|
||||
|
||||
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()
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 40-jamie-brand-editor
|
||||
name: jamie-brand-editor
|
||||
description: |
|
||||
Jamie Plastic Surgery branded content generator for blog posts and marketing.
|
||||
Triggers: write Jamie blog, Jamie content, brand copywriting, 제이미 콘텐츠.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 41-jamie-brand-audit
|
||||
name: jamie-brand-audit
|
||||
description: |
|
||||
Jamie Plastic Surgery brand compliance reviewer and content evaluator.
|
||||
Triggers: review content, brand audit, 제이미 브랜드 검토, tone and manner check.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 42-jamie-faq-entry
|
||||
name: jamie-faq-entry
|
||||
description: "카카오톡 플러스 채널 Kanana 상담매니저 Q&A 답변 생성 및 검토 스킬. 제이미성형외과의 카카오톡 채널에 등록할 고객 문의 질문과 답변 엔트리를 생성, 검토, 수정합니다. 의료광고 심의 준수, 브랜드 보이스 일관성, 카카오 카나나 가이드 규격을 모두 반영합니다. Triggers: 카나나 답변, Kanana Q&A, 카카오톡 챗봇, 카카오 상담 답변, 챗봇 문답, 자동답변 등록, 카나나 엔트리, chatbot QA, KakaoTalk channel reply, 카카오 자동응답. jamie-marketing-editor 및 jamie-brand-guardian 스킬과 연계하여 사용합니다."
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 43-jamie-youtube-manager
|
||||
name: jamie-youtube-manager
|
||||
description: |
|
||||
Jamie Clinic YouTube channel SEO auditor and content manager.
|
||||
Triggers: YouTube SEO, video audit, 제이미 유튜브, channel optimization.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 44-jamie-youtube-subtitle-checker
|
||||
name: jamie-youtube-subtitle-checker
|
||||
description: |
|
||||
SBV subtitle file typo corrector and YouTube metadata generator for Jamie Clinic.
|
||||
Triggers: check subtitles, subtitle QA, SBV correction, 자막 교정.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 45-jamie-instagram-manager
|
||||
name: jamie-instagram-manager
|
||||
description: |
|
||||
Jamie Clinic Instagram account manager for engagement, content planning, and boost strategy.
|
||||
Triggers: Instagram management, 제이미 인스타그램, IG strategy, social media.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 46-jamie-journal-editor
|
||||
name: jamie-journal-editor
|
||||
description: |
|
||||
Jamie Clinic journal/blog content editor for "정기호의 성형외과 진료실 이야기" (journal.jamie.clinic).
|
||||
Creates educational medical blog posts in Dr. Jung's authentic voice with Korean medical ad compliance.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 47-jamie-marketing-editor
|
||||
name: jamie-marketing-editor
|
||||
description: |
|
||||
Jamie Clinic marketing content editor for digital channels, ads, communications, and internal docs.
|
||||
Creates compliant marketing copy for website, blog, SNS, ads, and patient communications.
|
||||
|
||||
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?
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 50-notebooklm-agent
|
||||
name: notebooklm-agent
|
||||
description: |
|
||||
Q&A agent for NotebookLM notebooks. Ask questions and get grounded, citation-backed answers from your sources.
|
||||
Triggers: ask NotebookLM, query notebook, research question, 노트북 질문, NotebookLM 에이전트.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 51-notebooklm-automation
|
||||
name: notebooklm-automation
|
||||
description: |
|
||||
Complete NotebookLM automation for notebooks, sources, and artifacts management.
|
||||
Triggers: manage NotebookLM, create notebook, add sources, 노트북 관리, NotebookLM 자동화.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 52-notebooklm-studio
|
||||
name: notebooklm-studio
|
||||
description: |
|
||||
Content generation for NotebookLM Studio artifacts - podcasts, videos, quizzes, flashcards, and more.
|
||||
Triggers: create podcast, generate video, make quiz, 팟캐스트 만들기, 퀴즈 생성, NotebookLM 스튜디오.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 53-notebooklm-research
|
||||
name: notebooklm-research
|
||||
description: |
|
||||
Research and source discovery for NotebookLM. Web/Drive research, auto-import, and source text extraction.
|
||||
Triggers: research topic, find sources, web research, 리서치, 자료 조사, NotebookLM 연구.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 60-gtm-audit
|
||||
name: gtm-audit
|
||||
description: |
|
||||
GTM container audit using Chrome DevTools and DTM Agent for tag verification.
|
||||
Triggers: audit GTM, GTM analysis, tag debugging, dataLayer inspection.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 61-gtm-editor
|
||||
name: gtm-editor
|
||||
description: >
|
||||
GTM implementation toolkit. Creates, updates, and modifies GTM tags, triggers,
|
||||
variables via API. Generates Custom HTML with ES5 compliance. Handles workspace
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: 62-gtm-validator
|
||||
name: gtm-validator
|
||||
description: >
|
||||
GTM QA and validation toolkit. Verifies tags fire correctly on live pages,
|
||||
tests trigger conditions against actual DOM, validates dataLayer schemas,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: 70-dintel-brand-guardian
|
||||
name: dintel-brand-guardian
|
||||
version: 1.2.0
|
||||
last_updated: 2026-05-18
|
||||
canon_compliance: v1.3
|
||||
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.
|
||||
autonomy: auto
|
||||
---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user