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:
@@ -0,0 +1,74 @@
|
||||
# Defect Taxonomy
|
||||
|
||||
Every code `validate_schema.py` can emit, its default severity, and what to do.
|
||||
The validator writes these to `defect_log.csv` (columns: `entry_id, url, node_type,
|
||||
layer, code, severity, message, status, owner, note`) and `results.json`.
|
||||
|
||||
## Severity model
|
||||
|
||||
| Severity | Definition | Owner action |
|
||||
|---|---|---|
|
||||
| **P0** | Blocker — breaks parsing, blocks the rich result, or ships wrong/placeholder data. **Fails the gate.** | Must fix before the entry reaches client review. |
|
||||
| **P1** | Real defect, doesn't block the rich result. | Fix before launch; track in the triage log. |
|
||||
| **P2** | Optimization — recommended properties, formatting, orphan URLs. | Backlog; fix opportunistically. |
|
||||
|
||||
`--strict` promotes vocabulary/format warnings (and unknown types) from P2 to P1 and
|
||||
turns on `UNEXPECTED_PROPERTY`. `--no-recommended` drops `MISSING_RECOMMENDED` entirely.
|
||||
**Neither changes the gate — the gate is always "zero P0."**
|
||||
|
||||
## Code reference
|
||||
|
||||
### Layer 0 — Coverage
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `COVERAGE_MISSING` | P1 | Inventory URL has no authored entry. | Author the entry, or remove the URL from the inventory. |
|
||||
| `COVERAGE_ORPHAN` | P2 | Entry URL isn't in the inventory. | Fix the URL typo, or update the canonical list. |
|
||||
|
||||
### Layer 1 — Syntax
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `INVALID_JSON` | P0 | JSON does not parse. | Fix the JSON (trailing comma, unquoted key, smart quotes). |
|
||||
| `NO_SCHEMA_IN_HTML` | P0 | Live page has no `ld+json` block (Mode B). | Confirm the tag deployed and renders. |
|
||||
| `MISSING_CONTEXT` | P1 | No top-level `@context`. | Add `"@context": "https://schema.org"`. |
|
||||
| `WRONG_CONTEXT` | P1 | `@context` isn't schema.org. | Correct the context URL. |
|
||||
| `NO_TYPE` | P1 | No `@type` anywhere in the entry. | Add the intended `@type`. |
|
||||
| `ENCODING_CORRUPTION` | P1 | Replacement char `<60>` present. | Re-export as UTF-8; check the source pipeline. |
|
||||
| `FETCH_ERROR` | P1 | Live URL could not be fetched (Mode B). | Check the URL/network; retry. |
|
||||
|
||||
### Layer 2 — Vocabulary & value formats
|
||||
| Code | Sev (strict) | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `UNKNOWN_TYPE` | P2 (P1) | `@type` not in the curated rule set. | If intended, add it to `schema_rules.json`; else correct the type. |
|
||||
| `UNEXPECTED_PROPERTY` | — (P1) | Property unknown for a known type (**strict only**). | Remove the typo'd property, or add it to the type's `allowed`. |
|
||||
| `BAD_URL` | P2 (P1) | A URL property isn't an `http(s)` URL. | Use an absolute URL. |
|
||||
| `BAD_DATE` | P2 (P1) | A date property isn't ISO-8601. | Use `YYYY-MM-DD` (or full datetime). |
|
||||
| `BAD_LANG` | P2 (P1) | `inLanguage`/`availableLanguage` isn't a BCP-47 code. | Use `ko`, `en`, `ja`, `zh`, … |
|
||||
| `BAD_CURRENCY` | P2 (P1) | `priceCurrency` isn't a 3-letter ISO-4217 code. | Use `KRW`/`USD`, not `₩`/`$`. |
|
||||
| `BAD_NUMBER` | P2 (P1) | A numeric property isn't numeric. | Remove units/commas; keep digits. |
|
||||
|
||||
### Layer 3 — Rich-result requirements
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `MISSING_REQUIRED` | P0 | A Google-required property is absent. | Add the property — the rich result is blocked without it. |
|
||||
| `MISSING_RECOMMENDED` | P2 | Recommended properties absent (one line per node, lists all). | Add what applies to improve eligibility/appearance. |
|
||||
|
||||
### Layer 4 — Consistency
|
||||
| Code | Sev | Trigger | Fix |
|
||||
|---|---|---|---|
|
||||
| `PLACEHOLDER_TEXT` | P0 | Boilerplate token in a string (`예시`, `수정필요`, `lorem`, `{{`, …). | Replace with real content. |
|
||||
| `NAP_PHONE_MISMATCH` | P0 | Same business, different `telephone` across entries. | Reconcile to the canonical phone. |
|
||||
| `NAP_ADDRESS_MISMATCH` | P0 | Same business, different `streetAddress`. | Reconcile to the canonical address. |
|
||||
| `DUPLICATE_ID` | P1 | One `@id` defined ≥2× with differing content. | Make definitions identical, or split the `@id`. |
|
||||
| `DANGLING_ID` | P1 | `{"@id": …}` reference to a node never defined. | Define the node, or fix the reference. |
|
||||
| `GEO_SWAPPED` | P1 | latitude/longitude transposed (swapping fixes it). | Swap the values. |
|
||||
| `GEO_OUT_OF_RANGE` | P1 | Coordinates impossible (lat∉[-90,90] or lon∉[-180,180]). | Correct the coordinates. |
|
||||
| `DUPLICATE_DESCRIPTION` | P1 | Same description reused across ≥3 entries. | Write distinct descriptions per page. |
|
||||
|
||||
## Triage workflow
|
||||
|
||||
1. Sort `defect_log.csv` by severity (already sorted P0→P1→P2 on write).
|
||||
2. **P0:** assign an owner, fix, re-run. No P0 may survive into client review.
|
||||
3. **P1:** set `owner` + `status`, decide fix-now vs accept; log accepted ones in
|
||||
`templates/decision-log.md`.
|
||||
4. **P2:** schedule into the optimization backlog.
|
||||
5. Re-run after fixes and confirm no regressions before advancing the stage gate.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Hotel Page-Type → Schema-Type Map
|
||||
|
||||
The G1 (설계) deliverable: every page template gets an assigned schema `@type` and a
|
||||
required-property list *before* anyone authors entries. Locking this map first is what
|
||||
prevents the most expensive error — authoring hundreds of entries against the wrong type.
|
||||
|
||||
This is the reusable, hotel-domain map. The worked example is JHR (Josun Hotels &
|
||||
Resorts, `josunhotel.com`) — a multi-brand, multi-language, multi-property group — but
|
||||
the mapping applies to any lodging-group site (it replaces the earlier client-specific
|
||||
draft). Adapt the brand/property layer to the client; the page-type → type rules are stable.
|
||||
|
||||
## Site shape this map assumes
|
||||
|
||||
```
|
||||
대표 허브 (group) → Organization + WebSite (one canonical node set, @id-anchored)
|
||||
브랜드 허브 (brand) → Brand / Organization + Hotel families
|
||||
개별 호텔 (property) → Hotel / LodgingBusiness / Resort
|
||||
├─ 객실 (rooms) → HotelRoom / Suite (nested or itemList)
|
||||
├─ 다이닝 (dining) → Restaurant / BarOrPub
|
||||
├─ 시설·웨딩·연회 → LocalBusiness / MeetingRoom (nested)
|
||||
├─ 프로모션 (offers) → Offer / AggregateOffer
|
||||
└─ FAQ / 안내 → FAQPage
|
||||
```
|
||||
|
||||
Each rendered URL also multiplies by **language × device** (ko/en/ja/zh × PC/MOBILE),
|
||||
which is why the entry count reaches the thousands. The schema `@type` does **not**
|
||||
change across language/device — only the localized string values do. (Use that fact:
|
||||
NAP, geo, and `@id` must stay identical across the language variants of one property;
|
||||
Layer 4 will catch it when they drift.)
|
||||
|
||||
## The map
|
||||
|
||||
| Page template (Korean / English) | Primary `@type` | Required (P0) | Add these (recommended) |
|
||||
|---|---|---|---|
|
||||
| 대표 홈 / group home | `WebSite` (+ `Organization`) | name, url / name, url | publisher, potentialAction(SearchAction), inLanguage / logo, sameAs |
|
||||
| 브랜드 허브 / brand hub | `Organization` (+ `Brand`) | name, url | logo, sameAs, brand |
|
||||
| 호텔 메인 / property home | `Hotel` (or `LodgingBusiness`, `Resort`) | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
|
||||
| 객실 목록·상세 / rooms | `Hotel` w/ `containsPlace`→`HotelRoom`/`Suite`, or `ItemList` | name, address (host) | image, occupancy, bed, amenityFeature |
|
||||
| 다이닝 / restaurant | `Restaurant` (or `BarOrPub`) | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification, acceptsReservations |
|
||||
| 부대시설·웨딩·연회 / facilities | `LocalBusiness` w/ `MeetingRoom` nested | name, address | telephone, openingHoursSpecification, image, url |
|
||||
| 프로모션·패키지 / offers | `Offer` (or `AggregateOffer`) | price, priceCurrency / lowPrice, priceCurrency | availability, url, validFrom, priceValidUntil |
|
||||
| 멤버십 / membership | `MemberProgram` | name | hasTiers, hostingOrganization, url |
|
||||
| 위치·오시는 길 / location | `Hotel` w/ `geo`→`GeoCoordinates` | name, address | geo (lat/long), hasMap |
|
||||
| FAQ / 자주 묻는 질문 | `FAQPage` → `Question`/`Answer` | mainEntity / name, acceptedAnswer / text | — |
|
||||
| 공지·매거진·기사 / article | `Article` / `NewsArticle` / `BlogPosting` | headline | author, datePublished, image, dateModified, publisher |
|
||||
| 모든 하위 페이지 / breadcrumbs | `BreadcrumbList` → `ListItem` | itemListElement / position | item, name |
|
||||
|
||||
## Conventions that keep Layer 4 green
|
||||
|
||||
- **Anchor shared nodes by `@id`.** Define `Organization` and `WebSite` once
|
||||
(`https://…/#organization`, `https://…/#website`) and reference them everywhere with
|
||||
`{"@id": "…"}`. Avoids `DANGLING_ID` (define before you reference) and `DUPLICATE_ID`
|
||||
(don't redefine with different content).
|
||||
- **One canonical NAP per property.** The same `telephone` and `streetAddress` must
|
||||
appear in every language/device variant of a property, or `NAP_*_MISMATCH` (P0) fires.
|
||||
- **Distinct descriptions.** A reused boilerplate description across ≥3 pages →
|
||||
`DUPLICATE_DESCRIPTION` (P1). Write per-page copy.
|
||||
- **geo as `{latitude, longitude}`** in decimal degrees; Korea is lat ≈ 33–39, lon ≈
|
||||
124–132. Transposing them trips `GEO_SWAPPED`.
|
||||
- **No placeholders.** `예시 / 수정필요 / 임시 / lorem / {{…}}` anywhere → `PLACEHOLDER_TEXT`
|
||||
(P0). The gate exists precisely to stop these from reaching the client.
|
||||
|
||||
## Using the map in the lifecycle
|
||||
|
||||
- **G1 설계:** fill this table for the client's actual templates; that *is* the schema
|
||||
spec. DoD: every template has a type + required list.
|
||||
- **G2 개발:** authors produce entries against it; `--strict` run; zero P0 to advance.
|
||||
- **G3+:** the map is the reference reviewers and the validator agree on.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Validation Methodology
|
||||
|
||||
The reasoning behind the 5 layers, and the type → requirement matrix the validator
|
||||
enforces. The matrix is the human-readable mirror of `scripts/schema_rules.json` —
|
||||
if you change one, change the other.
|
||||
|
||||
## Why a machine gate before human review
|
||||
|
||||
At a few dozen entries, a person can eyeball JSON-LD. At hundreds (a multi-language,
|
||||
multi-device, multi-property hotel site easily reaches 2,000+ URLs), eyeballing
|
||||
fails in a predictable way: the reviewer drowns in *mechanical* errors (a missing
|
||||
required field, a bad date format, a typo'd URL) and never reaches the *judgement*
|
||||
errors that actually need a human (is this the right schema type for this page? is
|
||||
this description accurate?).
|
||||
|
||||
The fix is not "review harder." It is to split the work by who is best at it:
|
||||
|
||||
| Error class | Best checker | This skill |
|
||||
|---|---|---|
|
||||
| Mechanical (parse, required-present, value format, duplicate, consistency) | A script, every time | Layers 0–4, automated |
|
||||
| Judgement (type choice, copy accuracy, intent) | A human, once | Client reviews only P0-free entries |
|
||||
|
||||
So the gate runs first. **An entry reaches client review only when it has zero P0.**
|
||||
The client then reviews a clean set against a defect report — never raw JSON in a meeting.
|
||||
|
||||
## The layers, in order
|
||||
|
||||
Each layer assumes the previous one passed for that entry. A fatal L1 failure
|
||||
(unparseable JSON, no `@type`) stops deeper layers for that entry — there is nothing
|
||||
to inspect.
|
||||
|
||||
### L0 — Coverage (needs `--url-list`)
|
||||
Compares the canonical URL inventory against the URLs that actually have an entry.
|
||||
- `COVERAGE_MISSING` (P1): inventory URL with no authored entry — a gap to fill.
|
||||
- `COVERAGE_ORPHAN` (P2): entry whose URL isn't in the inventory — a typo, a stale
|
||||
path, or a list that's out of date. (Expect many orphans if your inventory is a
|
||||
subset; expect ~zero when it's the real canonical list.)
|
||||
|
||||
### L1 — Syntax
|
||||
The cheapest, hardest blockers. If these fail, nothing downstream is trustworthy.
|
||||
- `INVALID_JSON` (P0), `NO_SCHEMA_IN_HTML` (P0, live mode).
|
||||
- `MISSING_CONTEXT` / `WRONG_CONTEXT` / `NO_TYPE` / `ENCODING_CORRUPTION` (P1).
|
||||
|
||||
### L2 — Vocabulary & value formats
|
||||
Is the type known, and are values well-formed?
|
||||
- `UNKNOWN_TYPE` (P2; P1 in `--strict`): type isn't in the curated rule set. A
|
||||
*warning*, not an error — add it to `schema_rules.json` if it's intended.
|
||||
- `BAD_URL` / `BAD_DATE` / `BAD_LANG` / `BAD_CURRENCY` / `BAD_NUMBER` (P2; P1 strict).
|
||||
- `UNEXPECTED_PROPERTY` (P1, `--strict` only): a property not known for a known type.
|
||||
**Off by default** — flagging every unexpected property offline produces exactly the
|
||||
false-positive flood that makes reviewers distrust the tool.
|
||||
|
||||
### L3 — Rich-result requirements
|
||||
The contract Google enforces for eligibility.
|
||||
- `MISSING_REQUIRED` (P0): a required property is absent → the rich result is blocked.
|
||||
- `MISSING_RECOMMENDED` (P2): recommended properties absent. **Aggregated to one line
|
||||
per node** (never one defect per property) — this is the single most important
|
||||
noise-control decision in the tool.
|
||||
|
||||
### L4 — Consistency (cross-node / cross-entry)
|
||||
The errors a per-entry check can't see.
|
||||
- `PLACEHOLDER_TEXT` (P0): boilerplate that escaped authoring (`예시`, `수정필요`,
|
||||
`lorem`, `{{`, …). Almost always a real, embarrassing leak.
|
||||
- `NAP_PHONE_MISMATCH` / `NAP_ADDRESS_MISMATCH` (P0): the same business shows
|
||||
different Name/Address/Phone across entries — a local-SEO and trust problem.
|
||||
- `DUPLICATE_ID` (P1): one `@id` defined twice with different content.
|
||||
- `DANGLING_ID` (P1): a `{"@id": …}` reference points at a node never defined.
|
||||
- `GEO_SWAPPED` / `GEO_OUT_OF_RANGE` (P1): latitude/longitude transposed or impossible.
|
||||
- `DUPLICATE_DESCRIPTION` (P1): the same description reused across ≥3 entries.
|
||||
|
||||
## Severity → gate
|
||||
|
||||
| Severity | Meaning | Gate effect |
|
||||
|---|---|---|
|
||||
| **P0** | Blocker. Breaks parsing, blocks the rich result, or publishes wrong data. | **Fails the gate.** Process exits 1. Entry must not reach client review. |
|
||||
| **P1** | Fix before launch. Real defect, doesn't block the rich result. | Triage backlog. |
|
||||
| **P2** | Optimization. Recommended props, style, orphan URLs. | Optimization backlog. |
|
||||
|
||||
Full code list: `defect-taxonomy.md`.
|
||||
|
||||
## Type → requirement matrix (mirror of `schema_rules.json`)
|
||||
|
||||
`required` missing → **P0**. `recommended` missing → **P2** (aggregated). Anything in
|
||||
`allowed` is accepted silently. Properties outside all three are flagged only in `--strict`.
|
||||
|
||||
| Type | Required (P0 if missing) | Recommended (P2 if missing) |
|
||||
|---|---|---|
|
||||
| Organization | name, url | logo, sameAs, contactPoint, address |
|
||||
| WebSite | name, url | publisher, potentialAction, inLanguage |
|
||||
| WebPage | name | url, isPartOf, primaryImageOfPage, breadcrumb, datePublished, dateModified |
|
||||
| Hotel / LodgingBusiness / Resort | name, address | telephone, image, priceRange, geo, url, starRating, aggregateRating |
|
||||
| LocalBusiness | name, address | telephone, openingHoursSpecification, geo, image, url, priceRange, aggregateRating |
|
||||
| Restaurant / FoodEstablishment | name, address | servesCuisine, priceRange, telephone, menu, openingHoursSpecification |
|
||||
| FAQPage | mainEntity | — |
|
||||
| Question | name, acceptedAnswer | — |
|
||||
| Answer | text | — |
|
||||
| BreadcrumbList / ItemList | itemListElement | — |
|
||||
| ListItem | position | item, name |
|
||||
| Product | name | image, offers, brand, aggregateRating, review, description, sku |
|
||||
| Offer | price, priceCurrency | availability, url, validFrom, priceValidUntil |
|
||||
| Article / NewsArticle / BlogPosting | headline | author, datePublished, image, dateModified, publisher |
|
||||
| Event | name, startDate, location | endDate, offers, performer, image, eventStatus, eventAttendanceMode, organizer |
|
||||
| Review | reviewRating, author | datePublished, reviewBody, itemReviewed |
|
||||
| AggregateRating | ratingValue | reviewCount, ratingCount, bestRating |
|
||||
| MemberProgram | name | hasTiers, hostingOrganization, url |
|
||||
|
||||
**Container types** (validated for value formats, but *not* for required/recommended,
|
||||
because they only ever appear nested): PostalAddress, GeoCoordinates, ImageObject,
|
||||
ContactPoint, OpeningHoursSpecification, Rating, Brand, EntryPoint, Place, OfferCatalog,
|
||||
ReserveAction, MeetingRoom, Room/HotelRoom/Suite, MemberProgramTier, Menu/MenuItem, … (full
|
||||
list in `schema_rules.json` → `container_types`).
|
||||
|
||||
## Extending the rules
|
||||
|
||||
Add a type, tighten a requirement, or recognize a new container by editing
|
||||
`scripts/schema_rules.json` **only** — no Python change needed:
|
||||
- New rich-result type → add to `known_types` with `required` / `recommended` / `allowed`.
|
||||
- New nested type to stop "unknown type" warnings → add to `container_types`.
|
||||
- New value-format property → add to the relevant `value_formats` group.
|
||||
- New placeholder token to catch → add to `placeholder_tokens`.
|
||||
|
||||
After any edit, re-run `make_sample.py` + `validate_schema.py` against the fixture to
|
||||
confirm you didn't regress.
|
||||
Reference in New Issue
Block a user