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>
6.8 KiB
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 toschema_rules.jsonif it's intended.BAD_URL/BAD_DATE/BAD_LANG/BAD_CURRENCY/BAD_NUMBER(P2; P1 strict).UNEXPECTED_PROPERTY(P1,--strictonly): 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@iddefined 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_typeswithrequired/recommended/allowed. - New nested type to stop "unknown type" warnings → add to
container_types. - New value-format property → add to the relevant
value_formatsgroup. - 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.