feat(seo-schema-validator): back the upgraded SKILL.md with a working 5-layer pipeline

The "Upgrade Schema Validator" commit added SKILL.md referencing files that did
not exist. Implement them so the skill actually runs:

- scripts/validate_schema.py — 5-layer offline validator (L0 coverage, L1 syntax,
  L2 vocabulary/value-format, L3 rich-result, L4 consistency) with xlsx/csv/jsonl/
  json/dir/live-URL adapters. Gate = zero P0; exits 1 on failure.
- scripts/schema_rules.json — curated hotel-focused, offline rule set (edit-only
  extension point).
- scripts/make_sample.py + fixtures/sample_schema.csv — deliberately flawed fixture
  seeding ≥1 defect per layer; used to self-test.
- references/ — validation-methodology, defect-taxonomy (25 codes), hotel-type-map.
- templates/ — client-qa-report, decision-log.
- code/CLAUDE.md — redirect legacy single-URL tool to the new pipeline.

Noise control: MISSING_RECOMMENDED aggregated one-line-per-node; unexpected-property
checks opt-in via --strict. Generalized client-specific shilla-type-map → hotel-type-map.
Self-tested: default P0=5/P1=4/P2=14 FAIL, --strict --no-recommended P2=0, adapters verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:48:51 +09:00
parent ba88247496
commit 4f48ba3c59
12 changed files with 1636 additions and 130 deletions

View File

@@ -0,0 +1,74 @@
# Defect Taxonomy
Every code `validate_schema.py` can emit, its default severity, and what to do.
The validator writes these to `defect_log.csv` (columns: `entry_id, url, node_type,
layer, code, severity, message, status, owner, note`) and `results.json`.
## Severity model
| Severity | Definition | Owner action |
|---|---|---|
| **P0** | Blocker — breaks parsing, blocks the rich result, or ships wrong/placeholder data. **Fails the gate.** | Must fix before the entry reaches client review. |
| **P1** | Real defect, doesn't block the rich result. | Fix before launch; track in the triage log. |
| **P2** | Optimization — recommended properties, formatting, orphan URLs. | Backlog; fix opportunistically. |
`--strict` promotes vocabulary/format warnings (and unknown types) from P2 to P1 and
turns on `UNEXPECTED_PROPERTY`. `--no-recommended` drops `MISSING_RECOMMENDED` entirely.
**Neither changes the gate — the gate is always "zero P0."**
## Code reference
### Layer 0 — Coverage
| Code | Sev | Trigger | Fix |
|---|---|---|---|
| `COVERAGE_MISSING` | P1 | Inventory URL has no authored entry. | Author the entry, or remove the URL from the inventory. |
| `COVERAGE_ORPHAN` | P2 | Entry URL isn't in the inventory. | Fix the URL typo, or update the canonical list. |
### Layer 1 — Syntax
| Code | Sev | Trigger | Fix |
|---|---|---|---|
| `INVALID_JSON` | P0 | JSON does not parse. | Fix the JSON (trailing comma, unquoted key, smart quotes). |
| `NO_SCHEMA_IN_HTML` | P0 | Live page has no `ld+json` block (Mode B). | Confirm the tag deployed and renders. |
| `MISSING_CONTEXT` | P1 | No top-level `@context`. | Add `"@context": "https://schema.org"`. |
| `WRONG_CONTEXT` | P1 | `@context` isn't schema.org. | Correct the context URL. |
| `NO_TYPE` | P1 | No `@type` anywhere in the entry. | Add the intended `@type`. |
| `ENCODING_CORRUPTION` | P1 | Replacement char `<60>` present. | Re-export as UTF-8; check the source pipeline. |
| `FETCH_ERROR` | P1 | Live URL could not be fetched (Mode B). | Check the URL/network; retry. |
### Layer 2 — Vocabulary & value formats
| Code | Sev (strict) | Trigger | Fix |
|---|---|---|---|
| `UNKNOWN_TYPE` | P2 (P1) | `@type` not in the curated rule set. | If intended, add it to `schema_rules.json`; else correct the type. |
| `UNEXPECTED_PROPERTY` | — (P1) | Property unknown for a known type (**strict only**). | Remove the typo'd property, or add it to the type's `allowed`. |
| `BAD_URL` | P2 (P1) | A URL property isn't an `http(s)` URL. | Use an absolute URL. |
| `BAD_DATE` | P2 (P1) | A date property isn't ISO-8601. | Use `YYYY-MM-DD` (or full datetime). |
| `BAD_LANG` | P2 (P1) | `inLanguage`/`availableLanguage` isn't a BCP-47 code. | Use `ko`, `en`, `ja`, `zh`, … |
| `BAD_CURRENCY` | P2 (P1) | `priceCurrency` isn't a 3-letter ISO-4217 code. | Use `KRW`/`USD`, not `₩`/`$`. |
| `BAD_NUMBER` | P2 (P1) | A numeric property isn't numeric. | Remove units/commas; keep digits. |
### Layer 3 — Rich-result requirements
| Code | Sev | Trigger | Fix |
|---|---|---|---|
| `MISSING_REQUIRED` | P0 | A Google-required property is absent. | Add the property — the rich result is blocked without it. |
| `MISSING_RECOMMENDED` | P2 | Recommended properties absent (one line per node, lists all). | Add what applies to improve eligibility/appearance. |
### Layer 4 — Consistency
| Code | Sev | Trigger | Fix |
|---|---|---|---|
| `PLACEHOLDER_TEXT` | P0 | Boilerplate token in a string (`예시`, `수정필요`, `lorem`, `{{`, …). | Replace with real content. |
| `NAP_PHONE_MISMATCH` | P0 | Same business, different `telephone` across entries. | Reconcile to the canonical phone. |
| `NAP_ADDRESS_MISMATCH` | P0 | Same business, different `streetAddress`. | Reconcile to the canonical address. |
| `DUPLICATE_ID` | P1 | One `@id` defined ≥2× with differing content. | Make definitions identical, or split the `@id`. |
| `DANGLING_ID` | P1 | `{"@id": …}` reference to a node never defined. | Define the node, or fix the reference. |
| `GEO_SWAPPED` | P1 | latitude/longitude transposed (swapping fixes it). | Swap the values. |
| `GEO_OUT_OF_RANGE` | P1 | Coordinates impossible (lat∉[-90,90] or lon∉[-180,180]). | Correct the coordinates. |
| `DUPLICATE_DESCRIPTION` | P1 | Same description reused across ≥3 entries. | Write distinct descriptions per page. |
## Triage workflow
1. Sort `defect_log.csv` by severity (already sorted P0→P1→P2 on write).
2. **P0:** assign an owner, fix, re-run. No P0 may survive into client review.
3. **P1:** set `owner` + `status`, decide fix-now vs accept; log accepted ones in
`templates/decision-log.md`.
4. **P2:** schedule into the optimization backlog.
5. Re-run after fixes and confirm no regressions before advancing the stage gate.

View File

@@ -0,0 +1,68 @@
# Hotel Page-Type → Schema-Type Map
The G1 (설계) deliverable: every page template gets an assigned schema `@type` and a
required-property list *before* anyone authors entries. Locking this map first is what
prevents the most expensive error — authoring hundreds of entries against the wrong type.
This is the reusable, hotel-domain map. The worked example is JHR (Josun Hotels &
Resorts, `josunhotel.com`) — a multi-brand, multi-language, multi-property group — but
the mapping applies to any lodging-group site (it replaces the earlier client-specific
draft). Adapt the brand/property layer to the client; the page-type → type rules are stable.
## Site shape this map assumes
```
대표 허브 (group) → Organization + WebSite (one canonical node set, @id-anchored)
브랜드 허브 (brand) → Brand / Organization + Hotel families
개별 호텔 (property) → Hotel / LodgingBusiness / Resort
├─ 객실 (rooms) → HotelRoom / Suite (nested or itemList)
├─ 다이닝 (dining) → Restaurant / BarOrPub
├─ 시설·웨딩·연회 → LocalBusiness / MeetingRoom (nested)
├─ 프로모션 (offers) → Offer / AggregateOffer
└─ FAQ / 안내 → FAQPage
```
Each rendered URL also multiplies by **language × device** (ko/en/ja/zh × PC/MOBILE),
which is why the entry count reaches the thousands. The schema `@type` does **not**
change across language/device — only the localized string values do. (Use that fact:
NAP, geo, and `@id` must stay identical across the language variants of one property;
Layer 4 will catch it when they drift.)
## The map
| Page template (Korean / English) | Primary `@type` | Required (P0) | Add these (recommended) |
|---|---|---|---|
| 대표 홈 / group home | `WebSite` (+ `Organization`) | name, url / name, url | publisher, potentialAction(SearchAction), inLanguage / logo, sameAs |
| 브랜드 허브 / brand hub | `Organization` (+ `Brand`) | name, url | logo, sameAs, brand |
| 호텔 메인 / property home | `Hotel` (or `LodgingBusiness`, `Resort`) | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
| 객실 목록·상세 / rooms | `Hotel` w/ `containsPlace``HotelRoom`/`Suite`, or `ItemList` | name, address (host) | image, occupancy, bed, amenityFeature |
| 다이닝 / restaurant | `Restaurant` (or `BarOrPub`) | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification, acceptsReservations |
| 부대시설·웨딩·연회 / facilities | `LocalBusiness` w/ `MeetingRoom` nested | name, address | telephone, openingHoursSpecification, image, url |
| 프로모션·패키지 / offers | `Offer` (or `AggregateOffer`) | price, priceCurrency / lowPrice, priceCurrency | availability, url, validFrom, priceValidUntil |
| 멤버십 / membership | `MemberProgram` | name | hasTiers, hostingOrganization, url |
| 위치·오시는 길 / location | `Hotel` w/ `geo``GeoCoordinates` | name, address | geo (lat/long), hasMap |
| FAQ / 자주 묻는 질문 | `FAQPage``Question`/`Answer` | mainEntity / name, acceptedAnswer / text | — |
| 공지·매거진·기사 / article | `Article` / `NewsArticle` / `BlogPosting` | headline | author, datePublished, image, dateModified, publisher |
| 모든 하위 페이지 / breadcrumbs | `BreadcrumbList``ListItem` | itemListElement / position | item, name |
## Conventions that keep Layer 4 green
- **Anchor shared nodes by `@id`.** Define `Organization` and `WebSite` once
(`https://…/#organization`, `https://…/#website`) and reference them everywhere with
`{"@id": "…"}`. Avoids `DANGLING_ID` (define before you reference) and `DUPLICATE_ID`
(don't redefine with different content).
- **One canonical NAP per property.** The same `telephone` and `streetAddress` must
appear in every language/device variant of a property, or `NAP_*_MISMATCH` (P0) fires.
- **Distinct descriptions.** A reused boilerplate description across ≥3 pages →
`DUPLICATE_DESCRIPTION` (P1). Write per-page copy.
- **geo as `{latitude, longitude}`** in decimal degrees; Korea is lat ≈ 3339, lon ≈
124132. Transposing them trips `GEO_SWAPPED`.
- **No placeholders.** `예시 / 수정필요 / 임시 / lorem / {{…}}` anywhere → `PLACEHOLDER_TEXT`
(P0). The gate exists precisely to stop these from reaching the client.
## Using the map in the lifecycle
- **G1 설계:** fill this table for the client's actual templates; that *is* the schema
spec. DoD: every template has a type + required list.
- **G2 개발:** authors produce entries against it; `--strict` run; zero P0 to advance.
- **G3+:** the map is the reference reviewers and the validator agree on.

View File

@@ -0,0 +1,123 @@
# Validation Methodology
The reasoning behind the 5 layers, and the type → requirement matrix the validator
enforces. The matrix is the human-readable mirror of `scripts/schema_rules.json`
if you change one, change the other.
## Why a machine gate before human review
At a few dozen entries, a person can eyeball JSON-LD. At hundreds (a multi-language,
multi-device, multi-property hotel site easily reaches 2,000+ URLs), eyeballing
fails in a predictable way: the reviewer drowns in *mechanical* errors (a missing
required field, a bad date format, a typo'd URL) and never reaches the *judgement*
errors that actually need a human (is this the right schema type for this page? is
this description accurate?).
The fix is not "review harder." It is to split the work by who is best at it:
| Error class | Best checker | This skill |
|---|---|---|
| Mechanical (parse, required-present, value format, duplicate, consistency) | A script, every time | Layers 04, automated |
| Judgement (type choice, copy accuracy, intent) | A human, once | Client reviews only P0-free entries |
So the gate runs first. **An entry reaches client review only when it has zero P0.**
The client then reviews a clean set against a defect report — never raw JSON in a meeting.
## The layers, in order
Each layer assumes the previous one passed for that entry. A fatal L1 failure
(unparseable JSON, no `@type`) stops deeper layers for that entry — there is nothing
to inspect.
### L0 — Coverage (needs `--url-list`)
Compares the canonical URL inventory against the URLs that actually have an entry.
- `COVERAGE_MISSING` (P1): inventory URL with no authored entry — a gap to fill.
- `COVERAGE_ORPHAN` (P2): entry whose URL isn't in the inventory — a typo, a stale
path, or a list that's out of date. (Expect many orphans if your inventory is a
subset; expect ~zero when it's the real canonical list.)
### L1 — Syntax
The cheapest, hardest blockers. If these fail, nothing downstream is trustworthy.
- `INVALID_JSON` (P0), `NO_SCHEMA_IN_HTML` (P0, live mode).
- `MISSING_CONTEXT` / `WRONG_CONTEXT` / `NO_TYPE` / `ENCODING_CORRUPTION` (P1).
### L2 — Vocabulary & value formats
Is the type known, and are values well-formed?
- `UNKNOWN_TYPE` (P2; P1 in `--strict`): type isn't in the curated rule set. A
*warning*, not an error — add it to `schema_rules.json` if it's intended.
- `BAD_URL` / `BAD_DATE` / `BAD_LANG` / `BAD_CURRENCY` / `BAD_NUMBER` (P2; P1 strict).
- `UNEXPECTED_PROPERTY` (P1, `--strict` only): a property not known for a known type.
**Off by default** — flagging every unexpected property offline produces exactly the
false-positive flood that makes reviewers distrust the tool.
### L3 — Rich-result requirements
The contract Google enforces for eligibility.
- `MISSING_REQUIRED` (P0): a required property is absent → the rich result is blocked.
- `MISSING_RECOMMENDED` (P2): recommended properties absent. **Aggregated to one line
per node** (never one defect per property) — this is the single most important
noise-control decision in the tool.
### L4 — Consistency (cross-node / cross-entry)
The errors a per-entry check can't see.
- `PLACEHOLDER_TEXT` (P0): boilerplate that escaped authoring (`예시`, `수정필요`,
`lorem`, `{{`, …). Almost always a real, embarrassing leak.
- `NAP_PHONE_MISMATCH` / `NAP_ADDRESS_MISMATCH` (P0): the same business shows
different Name/Address/Phone across entries — a local-SEO and trust problem.
- `DUPLICATE_ID` (P1): one `@id` defined twice with different content.
- `DANGLING_ID` (P1): a `{"@id": …}` reference points at a node never defined.
- `GEO_SWAPPED` / `GEO_OUT_OF_RANGE` (P1): latitude/longitude transposed or impossible.
- `DUPLICATE_DESCRIPTION` (P1): the same description reused across ≥3 entries.
## Severity → gate
| Severity | Meaning | Gate effect |
|---|---|---|
| **P0** | Blocker. Breaks parsing, blocks the rich result, or publishes wrong data. | **Fails the gate.** Process exits 1. Entry must not reach client review. |
| **P1** | Fix before launch. Real defect, doesn't block the rich result. | Triage backlog. |
| **P2** | Optimization. Recommended props, style, orphan URLs. | Optimization backlog. |
Full code list: `defect-taxonomy.md`.
## Type → requirement matrix (mirror of `schema_rules.json`)
`required` missing → **P0**. `recommended` missing → **P2** (aggregated). Anything in
`allowed` is accepted silently. Properties outside all three are flagged only in `--strict`.
| Type | Required (P0 if missing) | Recommended (P2 if missing) |
|---|---|---|
| Organization | name, url | logo, sameAs, contactPoint, address |
| WebSite | name, url | publisher, potentialAction, inLanguage |
| WebPage | name | url, isPartOf, primaryImageOfPage, breadcrumb, datePublished, dateModified |
| Hotel / LodgingBusiness / Resort | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
| LocalBusiness | name, address | telephone, openingHoursSpecification, geo, image, url, priceRange, aggregateRating |
| Restaurant / FoodEstablishment | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification |
| FAQPage | mainEntity | — |
| Question | name, acceptedAnswer | — |
| Answer | text | — |
| BreadcrumbList / ItemList | itemListElement | — |
| ListItem | position | item, name |
| Product | name | image, offers, brand, aggregateRating, review, description, sku |
| Offer | price, priceCurrency | availability, url, validFrom, priceValidUntil |
| Article / NewsArticle / BlogPosting | headline | author, datePublished, image, dateModified, publisher |
| Event | name, startDate, location | endDate, offers, performer, image, eventStatus, eventAttendanceMode, organizer |
| Review | reviewRating, author | datePublished, reviewBody, itemReviewed |
| AggregateRating | ratingValue | reviewCount, ratingCount, bestRating |
| MemberProgram | name | hasTiers, hostingOrganization, url |
**Container types** (validated for value formats, but *not* for required/recommended,
because they only ever appear nested): PostalAddress, GeoCoordinates, ImageObject,
ContactPoint, OpeningHoursSpecification, Rating, Brand, EntryPoint, Place, OfferCatalog,
ReserveAction, MeetingRoom, Room/HotelRoom/Suite, MemberProgramTier, Menu/MenuItem, … (full
list in `schema_rules.json``container_types`).
## Extending the rules
Add a type, tighten a requirement, or recognize a new container by editing
`scripts/schema_rules.json` **only** — no Python change needed:
- New rich-result type → add to `known_types` with `required` / `recommended` / `allowed`.
- New nested type to stop "unknown type" warnings → add to `container_types`.
- New value-format property → add to the relevant `value_formats` group.
- New placeholder token to catch → add to `placeholder_tokens`.
After any edit, re-run `make_sample.py` + `validate_schema.py` against the fixture to
confirm you didn't regress.