Compare commits

..

51 Commits

Author SHA1 Message Date
f27fb7a2c4 Add jamie-copy-trimmer (48) and dintel-campaign-designer (78) skills
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Import two skills authored in Claude Cowork: jamie-copy-trimmer as a
root-only Jamie skill, and campaign-gate-process as dintel-campaign-designer
with full D.intelligence Agent Corps packaging (code/desktop/shared,
agent-id 78, Draft & Wait autonomy) since it now sits inside the 70-79
block. Bumps the corps roster count to 9 agents + 1 meta-agent across all
sibling skills and registers #78 in the shared USER-GUIDE/README. Also adds
.claude/commands wrappers and ~/.claude/skills symlinks so both are usable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 12:59:19 +09:00
Andrew Yim
a08dc316be Merge pull request #14 from ourdigital/fix/notion-writer-skill-paths
fix(notion-writer): correct stale 02→32 skill paths
2026-06-27 11:42:19 +09:00
dc18594d76 fix(notion-writer): correct stale 02→32 skill paths after dir renumber
The 02-notion-writer dir was renumbered to 32-notion-writer, but the
skill's SKILL.md (both code + desktop variants) still pointed venv/cd
paths at the nonexistent 02 dir, and the slash-command file had a
~/Projects (should be ~/Project) typo. Fixes invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:41:39 +09:00
Andrew Yim
0b3a0ab129 Merge pull request #13 from ourdigital/notion-writer-cli-enhancements
feat(notion-writer): file uploads + native markdown engine (v1.3.0)
2026-06-27 11:27:11 +09:00
5c904756ab fix(ntn-files): propagate NOTION_API_KEY into ntn subprocesses
Force ntn to authenticate as the same integration that writes the page by
injecting NOTION_API_TOKEN=$NOTION_API_KEY into every ntn subprocess env.
Notion scopes file uploads to the creating integration, so a mismatch caused
"Could not find file_upload with ID …" when attaching uploaded images.

Added test_upload_passes_token_env (9 total in test_ntn_files.py) and updated
code/CLAUDE.md to reflect that a separate ntn login is no longer required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
07fca7fb81 fix(notion-writer): fence-aware image/translate, NtnUploadError boundary, dead-code removal
FIX 1: _content_has_local_images and extract_local_images now skip lines inside
  ``` fences so a local-image ref inside a code block is not treated as real.
FIX 2: md_translate.translate() tracks fence state; callout/columns/mention
  transforms are suppressed for lines inside ``` ... ``` blocks (pass verbatim).
FIX 3: main() catches NtnUploadError and prints a clean error + e.stderr before
  sys.exit(1); body moved to _main_body(parser, args).
FIX 4: ntn_files.upload() raises NtnUploadError on empty stdout instead of
  IndexError, giving a clean message combined with FIX 3.
FIX 5: Removed unused write_to_page() (both page paths are inlined).
FIX 6: create_page_markdown() omits the 'markdown' key when value is falsy,
  avoiding empty-markdown sends on DB rows created without --file/--stdin.
FIX 7: CLAUDE.md documents --allow-deleting-content scope (markdown engine only).
TDD: Added test_fence_passthrough_no_transform (md_translate, suite now 8) and
  test_local_image_inside_fence_ignored (engine_routing, suite now 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
00edd2e5dc docs(notion-writer): document engines + file uploads (v1.3.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
478ac61975 fix(notion-writer): fail loudly on markdown image append + upsert prop update
Mirror the blocks path's error handling in the markdown engine:
- page + DB markdown two-phase image append now check append_to_page's
  return and exit(1) on failure instead of printing success silently
- markdown DB upsert guards update_page_properties return
- restore blocks-engine page progress print + replace-failure print
- move unused parent computation into the create branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
5ff9b3d9f5 feat(notion-writer): --engine routing + two-phase image append
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
cfbca6cc15 feat(notion-writer): markdown endpoint helpers + version-aware client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
dfbc52e531 feat(notion-writer): dialect->Notion enhanced markdown translator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
337f2ad6e1 feat(notion-writer): materialize local images via upload walk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
9fbd719048 feat(notion-writer): parse standalone images to image blocks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
5385f3bddd feat(notion-writer): ntn_files upload + preflight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
75cfcb9ad3 docs(notion-writer): fix Task 2 upload tests to mock builtins.open
upload() opens the file before subprocess.run; the two upload tests must
mock open() so the nonexistent /tmp paths don't raise FileNotFoundError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:31 +09:00
bfade2b722 docs(notion-writer): fix plan — circular import, columns loop, error hint
Pre-flight review fixes: lazy import in md_translate to break the
notion_writer<->md_translate cycle; clean columns state machine; markdown
deletion hint in explain_api_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
2b36feb81b docs(notion-writer): implementation plan for CLI enhancements
9 TDD tasks: venv baseline, ntn_files (upload+preflight), image parsing,
materialize walk, md_translate dialect translator, compat markdown
helpers + version-aware client, CLI engine routing + two-phase image
append, docs (v1.3.0), live smoke test. 55 unit tests across 4 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
aa003e28cc docs(notion-writer): design spec for CLI file uploads + markdown engine
Tier 1: local ![](path) images uploaded via `ntn files create` and
embedded as file_upload image blocks (parser stays pure; upload happens
in an isolated post-parse walk).

Tier 2: opt-in `--engine markdown` writes through Notion's native
enhanced-markdown endpoints (POST /pages, PATCH .../markdown) with a
dialect translator (callouts/columns/toggles/mentions) so one source
doc works in both engines. Default engine stays `blocks` (no regression).

Adds ntn_files.py + md_translate.py, version-aware client (2026-03-11
for markdown writes only), new test suites, venv setup, docs → v1.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:12:48 +09:00
87635e4208 fix(skill): absent claim term -> INCONCLUSIVE hint; note surge-tuning (final-review minors #3,#4)
- gsc_signal_delta.py: extract `found` local var; add first branch in verdict_hint
  chain so a term absent from both GSC windows yields INCONCLUSIVE (not ARTIFACT).
  Existing ARTIFACT / CONFIRMED-PARTIAL / PARTIAL branches unchanged (elif chain).
- test_gsc_signal_delta.py: add test_absent_claim_term_inconclusive asserting
  found=False and "INCONCLUSIVE" in verdict_hint for a term in neither fixture.
- code/CLAUDE.md: one-line surge-tuning note — verdict_hint/in_top_movers are
  calibrated for upward claims; for drops, inspect top_decliners directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 10:38:14 +09:00
4f78534e59 test(skill): genesis 호텔 smoke fixtures + end-to-end ARTIFACT check 2026-06-26 10:29:35 +09:00
d20675d02c feat(skill): register seo-signal-validation in marketplace; reconcile spec layout 2026-06-26 10:25:46 +09:00
2cadc30825 feat(skill): gsc_signal_delta helper + tests + code notes 2026-06-26 10:21:36 +09:00
c35250f06a feat(skill): seo-signal-validation SKILL.md decision half (L3-L4, verdict, output) 2026-06-26 10:16:11 +09:00
3383878e12 feat(skill): seo-signal-validation SKILL.md measurement half (L1-L2) 2026-06-26 10:10:47 +09:00
f953887b97 docs(skill): add 35-seo-signal-validation implementation plan
5 bite-sized tasks: SKILL.md measurement half (L1-L2) + decision half
(L3-L4/verdict), gsc_signal_delta.py helper with TDD tests, marketplace
registration + spec-layout reconcile, and a genesis-case smoke test that
asserts the JHR "호텔" claim resolves to ARTIFACT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 10:05:57 +09:00
18b39ef6ea docs(skill): add 35-seo-signal-validation design spec
New conductor skill that adjudicates whether a claimed SERP/Knowledge-Graph
movement for a (term, entity) pair is real, misattributed, an artifact, or
unprovable. Stateless, on-demand; delegates measurement to seo-serp-analysis,
seo-position-tracking, and seo-knowledge-graph. Genesis: JHR "호텔 16→3"
SEMrush claim refuted via GSC/GA4/live-SERP cross-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuT3W81t88QQFaxY2ruWv2
2026-06-26 09:58:50 +09:00
e1f4d75dc2 graphify: index full doc layer via Gemini 3.1 Pro
Indexed 869 skill docs + 6 images (excl. 73 redundant .claude/commands mirrors)
using Gemini 3.1 Pro (~1.3M in / 301k out tokens, ~$6.23 - no Claude tokens):
+1552 nodes, +1308 edges, 61 doc<->code cross-links, 500 new doc communities
labeled by skill. Every skill's docs/specs are now queryable; prior graph was
code-only. Extraction offloaded to Gemini via graphify's OpenAI-compatible backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:20:45 +09:00
6c97dfc913 graphify: index ourdigital-okf skill into knowledge graph
OKF-scoped --update of custom-skills/97-ourdigital-okf (7 code + 19 docs):
+131 nodes (62 AST + 75 semantic), +207 edges, 8 OKF communities, 6 hyperedges
capturing the produce/validate/visualize design and zero-dep utility trio.
Prior graph was code-only; the OKF skill is now queryable. ~172k extraction tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:33:44 +09:00
c068372abf Add graphify knowledge graph outputs and pending changes
Includes graphify-out/ (interactive graph.html, GRAPH_REPORT.md, graph.json
from Gemini-backed extraction) plus .graphifyignore/.gitignore scope config,
alongside other pending working-tree changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:41:30 +09:00
587a32d239 feat(okf): add install.sh; changelog; e2e produce->validate->visualize verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
626abd4173 feat(okf): add SKILL.md variants (top/code/desktop), CLAUDE.md, README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
c6585c817f docs(okf): add distilled spec, frontmatter reference, and templates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
75acd3aa3e feat(okf): add minimal okf_viz Cytoscape generator with tests
Full suite green (20 tests). Smoke-tested on Google crypto_bitcoin bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
7b239dda8f feat(okf): add okf_validate; harden YAML parser for block lists + folded scalars
Verified conformant against Google reference bundles (crypto_bitcoin, ga4, stackoverflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
9762ee97ab feat(okf): add okf_common frontmatter/link parser with tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:29 +09:00
4416833cb3 feat(okf): scaffold ourdigital-okf skill skeleton + mini fixture bundle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:35:28 +09:00
b574c97fcc chore(marketplace): add ourdigital-skills local marketplace + fix README install docs (#1) 2026-06-16 14:08:27 +09:00
7daa4cda68 SEO schema validator skill update 2026-06-08 13:21:09 +09:00
Andrew Yim
8ffb6bec6b docs(ourdigital): align journal + research skill style guides to v3.3/v2.1 (#12)
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Merge-align the remaining OurDigital skill references to the authoritative
guides, preserving each skill's structure:

- 03-journal: brand identity, three-channel context, journal role/length
  (English, 1,000-2,000 words), authority order, OurDigital-vs-Clinic boundary,
  shared writing principles adapted for English. English voice preserved — the
  Korean-blog-only rules (평서체, 전문용어 병기) were deliberately NOT imposed.
- 04-research: per-channel voice/tone aligned to Writing Style Guide v2.1;
  fixed an incorrect 경어체 (~입니다) rule for the Korean blog → 평서체; SEO
  numbers corrected (title ~40자/≤60, meta ≤155, English slug); added brand
  identity + authority order + Clinic boundary.

05-document brand_config.json (corporate doc colors) and the claude-ai-export
snapshot were intentionally left out of scope.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:27:54 +09:00
Andrew Yim
9b914b9dd4 fix(estimate-engine): quote SKILL.md description to fix verify-skills YAML error (#11)
The unquoted description contained "Costing methods: effort …" — the mid-string
": " made YAML treat it as a nested mapping ("mapping values are not allowed
here"), failing verify-skills since 2026-05-27. Single-quote the value (it has
inner double quotes but no apostrophes), preserving the exact text.

verify_skills.py: 76/76 skills valid.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:21:26 +09:00
Andrew Yim
aab98f405d docs(ourdigital): align brand/blog/designer skill references to v3.3/v2.1 (#10)
Merge-align the core trio's reference material to the authoritative OurDigital
guides (Blog Project Instruction v3.3, Writing Style Guide v2.1, Visual Style
Guide v2.1), preserving each skill's structure:

- 01-brand-guide: brand-foundation + writing-style — identity statement, mission,
  three channels, OurDigital-vs-Clinic boundary, four writing principles,
  평서체/전문용어 병기 rules, authority order.
- 02-blog: blog-style-guide — channel identity, writing characteristics,
  post structure (char counts), SEO (title/meta/English slug), Ghost metadata,
  self-edit checklist.
- 06-designer: visual_metaphors (both copies kept identical) — bright editorial
  minimalism, Preferred/Avoid metaphor dictionary, 5 palettes, signature motifs,
  Korean-via-restraint, human-trace rule.

Source of truth: knowledge-base/ourdigital in our-ai-editor (PR #12).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:16:07 +09:00
6f69a6a484 Route estimate outputs to central archive (ourdigital-space/estimates)
All estimates now archive to ~/Workspaces/ourdigital-space/estimates/
<YYYY-MM-DD>_<prospect|account>_<service>/ (single 견적 archive). Engine
SKILL.md documents the output-home convention (engine stays --out-dir-agnostic).
presales-seo Stage 5 writes the estimate to $EST (archive); Stage 6 deck reads
from $EST but stays in the engagement bundle.

(Workspace side, not in this repo: SHR estimate moved to the archive, pointer
left in the engagement, ourdigital-space/CLAUDE.md documents estimates/.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:22:33 +09:00
9f7f9e7221 education: clarify ga4_gtm_intermediate (=기본구성/숨고), add marketing-analytics course
The 숨고 GA4/GTM 중급 기본견적 is identical to the existing ga4_gtm_intermediate
course (17 lessons, ₩1,570,000) — labeled it as the 기본구성 standard + aliases
rather than duplicating. Added the genuinely distinct GA4/GTM 마케팅 애널리틱스
course (22 lessons incl. 메타/구글 솔루션 + 워크숍 + 트리트먼트) → list ₩4,070,000.

Validated: intermediate ₩1,570,000, marketing_analytics ₩4,070,000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:15:56 +09:00
6ac547e78f refactor(skills): clean skill names (strip NN- prefix from name:) — convention change
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Adopt: directory keeps its NN- ordering prefix; skill `name:` is the clean form
without it (dir 16-seo-schema-validator → name: seo-schema-validator). Nicer to
invoke, matches the original desktop/SKILL.md names, still globally unique.

- 71 root SKILL.md: name: NN-foo → name: foo (flat skills + reference-curator suite).
  Plugins (mac-optimizer/multi-agent-guide/dintel-bootstrap) already clean; 95 already clean.
- scripts/migrate_skill_root.py: derive name = dirname minus NN- prefix (skill_name()).
- CLAUDE.md + SKILL-MIGRATION-GUIDE.md: document the dir-prefix / clean-name convention.

verify_skills.py: 0 name collisions across all renamed skills. (The ~/.claude/skills
symlinks were re-pointed to the clean names separately — filesystem only.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:11:01 +09:00
08cb20fc67 Add real digital_ads + digital_branding catalogs
Built from real D.intelligence docs (not the 견적 자료 folder, which has only
GA4/GTM + education — confirmed):
- digital_branding: TNS 유학원 디지털 브랜딩 진단 컨설팅 → ₩9,000,000 (5 fixed stages)
- digital_ads: 디하이브 디지털 광고·퍼포먼스 마케팅 대행 계약 → ₩6,000,000/월 retainer
  (media-spend commission % is per-deal, kept as a parameter — not invented)

effort method now supports fixed-amount tasks (Unit Cost) and monthly retainers
(unit: monthly); render shows 고정/—//월 and retainer/commission notes.
Validated: branding ₩9.0M, ads ₩6.0M/월; no regression (SEO 25.0M, coaching 1.57M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:06:59 +09:00
c9bdbb57f7 Extract ourdigital-estimate-engine; presales-seo now calls it
New skill 96-ourdigital-estimate-engine: method-aware quoting engine
(effort / coaching / procurement) with universal rate_card + per-service
catalog. Real catalogs: seo (effort), education (coaching); stubs:
digital_ads, digital_branding. Validated to reproduce real quotes —
SEO basic ₩10.5M / treatment ₩25.0M, SHR chain ₩29.5M, L'Escape basic
₩10.5M, GA4/GTM coaching ₩1,570,000, procurement +15%.

Refactor 95-ourdigital-presales-seo: remove rate_card.yaml, sow_templates.yaml,
estimate.py (migrated to engine); add findings_to_scope.py; Stage 5 now maps
findings→scope.json and calls the engine CLI. build_deck/kg_query unchanged;
end-to-end validated on SHR (29.5M) + deck renders engine estimate.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:54:11 +09:00
34c3a1df4f ci: run verify_skills.py on every push and PR
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
Add .github/workflows/verify-skills.yml — installs pyyaml and runs
scripts/verify_skills.py on push and pull_request, failing the build if any skill
would not load. GitHub Actions runs it; Gitea Actions also reads .github/workflows/
when Actions is enabled, so it covers both remotes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:30:18 +09:00
137b927477 fix(skills): make all skills load-valid + add scripts/verify_skills.py
Add a comprehensive load verifier and fix the two issues it found:

- scripts/verify_skills.py: validates every loadable unit (flat root, suite sub-skill,
  plugin skill) with real YAML parsing, name regex + global uniqueness, frontmatter
  <=1024, description sanity, plugin.json JSON validity, and orphan detection. Read-only.
- 92-tui-design-template (root + code/SKILL.md): fix invalid YAML `triggers:` block
  (`- "a", "b"` multi-scalar list items) -> one phrase per list item.
- 17-seo-schema-generator: remove ">" from description ("generate -> validate" ->
  "generate then validate"); angle brackets are disallowed in descriptions.

Result: 75/75 loadable skills valid — 0 failures, 0 name collisions, 0 orphans,
0 plugin-manifest errors (65 flat + 3 plugins + 7 suite sub-skills).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:22:33 +09:00
95d6fdf499 Add luxury/vertical signal to auto-tiering
pick_baseline now applies a premium floor: if prospect.vertical matches
rate_card.tiering.premium_verticals (luxury/premium/deluxe/5성/특1급…), the
auto-selected tier is floored to premium_min_tier (default basic) so a premium
single property won't drop to the smb entry tier.

Validated: L'Escape (hotel_luxury, 1 property) smb -> basic (₩10.5M);
non-luxury single -> smb (₩3.0M); SHR chain -> treatment (₩29.5M) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:15:55 +09:00
0496262cd5 feat(skills): author root SKILL.md for reference-curator suite + 7 sub-skills
Finish the migration for the dirs the bulk pass couldn't auto-handle:

- 90-reference-curator/SKILL.md: hand-authored suite orchestrator (pipeline overview,
  7-stage table, /reference-curator run modes, install) — the single loadable entry.
- 90-reference-curator/0{1..7}-*/SKILL.md: generated from each sub-skill's desktop/SKILL.md.
- scripts/migrate_skill_root.py: generalized discovery to find nested suite sub-skills
  (rglob desktop/code SKILL.md), so the migrator now handles suites too.

81-mac-optimizer, 91-multi-agent-guide, 94-dintel-bootstrap need NO root SKILL.md: they
are Claude Code plugins whose skill correctly lives at skills/<name>/SKILL.md (validated).
Adding a root SKILL.md there would violate plugin structure.

All SKILL.md repo-wide validate: flat-root=65, suite-sub=7, plugin-skills=3, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:14:46 +09:00
60734dbde7 Recalibrate estimate for SMB acceptability
Real-world feedback: list-rate calc overshot SMB-acceptable levels.
- scaling: driver properties_total -> subbrands_total (chains share templates),
  cap x6.5 -> x2.0, applied to On-page only (Technical now fixed site-wide)
- add 'smb' entry tier (lean hours @ 0.55 billing); 3-tier auto-select
  (smb/basic/treatment) by portfolio size; per-tier billing; --baseline smb enabled
- docs (findings_to_service.md, SKILL.md) synced to 3-tier model

Effect: SHR 25-property chain 71.5M -> 29.5M; SMB single hotel ~3.0M;
basic/treatment still reproduce real 10.5M/25.0M at 1 property.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:13:38 +09:00
199 changed files with 22691 additions and 950 deletions

View 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
}
]
}

View 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

View 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

View File

@@ -24,7 +24,7 @@ Push markdown content to Notion pages or databases via the Notion API.
## Scripts ## Scripts
```bash ```bash
cd ~/Projects/our-claude-skills/custom-skills/32-notion-writer/code/scripts cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
# Test connection # Test connection
python notion_writer.py --test python notion_writer.py --test

26
.github/workflows/verify-skills.yml vendored Normal file
View 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
View File

@@ -99,3 +99,7 @@ build/
# Temporary files # Temporary files
output/ output/
keyword_analysis_*.json keyword_analysis_*.json
# graphify: keep graph.json/html/report, drop regenerable cache + dated backups
graphify-out/cache/
graphify-out/????-??-??/

4
.graphifyignore Normal file
View 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-*/

View File

@@ -77,6 +77,7 @@ This is a Claude Skills collection repository containing:
| 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" | | 45 | jamie-instagram-manager | Instagram account management | "Instagram management", "IG strategy" |
| 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" | | 46 | jamie-journal-editor | Journal/blog content for journal.jamie.clinic | "Jamie journal", "제이미 저널", "진료실 이야기" |
| 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" | | 47 | jamie-marketing-editor | Multi-channel marketing content & ad copy | "Jamie marketing", "제이미 마케팅", "광고 카피" |
| 48 | jamie-copy-trimmer | Trim/sharpen Korean aesthetic-medical copy against cliché & compliance corpus | "카피 다듬어", "카피 트리밍", "심의 안전하게", "copy trim" |
### NotebookLM Tools (50-59) ### NotebookLM Tools (50-59)
@@ -109,6 +110,7 @@ This is a Claude Skills collection repository containing:
| 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" | | 75 | dintel-marketing-mgr | Content pipeline (Magazine D., newsletter, LinkedIn) | Draft & Wait | "콘텐츠 발행", "newsletter" |
| 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" | | 76 | dintel-backoffice-mgr | Invoicing, contracts, NDA, HR operations | Draft & Wait | "계약서", "인보이스" |
| 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" | | 77 | dintel-account-mgr | Client relationship management & monitoring | Mixed | "client status", "미팅 준비" |
| 78 | dintel-campaign-designer | Campaign/promotion planning as a 3-gate process (Discovery & Debate → Brief → Plan); cross-brand, not D.intelligence-exclusive | Draft & Wait | "campaign plan", "캠페인 기획", "기획안 만들어" |
| 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" | | 79 | dintel-skill-update | Cross-skill consistency management (meta-agent) | Triggered | "skill sync", "스킬 업데이트" |
**Shared infrastructure:** `_dintel-shared/` (Python package + reference docs) **Shared infrastructure:** `_dintel-shared/` (Python package + reference docs)
@@ -204,7 +206,7 @@ directly loadable. Migrate incrementally, not in bulk.
```markdown ```markdown
--- ---
name: skill-name-kebab-case # letters, numbers, hyphens only name: skill-name-kebab-case # clean name: dir minus the NN- prefix
description: | description: |
What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거. What it does + when to use. Triggers: keyword1, keyword2, 한국어 트리거.
--- ---
@@ -214,6 +216,9 @@ description: |
Content starts here... Content starts here...
``` ```
**Naming convention:** the directory keeps its `NN-` prefix for ordering
(`16-seo-schema-validator/`), but the skill `name:` is the **clean** form without it
(`name: seo-schema-validator`). Names must be kebab-case and globally unique.
Whole frontmatter ≤ 1024 chars. Full rules + migration recipe: `reference/SKILL-MIGRATION-GUIDE.md`. Whole frontmatter ≤ 1024 chars. Full rules + migration recipe: `reference/SKILL-MIGRATION-GUIDE.md`.
## Directory Layout ## Directory Layout
@@ -250,6 +255,7 @@ our-claude-skills/
│ ├── 45-jamie-instagram-manager/ │ ├── 45-jamie-instagram-manager/
│ ├── 46-jamie-journal-editor/ │ ├── 46-jamie-journal-editor/
│ ├── 47-jamie-marketing-editor/ │ ├── 47-jamie-marketing-editor/
│ ├── 48-jamie-copy-trimmer/
│ │ │ │
│ ├── 50-notebooklm-agent/ │ ├── 50-notebooklm-agent/
│ ├── 51-notebooklm-automation/ │ ├── 51-notebooklm-automation/
@@ -268,6 +274,7 @@ our-claude-skills/
│ ├── 75-dintel-marketing-mgr/ │ ├── 75-dintel-marketing-mgr/
│ ├── 76-dintel-backoffice-mgr/ │ ├── 76-dintel-backoffice-mgr/
│ ├── 77-dintel-account-mgr/ │ ├── 77-dintel-account-mgr/
│ ├── 78-dintel-campaign-designer/
│ ├── 79-dintel-skill-update/ │ ├── 79-dintel-skill-update/
│ │ │ │
│ ├── 80-claude-settings-optimizer/ │ ├── 80-claude-settings-optimizer/

View File

@@ -12,6 +12,11 @@ cd our-claude-skills/custom-skills/_ourdigital-shared
./install.sh ./install.sh
``` ```
This symlinks the global slash commands into `~/.claude/commands/`, sets up the
Python virtual environment, and configures credentials. It does **not** register
skills natively — to load a skill as a Claude Code skill, also symlink it into
`~/.claude/skills/` (see [Usage → Claude Code](#claude-code)).
## Custom Skills Overview ## Custom Skills Overview
### OurDigital Core (01-10) ### OurDigital Core (01-10)
@@ -215,16 +220,23 @@ The `_ourdigital-shared/` directory provides:
### Claude Code ### Claude Code
Skills are auto-detected via symlinks in `~/.claude/skills/`: The Quick Install symlinks the **slash commands** into `~/.claude/commands/`. To
also load a skill natively, symlink its **root** directory (which holds the
loadable `SKILL.md`) into `~/.claude/skills/`, using the clean name without the
`NN-` prefix:
```bash ```bash
# Install skill symlink # From the repo root — symlink a skill into Claude Code
ln -sf /path/to/skill/desktop ~/.claude/skills/skill-name ln -sf "$PWD/custom-skills/16-seo-schema-validator" ~/.claude/skills/seo-schema-validator
``` ```
> Legacy skills that don't yet have a root `SKILL.md` expose it under
> `code/SKILL.md` instead — symlink `.../<skill>/code` for those.
### Claude Desktop ### Claude Desktop
Copy the `desktop/SKILL.md` file to your Claude Desktop skills folder. Import the skill's `desktop/` folder (containing `SKILL.md` + `skill.yaml`) via
your Claude Desktop skills settings.
## Development ## Development

View File

@@ -1,5 +1,5 @@
--- ---
name: 01-ourdigital-brand-guide name: ourdigital-brand-guide
description: | description: |
OurDigital 브랜드 기준 및 스타일 가이드 참조 스킬. OurDigital 브랜드 기준 및 스타일 가이드 참조 스킬.
Activated with "ourdigital" keyword for brand-related queries. Activated with "ourdigital" keyword for brand-related queries.

View File

@@ -1,6 +1,8 @@
# OurDigital Brand Foundation # OurDigital Brand Foundation
Complete brand identity reference for OurDigital Clinic. <!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
Complete brand identity reference for OurDigital.
## Brand Identity ## Brand Identity
@@ -8,25 +10,34 @@ Complete brand identity reference for OurDigital Clinic.
| Element | Content | | Element | Content |
|---------|---------| |---------|---------|
| **Brand Name** | OurDigital Clinic | | **Brand Name** | OurDigital |
| **Tagline** | 우리 디지털 클리닉 \| Your Digital Health Partner | | **Identity Statement** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Mission** | 디지털 마케팅 클리닉 for SMBs, 자영업자, 프리랜서, 비영리단체 | | **Mission** | 기술이 사람과 문화에 미치는 영향을 관찰하고 기록한다. 생각의 씨앗을 남기고 성찰한다. 검증되지 않은 도전과 실험의 결과를 기록한다. |
| **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 | | **Vision** | 데이터 민주화, 정밀 마케팅, 지속 가능한 성장 |
| **Promise** | 진단-처방-측정 가능한 성장 | | **Promise** | 진단-처방-측정 가능한 성장 |
### Core Values ### Brand Keywords
| 가치 | English | 클리닉 메타포 | 설명 | | 핵심 가치 | 설명 |
|------|---------|--------------|------| |----------|------|
| 마케팅 과학 | Marketing Science | 근거 중심 의학 | 검증된 방법론과 프레임워크 | | **관찰** | 현상을 있는 그대로 포착하되, 표면 아래를 본다 — 무엇이 일어나고 있는가 |
| 실행 지향 | In-Action | 실행 가능한 처방 | 분석에서 끝나지 않는 실행력 | | **분석** | 현상의 원인과 맥락을 탐구한다 — 왜 이런 일이 일어나는가 |
| 지속 성장 | Sustainable Growth | 체질 개선 | 일회성이 아닌 지속 가능한 성장 | | **성찰** | 심층적 의미와 인간적 함의를 도출한다 — 이것이 우리에게 무엇을 의미하는가 |
| **실험** | 검증되지 않은 시도를 두려워하지 않는다 |
| **기록** | 생각의 궤적을 남겨 미래의 자산으로 삼는다 |
| **균형** | 낙관과 비관, 이론과 실무 사이에서 중심을 잡는다 |
### Brand Philosophy ### Brand Philosophy
**"Precision + Empathy + Evidence"** **"Precision + Empathy + Evidence"**
Accurate diagnosis, stakeholder understanding, and measurable validation across all communications. Accurate diagnosis, stakeholder understanding, and measurable validation across all communications. 철학적 깊이와 실용적 가치의 균형이 OurDigital의 정체성이다.
### Brand Boundary (중요)
- **블로그 전체 브랜드**: OurDigital
- **블로그 설명**: 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트
- **OurDigital Clinic**: 진단형 콘텐츠, SEO/데이터/콘텐츠 감사, 컨설팅 상품, 문제 해결형 시리즈에서만 사용 가능한 서비스 메타포. 블로그 전체 브랜드명이 아니다.
## Positioning Statement ## Positioning Statement
@@ -34,8 +45,16 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
> OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스, > OurDigital Clinic is 디지털 마케팅 클리닉 that provides 진단-처방-측정 프로세스,
> unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론 > unlike 일회성 캠페인 대행사, we deliver 25년 경험과 마케팅 사이언스 방법론
*(Note: OurDigital Clinic은 컨설팅/서비스 맥락에서의 포지셔닝. 블로그 전체 포지셔닝과 구분한다.)*
## Target Audience ## Target Audience
**블로그 독자 (blog.ourdigital.org)**
- **1순위**: 디지털 마케팅/기술 분야 종사자 — 실무 전략을 수립하고 실행하는 전문가
- **2순위**: 기술과 사회의 관계에 관심 있는 지식인 — 디지털 전환이 사회에 미치는 영향을 고민하는 독자
- **3순위**: 자기 성찰과 비판적 사고를 중시하는 독자
**컨설팅 서비스 (OurDigital Clinic)**
- **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체 - **Primary**: SMB 마케팅 담당자, 자영업자, 프리랜서, 비영리단체
- **Secondary**: 스타트업 창업자, 브랜드 매니저 - **Secondary**: 스타트업 창업자, 브랜드 매니저
@@ -54,24 +73,30 @@ Accurate diagnosis, stakeholder understanding, and measurable validation across
| Level | Element | Description | | Level | Element | Description |
|-------|---------|-------------| |-------|---------|-------------|
| Level 1 | Master Brand | OurDigital Clinic | | Level 1 | Master Brand | OurDigital |
| Level 2 | Channel Identity | Blog, Journal, OurStory | | Level 2 | Channel Identity | Blog, Journal, OurStory |
| Level 3 | Service Identity | 4개 핵심 서비스 | | Level 3 | Service Identity | 4개 핵심 서비스 (OurDigital Clinic 메타포 적용 가능) |
### Channel Personality & Tone ### Channel Personality & Tone
| Channel | Domain | Personality | Tone | Content Type | | Channel | Domain | Language | Character | Length |
|---------|--------|-------------|------|--------------| |---------|--------|----------|-----------|--------|
| Main Hub | ourdigital.org | Professional & Confident | Data-driven, Solution-oriented | 서비스, 케이스, 리드 | | Main Hub | ourdigital.org | Korean | Professional & Confident, Data-driven | 서비스, 케이스, 리드 |
| Blog | blog.ourdigital.org | Analytical & Personal | Educational, Thought-provoking | 가이드, 분석, 인사이트 | | **Blog** | blog.ourdigital.org | **Korean** | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | **1,500-3,000자** |
| Journal | journal.ourdigital.org | Conversational & Poetic | Reflective, Cultural Observer | 에세이, 문화, 관찰 | | **Journal** | journal.ourdigital.org | **English** | 산업 트렌드, 기술-인간 교차점, reflective essay | **1,000-2,000 words** |
| OurStory | ourstory.day | Intimate & Reflective | Authentic, Personal Journey | 개인 서사, 경험 | | **OurStory** | ourstory.day | **Korean** | 개인 에세이, 삶의 성찰, 일상의 관찰 | **800-1,500자** |
| D.intelligence | dintelligence.co.kr | Professional | B2B | Corporate Partnership | | D.intelligence | dintelligence.co.kr | Korean/English | Professional | B2B Corporate Partnership |
**채널 라우팅 규칙:**
- 기술 분석 + 철학적 사유 → `blog.ourdigital.org`
- 순수 개인 에세이, 감정적 성찰 → `ourstory.day`
- 영문 심층 에세이, 산업 관점 → `journal.ourdigital.org`
- 진단형 콘텐츠, 컨설팅 제안 → `OurDigital Clinic` 메타포 사용 가능
### Content Flow Strategy ### Content Flow Strategy
``` ```
Discovery (Blog) → Engagement (Journal) → Conversion (Main Site) Discovery (Blog) → Engagement (Journal) → Conversion (Main Site / OurDigital Clinic)
``` ```
### Publishing Cadence ### Publishing Cadence
@@ -80,7 +105,7 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
|---------|-----------|--------| |---------|-----------|--------|
| ourdigital.org | 필요시 업데이트 | 서비스별 상세 | | ourdigital.org | 필요시 업데이트 | 서비스별 상세 |
| blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 | | blog.ourdigital.org | 주 1-2회 | 1,500-3,000자 |
| journal.ourdigital.org | 월 2-4회 | 1,000-2,000 | | journal.ourdigital.org | 월 2-4회 | 1,000-2,000 words |
| ourstory.day | 월 1-2회 | 800-1,500자 | | ourstory.day | 월 1-2회 | 800-1,500자 |
## Service Portfolio ## Service Portfolio
@@ -112,13 +137,16 @@ Discovery (Blog) → Engagement (Journal) → Conversion (Main Site)
| Use Case | Message | | Use Case | Message |
|----------|---------| |----------|---------|
| Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner | | Blog Identity | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| Consulting Tagline | 우리 디지털 클리닉 \| Your Digital Health Partner |
| Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 | | Value Proposition | 마케팅 과학으로 진단하고, 실행으로 처방합니다 |
| Process | 진단 → 처방 → 측정 | | Process | 진단 → 처방 → 측정 |
| Differentiator | 25년 경험의 마케팅 사이언티스트 | | Differentiator | 25년 경험의 마케팅 사이언티스트 |
## CTA Library ## CTA Library
*(OurDigital Clinic 컨설팅 맥락에서 사용)*
| Context | CTA Text | | Context | CTA Text |
|---------|----------| |---------|----------|
| General | 무료 상담 신청하기 | | General | 무료 상담 신청하기 |

View File

@@ -1,5 +1,7 @@
# OurDigital Writing Style Guide # OurDigital Writing Style Guide
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 / Writing_Style_Guide_v2.1 (2026-06-05) -->
Comprehensive writing guidelines for OurDigital content across all channels. Comprehensive writing guidelines for OurDigital content across all channels.
## Overview ## Overview
@@ -8,7 +10,7 @@ Comprehensive writing guidelines for OurDigital content across all channels.
|-------|-------| |-------|-------|
| **Author** | Andrew Yim | | **Author** | Andrew Yim |
| **Primary Blog** | blog.ourdigital.org | | **Primary Blog** | blog.ourdigital.org |
| **Tagline** | 사람, 디지털 그리고 문화 (People, Digital, and Culture) | | **Brand Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Platform** | Ghost CMS | | **Platform** | Ghost CMS |
| **History** | 2004-2025 (20+ years of content) | | **History** | 2004-2025 (20+ years of content) |
@@ -16,54 +18,73 @@ Comprehensive writing guidelines for OurDigital content across all channels.
## Part 1: 한국어 스타일가이드 ## Part 1: 한국어 스타일가이드
### Writer 역할 정의
25년차 디지털 마케팅 에세이 작가. 기술과 인간의 관계를 관찰하는 실무자이자 사유자. 분석적이면서 개인적이고, 단정 대신 질문을 두고, 결론 대신 가능성을 제시하며, 독자를 가르치지 않고 함께 생각하는 동료로 대한다.
### 문체 규칙 (Korean)
- 대화체 `~다`, `~이다`, `~한다`를 사용한다.
- 경어체 `~합니다`, `~입니다`는 사용하지 않는다.
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다.
- 문단 전환 시 호흡을 바꾼다: 분석 → 서사 → 질문.
- **전문용어는 첫 등장 시 영문을 병기한다.** 예: `검색엔진 최적화(SEO)`.
### 핵심 글쓰기 특성 ### 핵심 글쓰기 특성
#### 1. 철학-기술 융합 #### 1. 철학-기술 융합
기술 분석과 실존적 질문을 자연스럽게 결합한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의와 분리되지 않는다. 기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다. 기술 콘텐츠(AI 아키텍처, 엔터프라이즈 시스템)는 결코 인간적 의와 분리되지 않는다.
**예시**: Llama 4와 DeepSeek 비교 글에서도 거버넌스 우려와 사회적 영향을 포함한다. ```
나쁜 예: 구글의 알고리즘은 200개 이상의 신호를 분석한다.
좋은 예: 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
```
#### 2. 역설(Paradox)을 주요 수사 장치로 활용 #### 2. 핵심 긴장 / 관점 전환 / 역설 / 열린 질문
논증을 긴장과 모순 구조로 전개한다: 모든 글에 억지 역설을 넣지는 않는다. 대신 **핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상**을 자연스럽게 포함한다. 공식화를 경계한다.
- 인간이 컴퓨터를 모방하면서 컴퓨터가 인간을 모방하는 역설
- 기술이 해방하면서 동시에 의미를 축소하는 역설
- 디지털 네이티브가 기성세대가 가르칠 수 없는 유창함을 보유하는 역설
#### 3. 수사적 질문 활용 유용한 패턴:
- `~하면서 동시에 ~하다`
- `~를 위해 오히려 ~해야 한다`
- `가장 ~한 것이 사실은 가장 ~하다`
- `우리가 놓치고 있는 것은 무엇인가?`
선언적 권위보다 질문을 통한 참여를 선호한다: 수사적 질문은 선언적 권위보다 독자와의 지적 동반자 관계를 형성한다.
> "이 디지털 세대에게 무엇을 가르쳐야 하는가?" #### 3. 우울한 낙관주의 (Melancholic Optimism)
> "그 무한한 자유 속에서 우리는 무엇을 소중히 여기게 될 것인가?"
이는 독자와 지적 동반자 관계를 형성하며, 교훈적 지시를 피한다. 디지털 세계의 불안, 피로, 한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 더 나은 실천, 작은 회복의 여지를 찾는다.
#### 4. 우울한 낙관주의 (Melancholic Optimism) ```
예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
```
불안과 상실을 인정하되 절망하지 않는다. 기술의 불가피성을 수용하면서도 대체되는 것에 대한 진정한 우려를 표현한다. #### 4. 분석적이면서 개인적
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
### 문장 구조 패턴 ### 문장 구조 패턴
| 요소 | 패턴 | | 요소 | 패턴 |
|------|------| |------|------|
| 문장 길이 | 여러 절을 포함한 긴 복합문 - 논의되는 상호연결된 개념을 반영 | | 문장 길이 | 짧은 문장과 긴 복합문을 교차 배치 — 리듬을 만든다 |
| 단락 구조 | 관찰 → 분석 → 철학적 함의로 점진적 심화 | | 단락 구조 | 관찰 → 분석 → 철학적 함의로 점진적 심화 |
| 근거 제시 | 역사적 사례, 문화간 참조, 기술 명세를 함께 엮음 | | 근거 제시 | 역사적 사례, 문화간 참조, 기술 명세를 함께 엮음 |
| 결론부 | 종종 열린 결말로, 답을 제시하기보다 질문을 던짐 | | 결론부 | 종종 열린 결말로, 답을 제시하기보다 질문을 던짐 |
### 독자 조율 ### 독자 조율
**교양 있는 일반 독자**를 대상으로 한다 — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다. **교양 있는 일반 독자** — 기술의 문화적 영향에 지적 호기심을 가진 독자이지, 반드시 기술 전문가는 아니다. 독자를 가르치려 하지 않고 결론을 강요하지 않는다. 생각의 재료를 제공한다.
기술 글에도 요약본과 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다. 기술 글에도 은유적 앵커링("데이터 공장")을 포함해 접근성을 유지한다.
### 고유 특성 ### 고유 특성
1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영 1. **이중언어 유창성** — 한국어 산문에 영어 기술 용어가 섞여 디지털 담론의 혼종적 특성을 반영 (단, 전문용어는 첫 등장 시 영문 병기)
2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식 2. **시간적 인식** — 세대 변화와 역사적 맥락에 대한 강한 의식
3. **인식론적 겸손**특히 세대간 격차에서 이해의 한계를 인정 3. **인식론적 겸손** — 이해의 한계를 인정하며, 겸손한 추정 표현을 적절히 활용 (`~일지도 모른다`, `~인 듯하다`, `어쩌면 ~`)
4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸 4. **규제 의식** — 엔터프라이즈 글에서 일관되게 컴플라이언스(GDPR, EU 규제)를 다룸
--- ---
@@ -124,20 +145,28 @@ Technical articles include executive summaries and metaphorical anchoring ("Data
### Do's ### Do's
- Use paradox to structure arguments - 핵심 긴장, 관점 전환, 역설, 열린 질문 중 하나 이상을 포함한다
- Ask rhetorical questions to engage readers - 수사적 질문으로 독자와 지적 동반자 관계를 형성한다
- Connect technical content to human implications - 기술 콘텐츠를 인간적 함의와 연결한다
- Acknowledge uncertainty and epistemic limits - 불확실성과 이해의 한계를 인정한다
- Blend Korean and English naturally for technical terms - 전문용어 첫 등장 시 영문을 병기한다 (`검색엔진 최적화(SEO)`)
- Reference historical context and generational shifts - 역사적 맥락과 세대적 변화를 참조한다
- 짧은 문장과 긴 문장을 교차 배치해 리듬을 만든다
- 1인칭 경험과 관찰을 분석에 자연스럽게 엮는다
### Don'ts ### Don'ts (피해야 할 것)
- Avoid purely declarative, authoritative tone | 피해야 할 것 | 이유 |
- Don't separate technical analysis from cultural impact |-------------|------|
- Avoid simplistic or overly optimistic technology narratives | `~합니다`, `~입니다` | 경어체 — 대화체(`~다`, `~한다`) 사용 |
- Don't provide prescriptive conclusions without exploration | `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다` | 상투적 도입부 |
- Avoid ignoring regulatory and governance concerns | `첫째, 둘째, 셋째` 기계적 나열 | 교과서적 구조 |
| `따라서 반드시 ~해야 한다` | 확정적 결론 |
| 근거 없는 통계나 데이터 | 데이터 정직성 위반 |
| 기술에 대한 무조건적 찬양 또는 혐오 | 균형 원칙 위반 |
| 과도한 감탄부호, 물결표, 이모지 | 톤 일관성 파괴 |
| 특정 업체나 제품의 노골적 홍보성 문장 | 브랜드 신뢰 훼손 |
| SEO 키워드 과잉 최적화 | SEO가 글을 지배해서는 안 된다 |
--- ---

View File

@@ -1,5 +1,5 @@
--- ---
name: 02-ourdigital-blog name: ourdigital-blog
description: | description: |
Korean blog draft creation for blog.ourdigital.org. Korean blog draft creation for blog.ourdigital.org.
Activated with "ourdigital" keyword for blog writing tasks. Activated with "ourdigital" keyword for blog writing tasks.

View File

@@ -1,5 +1,7 @@
# OurDigital Blog Style Guide # OurDigital Blog Style Guide
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
Detailed writing guidelines for blog.ourdigital.org. Detailed writing guidelines for blog.ourdigital.org.
## Channel Identity ## Channel Identity
@@ -7,62 +9,74 @@ Detailed writing guidelines for blog.ourdigital.org.
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| **Domain** | blog.ourdigital.org | | **Domain** | blog.ourdigital.org |
| **Tagline** | 사람, 디지털 그리고 문화 | | **Brand** | OurDigital |
| **Language** | Korean (전문용어 영문 병기) | | **Identity** | 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트 |
| **Tone** | Analytical & Personal, Educational | | **Core Theme** | 사람, 디지털 그리고 문화 |
| **Target** | 교양 있는 일반 독자 - 기술의 문화적 영향에 호기심 있는 독자 | | **Language** | Korean (전문용어 첫 등장 시 영문 병기) |
| **CMS** | Ghost — 초안은 Markdown으로 작성 |
| **Tone** | Analytical & Personal (비판적이되 공정, 겸손하되 자신감 있음) |
| **Target** | 디지털 마케팅/기술 실무자 (1순위), 기술-사회 관계에 관심 있는 지식인 (2순위), 비판적 사고를 중시하는 일반 독자 (3순위) |
**브랜드 경계**: `OurDigital`이 블로그 전체 정체성이다. `OurDigital Clinic`은 진단형 콘텐츠·컨설팅 상품에서만 사용하는 서비스 메타포다.
**우선순위**: 지침 충돌 시 Blog_Project_Instruction_v3.3 → 이 스타일 가이드 → 사용자 일반 스타일 순으로 따른다.
## Writing Characteristics ## Writing Characteristics
### 1. 철학-기술 융합체 모든 초안은 아래 네 원칙을 기준으로 자기 점검한다.
기술 분석과 실존적 질문을 자연스럽게 결합한다. ### 1. 철학-기술 융합
**Good Example:** 기술 주제를 다루되, 항상 이면의 인간적 함의를 탐구한다.
> AI가 우리의 업무를 대체할 수 있다는 사실은 분명하다. 그러나 더 중요한 질문은 "AI가 대체할 수 없는 것은 무엇인가?"이다.
**Bad Example:**
> AI는 업무 효율성을 높여준다. 다양한 분야에서 활용되고 있다.
### 2. 역설(Paradox) 활용
논증을 긴장과 모순 구조로 전개한다.
**Paradox Patterns:**
- "~하면서 동시에 ~하다"
- "~인 것 같지만 실은 ~이다"
- "~를 얻었지만 ~를 잃었다"
### 3. 수사적 질문
선언적 권위보다 질문을 통한 참여를 선호한다.
**Good:** **Good:**
> 우리는 정말 데이터를 이해하고 있는 것일까? > 구글은 200개 이상의 신호를 읽는다. 그런데 정작 글을 쓰는 사람은 단 하나의 신호, 독자의 진짜 질문도 제대로 읽지 못할 때가 많다.
**Bad:** **Bad:**
> 데이터를 이해하는 것이 중요하다. > 구글의 알고리즘은 200개 이상의 신호를 분석한다.
### 4. 우울한 낙관주의 ### 2. 긴장과 역설
불안과 상실을 인정하되 절망하지 않는다. 억지 역설을 억지로 넣지 않는다. 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
**Useful Patterns:**
- `~하면서 동시에 ~하다`
- `~를 위해 오히려 ~해야 한다`
- `가장 ~한 것이 사실은 가장 ~하다`
- `우리가 놓치고 있는 것은 무엇인가?`
> 예: 데이터를 가장 잘 활용하는 방법은, 때때로 데이터를 내려놓는 것이다.
### 3. 우울한 낙관주의
디지털 세계의 불안·피로·한계를 솔직히 인정한다. 그러나 냉소로 끝내지 않는다. 그 안에서 다시 생각할 가능성, 작은 회복의 여지를 찾는다.
**Tone Spectrum:** **Tone Spectrum:**
``` ```
비관 ←――――――――――――――――――→ 낙관 비관 ←――――――――――――――――――→ 낙관
우울한 낙관주의 우울한 낙관주의
(여기에 위치) (여기에 위치)
``` ```
> 예: AI가 일자리를 대체할 수 있다는 불안은 근거가 있다. 그리고 그 불안이야말로 '대체 불가능한 것'이 무엇인지 진지하게 물어보게 만드는 시작점이다.
### 4. 분석적이면서 개인적
데이터와 논거를 제시하되, 1인칭 경험과 관찰을 자연스럽게 엮는다. 논문이 아니라 에세이다. 그러나 근거 없는 감상문도 아니다.
> 예: 지난 3년간 50개 이상의 사이트 마이그레이션을 지켜봤다. 숫자로 보면 성공률은 70% 정도다. 하지만 '성공'의 정의가 사이트마다 달랐다는 게 진짜 이야기다.
## 문장 구조 ## 문장 구조
| Element | Pattern | | Element | Pattern |
|---------|---------| |---------|---------|
| 문장 길이 | 긴 복합문 허용 - 상호연결된 개념 반영 | | 문장 길이 | 단문 기본, 짧은 문장과 긴 문장을 교차해 리듬을 만든다 |
| 단락 구조 | 관찰 → 분석 → 철학적 함의 | | 단락 구조 | 관찰 → 분석 → 철학적 함의 |
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조 | | 단락 전환 | 분석 → 서사 → 질문으로 호흡을 바꾼다 |
| 근거 제시 | 역사적 사례 + 기술 명세 + 문화적 참조; 출처 없는 통계는 사용하지 않는다 |
| 결론 | 열린 결말, 답보다 질문 | | 결론 | 열린 결말, 답보다 질문 |
| 문체 | `~다`, `~이다`, `~한다` 평서체; `~합니다`, `~입니다` 경어체 사용 금지 |
## 언어 규칙 ## 언어 규칙
@@ -83,32 +97,61 @@ Detailed writing guidelines for blog.ourdigital.org.
## 포스트 구조 ## 포스트 구조
### 도입부 (10-15%) ### 도입부 (200-300자)
1. **Hook**: 독자의 관심을 끄는 질문/통계/역설 - 장면·질문·역설·데이터 중 하나로 시작한다.
2. **Context**: 주제의 배경 설명 - 핵심 질문을 제시하고, 독자가 왜 이 글을 읽어야 하는지 암시한다.
3. **Preview**: 글에서 다룰 내용 암시 - 상투적 표현 금지: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
### 본론 (70-80%) ### 본론 (3-5개 섹션, 각 300-500자)
3-5개의 핵심 포인트, 각각: - 소제목(`##`, `###`)을 사용한다.
1. **주장**: 명확한 포인트 제시 - 분석·서사·데이터·개인 관찰을 교차시킨다.
2. **근거**: 데이터, 사례, 전문가 의견 - 최소 1회 관점 전환 또는 핵심 긴장을 포함한다.
3. **함의**: 이것이 의미하는 바 - 전문용어는 첫 등장 시 영문을 병기한다. 예: `검색엔진 최적화(SEO)`
### 결론 (10-15%) ### 결론 (200-300자)
1. **Summary**: 핵심 내용 요약 - 인사이트를 정리하되 단정하지 않는다.
2. **Reflection**: 더 넓은 맥락에서의 의미 - 열린 질문 또는 다음 사유의 출발점으로 마무리한다.
3. **Open Question**: 독자가 생각할 질문 - 독자가 이어서 생각할 여운을 남긴다.
### CTA (선택, 1-2문장)
관련 글, 뉴스레터, 댓글, 실무 문의 등 필요한 경우만 사용한다.
### 길이 기준
| Type | 길이 | 용도 |
|------|-----:|------|
| Short-form | 1,000-1,500자 | 단일 인사이트, 짧은 단상 |
| Standard | 2,000-2,500자 | 기본 블로그 포스트 |
| Long-form | 2,500-3,000자 | 심층 분석, 리서치 기반 글 |
| Research Essay | 3,000자 이상 | 사용자 명시 요청 시만 |
기본값: 별도 지정이 없으면 **2,000자 ±300자**.
## SEO Guidelines ## SEO Guidelines
Ghost CMS 메타데이터는 아래 형식으로 완성한다.
```yaml
title: "글 제목"
slug: "url-friendly-english-slug"
meta_title: "SEO 타이틀, 60자 이내"
meta_description: "SEO 디스크립션, 155자 이내"
tags:
- primary: "메인 카테고리"
- secondary: ["태그1", "태그2"]
featured: false
excerpt: "카드/프리뷰용 발췌문, 2-3문장"
```
### Title (제목) ### Title (제목)
- 60자 이내 - **60자 이내**, 핵심 키워드 자연스럽게 포함
- 핵심 키워드 포함 - 호기심·긴장·관점 전환을 만든다 (40자 내외 기준)
- 호기심 유발 또는 가치 제안 - 키워드 밀도만 의식한 SEO 과잉 최적화 금지
**Patterns:** **Patterns:**
- "[주제]의 역설: ~하면서 ~하는 시대" - "[주제]의 역설: ~하면서 ~하는 시대"
@@ -117,15 +160,13 @@ Detailed writing guidelines for blog.ourdigital.org.
### Meta Description ### Meta Description
- 155자 이내 - **155자 이내**, 글의 핵심 질문과 독자 효용을 함께 담는다
- 글의 핵심 가치 요약 - 클릭을 유도하되 과장하지 않는다
- 클릭 유도 문구
### URL Slug ### URL Slug
- 영문 소문자 - **영문** 소문자, 하이픈으로 구분, 3-5 단어
- 하이픈으로 구분 - 한국어 제목이라도 slug는 영문으로 작성한다
- 3-5 단어
## Content Calendar ## Content Calendar
@@ -138,12 +179,34 @@ Detailed writing guidelines for blog.ourdigital.org.
## Quality Checklist ## Quality Checklist
Before publishing: ### Must Have
- [ ] 제목이 60자 이내인가? - [ ] 핵심 긴장·역설·관점 전환·열린 질문 중 하나 이상이 있다.
- [ ] 메타 설명이 155자 이내인가? - [ ] 도입부 300자 이내에 hook과 핵심 질문이 있다.
- [ ] 전문용어 영문 병기되었는가? - [ ] 전문용어 첫 등장 시 영문 병기했다.
- [ ] 수사적 질문이 포함되었는가? - [ ] 기술 내용에 인간적 함의가 있다.
- [ ] 기술 내용에 인간적 함의가 있는가? - [ ] 독자를 가르치려 하지 않고 함께 생각하는 태도를 유지했다.
- [ ] 결론이 열린 질문으로 끝나는가? - [ ] 결론이 열린 질문 또는 여운으로 마무리된다.
- [ ] 1,500-3,000자 범위인가? - [ ] SEO 메타데이터(title ≤60자, meta ≤155자, 영문 slug)가 완성되었다.
- [ ] 기본 길이 2,000자 ±300자를 준수했다 (또는 이탈 이유가 있다).
### Must Avoid
- [ ] 상투적 도입: `오늘날 디지털 시대에`, `급변하는 환경 속에서`, `요즘 ~가 화제다`
- [ ] 경어체: `~합니다`, `~입니다`
- [ ] 교과서적 나열: `첫째`, `둘째`, `셋째`의 기계적 반복
- [ ] 확정적 결론: `따라서 반드시 ~해야 한다`
- [ ] 출처 없는 통계나 데이터
- [ ] 기술에 대한 무조건적 찬양 또는 혐오
- [ ] 과도한 감탄부호, 물결표, 이모지
- [ ] 특정 업체·제품의 노골적 홍보성 문장
### 자기 편집 (Self-Edit) 의무
초안 완성 후 아래 형식으로 가장 약한 항목 1개를 반드시 지목한다. "모두 통과"는 허용하지 않는다.
```
[자기 편집] 가장 약한 부분: [항목명]
이유: [1문장]
개선 방향: [1문장]
```

View File

@@ -1,5 +1,5 @@
--- ---
name: 03-ourdigital-journal name: ourdigital-journal
description: | description: |
English essay and article creation for journal.ourdigital.org. English essay and article creation for journal.ourdigital.org.
Activated with "ourdigital" keyword for English writing tasks. Activated with "ourdigital" keyword for English writing tasks.

View File

@@ -1,7 +1,42 @@
<!-- Aligned to OurDigital_Blog_Project_Instruction_v3.3 + Writing_Style_Guide_v2.1 (2026-06-05); journal = English channel, voice preserved -->
# OurDigital Journal Style Guide # OurDigital Journal Style Guide
Writing guidelines for journal.ourdigital.org - English essays and articles. Writing guidelines for journal.ourdigital.org - English essays and articles.
## Brand Identity
OurDigital is a personal digital research notebook that **observes and records how technology shapes people and culture** ("사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트"). It moves between three registers:
- **Observation** — what is happening
- **Analysis** — why it is happening
- **Reflection** — what it means for us
`journal.ourdigital.org` is the **English essay channel** within the OurDigital family. It carries the same philosophical core as the Korean blog but in a distinct voice: conversational, poetic, reflective.
### Brand Boundary
`OurDigital` is the brand. `OurDigital Clinic` is a service metaphor reserved for diagnostic content, audits, or consulting products — it is **not** the overall blog or journal brand.
## Channel Context
| Channel | Language | Character | Length |
|---------|----------|-----------|--------|
| `blog.ourdigital.org` | Korean | 디지털 문화 분석 + 철학적 성찰 + 실무 인사이트 | 1,5003,000자 |
| **`journal.ourdigital.org`** | **English** | **Industry trends, techhuman intersection, reflective essay** | **1,0002,000 words** |
| `ourstory.day` | Korean | 개인 에세이, 삶의 성찰, 일상의 관찰 | 8001,500자 |
| `Medium` | English | Technology, marketing, AI for broad audiences | 8001,500 words |
## Instruction Authority Order
When instructions conflict, follow this order:
| Priority | Source |
|----------|--------|
| **1** | OurDigital_Blog_Project_Instruction_v3.3 (channel routing + brand rules) |
| **2** | This style guide (journal-specific voice and structure) |
| **3** | Writing_Style_Guide_v2.1 (shared brand principles, adapted for English) |
## Channel Identity ## Channel Identity
| Field | Value | | Field | Value |
@@ -10,6 +45,8 @@ Writing guidelines for journal.ourdigital.org - English essays and articles.
| **Language** | English | | **Language** | English |
| **Tone** | Conversational & Poetic, Reflective | | **Tone** | Conversational & Poetic, Reflective |
| **Target** | Informed generalists with intellectual curiosity | | **Target** | Informed generalists with intellectual curiosity |
| **Default length** | 1,0002,000 words |
| **Content focus** | Industry trends, techhuman intersection, reflective essays |
## Voice Characteristics ## Voice Characteristics
@@ -20,11 +57,16 @@ Seamlessly blend technical analysis with existential questioning. Technology is
**Example:** **Example:**
> The dashboard promises clarity—every metric tracked, every trend visualized. Yet as I stared at the perfectly organized data, I wondered: does seeing everything mean understanding anything? > The dashboard promises clarity—every metric tracked, every trend visualized. Yet as I stared at the perfectly organized data, I wondered: does seeing everything mean understanding anything?
### Paradox as Primary Device ### Tension and Paradox
Structure arguments around tensions and contradictions that illuminate rather than confuse. Structure arguments around core tensions that illuminate rather than confuse. Not every essay needs a paradox — forced ones feel mechanical. Instead, ensure at least one of the following is naturally present:
**Paradox Patterns:** - A genuine tension or contradiction
- A perspective shift that reframes the subject
- A rhetorical question that opens rather than closes
- An ending that leaves productive uncertainty
**Tension patterns:**
- "The more we measure, the less we understand" - "The more we measure, the less we understand"
- "In optimizing for efficiency, we optimize away meaning" - "In optimizing for efficiency, we optimize away meaning"
- "The tools that connect us also isolate us" - "The tools that connect us also isolate us"
@@ -39,6 +81,10 @@ Favor interrogative engagement. Questions create intellectual partnership with r
**Avoid:** **Avoid:**
> Data-driven decision-making is important for businesses. > Data-driven decision-making is important for businesses.
### Analytical and Personal
Bring data, evidence, and argument — then weave in first-person experience and observation. This is an essay, not a paper, but it is not impressionism without evidence either. The personal grounds the analytical; the analytical elevates the personal.
### Melancholic Optimism ### Melancholic Optimism
Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced. Acknowledge loss and anxiety without despair. Accept technological inevitability while mourning what's displaced.
@@ -157,10 +203,14 @@ Connect Korean and Western perspectives, offering unique viewpoints.
Before publishing: Before publishing:
- [ ] Does the opening draw readers in? - [ ] Does the opening draw readers in within the first paragraph?
- [ ] Are there rhetorical questions? - [ ] Does technical content connect to human experience (philosophy-tech fusion)?
- [ ] Does technical content connect to human experience? - [ ] Is at least one of the following naturally present: tension, paradox, perspective shift, or open question?
- [ ] Is there at least one paradox or tension? - [ ] Are rhetorical questions used to create intellectual partnership (not overused)?
- [ ] Does the closing leave an open question? - [ ] Is analysis grounded in personal observation or experience?
- [ ] Is the tone melancholic but not despairing? - [ ] Does the closing leave an open question or productive uncertainty?
- [ ] Is the tone melancholic but not despairing, hopeful but not naive?
- [ ] Are sentences varied in length and rhythm? - [ ] Are sentences varied in length and rhythm?
- [ ] Does the essay avoid lecturing — does it treat readers as fellow thinkers?
- [ ] Is the essay within 1,0002,000 words?
- [ ] **Self-edit**: identify the single weakest element ("all pass" is not allowed).

View File

@@ -1,5 +1,5 @@
--- ---
name: 04-ourdigital-research name: ourdigital-research
description: | description: |
Deep research and structured prompt generation for OurDigital workflows. Deep research and structured prompt generation for OurDigital workflows.
Activated with "ourdigital" keyword for research tasks. Activated with "ourdigital" keyword for research tasks.

View File

@@ -1,38 +1,64 @@
<!-- Aligned to OurDigital_Writing_Style_Guide_v2.1 + Blog_Project_Instruction_v3.3 (2026-06-05) -->
# OurDigital Blog Style Guide # OurDigital Blog Style Guide
## Brand Identity & Authority Order
**Brand**: OurDigital — 사람, 디지털 그리고 문화를 관찰하는 개인 디지털 연구 노트.
**OurDigital vs. OurDigital Clinic**: `OurDigital`은 블로그 전체 정체성. `OurDigital Clinic`은 진단형 콘텐츠, SEO/데이터 감사, 컨설팅 상품에서만 사용하는 서비스 메타포. 블로그 본문에서 전체 브랜드명으로 사용하지 않는다.
**Authority order** (conflicts: higher wins):
1. `OurDigital_Blog_Project_Instruction_v3.3` — blog.ourdigital.org 최종 권위
2. Skills Bundle `SKILL.md` files — workflow detail
3. Project reference files (this guide, Visual Style Guide, etc.)
4. `userStyle` — non-blog conversation only
---
## Channel-Specific Voice & Tone ## Channel-Specific Voice & Tone
### blog.ourdigital.org (Korean) ### blog.ourdigital.org (Korean)
**Voice**: 전문적이면서 친근한 선배 마케터
**Tone**: 실용적, 데이터 기반, 인사이트 중심 **Platform**: Ghost CMS
**Voice**: 분석적이면서 개인적인 관찰자 — 호기심 어린 실무자이자 사유자. 가르치는 선배가 아니라 함께 생각하는 동료.
**Tone**: 차분하고 성찰적; 에세이는 사려 깊게, 분석은 객관적으로, 비평은 날카롭되 공정하게.
Writing patterns: Writing patterns:
- 제목: 핵심 키워드 포함, 30자 이내 - 제목: SEO 키워드 자연스럽게 포함, **40자 내외 기준 (≤60자)**
- 도입부: 독자의 고민/질문으로 시작 - 도입부: 개인 관찰·장면·질문·역설·데이터 중 하나로 시작; 독자 고민 직접 나열 지양
- 본문: 번호 매기기보다 소제목 활용 - 본문: 소제목 활용; 분석-서사-질문 교차; 최소 1회 관점 전환 또는 핵심 긴장 포함
- 전문용어: 한글(영문) 형식 - 예: 검색엔진최적화(SEO) - 전문용어: **첫 등장 시 영문 병기** 예: `검색엔진 최적화(SEO)`
- 문장: ~입니다/~니다 경어체 - 문장: **`~다`, `~이다`, `~한다` 평서체** (경어체 `~합니다/~니다` 사용 금지)
- 단락: 3-4문장, 모바일 가독성 고려 - 단락: 3-4문장, 모바일 가독성 고려; 문장 길이에 변화를 줌
- 마무리: 열린 질문 또는 다음 사유의 출발점; 단정적 결론 지양
Example opening: Writing principles (Writing Style Guide v2.1 §3-4):
- **철학-기술 융합**: 기술 주제 이면의 인간적 함의를 탐구한다.
- **긴장과 역설**: 억지로 넣지 않되, 핵심 긴장·관점 전환·역설·열린 질문 중 하나 이상을 자연스럽게 포함한다.
- **분석적이면서 개인적**: 데이터/논거를 제시하되 1인칭 경험·관찰을 자연스럽게 엮는다. 논문이 아니라 에세이.
Example opening (올바른 톤):
``` ```
"구글 상위 노출, 왜 이렇게 어려울까요? 검색엔진 최적화(SEO)를 가장 잘하는 방법이 뭐냐고 물으면,
많은 마케터들이 SEO에 시간을 투자하지만 나는 종종 엉뚱한 대답을 한다. "SEO를 잊어버리세요."
결과가 보이지 않아 좌절합니다.
오늘은 실제로 효과를 본 전략 3가지를 공유합니다." 알고리즘을 쫓는 사람은 항상 알고리즘에 뒤처진다는 역설.
구글이 원하는 건 구글을 위한 콘텐츠가 아니라, 사람을 위한 콘텐츠다.
``` ```
### journal.ourdigital.org (English) ### journal.ourdigital.org (English)
**Voice**: Thoughtful industry analyst
**Tone**: Insightful, evidence-based, forward-looking **Voice**: Thoughtful industry analyst — reflective, personal, essayistic
**Tone**: Insightful, evidence-based, forward-looking; confident but not arrogant
Writing patterns: Writing patterns:
- Headlines: Clear value proposition, under 60 chars - Headlines: Clear value proposition, under 60 chars
- Opening: Hook with industry trend or data point - Opening: Hook with personal observation, industry trend, or data point
- Body: Structured arguments with supporting evidence - Body: Structured arguments with supporting evidence; analysis and personal observation interwoven
- Terminology: Define jargon on first use - Terminology: Define jargon on first use
- Style: Active voice, varied sentence length - Style: Active voice, varied sentence length; short sentences as default
- Paragraphs: 2-4 sentences for scannability - Paragraphs: 2-4 sentences for scannability
- Closing: Open question or reflection — avoid definitive wrap-ups
Example opening: Example opening:
``` ```
@@ -43,17 +69,19 @@ and what it means for your strategy."
``` ```
### ourstory.day (Korean) ### ourstory.day (Korean)
**Voice**: 성찰하는 동료, 이야기꾼
**Voice**: 성찰하는 동료, 이야기꾼 — 개인 에세이, 삶의 성찰, 일상의 관찰
**Tone**: 개인적, 진솔한, 영감을 주는 **Tone**: 개인적, 진솔한, 영감을 주는
Writing patterns: Writing patterns:
- 제목: 감성적, 질문형 또는 은유적 - 제목: 감성적, 질문형 또는 은유적
- 도입부: 개인 경험이나 장면 묘사로 시작 - 도입부: 개인 경험이나 장면 묘사로 시작
- 본문: 이야기 흐름, 대화체 허용 - 본문: 이야기 흐름, 대화체 허용
- 문장: ~해요/~네요 부드러운 경어체 가능
- 단락: 자유로운 길이, 호흡에 따라 - 단락: 자유로운 길이, 호흡에 따라
- 마무리: 열린 질문 또는 여운 - 마무리: 열린 질문 또는 여운
> **Channel boundary**: 순수 개인 에세이, 감정적 성찰은 `ourstory.day`. 기술 분석+철학적 사유는 `blog.ourdigital.org`. 둘을 혼동하지 않는다.
Example opening: Example opening:
``` ```
"새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다. "새벽 5시, 아이를 깨우지 않으려 살금살금 책상에 앉았다.
@@ -63,6 +91,7 @@ Example opening:
``` ```
### Medium (English) ### Medium (English)
**Voice**: Knowledgeable peer sharing discoveries **Voice**: Knowledgeable peer sharing discoveries
**Tone**: Conversational, practical, slightly informal **Tone**: Conversational, practical, slightly informal
@@ -82,33 +111,40 @@ Last month, I ran an experiment that changed how I think
about content strategy entirely. Let me walk you through it." about content strategy entirely. Let me walk you through it."
``` ```
---
## Universal Guidelines ## Universal Guidelines
### SEO Considerations ### SEO & Metadata
- Primary keyword in title and first 100 words - Primary keyword in title and first 100 words
- Secondary keywords naturally distributed - Secondary keywords naturally distributed
- Meta description: 150-160 chars, action-oriented - **Meta description: 155 chars**, action-oriented, captures the article's question and reader value
- URL slug: Short, keyword-rich, no dates - **URL slug: English, keyword-rich, no dates** — even for Korean-title posts
- Alt text for all images - Alt text for all images
### Formatting Rules ### Formatting Rules
- Use `##` for main sections, `###` for subsections - Use `##` for main sections, `###` for subsections
- Code blocks with language specification - Code blocks with language specification
- Blockquotes for key insights or quotes - Blockquotes for key insights or quotes
- Bold for emphasis (sparingly) - Bold for emphasis (sparingly)
- Lists only when truly listing items - Lists only when truly listing items; avoid in essay-form posts
### Citation Style ### Citation Style
- Inline links preferred over footnotes - Inline links preferred over footnotes
- Source attribution: "According to [Source Name](URL)..." - Source attribution: "According to [Source Name](URL)..."
- Data citations: Include date of data - Data citations: Include date of data
- Internal links: Reference related OurDigital posts - Internal links: Reference related OurDigital posts
---
## Word Count Guidelines ## Word Count Guidelines
| Channel | Target | Min | Max | | Channel | Target | Min | Max |
|---------|--------|-----|-----| |---------|--------|-----|-----|
| blog.ourdigital.org | 1,500 | 1,000 | 2,500 | | blog.ourdigital.org | 2,000 | 1,000 | 3,000 |
| journal.ourdigital.org | 1,800 | 1,200 | 3,000 | | journal.ourdigital.org | 1,500 words | 1,000 words | 2,000 words |
| ourstory.day | 1,000 | 500 | 2,000 | | ourstory.day | 1,000 | 800 | 1,500 |
| Medium | 1,500 | 800 | 2,500 | | Medium | 1,500 words | 800 words | 2,500 words |

View File

@@ -1,5 +1,5 @@
--- ---
name: 05-ourdigital-document name: ourdigital-document
description: | description: |
Notion-to-presentation workflow for OurDigital. Notion-to-presentation workflow for OurDigital.
Activated with "ourdigital" keyword for document creation. Activated with "ourdigital" keyword for document creation.

View File

@@ -1,5 +1,5 @@
--- ---
name: 06-ourdigital-designer name: ourdigital-designer
description: | description: |
Visual storytelling and image prompt generation for OurDigital. Visual storytelling and image prompt generation for OurDigital.
Activated with "ourdigital" keyword for design tasks. Activated with "ourdigital" keyword for design tasks.

View File

@@ -1,101 +1,104 @@
# Visual Metaphor Dictionary # Visual Metaphor Dictionary
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
Quick reference for translating abstract concepts into visual elements. Quick reference for translating abstract concepts into visual elements.
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
## Technology & Digital ## Technology & Digital
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Algorithm | Constellation patterns | Maze structures, flow charts as art | | AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
| AI | Crystalline growth | Mirror reflections, fractal patterns | | Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
| Data | Water flow, particles | Bird murmurations, sand grains | | Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
| Network | Root systems | Neural pathways, spider silk, web | | SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
| Code | Musical notation | DNA strands, city blueprints | | Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
| Cloud | Atmospheric forms | Floating islands, ethereal spaces | | Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
| Privacy | Veils, shadows | One-way mirrors, fog, barriers | | Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
| Security | Locks dissolving | Fortresses becoming permeable | | Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
| Automation | Clockwork organic | Self-assembling structures | | Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
| Virtual | Layers of reality | Parallel dimensions, glass planes |
## Social & Cultural ## Social & Cultural
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Identity | Layered masks | Fingerprints merging, mirrors | | Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
| Community | Overlapping circles | Shared spaces, woven threads | | Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
| Isolation | Islands in fog | Glass barriers, empty chairs | | Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
| Communication | Bridge structures | Echo patterns, light beams | | Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
| Conflict | Opposing forces | Tectonic plates, storm systems | | Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
| Harmony | Resonance patterns | Orchestra arrangements, balance | | Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
| Culture | Textile patterns | Layered sediments, palimpsest | | Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
| Tradition | Tree rings | Ancient stones, inherited objects |
| Change | Metamorphosis | Phase transitions, seasonal cycles |
| Power | Pyramids inverting | Current flows, gravity wells |
## Philosophical & Abstract ## Philosophical & Abstract
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Time | Spirals, loops | Sediment layers, clock dissolution | | Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
| Knowledge | Light sources | Growing trees, opening books | | Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
| Wisdom | Mountain vistas | Deep waters, ancient libraries | | Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
| Truth | Clear water | Prisms splitting light, unveiled | | Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
| Illusion | Distorted mirrors | Smoke shapes, double images | | Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
| Choice | Diverging paths | Doors opening, quantum splits | | Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
| Balance | Tensegrity | Scales reimagined, equilibrium | | Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
| Paradox | Möbius strips | Impossible objects, Escher-like |
| Existence | Breath patterns | Pulse rhythms, presence/absence |
| Consciousness | Nested awareness | Recursive mirrors, awakening |
## Emotional States ## Emotional States
| Emotion | Visual Translation | Color Association | | Emotion | Visual Translation | Color Guidance |
|---------|-------------------|------------------| |---------|-------------------|----------------|
| Anxiety | Fragmented grids | Desaturated, glitch | | Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
| Hope | Light breaking through | Warm gradients | | Hope | Light breaking through window, seedling | Warm gradients on light background |
| Melancholy | Soft dissolution | Muted blues, grays | | Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
| Joy | Expansion patterns | Bright, ascending | | Joy | Expansion patterns, open space | Bright, ascending on warm white |
| Fear | Contracting spaces | Sharp contrasts | | Peace | Still water, open notebook | Soft neutrals, generous negative space |
| Peace | Still water | Soft neutrals | | Clarity | Clean geometry, clear window | Pure, minimal with one accent |
| Confusion | Tangled lines | Overlapping hues |
| Clarity | Clean geometry | Pure, minimal |
## Transformation & Process ## Signature Motifs (Brand Consistency)
| Process | Visual Narrative | Symbolic Elements | | Motif | Description |
|---------|------------------|------------------| |-------|-------------|
| Growth | Seeds → trees | Fibonacci spirals | | **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
| Decay | Entropy patterns | Rust, dissolution | | **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
| Evolution | Branching forms | Darwin's tree reimagined | | **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
| Revolution | Circles breaking | Shattered patterns | | **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
| Innovation | Spark → flame | Lightning, fusion | | **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
| Tradition | Continuous thread | Inherited patterns |
| Disruption | Broken grids | Glitch aesthetics |
| Integration | Merging streams | Confluence points |
## Korean-Western Fusion Elements ## Recommended Color Palettes
| Korean Element | Western Parallel | Fusion Approach | | Palette | Background | Accent | Mood |
|---------------|-----------------|-----------------| |---------|-----------|--------|------|
| 여백 (Empty space) | Negative space | Active emptiness | | Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
| 오방색 (Five colors) | Color theory | Symbolic palette | | Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
| 달항아리 (Moon jar) | Minimalism | Imperfect circles | | Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
| 한글 geometry | Typography | Structural letters | | Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth | | Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
**Dark color limit:** max 15% of canvas; never full dark background by default.
## Korean Design Elements
| Korean Element | Western Parallel | Application |
|----------------|-----------------|-------------|
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
| 한지 texture | Paper grain | Subtle background texture only |
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
## Usage Notes ## Usage Notes
1. **Layer metaphors**: Combine 2-3 for depth 1. **Layer metaphors**: Combine 23 elements; one clear focal object + one human-scale anchor
2. **Avoid clichés**: No obvious tech symbols 2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
3. **Cultural sensitivity**: Universal over specific 3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
4. **Abstraction levels**: Match essay tone 4. **30%+ negative space**: default; minimum 20% only when composition needs density
5. **Emotional resonance**: Feel over literal 5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
## Quick Selection Guide ## Quick Selection Guide
For **technology essays**: organic-digital hybrids For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
For **social commentary**: human elements in systems For **social commentary**: network patterns with small human traces
For **philosophy pieces**: space and light For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
For **cultural topics**: layered traditions For **practical guides**: clean editorial diagrams with warm accents
For **future themes**: transformation states For **personal reflections**: soft everyday scenes with metaphorical detail

View File

@@ -1,101 +1,104 @@
# Visual Metaphor Dictionary # Visual Metaphor Dictionary
<!-- Aligned to OurDigital_Visual_Style_Guide_v2.1 (2026-06-05) -->
Quick reference for translating abstract concepts into visual elements. Quick reference for translating abstract concepts into visual elements.
Default direction: **bright editorial minimalism with conceptual depth** — light/warm-neutral backgrounds, 30%+ negative space, one clear focal metaphor, one human-scale anchor.
## Technology & Digital ## Technology & Digital
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Algorithm | Constellation patterns | Maze structures, flow charts as art | | AI | Co-writing desk, translucent companion shape, mirror | Soft geometric overlay, window reflection | Robot face, glowing brain, Terminator mood, crystalline growth |
| AI | Crystalline growth | Mirror reflections, fractal patterns | | Algorithm | Path map, constellation over paper, sorting trays | Gentle maze, flow chart as art | Black-box cube, surveillance grid |
| Data | Water flow, particles | Bird murmurations, sand grains | | Data | Flowing dots, paper charts, seed-like particles | Water stream, gentle scatter | Neon matrix, endless binary code |
| Network | Root systems | Neural pathways, spider silk, web | | SEO/Search | Compass, map, signpost, light through shelves | Library index, folded path | Magnifying glass cliché alone |
| Code | Musical notation | DNA strands, city blueprints | | Automation | Clockwork garden, conveyor of paper, small helpful mechanism | Self-assembling organic structure | Industrial robot arm, factory dystopia |
| Cloud | Atmospheric forms | Floating islands, ethereal spaces | | Network | Roots, threads, bridges, neurons, community table | Constellation, river delta | Spider web trap, dark cables |
| Privacy | Veils, shadows | One-way mirrors, fog, barriers | | Privacy | Curtain, frosted glass, closed notebook, soft boundary | One-way window | Lock icon alone, heavy shadow |
| Security | Locks dissolving | Fortresses becoming permeable | | Content | Notebook, seeds, layered paper, archive boxes, small lamp | Open shelves | Generic document icons |
| Automation | Clockwork organic | Self-assembling structures | | Code | Musical notation, blueprint detail | DNA strands | Heavy terminal/green-code imagery |
| Virtual | Layers of reality | Parallel dimensions, glass planes |
## Social & Cultural ## Social & Cultural
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Identity | Layered masks | Fingerprints merging, mirrors | | Identity | Layered paper portrait, reflection in window, fingerprint as landscape | Translucent profile | Faceless mask overload |
| Community | Overlapping circles | Shared spaces, woven threads | | Community | Shared table, overlapping circles, lighted windows | Small bridges, woven threads | Crowd silhouettes in darkness |
| Isolation | Islands in fog | Glass barriers, empty chairs | | Isolation | Single lit desk, island of paper, window distance | Empty chair in warm light | Lonely person in black void |
| Communication | Bridge structures | Echo patterns, light beams | | Communication | Threads, folded letters, bridge, echo rings | Light beams | Speech bubble clutter |
| Conflict | Opposing forces | Tectonic plates, storm systems | | Trust | Clear water, open notebook, steady lamp | Transparent materials | Handshake stock image |
| Harmony | Resonance patterns | Orchestra arrangements, balance | | Reputation | Ripples, layered traces, visible footprints | Soft badges | Star-rating cliché |
| Culture | Textile patterns | Layered sediments, palimpsest | | Change | Folded paper becoming path, seed from circuit, gentle transition | Phase shift, seasonal cycle | Dramatic explosion, shattered pattern |
| Tradition | Tree rings | Ancient stones, inherited objects |
| Change | Metamorphosis | Phase transitions, seasonal cycles |
| Power | Pyramids inverting | Current flows, gravity wells |
## Philosophical & Abstract ## Philosophical & Abstract
| Concept | Primary Metaphor | Alternative Visuals | | Concept | Preferred Metaphor | Alternatives | Avoid |
|---------|-----------------|-------------------| |---------|-------------------|--------------|-------|
| Time | Spirals, loops | Sediment layers, clock dissolution | | Time | Calendar pages, soft spiral, sediment-like paper layers | Loops, tide marks | Melting clock cliché |
| Knowledge | Light sources | Growing trees, opening books | | Knowledge | Lamp, window light, open book, growing plant | Expanding shelves | Glowing brain |
| Wisdom | Mountain vistas | Deep waters, ancient libraries | | Uncertainty | Foggy path, half-open door, incomplete map | Soft blur at edges | Storm clouds only |
| Truth | Clear water | Prisms splitting light, unveiled | | Balance | Asymmetrical stones, mobile, table edge, quiet scale | Tensegrity | Literal scale icon only |
| Illusion | Distorted mirrors | Smoke shapes, double images | | Paradox | Möbius paper strip, two paths meeting, shadow/light on same object | Nested frames | Escher-like complexity overload |
| Choice | Diverging paths | Doors opening, quantum splits | | Wisdom | Window overlooking depth, layered book spines | Ancient stone in light | Heavy mystical imagery |
| Balance | Tensegrity | Scales reimagined, equilibrium | | Choice | Diverging paths, open doors, fork in paper map | Branching thread | Dramatic split/explosion |
| Paradox | Möbius strips | Impossible objects, Escher-like |
| Existence | Breath patterns | Pulse rhythms, presence/absence |
| Consciousness | Nested awareness | Recursive mirrors, awakening |
## Emotional States ## Emotional States
| Emotion | Visual Translation | Color Association | | Emotion | Visual Translation | Color Guidance |
|---------|-------------------|------------------| |---------|-------------------|----------------|
| Anxiety | Fragmented grids | Desaturated, glitch | | Anxiety | Fragmented grids, incomplete maps | Desaturated warm neutrals; avoid pure glitch |
| Hope | Light breaking through | Warm gradients | | Hope | Light breaking through window, seedling | Warm gradients on light background |
| Melancholy | Soft dissolution | Muted blues, grays | | Melancholy | Single lit desk, soft dissolution | Muted blues on ivory; avoid black void |
| Joy | Expansion patterns | Bright, ascending | | Joy | Expansion patterns, open space | Bright, ascending on warm white |
| Fear | Contracting spaces | Sharp contrasts | | Peace | Still water, open notebook | Soft neutrals, generous negative space |
| Peace | Still water | Soft neutrals | | Clarity | Clean geometry, clear window | Pure, minimal with one accent |
| Confusion | Tangled lines | Overlapping hues |
| Clarity | Clean geometry | Pure, minimal |
## Transformation & Process ## Signature Motifs (Brand Consistency)
| Process | Visual Narrative | Symbolic Elements | | Motif | Description |
|---------|------------------|------------------| |-------|-------------|
| Growth | Seeds → trees | Fibonacci spirals | | **Threshold Spaces** | Doorways, bridges, windows, paths, liminal rooms — transition |
| Decay | Entropy patterns | Rust, dissolution | | **Network Organic** | Roots, threads, neurons, constellations as soft digital networks |
| Evolution | Branching forms | Darwin's tree reimagined | | **Fragment Philosophy** | Folded paper, layered cards, gentle fragmentation and reassembly |
| Revolution | Circles breaking | Shattered patterns | | **Light Studies** | Knowledge/uncertainty via window light, lamps, soft gradients |
| Innovation | Spark → flame | Lightning, fusion | | **Human Traces** | Hands, desks, notebooks, chairs, small figures within conceptual scenes |
| Tradition | Continuous thread | Inherited patterns |
| Disruption | Broken grids | Glitch aesthetics |
| Integration | Merging streams | Confluence points |
## Korean-Western Fusion Elements ## Recommended Color Palettes
| Korean Element | Western Parallel | Fusion Approach | | Palette | Background | Accent | Mood |
|---------------|-----------------|-----------------| |---------|-----------|--------|------|
| 여백 (Empty space) | Negative space | Active emptiness | | Morning Desk | `#F7F3EA` warm ivory | `#4A90A4` muted teal | calm, thoughtful |
| 오방색 (Five colors) | Color theory | Symbolic palette | | Soft Technology | `#F6F8FA` cloud white | `#F2A65A` warm amber | clear, quietly optimistic |
| 달항아리 (Moon jar) | Minimalism | Imperfect circles | | Warm Data | `#FFF8EF` soft cream | `#5EAAA8` soft turquoise | human, analytical |
| 한글 geometry | Typography | Structural letters | | Clear Critique | `#F4F6F8` light gray | `#E07A5F` soft coral | critical but not aggressive |
| 산수화 (Landscape) | Abstract landscape | Atmospheric depth | | Human Network | `#FAF9F6` off-white | `#8AB17D` muted green | organic, connected |
| 전통문양 (Patterns) | Geometric design | Cultural geometry |
**Dark color limit:** max 15% of canvas; never full dark background by default.
## Korean Design Elements
| Korean Element | Western Parallel | Application |
|----------------|-----------------|-------------|
| 여백 (Empty space) | Negative space | Active emptiness — 30%+ default |
| 달항아리 (Moon jar) | Minimalism | Roundness, asymmetry, soft white |
| 한지 texture | Paper grain | Subtle background texture only |
| 산수화 (Landscape) | Editorial landscape | Atmospheric depth without ornament |
**Avoid:** decorative oriental motifs, calligraphy clichés, 오방색 as obvious symbols — express Korean sensibility through spacing and restraint, not surface pattern.
## Usage Notes ## Usage Notes
1. **Layer metaphors**: Combine 2-3 for depth 1. **Layer metaphors**: Combine 23 elements; one clear focal object + one human-scale anchor
2. **Avoid clichés**: No obvious tech symbols 2. **Avoid clichés**: No obvious tech icons (robot, glowing brain, magnifying glass alone)
3. **Cultural sensitivity**: Universal over specific 3. **Match essay tone**: bright → practical guides; balanced → analysis; warmer → personal reflections
4. **Abstraction levels**: Match essay tone 4. **30%+ negative space**: default; minimum 20% only when composition needs density
5. **Emotional resonance**: Feel over literal 5. **Human trace required**: include hand, desk, figure, window, or everyday object unless topic demands pure abstraction
## Quick Selection Guide ## Quick Selection Guide
For **technology essays**: organic-digital hybrids For **technology essays**: organic-digital hybrid forms in a light, human-scale environment
For **social commentary**: human elements in systems For **social commentary**: network patterns with small human traces
For **philosophy pieces**: space and light For **philosophy pieces**: calm symbolic scenes with Zen-like spacing
For **cultural topics**: layered traditions For **practical guides**: clean editorial diagrams with warm accents
For **future themes**: transformation states For **personal reflections**: soft everyday scenes with metaphorical detail

View File

@@ -1,5 +1,5 @@
--- ---
name: 07-ourdigital-ad-manager name: ourdigital-ad-manager
description: | description: |
Ad copywriting and keyword research for OurDigital marketing. Ad copywriting and keyword research for OurDigital marketing.
Activated with "ourdigital" keyword for advertising tasks. Activated with "ourdigital" keyword for advertising tasks.

View File

@@ -1,5 +1,5 @@
--- ---
name: 08-ourdigital-trainer name: ourdigital-trainer
description: | description: |
Training material creation and workshop planning for OurDigital. Training material creation and workshop planning for OurDigital.
Activated with "ourdigital" keyword for education tasks. Activated with "ourdigital" keyword for education tasks.

View File

@@ -1,5 +1,5 @@
--- ---
name: 09-ourdigital-backoffice name: ourdigital-backoffice
description: | description: |
Business document creation for OurDigital consulting services. Business document creation for OurDigital consulting services.
Activated with "ourdigital" keyword for business documents. Activated with "ourdigital" keyword for business documents.

View File

@@ -1,5 +1,5 @@
--- ---
name: 10-ourdigital-skill-creator name: ourdigital-skill-creator
description: | description: |
Meta skill for creating and managing OurDigital Claude Skills. Meta skill for creating and managing OurDigital Claude Skills.
Activated when user includes "ourdigital" keyword with skill creation requests. Activated when user includes "ourdigital" keyword with skill creation requests.

View File

@@ -1,5 +1,5 @@
--- ---
name: 11-seo-comprehensive-audit name: seo-comprehensive-audit
description: | 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. 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. Triggers: comprehensive SEO, full SEO audit, 종합 SEO 감사, site audit, SEO health check.

View File

@@ -1,5 +1,5 @@
--- ---
name: 12-seo-technical-audit name: seo-technical-audit
description: | description: |
Technical SEO analyzer for robots.txt, sitemap, and crawlability fundamentals. Technical SEO analyzer for robots.txt, sitemap, and crawlability fundamentals.
Triggers: technical SEO, robots.txt, sitemap validation, crawlability, URL accessibility. Triggers: technical SEO, robots.txt, sitemap validation, crawlability, URL accessibility.

View File

@@ -1,5 +1,5 @@
--- ---
name: 13-seo-on-page-audit name: seo-on-page-audit
description: | description: |
On-page SEO analyzer for meta tags, headings, links, images, and Open Graph. 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. Triggers: on-page SEO, meta tags, title tag, heading structure, alt text.

View File

@@ -1,5 +1,5 @@
--- ---
name: 14-seo-core-web-vitals name: seo-core-web-vitals
description: | description: |
Core Web Vitals analyzer for LCP, FID, CLS, and INP optimization recommendations. Core Web Vitals analyzer for LCP, FID, CLS, and INP optimization recommendations.
Triggers: Core Web Vitals, page speed, LCP optimization, CLS fix, INP analysis. Triggers: Core Web Vitals, page speed, LCP optimization, CLS fix, INP analysis.

View File

@@ -1,5 +1,5 @@
--- ---
name: 15-seo-search-console name: seo-search-console
description: | description: |
Google Search Console data analyzer for performance, queries, and index coverage. Google Search Console data analyzer for performance, queries, and index coverage.
Triggers: Search Console, GSC analysis, search performance, rankings, CTR optimization. Triggers: Search Console, GSC analysis, search performance, rankings, CTR optimization.

View File

@@ -1,5 +1,5 @@
--- ---
name: 16-seo-schema-validator name: seo-schema-validator
description: | description: |
Validates an AUTHORED JSON-LD schema dataset (pre-deployment QA) and audits Validates an AUTHORED JSON-LD schema dataset (pre-deployment QA) and audits
live structured data (post-deployment). Runs a 5-layer offline validation live structured data (post-deployment). Runs a 5-layer offline validation

File diff suppressed because it is too large Load Diff

View File

@@ -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]
}
}

View File

@@ -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
]
}
}

View File

@@ -607,6 +607,16 @@ def _address_street(node):
return "" return ""
def _address_locality(node):
"""City/region key, used to keep distinct same-name locations of a chain apart."""
addr = node.get("address")
if isinstance(addr, list) and addr and isinstance(addr[0], dict):
addr = addr[0]
if isinstance(addr, dict):
return normalize_name(addr.get("addressLocality") or addr.get("addressRegion"))
return ""
def _walk_ids(obj, defined, referenced): def _walk_ids(obj, defined, referenced):
"""Collect @id definitions vs pure references by walking the whole document. """Collect @id definitions vs pure references by walking the whole document.
@@ -645,21 +655,26 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
break # one placeholder defect per node is enough signal break # one placeholder defect per node is enough signal
# ---- NAP consistency (P0) ---- # ---- NAP consistency (P0) ----
# Group by (name, locality): a multi-location chain legitimately shares a name
# across cities (e.g. "더 파크뷰" in Seoul AND Jeju). A real NAP conflict is a
# SINGLE location with contradictory phone/street, so scope the check per city.
by_name = defaultdict(list) by_name = defaultdict(list)
for entry, node in node_index: for entry, node in node_index:
if type_of(node) in NAP_TYPES and node.get("name"): if type_of(node) in NAP_TYPES and node.get("name"):
by_name[normalize_name(first_text(node.get("name")))].append((entry, node)) key = (normalize_name(first_text(node.get("name"))), _address_locality(node))
for name, group in by_name.items(): by_name[key].append((entry, node))
for (name, locality), group in by_name.items():
loc = f" ({locality})" if locality else ""
phones = {str(first_text(n.get("telephone"))).strip() phones = {str(first_text(n.get("telephone"))).strip()
for _, n in group if n.get("telephone")} for _, n in group if n.get("telephone")}
streets = {_address_street(n) for _, n in group if _address_street(n)} streets = {_address_street(n) for _, n in group if _address_street(n)}
if len(phones) > 1: if len(phones) > 1:
defects.add("P0", "L4", "NAP_PHONE_MISMATCH", defects.add("P0", "L4", "NAP_PHONE_MISMATCH",
f"Business '{name}' has conflicting telephone values across " f"Business '{name}'{loc} has conflicting telephone values across "
f"entries: {sorted(phones)}.", entry_id="(dataset)") f"entries: {sorted(phones)}.", entry_id="(dataset)")
if len(streets) > 1: if len(streets) > 1:
defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH", defects.add("P0", "L4", "NAP_ADDRESS_MISMATCH",
f"Business '{name}' has conflicting streetAddress values across " f"Business '{name}'{loc} has conflicting streetAddress values across "
f"entries: {sorted(streets)}.", entry_id="(dataset)") f"entries: {sorted(streets)}.", entry_id="(dataset)")
# ---- @id duplicates + dangling references (P1) ---- # ---- @id duplicates + dangling references (P1) ----
@@ -719,10 +734,147 @@ def layer4_consistency(node_index, parsed_docs, rules, defects):
f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}", entry_id="(dataset)") f"(e.g. {sorted(eids)[:3]}): {desc[:50]!r}", entry_id="(dataset)")
# --------------------------------------------------------------------------- #
# Layer R — Reference-URL integrity (sameAs / external identity links)
# Hardened after the Shilla incident (2026-05-29): LLM-fabricated Wikidata IDs
# (Q-numbers pointing to unrelated entities) and google.com/search reference
# URLs shipped undetected. Offline: forbid search-result URLs (P0) and flag any
# external identity ref as REFERENCE_UNVERIFIED (P1). Online (--verify-refs):
# resolve every ref; for Wikidata, fetch the label and compare to the entity
# name — a mismatch is a FALSE_REFERENCE (P0).
# --------------------------------------------------------------------------- #
def _http_get(url, timeout=12, accept=None):
import urllib.request, urllib.parse
# percent-encode non-ASCII path/query (e.g. ko.wikipedia.org/wiki/호텔신라)
p = urllib.parse.urlsplit(url)
url = urllib.parse.urlunsplit((p.scheme, p.netloc,
urllib.parse.quote(p.path),
urllib.parse.quote(p.query, safe="=&?"),
p.fragment))
req = urllib.request.Request(url, headers={
"User-Agent": "schema-ref-validator/1.0 (+offline-qa)",
**({"Accept": accept} if accept else {})})
return urllib.request.urlopen(req, timeout=timeout)
def _wikidata_labels(qid, timeout=12):
import urllib.request, json as _json
url = f"https://www.wikidata.org/w/api.php?action=wbgetentities&ids={qid}&props=labels&format=json"
with _http_get(url, timeout=timeout, accept="application/json") as r:
data = _json.loads(r.read().decode("utf-8"))
ent = data.get("entities", {}).get(qid, {})
if "missing" in ent:
return None
return {lang: v.get("value", "") for lang, v in ent.get("labels", {}).items()}
def layer_references(node_index, rules, defects, verify_refs=False):
policy = rules.get("reference_policy")
if not policy:
return
forbidden = policy.get("forbidden_url_substrings", [])
ref_props = set(policy.get("identity_ref_props", ["sameAs"]))
import re as _re
qid_re = _re.compile(r"wikidata\.org/(?:wiki|entity)/(Q\d+)")
def every_string(obj):
# Unlike all_strings(), this also yields strings nested inside lists
# (e.g. each URL in a sameAs array) — the exact case missed before.
if isinstance(obj, dict):
for v in obj.values():
yield from every_string(v)
elif isinstance(obj, list):
for v in obj:
yield from every_string(v)
elif isinstance(obj, str):
yield obj
for entry, node in node_index:
eid, url, ntype = entry["entry_id"], entry["url"], type_of(node)
# (1) forbidden search-result URLs anywhere in the node (incl. list items) -> P0
for val in every_string(node):
low = val.lower()
hit = next((s for s in forbidden if s in low), None)
if hit:
defects.add("P0", "LR", "FORBIDDEN_REFERENCE",
f"Search-result URL used as reference (contains {hit!r}); "
f"not a valid entity reference: {val[:80]!r}.", eid, url, ntype)
# (2) external identity references (sameAs)
refs = []
for prop in ref_props:
v = node.get(prop)
if isinstance(v, str):
refs.append(v)
elif isinstance(v, list):
refs += [x for x in v if isinstance(x, str)]
if not refs:
continue
# (2a) discouraged reference sources (policy: prefer Wikipedia over Wikidata)
for dd in policy.get("discouraged_ref_domains", []):
for ref in refs:
if dd in ref:
defects.add("P1", "LR", "DISCOURAGED_REFERENCE",
f"{ref} uses a discouraged source ({dd}). Policy: prefer "
"Wikipedia; if none verified, omit — never fabricate.",
eid, url, ntype)
if not verify_refs:
defects.add("P1", "LR", "REFERENCE_UNVERIFIED",
f"{len(refs)} external reference(s) on '{ntype}' not machine-verified "
f"(run with --verify-refs / confirm online): {refs}.", eid, url, ntype)
continue
# online verification
name = normalize_name(first_text(node.get("name")))
alts = node.get("alternateName") or []
if isinstance(alts, str):
alts = [alts]
names = {name} | {normalize_name(a) for a in alts if isinstance(a, str)}
for ref in refs:
m = qid_re.search(ref)
if m:
try:
labels = _wikidata_labels(m.group(1))
except Exception as e:
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
f"Could not fetch Wikidata {m.group(1)} ({e}).", eid, url, ntype)
continue
if labels is None:
defects.add("P0", "LR", "FALSE_REFERENCE",
f"sameAs {ref} → Wikidata item is missing/deleted.", eid, url, ntype)
continue
lab = {normalize_name(v) for v in labels.values()}
# match if any entity name appears in any label or vice-versa
ok = any(n and (n in l or l in n) for n in names for l in lab)
if not ok:
defects.add("P0", "LR", "FALSE_REFERENCE",
f"sameAs {ref} label {sorted(lab)[:3]} does NOT match entity "
f"name {sorted(n for n in names if n)[:3]} — fabricated/incorrect ID.",
eid, url, ntype)
else:
is_social = any(d in ref for d in policy.get("social_profile_domains", []))
try:
code = _http_get(ref).status
except Exception as e:
code = f"error: {e}"
if is_social:
# HTTP 200 does NOT prove official ownership, and platforms
# often bot-block live pages (e.g. Facebook 400). Always hand
# social/profile refs to a human. (Shilla: a 200 YouTube
# channel was not the official one; FB page was closed.)
defects.add("P1", "LR", "SOCIAL_UNVERIFIED",
f"sameAs {ref} is a social/profile URL (HTTP {code}). "
"Confirm official ownership AND active status manually — "
"a 200 is not proof of ownership.", eid, url, ntype)
elif isinstance(code, int) and code >= 400:
defects.add("P0", "LR", "BROKEN_REFERENCE",
f"sameAs {ref} returned HTTP {code}.", eid, url, ntype)
elif not isinstance(code, int):
defects.add("P1", "LR", "REFERENCE_UNREACHABLE",
f"sameAs {ref} not reachable ({code}).", eid, url, ntype)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Orchestration + output # Orchestration + output
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def run(entries, rules, inventory, strict, no_recommended): def run(entries, rules, inventory, strict, no_recommended, verify_refs=False):
defects = DefectLog() defects = DefectLog()
if inventory is not None: if inventory is not None:
layer0_coverage(entries, inventory, defects) layer0_coverage(entries, inventory, defects)
@@ -743,6 +895,7 @@ def run(entries, rules, inventory, strict, no_recommended):
node_index.append((entry, node)) node_index.append((entry, node))
layer4_consistency(node_index, parsed_docs, rules, defects) layer4_consistency(node_index, parsed_docs, rules, defects)
layer_references(node_index, rules, defects, verify_refs=verify_refs)
return defects, valid_entries, len(node_index) return defects, valid_entries, len(node_index)
@@ -822,6 +975,9 @@ def main(argv=None):
ap.add_argument("--live", nargs="+", metavar="URL", ap.add_argument("--live", nargs="+", metavar="URL",
help="Mode B: validate live URLs (extract embedded JSON-LD)") help="Mode B: validate live URLs (extract embedded JSON-LD)")
ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json") ap.add_argument("--rules", default=str(RULES_DEFAULT), help="path to schema_rules.json")
ap.add_argument("--verify-refs", action="store_true",
help="online: resolve every sameAs and verify Wikidata labels match the "
"entity name (catches fabricated/incorrect reference IDs). Needs network.")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if not args.dataset and not args.live: if not args.dataset and not args.live:
@@ -838,7 +994,8 @@ def main(argv=None):
inventory = load_url_inventory(args.url_list) if args.url_list else None inventory = load_url_inventory(args.url_list) if args.url_list else None
defects, valid_entries, nodes = run(entries, rules, inventory, defects, valid_entries, nodes = run(entries, rules, inventory,
args.strict, args.no_recommended) args.strict, args.no_recommended,
verify_refs=args.verify_refs)
meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes, meta = {"entries": len(entries), "valid_entries": valid_entries, "nodes": nodes,
"mode": "B-live" if args.live else "A-dataset", "strict": args.strict, "mode": "B-live" if args.live else "A-dataset", "strict": args.strict,
"coverage": inventory is not None} "coverage": inventory is not None}

View File

@@ -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())

View File

@@ -1,12 +1,12 @@
--- ---
name: 17-seo-schema-generator name: seo-schema-generator
description: | description: |
Generates validation-ready JSON-LD structured data for a site, covering BOTH Generates validation-ready JSON-LD structured data for a site, covering BOTH
scenarios: (1) from an existing website — extract facts from live pages; and scenarios: (1) from an existing website — extract facts from live pages; and
(2) from collected sources for a not-yet-published site — reconcile conflicting (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 facts into a provenance-tracked claims register. Both modes emit the same claims
register, build pruned drafts from type templates (no placeholders shipped), and 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, Triggers: generate schema, create JSON-LD, schema markup, structured data generator,
source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작, source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작,
구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출. 구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출.

View File

@@ -1,5 +1,5 @@
--- ---
name: 18-seo-local-audit name: seo-local-audit
description: | description: |
Local business SEO auditor for Korean-market businesses. Covers business identity extraction, Local business SEO auditor for Korean-market businesses. Covers business identity extraction,
NAP consistency, Google Business Profile, Naver Smart Place, Kakao Map, local citations, NAP consistency, Google Business Profile, Naver Smart Place, Kakao Map, local citations,

View File

@@ -1,5 +1,5 @@
--- ---
name: 19-seo-keyword-strategy name: seo-keyword-strategy
description: | description: |
Keyword strategy and research for SEO campaigns. Keyword strategy and research for SEO campaigns.
Triggers: keyword research, keyword analysis, keyword gap, search volume, Triggers: keyword research, keyword analysis, keyword gap, search volume,

View File

@@ -1,5 +1,5 @@
--- ---
name: 20-seo-serp-analysis name: seo-serp-analysis
description: | description: |
SERP analysis for Google and Naver search results. SERP analysis for Google and Naver search results.
Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP, 검색결과 분석, SERP 분석. Triggers: SERP analysis, search results, featured snippet, SERP features, Naver SERP, 검색결과 분석, SERP 분석.

View File

@@ -1,5 +1,5 @@
--- ---
name: 21-seo-position-tracking name: seo-position-tracking
description: | description: |
Keyword position tracking for keyword ranking monitoring. Keyword position tracking for keyword ranking monitoring.
Triggers: rank tracking, position monitoring, keyword rankings, visibility score, ranking report, 키워드 순위, 순위 추적. Triggers: rank tracking, position monitoring, keyword rankings, visibility score, ranking report, 키워드 순위, 순위 추적.

View File

@@ -1,5 +1,5 @@
--- ---
name: 22-seo-link-building name: seo-link-building
description: | description: |
Link building diagnosis and backlink analysis tool. Link building diagnosis and backlink analysis tool.
Triggers: backlink audit, link building, referring domains, toxic links, link gap, broken backlinks, 백링크 분석, 링크빌딩. Triggers: backlink audit, link building, referring domains, toxic links, link gap, broken backlinks, 백링크 분석, 링크빌딩.

View File

@@ -1,5 +1,5 @@
--- ---
name: 23-seo-content-strategy name: seo-content-strategy
description: | description: |
Content strategy and planning for SEO. Triggers: content audit, content strategy, content gap, topic clusters, content brief, editorial calendar, content decay, 콘텐츠 전략, 콘텐츠 감사. Content strategy and planning for SEO. Triggers: content audit, content strategy, content gap, topic clusters, content brief, editorial calendar, content decay, 콘텐츠 전략, 콘텐츠 감사.
--- ---

View File

@@ -1,5 +1,5 @@
--- ---
name: 24-seo-ecommerce name: seo-ecommerce
description: | description: |
E-commerce SEO audit and optimization for product pages, product schema, category taxonomy, E-commerce SEO audit and optimization for product pages, product schema, category taxonomy,
and Korean marketplace presence. and Korean marketplace presence.

View File

@@ -1,5 +1,5 @@
--- ---
name: 25-seo-kpi-framework name: seo-kpi-framework
description: | description: |
SEO KPI and performance framework for unified metrics, health scores, ROI, and period-over-period reporting. 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, Triggers: SEO KPI, performance report, health score, SEO metrics, ROI,

View File

@@ -1,5 +1,5 @@
--- ---
name: 26-seo-international name: seo-international
description: | description: |
International SEO audit and hreflang validation for multi-language and multi-region websites. 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. Triggers: hreflang, international SEO, multi-language, multi-region, content parity, x-default, ccTLD, 다국어 SEO.

View File

@@ -1,5 +1,5 @@
--- ---
name: 27-seo-ai-visibility name: seo-ai-visibility
description: | description: |
AI search visibility and brand radar monitoring. Tracks how a brand appears 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. in AI-generated search answers using our-seo-agent CLI or pre-fetched data.

View File

@@ -1,5 +1,5 @@
--- ---
name: 28-seo-knowledge-graph name: seo-knowledge-graph
description: | description: |
Knowledge Graph and entity SEO analysis. Knowledge Graph and entity SEO analysis.
Triggers: knowledge panel, entity SEO, knowledge graph, PAA, FAQ schema, Triggers: knowledge panel, entity SEO, knowledge graph, PAA, FAQ schema,

View File

@@ -1,5 +1,5 @@
--- ---
name: 29-seo-gateway-architect name: seo-gateway-architect
description: | description: |
Gateway page strategy planner for keyword research, content architecture, and SEO KPIs. Gateway page strategy planner for keyword research, content architecture, and SEO KPIs.
Triggers: SEO strategy, gateway pages, keyword research, content architecture. Triggers: SEO strategy, gateway pages, keyword research, content architecture.

View File

@@ -1,5 +1,5 @@
--- ---
name: 30-seo-gateway-builder name: seo-gateway-builder
description: | description: |
Gateway page content builder with templates, schema markup, and local SEO optimization. Gateway page content builder with templates, schema markup, and local SEO optimization.
Triggers: build gateway page, create landing page, local service page, location pages. Triggers: build gateway page, create landing page, local service page, location pages.

View File

@@ -1,5 +1,5 @@
--- ---
name: 31-notion-organizer name: notion-organizer
description: | description: |
Notion workspace manager for database optimization, property cleanup, and bulk operations. Notion workspace manager for database optimization, property cleanup, and bulk operations.
Triggers: organize Notion, workspace cleanup, database schema, property standardization. Triggers: organize Notion, workspace cleanup, database schema, property standardization.

View File

@@ -1,5 +1,5 @@
--- ---
name: 31-seo-competitor-intel name: seo-competitor-intel
description: | description: |
Competitor intelligence and SEO benchmarking. Competitor intelligence and SEO benchmarking.
Triggers: competitor analysis, competitive intelligence, competitor comparison, Triggers: competitor analysis, competitive intelligence, competitor comparison,

View File

@@ -1,5 +1,5 @@
--- ---
name: 32-notion-writer name: notion-writer
description: | description: |
Markdown to Notion page writer with database row creation support. Markdown to Notion page writer with database row creation support.
Triggers: write to Notion, export to Notion, push content, create Notion page. 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 ## Prerequisites
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv` - Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below) - Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration) - Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
## Quick Start ## Quick Start
```bash ```bash
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
source venv/bin/activate source venv/bin/activate
``` ```
@@ -139,6 +139,19 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
| `---` | Divider | | `---` | Divider |
| Paragraphs | Paragraph | | Paragraphs | Paragraph |
### Engines and image uploads
Two write engines via `--engine {blocks,markdown}` (default: `blocks`).
The **blocks engine** (default) converts markdown locally to Notion block objects. Local images (`![alt](./file.png)`) are auto-uploaded via the `ntn` CLI and embedded at their original position in the page. Requires `ntn` installed and `ntn login`.
The **markdown engine** (`--engine markdown`) posts the document through Notion's native enhanced-markdown API (`Notion-Version: 2026-03-11`, set automatically; override with `--notion-version`). The skill's authoring dialect — GitHub alerts (`[!NOTE]`), Pandoc columns (`::: columns`), `<details>` toggles, and `@[mention]` — is auto-translated before posting. Note: local images are appended at the end of the page rather than inline with this engine; use `--engine blocks` when image position matters. Pass `--allow-deleting-content` when `--replace` needs to remove child pages or databases.
```bash
# Markdown engine — create a DB row from a doc with callouts or columns
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
```
## Workflow Example ## Workflow Example
Integrate with Jamie YouTube Manager to log video info: Integrate with Jamie YouTube Manager to log video info:

View File

@@ -189,6 +189,52 @@ print("Hello")
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths. Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
### File uploads
Standalone local-image lines (`![alt](./image.png)`) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (`![](https://...)`) 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 ![chart](./chart.png)
# Markdown engine, replace page allowing child deletion
python notion_writer.py -p PAGE_URL -f doc.md --replace --engine markdown --allow-deleting-content
```
--- ---
## Examples ## Examples
@@ -398,9 +444,10 @@ python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
--- ---
*Version 1.2.0 | Claude Code | 2026-04-27* *Version 1.3.0 | Claude Code | 2026-06-27*
Changelog: Changelog:
- 1.3.0 — Local file/image uploads via the `ntn` CLI (`![](local)` → file_upload image blocks). New `--engine markdown` path writing through Notion's native enhanced-markdown endpoints with a dialect translator. Added `--notion-version` and `--allow-deleting-content`.
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks. - 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages. - 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
- 1.0.0 — Initial release with markdown→Notion block conversion. - 1.0.0 — Initial release with markdown→Notion block conversion.

View File

@@ -16,8 +16,11 @@ from notion_client import Client
from notion_client.errors import APIErrorCode, APIResponseError from notion_client.errors import APIErrorCode, APIResponseError
def make_client(api_key: str) -> Client: def make_client(api_key: str, notion_version: str = None) -> Client:
"""Build a sync Notion client on the SDK default API version (2025-09-03+).""" """Build a sync Notion client. Pass notion_version to override the SDK
default (needed: 2026-03-11 for the markdown content endpoints)."""
if notion_version:
return Client(auth=api_key, notion_version=notion_version)
return Client(auth=api_key) return Client(auth=api_key)
@@ -210,7 +213,10 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
"https://www.notion.so/my-integrations." "https://www.notion.so/my-integrations."
) )
if code == APIErrorCode.ValidationError: if code == APIErrorCode.ValidationError:
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}" msg = exc.body.get('message', str(exc))
if 'delet' in msg.lower():
msg += " — re-run with --allow-deleting-content to permit this."
return f"Validation error{suffix}: {msg}"
if code == APIErrorCode.RateLimited: if code == APIErrorCode.RateLimited:
return f"Rate limited{suffix}. Back off and retry." return f"Rate limited{suffix}. Back off and retry."
return f"Notion API error [{code}]{suffix}: {exc}" return f"Notion API error [{code}]{suffix}: {exc}"
@@ -253,3 +259,33 @@ def find_existing_page(
) )
results = response.get("results") or [] results = response.get("results") or []
return results[0] if results else None return results[0] if results else None
def create_page_markdown(client, parent, properties, markdown):
"""POST /v1/pages with the enhanced-markdown body param.
`markdown` is only included in the body when non-empty to avoid sending
a blank markdown field when no --file/--stdin was supplied."""
body: Dict[str, Any] = {"parent": parent, "properties": properties}
if markdown:
body["markdown"] = markdown
return client.request(path="pages", method="POST", body=body)
def append_markdown(client, page_id, markdown):
"""PATCH /v1/pages/:id/markdown — append at end (insert_content)."""
return client.request(
path=f"pages/{page_id}/markdown", method="PATCH",
body={"type": "insert_content",
"insert_content": {"content": markdown,
"position": {"type": "end"}}},
)
def replace_markdown(client, page_id, markdown, allow_deleting=False):
"""PATCH /v1/pages/:id/markdown — replace all content."""
return client.request(
path=f"pages/{page_id}/markdown", method="PATCH",
body={"type": "replace_content",
"replace_content": {"new_str": markdown,
"allow_deleting_content": allow_deleting}},
)

View 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)

View File

@@ -17,6 +17,11 @@ from notion_client import Client
from notion_client.errors import APIResponseError from notion_client.errors import APIResponseError
import _notion_compat as compat import _notion_compat as compat
import ntn_files
from ntn_files import NtnUploadError
import md_translate
MARKDOWN_NOTION_VERSION = "2026-03-11"
# Load environment variables # Load environment variables
load_dotenv(Path(__file__).parent / '.env') load_dotenv(Path(__file__).parent / '.env')
@@ -56,6 +61,7 @@ def format_id_with_dashes(raw_id: str) -> str:
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$') TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
def _is_table_row(line: str) -> bool: def _is_table_row(line: str) -> bool:
@@ -210,6 +216,13 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
blocks.append(create_column_list_block(column_blocks)) blocks.append(create_column_list_block(column_blocks))
continue continue
image_match = IMAGE_RE.match(line)
if image_match:
blocks.append(create_image_block(
image_match.group(1), image_match.group(2)))
i += 1
continue
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]): if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
header_cells = _split_table_row(line) header_cells = _split_table_row(line)
i += 2 i += 2
@@ -507,6 +520,55 @@ def create_divider_block() -> Dict[str, Any]:
} }
def create_image_block(alt: str, target: str) -> Dict[str, Any]:
"""Image block in external shape. Local targets are converted to
file_upload shape later by ntn_files.materialize_local_media."""
block: Dict[str, Any] = {
"object": "block",
"type": "image",
"image": {"type": "external", "external": {"url": target}},
}
if alt:
block["image"]["caption"] = parse_rich_text(alt)
return block
def _content_has_local_images(content: str) -> bool:
in_fence = False
for line in content.split('\n'):
if line.strip().startswith('```'):
in_fence = not in_fence
continue # fence delimiter lines are never image lines
if in_fence:
continue
m = IMAGE_RE.match(line)
if m and not m.group(2).startswith(('http://', 'https://')):
return True
return False
def extract_local_images(content: str):
"""Remove standalone LOCAL image lines; keep remote ones inline.
Returns (content_without_local_images, [(alt, target), ...]).
Lines inside fenced code blocks are passed through unchanged."""
kept, imgs = [], []
in_fence = False
for line in content.split('\n'):
if line.strip().startswith('```'):
in_fence = not in_fence
kept.append(line)
continue
if in_fence:
kept.append(line)
continue
m = IMAGE_RE.match(line)
if m and not m.group(2).startswith(('http://', 'https://')):
imgs.append((m.group(1), m.group(2)))
continue
kept.append(line)
return "\n".join(kept), imgs
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]: def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]:
"""Build a Notion `table` block with header + body rows. """Build a Notion `table` block with header + body rows.
@@ -746,21 +808,6 @@ def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bo
return False return False
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
"""Write markdown content to a Notion page."""
blocks = markdown_to_notion_blocks(markdown_content)
if not blocks:
print("No content to write")
return False
if mode == 'replace':
if not clear_page_content(notion, page_id):
return False
return append_to_page(notion, page_id, blocks)
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Push markdown content to Notion pages or databases', description='Push markdown content to Notion pages or databases',
@@ -796,9 +843,28 @@ Examples:
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'], parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
help='List accessible pages and/or databases (default: all)') help='List accessible pages and/or databases (default: all)')
parser.add_argument('--info', action='store_true', help='Show page/database info') parser.add_argument('--info', action='store_true', help='Show page/database info')
parser.add_argument('--engine', choices=['blocks', 'markdown'],
default='blocks',
help='Write engine: blocks (default) or markdown')
parser.add_argument('--notion-version', dest='notion_version',
help='Override Notion API version')
parser.add_argument('--allow-deleting-content', dest='allow_deleting',
action='store_true',
help='Markdown replace may delete child pages/dbs')
args = parser.parse_args() args = parser.parse_args()
try:
_main_body(parser, args)
except NtnUploadError as e:
print(f"Error: {e}")
if e.stderr:
print(e.stderr)
sys.exit(1)
def _main_body(parser, args):
"""Body of main() after argparse; separated so NtnUploadError can be caught cleanly."""
if not NOTION_TOKEN: if not NOTION_TOKEN:
print("Error: NOTION_API_KEY not set in environment.") print("Error: NOTION_API_KEY not set in environment.")
print("Preferred: fetch from 1Password at runtime —") print("Preferred: fetch from 1Password at runtime —")
@@ -885,23 +951,64 @@ Examples:
sys.exit(1) sys.exit(1)
content = file_path.read_text(encoding='utf-8') content = file_path.read_text(encoding='utf-8')
base_dir = Path(args.file).parent if args.file else Path.cwd()
def _md_client():
version = args.notion_version or MARKDOWN_NOTION_VERSION
return compat.make_client(NOTION_TOKEN, notion_version=version)
def _upload_blocks_for(images):
"""Build + materialize image blocks for two-phase markdown writes."""
blocks = [create_image_block(alt, target) for alt, target in images]
return ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
# Write to page # Write to page
if args.page: if args.page:
if not content: if not content:
print("Error: No content provided. Use --file or --stdin") print("Error: No content provided. Use --file or --stdin")
sys.exit(1) sys.exit(1)
page_id = extract_notion_id(args.page) page_id = extract_notion_id(args.page)
if not page_id: if not page_id:
print(f"Error: Invalid Notion page URL/ID: {args.page}") print(f"Error: Invalid Notion page URL/ID: {args.page}")
sys.exit(1) sys.exit(1)
formatted_id = format_id_with_dashes(page_id)
mode = 'replace' if args.replace else 'append' if args.engine == 'markdown':
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...") 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): # blocks engine (default)
print(f"✅ Successfully wrote content to page") blocks = markdown_to_notion_blocks(content)
formatted_id = format_id_with_dashes(page_id) if _content_has_local_images(content):
ntn_files.preflight()
blocks = ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
if not blocks:
print("No content to write")
sys.exit(1)
print(f"{'Replacing' if args.replace else 'Appending'} content to page...")
if args.replace and not clear_page_content(notion, page_id):
print("❌ Failed to write content")
sys.exit(1)
if append_to_page(notion, page_id, blocks):
print("✅ Successfully wrote content to page")
print(f" https://notion.so/{formatted_id.replace('-', '')}") print(f" https://notion.so/{formatted_id.replace('-', '')}")
else: else:
print("❌ Failed to write content") print("❌ Failed to write content")
@@ -960,6 +1067,8 @@ Examples:
content_blocks = markdown_to_notion_blocks(content) if content else None content_blocks = markdown_to_notion_blocks(content) if content else None
existing = None
# Upsert path: look for existing row by the named property # Upsert path: look for existing row by the named property
if args.upsert_by: if args.upsert_by:
if args.upsert_by not in schema_props: if args.upsert_by not in schema_props:
@@ -980,19 +1089,52 @@ Examples:
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}") print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
sys.exit(1) sys.exit(1)
if existing: if args.engine == 'markdown':
page_id = existing['id'] md_content = content or ""
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...") if _content_has_local_images(md_content):
if not update_page_properties(notion, page_id, properties): 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) sys.exit(1)
if content_blocks: if not append_to_page(notion, page_id, content_blocks):
if not clear_page_content(notion, page_id): sys.exit(1)
sys.exit(1) print(f"✅ Successfully updated database row")
if not append_to_page(notion, page_id, content_blocks): print(f" https://notion.so/{page_id.replace('-', '')}")
sys.exit(1) return
print(f"✅ Successfully updated database row")
print(f" https://notion.so/{page_id.replace('-', '')}")
return
print(f"Creating database row...") print(f"Creating database row...")
row_id = create_database_row(notion, data_source_id, properties, content_blocks) row_id = create_database_row(notion, data_source_id, properties, content_blocks)

View 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

View File

@@ -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![remote](https://ex.com/a.png)\n\n"
"![local](./b.png)\n\nbody")
without, imgs = notion_writer.extract_local_images(content)
assert imgs == [("local", "./b.png")]
assert "![local](./b.png)" not in without
assert "![remote](https://ex.com/a.png)" in without # remote stays
def test_content_has_local_images():
assert notion_writer._content_has_local_images("![x](./y.png)") is True
assert notion_writer._content_has_local_images("![x](https://e/y.png)") 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![x](./y.png)\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 "![x](./y.png)" 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()

View 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()

View 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()

View File

@@ -413,6 +413,28 @@ def test_no_literal_markers_leak():
_assert("bold" in joined and "link" in joined, "visible words preserved") _assert("bold" in joined and "link" in joined, "visible words preserved")
def test_image_remote_external():
blocks = markdown_to_notion_blocks("![a chart](https://ex.com/c.png)")
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("![local](./pics/x.png)")
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 ![x](y.png) inline")
assert blocks[0]["type"] == "paragraph"
def run_all(): def run_all():
tests = [ tests = [
test_rich_text_plain, test_rich_text_plain,
@@ -447,6 +469,9 @@ def run_all():
test_rich_text_relative_link_becomes_plain, test_rich_text_relative_link_becomes_plain,
test_rich_text_absolute_link_preserved, test_rich_text_absolute_link_preserved,
test_no_literal_markers_leak, test_no_literal_markers_leak,
test_image_remote_external,
test_image_local_external_shape_preupload,
test_image_only_when_standalone,
] ]
for t in tests: for t in tests:
print(f"\n{t.__name__}") print(f"\n{t.__name__}")

View File

@@ -11,14 +11,14 @@ Push markdown content to Notion pages or databases via Claude Code.
## Prerequisites ## Prerequisites
- Python virtual environment at `~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts/venv` - Python virtual environment at `~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts/venv`
- Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below) - Notion integration token (preferred: stored in 1Password — see [Credential handling](#credential-handling) below)
- Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration) - Target pages/databases must be shared with the integration in Notion (Database/Page → ⋯ → Connections → add integration)
## Quick Start ## Quick Start
```bash ```bash
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
source venv/bin/activate source venv/bin/activate
``` ```

View File

@@ -1,5 +1,5 @@
--- ---
name: 32-seo-crawl-budget name: seo-crawl-budget
description: | description: |
Crawl budget optimization and server log analysis for search engine bots. Crawl budget optimization and server log analysis for search engine bots.
Triggers: crawl budget, log analysis, bot crawling, Googlebot, crawl waste, Triggers: crawl budget, log analysis, bot crawling, Googlebot, crawl waste,

View File

@@ -1,5 +1,5 @@
--- ---
name: 33-seo-migration-planner name: seo-migration-planner
description: | description: |
SEO site migration planning and monitoring. Triggers: site migration, domain move, redirect mapping, platform migration, URL restructuring, HTTPS migration, subdomain consolidation, 사이트 이전, 도메인 이전, 리디렉트 매핑. SEO site migration planning and monitoring. Triggers: site migration, domain move, redirect mapping, platform migration, URL restructuring, HTTPS migration, subdomain consolidation, 사이트 이전, 도메인 이전, 리디렉트 매핑.
--- ---

View File

@@ -1,5 +1,5 @@
--- ---
name: 34-seo-reporting-dashboard name: seo-reporting-dashboard
description: | description: |
SEO reporting dashboard and executive report generation. Aggregates data from all SEO skills SEO reporting dashboard and executive report generation. Aggregates data from all SEO skills
into stakeholder-ready reports and interactive HTML dashboards. into stakeholder-ready reports and interactive HTML dashboards.

View 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).

View 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, ~180220 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 L1L2)
**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 13.
- 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 12).
- 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, L1L4, short-circuit) → Tasks 1 (L1L2) + 2 (L3L4); 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.**

View 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).

View 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).

View File

@@ -0,0 +1,4 @@
query clicks impressions position
호텔 9 371 18.1
grand josun busan 49 1500 3.4
조선호텔 150 5000 1.3
1 query clicks impressions position
2 호텔 9 371 18.1
3 grand josun busan 49 1500 3.4
4 조선호텔 150 5000 1.3

View File

@@ -0,0 +1,4 @@
query clicks impressions position
호텔 5 572 11.6
grand josun busan 250 4000 1.2
조선호텔 300 6000 1.1
1 query clicks impressions position
2 호텔 5 572 11.6
3 grand josun busan 250 4000 1.2
4 조선호텔 300 6000 1.1

View File

@@ -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())

View File

@@ -0,0 +1 @@
# gsc_signal_delta.py uses the Python 3 standard library only — no deps.

View File

@@ -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()

View File

@@ -1,5 +1,5 @@
--- ---
name: 40-jamie-brand-editor name: jamie-brand-editor
description: | description: |
Jamie Plastic Surgery branded content generator for blog posts and marketing. Jamie Plastic Surgery branded content generator for blog posts and marketing.
Triggers: write Jamie blog, Jamie content, brand copywriting, 제이미 콘텐츠. Triggers: write Jamie blog, Jamie content, brand copywriting, 제이미 콘텐츠.

View File

@@ -1,5 +1,5 @@
--- ---
name: 41-jamie-brand-audit name: jamie-brand-audit
description: | description: |
Jamie Plastic Surgery brand compliance reviewer and content evaluator. Jamie Plastic Surgery brand compliance reviewer and content evaluator.
Triggers: review content, brand audit, 제이미 브랜드 검토, tone and manner check. Triggers: review content, brand audit, 제이미 브랜드 검토, tone and manner check.

View File

@@ -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 스킬과 연계하여 사용합니다." description: "카카오톡 플러스 채널 Kanana 상담매니저 Q&A 답변 생성 및 검토 스킬. 제이미성형외과의 카카오톡 채널에 등록할 고객 문의 질문과 답변 엔트리를 생성, 검토, 수정합니다. 의료광고 심의 준수, 브랜드 보이스 일관성, 카카오 카나나 가이드 규격을 모두 반영합니다. Triggers: 카나나 답변, Kanana Q&A, 카카오톡 챗봇, 카카오 상담 답변, 챗봇 문답, 자동답변 등록, 카나나 엔트리, chatbot QA, KakaoTalk channel reply, 카카오 자동응답. jamie-marketing-editor 및 jamie-brand-guardian 스킬과 연계하여 사용합니다."
--- ---

View File

@@ -1,5 +1,5 @@
--- ---
name: 43-jamie-youtube-manager name: jamie-youtube-manager
description: | description: |
Jamie Clinic YouTube channel SEO auditor and content manager. Jamie Clinic YouTube channel SEO auditor and content manager.
Triggers: YouTube SEO, video audit, 제이미 유튜브, channel optimization. Triggers: YouTube SEO, video audit, 제이미 유튜브, channel optimization.

View File

@@ -1,5 +1,5 @@
--- ---
name: 44-jamie-youtube-subtitle-checker name: jamie-youtube-subtitle-checker
description: | description: |
SBV subtitle file typo corrector and YouTube metadata generator for Jamie Clinic. SBV subtitle file typo corrector and YouTube metadata generator for Jamie Clinic.
Triggers: check subtitles, subtitle QA, SBV correction, 자막 교정. Triggers: check subtitles, subtitle QA, SBV correction, 자막 교정.

View File

@@ -1,5 +1,5 @@
--- ---
name: 45-jamie-instagram-manager name: jamie-instagram-manager
description: | description: |
Jamie Clinic Instagram account manager for engagement, content planning, and boost strategy. Jamie Clinic Instagram account manager for engagement, content planning, and boost strategy.
Triggers: Instagram management, 제이미 인스타그램, IG strategy, social media. Triggers: Instagram management, 제이미 인스타그램, IG strategy, social media.

View File

@@ -1,5 +1,5 @@
--- ---
name: 46-jamie-journal-editor name: jamie-journal-editor
description: | description: |
Jamie Clinic journal/blog content editor for "정기호의 성형외과 진료실 이야기" (journal.jamie.clinic). 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. Creates educational medical blog posts in Dr. Jung's authentic voice with Korean medical ad compliance.

View File

@@ -1,5 +1,5 @@
--- ---
name: 47-jamie-marketing-editor name: jamie-marketing-editor
description: | description: |
Jamie Clinic marketing content editor for digital channels, ads, communications, and internal docs. 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. Creates compliant marketing copy for website, blog, SNS, ads, and patient communications.

View 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 **23 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`.

View File

@@ -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.*

View 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.*

View File

@@ -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.*

View File

@@ -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

View File

@@ -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 (15 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
```

View File

@@ -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.

View File

@@ -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?

View File

@@ -1,5 +1,5 @@
--- ---
name: 50-notebooklm-agent name: notebooklm-agent
description: | description: |
Q&A agent for NotebookLM notebooks. Ask questions and get grounded, citation-backed answers from your sources. 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 에이전트. Triggers: ask NotebookLM, query notebook, research question, 노트북 질문, NotebookLM 에이전트.

View File

@@ -1,5 +1,5 @@
--- ---
name: 51-notebooklm-automation name: notebooklm-automation
description: | description: |
Complete NotebookLM automation for notebooks, sources, and artifacts management. Complete NotebookLM automation for notebooks, sources, and artifacts management.
Triggers: manage NotebookLM, create notebook, add sources, 노트북 관리, NotebookLM 자동화. Triggers: manage NotebookLM, create notebook, add sources, 노트북 관리, NotebookLM 자동화.

View File

@@ -1,5 +1,5 @@
--- ---
name: 52-notebooklm-studio name: notebooklm-studio
description: | description: |
Content generation for NotebookLM Studio artifacts - podcasts, videos, quizzes, flashcards, and more. Content generation for NotebookLM Studio artifacts - podcasts, videos, quizzes, flashcards, and more.
Triggers: create podcast, generate video, make quiz, 팟캐스트 만들기, 퀴즈 생성, NotebookLM 스튜디오. Triggers: create podcast, generate video, make quiz, 팟캐스트 만들기, 퀴즈 생성, NotebookLM 스튜디오.

View File

@@ -1,5 +1,5 @@
--- ---
name: 53-notebooklm-research name: notebooklm-research
description: | description: |
Research and source discovery for NotebookLM. Web/Drive research, auto-import, and source text extraction. Research and source discovery for NotebookLM. Web/Drive research, auto-import, and source text extraction.
Triggers: research topic, find sources, web research, 리서치, 자료 조사, NotebookLM 연구. Triggers: research topic, find sources, web research, 리서치, 자료 조사, NotebookLM 연구.

View File

@@ -1,5 +1,5 @@
--- ---
name: 60-gtm-audit name: gtm-audit
description: | description: |
GTM container audit using Chrome DevTools and DTM Agent for tag verification. GTM container audit using Chrome DevTools and DTM Agent for tag verification.
Triggers: audit GTM, GTM analysis, tag debugging, dataLayer inspection. Triggers: audit GTM, GTM analysis, tag debugging, dataLayer inspection.

View File

@@ -1,5 +1,5 @@
--- ---
name: 61-gtm-editor name: gtm-editor
description: > description: >
GTM implementation toolkit. Creates, updates, and modifies GTM tags, triggers, GTM implementation toolkit. Creates, updates, and modifies GTM tags, triggers,
variables via API. Generates Custom HTML with ES5 compliance. Handles workspace variables via API. Generates Custom HTML with ES5 compliance. Handles workspace

View File

@@ -1,5 +1,5 @@
--- ---
name: 62-gtm-validator name: gtm-validator
description: > description: >
GTM QA and validation toolkit. Verifies tags fire correctly on live pages, GTM QA and validation toolkit. Verifies tags fire correctly on live pages,
tests trigger conditions against actual DOM, validates dataLayer schemas, tests trigger conditions against actual DOM, validates dataLayer schemas,

View File

@@ -1,10 +1,10 @@
--- ---
name: 70-dintel-brand-guardian name: dintel-brand-guardian
version: 1.2.0 version: 1.2.0
last_updated: 2026-05-18 last_updated: 2026-05-18
canon_compliance: v1.3 canon_compliance: v1.3
agent-id: "70" agent-id: "70"
agent-corps: D.intelligence Agent Corps (8 agents + 1 meta-agent) agent-corps: D.intelligence Agent Corps (9 agents + 1 meta-agent)
description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence. description: Brand Guardian for D.intelligence (디인텔리전스). Reviews all D.intelligence documents, proposals, reports, blog posts, AI-generated content, presentations, and marketing materials for brand compliance. Checks tone & manner, message framework, service architecture accuracy, prohibited expressions, and AI/LLM output standards. Use this skill whenever creating or reviewing D.intelligence content — triggers include "D.intelligence", "디인텔리전스", "brand review", "brand check", "톤앤매너 검토", "브랜드 검토", "제안서 검토", "리포트 검토", "콘텐츠 검토", any mention of service modules (A1-A6, T1-T7, G1-G4), service categories (DI, MD, MPO, BVT), or the tagline "Analysis, Treatment & Growth". Also use when generating proposals, reports, blog posts, case studies, newsletter content, or any client-facing material for D.intelligence.
autonomy: auto autonomy: auto
--- ---

Some files were not shown because too many files have changed in this diff Show More