Schema Markup Audit Claude Skill
This skill audits a website's structured data and produces an actionable SEO report. It extracts JSON-LD, Microdata, and RDFa from pages, validates the markup against schema.org and Google rich result requirements, and finds missing, invalid, or incomplete schema, then recommends concrete fixes.
What The Skill Checks
- Invalid JSON-LD parse errors, missing required properties for declared types, and key page types with no schema at all, each reported with the exact page, schema type, and property.
- Missing recommended properties for rich results, schema that describes content not actually on the page, conflicting or duplicate schema blocks, and deprecated types or properties.
- Missing
OrganizationandWebSiteschema on the homepage, mixed JSON-LD and Microdata duplicates of the same entity, and missing optional enhancements likesameAsandBreadcrumbList.
How The Skill Is Packaged
The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that extract structured data from pages and validate the resulting inventory. Every report can be exported to a shareable Word .docx or a flat .csv of findings via the bundled md_to_docx.py and findings_to_csv.py scripts. Install it with npx claude-seo-skills install schema-markup-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.
Skill Files
Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.
SKILL.md
The skill definition: frontmatter, inputs to collect, the six-step audit workflow, and the resources the skill relies on.
Schema Markup Audit
Audit the structured data (schema markup) of a website and produce an actionable SEO report.
When to use this skill
Use this skill when the user asks to:
- Audit or validate schema markup / structured data on a website
- Check rich result eligibility (e.g., Product, Article, FAQ, Review snippets)
- Find missing, invalid, or incomplete JSON-LD, Microdata, or RDFa
- Recommend which schema types to add to which pages
- Fix errors or warnings reported by Google's Rich Results Test or Search Console
Inputs to collect
Before starting, confirm with the user:
- Site URL or local files — a live site root URL (e.g.,
https://example.com), a sitemap URL, a list of URLs, or a local folder of HTML files. - Scope — full site, a specific section (e.g.,
/products/), or a list of URLs. - Crawl limits — max pages (default 200) for live crawls.
- Business context — site type (e-commerce, blog, local business, SaaS, etc.) so page-type-to-schema-type expectations can be set.
Workflow
Step 1: Gather the page set
- If a sitemap is available (
/sitemap.xml), fetch it to get the canonical page list. - Otherwise, crawl from the homepage following only same-host links.
- For local HTML folders, enumerate all
.htmlfiles.
Step 2: Extract structured data
Use scripts/extract_schema.py to fetch pages and extract all structured data (JSON-LD, Microdata, RDFa) into a JSON inventory:
python3 scripts/extract_schema.py https://example.com --max-pages 200 --output schema_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_schema.py --from-cache page_cache.json --output schema_inventory.json
For every page, record:
- Each structured data block: format (
json-ld,microdata,rdfa),@type, raw parsed object - JSON parse errors in
<script type="application/ld+json">blocks - Page metadata: title, canonical URL, meta robots
Step 3: Run the audit checks
Use scripts/validate_schema.py to validate the inventory:
python3 scripts/validate_schema.py schema_inventory.json --output audit_report.json
Evaluate each check defined in references/audit-checks.md. The core checks are:
| Check | Severity |
|---|---|
| Invalid JSON-LD (parse errors) | High |
| Missing required properties for the declared type | High |
| Missing schema on key page types (e.g., Product pages without Product schema) | High |
| Missing recommended properties for rich results | Medium |
| Schema/content mismatch (markup describes content not on the page) | Medium |
| Conflicting or duplicate schema blocks on one page | Medium |
Deprecated types or properties (e.g., HowTo rich results retired) | Medium |
Missing Organization / WebSite schema on the homepage | Medium |
| Mixed formats for the same entity (JSON-LD + Microdata duplicates) | Low |
Missing optional enhancements (sameAs, BreadcrumbList, etc.) | Low |
Step 4: Verify against external validators
When the site is live, recommend the user confirm key templates with:
- Google Rich Results Test (
https://search.google.com/test/rich-results) - Schema.org validator (
https://validator.schema.org/)
Validate one representative URL per page template rather than every page.
Step 5: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — pages scanned, schema blocks found, types in use, issue counts by severity
- Coverage map — page types vs. expected schema types, showing gaps
- Issues — one section per failing check, listing affected page, schema type, property, and a specific fix
- Opportunities — schema types worth adding for rich result eligibility
- Prioritized action list — ordered by severity and estimated impact
Step 6: Recommend fixes
For each issue, give a concrete, copy-pasteable recommendation, e.g.:
- Fix the JSON syntax error in the JSON-LD block on
/products/widget(trailing comma at line 14) - Add the required
offersproperty to theProductschema on all product pages, includingprice,priceCurrency, andavailability - Provide a complete corrected JSON-LD snippet for the page template, using real values from the page content
Always recommend JSON-LD as the implementation format (Google's preferred format) when adding new markup.
Optional: export the report
As a Word document (.docx)
If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:
python3 scripts/md_to_docx.py report.md --output report.docx
scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.
As a CSV of findings (.csv)
If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:
python3 scripts/findings_to_csv.py audit_report.json --output findings.csv
scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.
Resources
scripts/md_to_docx.py— convert the Markdown report into a Word (.docx) document (standard library only)scripts/findings_to_csv.py— flatten the audit findings JSON into a CSV, one row per finding (standard library only)references/audit-checks.md— full definitions, severities, and rationale for every audit check, plus required/recommended properties per schema typereferences/report-template.md— report output structurescripts/extract_schema.py— crawl pages and extract all structured data into a JSON inventoryscripts/validate_schema.py— run audit checks against a schema inventory JSON file
Audit Checks Reference (references/audit-checks.md)
Full definitions, severities, and rationale for every audit check, plus required and recommended properties per schema type.
Schema Markup Audit Checks
Full definitions, severities, and rationale for each audit check, plus required/recommended properties for common schema types.
High severity
1. Invalid JSON-LD
- Definition: A
<script type="application/ld+json">block whose contents fail JSON parsing (trailing commas, unescaped quotes, comments, truncation). - Why it matters: Invalid blocks are ignored entirely by search engines — the page gets no structured data benefit from them.
- Fix: Correct the JSON syntax. Validate with a JSON parser before deploying.
2. Missing required properties
- Definition: A schema block declares a type but omits properties Google requires for that type's rich result (see property tables below).
- Why it matters: Missing required properties make the item ineligible for rich results and appear as errors in Search Console.
- Fix: Add each required property with a real value taken from the visible page content.
3. Missing schema on key page types
- Definition: A page whose template clearly maps to a schema type but has no matching markup (e.g., product pages without
Product, articles withoutArticle, location pages withoutLocalBusiness). - Why it matters: The page forfeits rich result eligibility competitors may have.
- Fix: Add JSON-LD markup of the appropriate type to the page template.
Medium severity
4. Missing recommended properties
- Definition: Required properties are present, but recommended ones (per Google's rich result docs) are missing.
- Why it matters: Recommended properties improve rich result quality and appear as warnings in Search Console.
- Fix: Add recommended properties where the data exists; do not fabricate values.
5. Schema/content mismatch
- Definition: Markup describes content not visible on the page (e.g., review stars with no visible reviews, FAQ markup without the Q&A on the page, prices that differ from the displayed price).
- Why it matters: Violates Google's structured data guidelines and can trigger manual actions.
- Fix: Make markup mirror visible page content exactly; remove markup for content that isn't there.
6. Conflicting or duplicate schema blocks
- Definition: Multiple blocks on one page declare the same entity with different values (e.g., two
Productblocks with different prices), or the same block is duplicated. - Why it matters: Search engines may pick the wrong values or ignore both.
- Fix: Keep one authoritative block per entity per page; merge or remove the rest.
7. Deprecated types or properties
- Definition: Markup uses types whose rich results Google has retired or restricted (e.g.,
HowToretired,FAQPagelimited to well-known authoritative government and health sites), or schema.org-deprecated properties. - Why it matters: Maintenance burden with no rich result benefit; signals stale implementation.
- Fix: Remove or deprioritize the markup; redirect effort to types that still produce rich results.
8. Missing Organization / WebSite schema on the homepage
- Definition: The homepage lacks
Organization(orLocalBusiness) andWebSitemarkup. - Why it matters: These feed Google's knowledge panel, sitelinks, and brand entity understanding.
- Fix: Add
Organizationwithname,url,logo, andsameAs(social profiles), plusWebSitewithnameandurl.
9. Broken or invalid property values
- Definition: Properties with malformed values: relative or 404 image URLs, invalid ISO 8601 dates, invalid currency codes, prices as
"$10"instead of"10.00", wrong enumeration values (e.g.,availabilitynot aschema.org/ItemAvailabilityURL). - Why it matters: Invalid values are treated as missing.
- Fix: Use absolute HTTPS URLs, ISO 8601 dates, ISO 4217 currency codes, and schema.org enumeration URLs.
10. Missing or mismatched entity linking (@id / url)
- Definition: Entities that should reference each other (e.g.,
Article.publisher→Organization) are disconnected, orurlvalues don't match the canonical URL. - Why it matters: Weakens entity graph consistency across the site.
- Fix: Use stable
@idvalues (e.g.,https://example.com/#organization) and reference them across pages; alignurlwith canonicals.
Low severity
11. Mixed formats for the same entity
- Definition: The same entity is marked up in both JSON-LD and Microdata/RDFa on the same page (often a CMS theme plus a plugin).
- Why it matters: Risk of conflicting values; harder to maintain.
- Fix: Standardize on JSON-LD and remove the duplicate Microdata/RDFa.
12. Missing optional enhancements
- Definition: Easy wins not implemented:
BreadcrumbListon inner pages,sameAssocial links,ImageObjectfor logos,inLanguage,authorwith aPersonprofile page. - Why it matters: Incremental gains in rich result quality and entity clarity.
- Fix: Add where data is readily available.
Required and recommended properties by type
Based on Google's rich result documentation. "Required" means required for rich result eligibility.
Product (with offer)
- Required:
name,offers(withprice,priceCurrency),image - Recommended:
offers.availability,offers.priceValidUntil,aggregateRating,review,sku,gtin/mpn,brand,description
Article / BlogPosting / NewsArticle
- Required: none strictly, but
headlineandimageare needed for rich display - Recommended:
author(withnameandurl),datePublished,dateModified,publisher,mainEntityOfPage
LocalBusiness
- Required:
name,address - Recommended:
telephone,openingHoursSpecification,geo,url,image,priceRange,sameAs
Organization
- Recommended:
name,url,logo,sameAs,contactPoint
WebSite
- Recommended:
name,url; addpotentialAction(SearchAction) only if the site has internal search.
BreadcrumbList
- Required:
itemListElementwithListItems, each withposition,name, anditem(URL; omit on the last item)
FAQPage
- Required:
mainEntityofQuestionitems, each withacceptedAnswer - Note: Rich results limited to authoritative government/health sites; markup must mirror visible Q&A.
Review / AggregateRating
- Required:
reviewRating/ratingValue,author(forReview),itemReviewed(when standalone) - Note: Self-serving reviews (a business marking up reviews of itself via
Organization/LocalBusiness) are ineligible for review rich results.
Event
- Required:
name,startDate,location - Recommended:
endDate,eventStatus,eventAttendanceMode,offers,performer,image,organizer
JobPosting
- Required:
title,description,datePosted,hiringOrganization,jobLocation(orapplicantLocationRequirements+jobLocationTypefor remote) - Recommended:
baseSalary,employmentType,validThrough,identifier
Recipe
- Required:
name,image - Recommended:
author,datePublished,description,prepTime,cookTime,totalTime,recipeIngredient,recipeInstructions,recipeYield,nutrition,aggregateRating,video
VideoObject
- Required:
name,thumbnailUrl,uploadDate - Recommended:
description,duration,contentUrl,embedUrl,interactionStatistic
Expected schema by page type
Use this mapping to detect coverage gaps (adjust with the user's business context):
| Page type | Expected schema |
|---|---|
| Homepage | Organization (or LocalBusiness), WebSite |
| Product page | Product with offers, BreadcrumbList |
| Category/listing page | BreadcrumbList; optionally ItemList |
| Blog post / article | Article/BlogPosting, BreadcrumbList |
| Contact / location page | LocalBusiness with address, hours |
| Event page | Event |
| Job listing | JobPosting |
| Recipe page | Recipe |
| Video page | VideoObject |
Report Template (references/report-template.md)
The output structure the skill follows when writing the final audit report.
Schema Markup Audit Report Template
Use this structure for the final report delivered to the user.
# Schema Markup Audit — {site}
**Date:** {date}
**Scope:** {scope description}
**Pages scanned:** {n} | **Schema blocks found:** {n} | **Types in use:** {Type1, Type2, ...}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentence overview of the site's structured data health and the biggest wins.}
## Coverage map
| Page type | Pages | Expected schema | Present | Gap |
|---|---|---|---|---|
| Product pages | {n} | Product, BreadcrumbList | Product only | BreadcrumbList missing |
| ... | ... | ... | ... | ... |
## High-severity issues
### Invalid JSON-LD ({n})
| Page | Error | Location | Suggested fix |
|---|---|---|---|
| ... | Trailing comma | line {n} | ... |
### Missing required properties ({n})
| Page | Schema type | Missing property | Suggested value source |
|---|---|---|---|
| ... | Product | offers.priceCurrency | Displayed price on page |
### Missing schema on key pages ({n})
| Page / template | Expected type | Recommendation |
|---|---|---|
| ... | ... | ... |
## Medium-severity issues
{One subsection per failing check, same table style: affected pages, schema type, property, specific fix.}
## Low-severity issues
{Brief list or table.}
## Opportunities
{Schema types worth adding for rich result eligibility, with the pages/templates to add them to and a ready-to-use JSON-LD snippet per template.}
## Prioritized action list
1. {Highest-impact fix, with exact pages and corrected JSON-LD snippets}
2. ...
## Validation checklist
- [ ] Re-test one URL per fixed template in Google's Rich Results Test
- [ ] Confirm no parse errors at validator.schema.org
- [ ] Monitor Search Console > Enhancements for error/warning trends after deploy
Guidelines:
- Include a corrected, copy-pasteable JSON-LD snippet for every template-level fix, populated with real values from the page.
- Group issues by template when many pages share the same problem; list a few example URLs rather than hundreds of rows.
- Only include sections that have findings.
Schema Extractor Script (scripts/extract_schema.py)
Crawls a live site or local HTML folder within the configured page limits and extracts all JSON-LD, Microdata, and RDFa into a JSON inventory.
#!/usr/bin/env python3
"""Crawl a website (or scan local HTML files) and extract structured data into a JSON inventory.
Usage:
python3 extract_schema.py https://example.com [--max-pages 200] [--output schema_inventory.json]
python3 extract_schema.py ./site-folder --local [--output schema_inventory.json]
python3 extract_schema.py urls.txt --url-list [--output schema_inventory.json]
Output JSON schema:
{
"site": "https://example.com",
"pages": {
"<url>": {
"status": 200,
"title": "...",
"canonical": "<url or null>",
"meta_robots": "<content or null>",
"schema_blocks": [
{"format": "json-ld", "types": ["Product"], "data": {...}},
{"format": "microdata", "types": ["Offer"], "data": {...}},
{"format": "rdfa", "types": ["Article"], "data": {...}}
],
"jsonld_errors": [
{"block_index": 0, "error": "Expecting value: line 14 column 3 (char 200)", "snippet": "..."}
]
}
}
}
Uses only the Python standard library.
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser
USER_AGENT = "SchemaMarkupAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
def extract_types(data):
"""Collect all @type values from a parsed JSON-LD object."""
types = []
def walk(node):
if isinstance(node, dict):
t = node.get("@type")
if isinstance(t, str):
types.append(t)
elif isinstance(t, list):
types.extend(x for x in t if isinstance(x, str))
for v in node.values():
walk(v)
elif isinstance(node, list):
for item in node:
walk(item)
walk(data)
return types
class SchemaParser(HTMLParser):
"""Extract JSON-LD blocks, Microdata, RDFa, page links, and metadata."""
def __init__(self, base_url):
super().__init__(convert_charrefs=True)
self.base_url = base_url
self.title = None
self.canonical = None
self.meta_robots = None
self.links = []
self.jsonld_raw = [] # raw text of each ld+json script
self._in_jsonld = False
self._jsonld_parts = []
self._in_title = False
# Microdata
self.microdata_items = [] # top-level items
self._md_stack = [] # (item_dict, opening_tag, depth_of_same_tag)
self._md_text_target = None # (item, prop) capturing text content
self._md_text_parts = []
# RDFa
self.rdfa_items = []
self._rdfa_stack = [] # (item_dict, opening_tag, depth_of_same_tag)
self._rdfa_text_target = None
self._rdfa_text_parts = []
self._elem_depth = 0 # depth of open non-void elements
# --- helpers -------------------------------------------------------
def _set_prop(self, item, name, value):
if name in item:
existing = item[name]
if isinstance(existing, list):
existing.append(value)
else:
item[name] = [existing, value]
else:
item[name] = value
VOID_TAGS = frozenset((
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
))
# --- HTMLParser hooks ----------------------------------------------
def handle_starttag(self, tag, attrs):
a = dict(attrs)
is_void = tag in self.VOID_TAGS
if not is_void:
self._elem_depth += 1
if tag == "script" and (a.get("type") or "").strip().lower() == "application/ld+json":
self._in_jsonld = True
self._jsonld_parts = []
return
if tag == "title":
self._in_title = True
elif tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
self.canonical = urllib.parse.urljoin(self.base_url, a["href"])
elif tag == "meta" and (a.get("name") or "").lower() == "robots":
self.meta_robots = a.get("content")
elif tag == "a" and a.get("href"):
href = a["href"].strip()
if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
target = urllib.parse.urljoin(self.base_url, href)
self.links.append(urllib.parse.urldefrag(target)[0])
# --- Microdata ---
if "itemscope" in a:
item = {"@type": (a.get("itemtype") or "").rsplit("/", 1)[-1] or None}
if a.get("itemid"):
item["@id"] = a["itemid"]
prop = a.get("itemprop")
if self._md_stack and prop:
parent = self._md_stack[-1][0]
self._set_prop(parent, prop, item)
else:
self.microdata_items.append(item)
if not is_void:
self._md_stack.append((item, self._elem_depth))
elif a.get("itemprop") and self._md_stack:
item = self._md_stack[-1][0]
prop = a["itemprop"]
value = None
if tag == "meta":
value = a.get("content")
elif tag in ("img", "audio", "video", "embed", "iframe", "source"):
value = a.get("src")
elif tag in ("a", "area", "link"):
value = a.get("href")
elif tag == "time":
value = a.get("datetime")
elif tag == "data":
value = a.get("value")
if value is not None:
if tag in ("a", "area", "link", "img", "audio", "video", "embed", "iframe", "source"):
value = urllib.parse.urljoin(self.base_url, value)
self._set_prop(item, prop, value)
else:
self._md_text_target = (item, prop)
self._md_text_parts = []
# --- RDFa (typeof/property with vocab or schema.org prefix) ---
if a.get("typeof"):
rdfa_item = {"@type": a["typeof"].rsplit("/", 1)[-1].rsplit(":", 1)[-1]}
prop = a.get("property")
if self._rdfa_stack and prop:
parent = self._rdfa_stack[-1][0]
self._set_prop(parent, prop.rsplit(":", 1)[-1], rdfa_item)
else:
self.rdfa_items.append(rdfa_item)
if not is_void:
self._rdfa_stack.append((rdfa_item, self._elem_depth))
elif a.get("property") and self._rdfa_stack:
item = self._rdfa_stack[-1][0]
prop = a["property"].rsplit(":", 1)[-1].rsplit("/", 1)[-1]
value = a.get("content") or a.get("href") or a.get("src") or a.get("datetime")
if value is not None:
self._set_prop(item, prop, value)
else:
self._rdfa_text_target = (item, prop)
self._rdfa_text_parts = []
def handle_endtag(self, tag):
if tag == "script" and self._in_jsonld:
self._in_jsonld = False
self.jsonld_raw.append("".join(self._jsonld_parts))
elif tag == "title":
self._in_title = False
if self._md_text_target is not None:
item, prop = self._md_text_target
self._set_prop(item, prop, "".join(self._md_text_parts).strip())
self._md_text_target = None
if self._rdfa_text_target is not None:
item, prop = self._rdfa_text_target
self._set_prop(item, prop, "".join(self._rdfa_text_parts).strip())
self._rdfa_text_target = None
if tag not in self.VOID_TAGS:
while self._md_stack and self._md_stack[-1][1] >= self._elem_depth:
self._md_stack.pop()
while self._rdfa_stack and self._rdfa_stack[-1][1] >= self._elem_depth:
self._rdfa_stack.pop()
self._elem_depth = max(0, self._elem_depth - 1)
def handle_data(self, data):
if self._in_jsonld:
self._jsonld_parts.append(data)
elif self._in_title and self.title is None:
self.title = data.strip() or None
if self._md_text_target is not None:
self._md_text_parts.append(data)
if self._rdfa_text_target is not None:
self._rdfa_text_parts.append(data)
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
ctype = resp.headers.get("Content-Type", "")
if "html" not in ctype and "xml" not in ctype:
return resp.status, None
return resp.status, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, None
except Exception:
return 0, None
def pages_from_sitemap(site):
status, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
if status != 200 or not body:
return []
urls = []
try:
root = ET.fromstring(body)
ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
for loc in root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc"):
urls.append(loc.text.strip())
except ET.ParseError:
return []
return urls
def parse_page(url, html):
parser = SchemaParser(url)
try:
parser.feed(html)
except Exception:
pass
blocks = []
errors = []
for i, raw in enumerate(parser.jsonld_raw):
text = raw.strip()
if not text:
errors.append({"block_index": i, "error": "empty JSON-LD block", "snippet": ""})
continue
try:
data = json.loads(text)
except json.JSONDecodeError as e:
errors.append({"block_index": i, "error": str(e), "snippet": text[:200]})
continue
items = data if isinstance(data, list) else [data]
for item in items:
if isinstance(item, dict) and "@graph" in item:
for node in item["@graph"]:
blocks.append({"format": "json-ld", "types": extract_types(node), "data": node})
else:
blocks.append({"format": "json-ld", "types": extract_types(item), "data": item})
for item in parser.microdata_items:
if item.get("@type"):
blocks.append({"format": "microdata", "types": extract_types(item), "data": item})
for item in parser.rdfa_items:
if item.get("@type"):
blocks.append({"format": "rdfa", "types": extract_types(item), "data": item})
return {
"title": parser.title,
"canonical": parser.canonical,
"meta_robots": parser.meta_robots,
"schema_blocks": blocks,
"jsonld_errors": errors,
}, parser.links
def extract_from_cache(cache_path):
"""Pure: turn a shared page cache (from fetch_pages.py) into a schema inventory.
No network — parses the cached HTML for exactly the fields this audit needs.
"""
with open(cache_path, encoding="utf-8") as f:
cache = json.load(f)
pages = {}
for url, page in cache.get("pages", {}).items():
html = page.get("html")
status = page.get("status", 0)
if not html:
pages[url] = {"status": status, "title": None, "canonical": None,
"meta_robots": None, "schema_blocks": [], "jsonld_errors": []}
continue
info, _links = parse_page(page.get("final_url") or url, html)
info["status"] = status
pages[url] = info
return cache.get("site", cache_path), pages
def crawl(site, max_pages):
host = urllib.parse.urlparse(site).netloc
seeds = pages_from_sitemap(site) or [site]
queue = deque(seeds[:max_pages])
seen = set(queue)
pages = {}
while queue and len(pages) < max_pages:
url = queue.popleft()
status, html = fetch(url)
if html is None:
pages[url] = {"status": status, "title": None, "canonical": None,
"meta_robots": None, "schema_blocks": [], "jsonld_errors": []}
continue
info, links = parse_page(url, html)
info["status"] = status
pages[url] = info
for link in links:
if urllib.parse.urlparse(link).netloc == host and link not in seen:
seen.add(link)
queue.append(link)
sys.stderr.write(f"\rScanned {len(pages)} pages...")
sys.stderr.write("\n")
return pages
def scan_local(folder):
pages = {}
for root, _dirs, files in os.walk(folder):
for name in files:
if name.lower().endswith((".html", ".htm")):
path = os.path.join(root, name)
with open(path, encoding="utf-8", errors="replace") as f:
html = f.read()
info, _links = parse_page("file://" + os.path.abspath(path), html)
info["status"] = 200
pages[os.path.relpath(path, folder)] = info
return pages
def scan_url_list(path, max_pages):
pages = {}
with open(path, encoding="utf-8") as f:
urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
for url in urls[:max_pages]:
status, html = fetch(url)
if html is None:
pages[url] = {"status": status, "title": None, "canonical": None,
"meta_robots": None, "schema_blocks": [], "jsonld_errors": []}
continue
info, _links = parse_page(url, html)
info["status"] = status
pages[url] = info
return pages
def main():
ap = argparse.ArgumentParser(description="Extract structured data from a site into a JSON inventory")
ap.add_argument("source", nargs="?",
help="Site root URL, local folder (--local), or URL list file (--url-list)")
ap.add_argument("--from-cache",
help="Extract from a shared page cache produced by fetch_pages.py (no network)")
ap.add_argument("--max-pages", type=int, default=200)
ap.add_argument("--output", default="schema_inventory.json")
ap.add_argument("--local", action="store_true", help="Treat source as a local folder of HTML files")
ap.add_argument("--url-list", action="store_true", help="Treat source as a text file of URLs")
args = ap.parse_args()
if args.from_cache:
site, pages = extract_from_cache(args.from_cache)
elif not args.source:
ap.error("provide a source, or --from-cache page_cache.json")
elif args.local:
site, pages = args.source, scan_local(args.source)
elif args.url_list:
site, pages = args.source, scan_url_list(args.source, args.max_pages)
else:
site, pages = args.source, crawl(args.source, args.max_pages)
inventory = {"site": site, "pages": pages}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(inventory, f, indent=2, ensure_ascii=False)
blocks = sum(len(p["schema_blocks"]) for p in pages.values())
print(f"Scanned {len(pages)} pages, found {blocks} schema blocks. Wrote {args.output}")
if __name__ == "__main__":
main()
Schema Validator Script (scripts/validate_schema.py)
Runs the audit checks against the schema inventory JSON and outputs the structured audit report data.
#!/usr/bin/env python3
"""Validate a schema inventory JSON file and produce an audit report JSON.
Usage:
python3 validate_schema.py schema_inventory.json [--output audit_report.json]
Runs the checks defined in references/audit-checks.md against the inventory
produced by extract_schema.py. Uses only the Python standard library.
"""
import argparse
import json
import re
import sys
from collections import Counter
# Required / recommended properties per type for Google rich result eligibility.
# Required-property entries support dotted paths (e.g., "offers.price").
TYPE_RULES = {
"Product": {
"required": ["name", "image", "offers", "offers.price", "offers.priceCurrency"],
"recommended": ["offers.availability", "aggregateRating", "review", "sku", "brand", "description"],
},
"Article": {
"required": ["headline", "image"],
"recommended": ["author", "datePublished", "dateModified", "publisher", "mainEntityOfPage"],
},
"BlogPosting": {
"required": ["headline", "image"],
"recommended": ["author", "datePublished", "dateModified", "publisher", "mainEntityOfPage"],
},
"NewsArticle": {
"required": ["headline", "image"],
"recommended": ["author", "datePublished", "dateModified", "publisher", "mainEntityOfPage"],
},
"LocalBusiness": {
"required": ["name", "address"],
"recommended": ["telephone", "openingHoursSpecification", "geo", "url", "image", "priceRange"],
},
"Organization": {
"required": ["name"],
"recommended": ["url", "logo", "sameAs", "contactPoint"],
},
"WebSite": {
"required": ["name", "url"],
"recommended": [],
},
"BreadcrumbList": {
"required": ["itemListElement"],
"recommended": [],
},
"FAQPage": {
"required": ["mainEntity"],
"recommended": [],
},
"Event": {
"required": ["name", "startDate", "location"],
"recommended": ["endDate", "eventStatus", "eventAttendanceMode", "offers", "performer", "image", "organizer"],
},
"JobPosting": {
"required": ["title", "description", "datePosted", "hiringOrganization", "jobLocation"],
"recommended": ["baseSalary", "employmentType", "validThrough", "identifier"],
},
"Recipe": {
"required": ["name", "image"],
"recommended": ["author", "description", "prepTime", "cookTime", "recipeIngredient", "recipeInstructions", "aggregateRating"],
},
"VideoObject": {
"required": ["name", "thumbnailUrl", "uploadDate"],
"recommended": ["description", "duration", "contentUrl", "embedUrl"],
},
"Review": {
"required": ["reviewRating", "author"],
"recommended": ["itemReviewed", "datePublished"],
},
"AggregateRating": {
"required": ["ratingValue"],
"recommended": ["ratingCount", "reviewCount", "bestRating"],
},
}
# Types whose Google rich results are retired or heavily restricted.
DEPRECATED_TYPES = {
"HowTo": "Google retired HowTo rich results (Sept 2023); markup yields no rich result benefit.",
"FAQPage": "FAQ rich results are limited to well-known, authoritative government and health websites.",
"SpeakableSpecification": "Speakable remains in limited beta with negligible adoption.",
}
ISO8601_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([+-]\d{2}:?\d{2}|Z)?)?$")
PRICE_RE = re.compile(r"^\d+(\.\d+)?$")
CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
def has_prop(data, path):
"""Check a (possibly dotted) property path on a JSON-LD-ish dict."""
node = data
for part in path.split("."):
if isinstance(node, list):
node = node[0] if node else None
if not isinstance(node, dict):
return False
if part not in node or node[part] in (None, "", []):
return False
node = node[part]
return True
def get_prop(data, path):
node = data
for part in path.split("."):
if isinstance(node, list):
node = node[0] if node else None
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
def check_value_formats(url, block, issues):
data = block["data"]
fmt_checks = [
("offers.price", PRICE_RE, "price should be a plain number string, e.g. \"10.00\" (no currency symbols)"),
("offers.priceCurrency", CURRENCY_RE, "priceCurrency should be an ISO 4217 code, e.g. \"USD\""),
("datePublished", ISO8601_DATE, "datePublished should be an ISO 8601 date"),
("dateModified", ISO8601_DATE, "dateModified should be an ISO 8601 date"),
("startDate", ISO8601_DATE, "startDate should be an ISO 8601 date"),
("datePosted", ISO8601_DATE, "datePosted should be an ISO 8601 date"),
("uploadDate", ISO8601_DATE, "uploadDate should be an ISO 8601 date"),
]
for path, regex, message in fmt_checks:
value = get_prop(data, path)
if isinstance(value, (int, float)):
continue
if isinstance(value, str) and value and not regex.match(value.strip()):
issues.append({
"check": "invalid_property_value", "severity": "medium", "page": url,
"type": "/".join(block["types"][:1]) or "(unknown)",
"detail": f"{path} = {value!r}: {message}",
})
avail = get_prop(data, "offers.availability")
if isinstance(avail, str) and avail and "schema.org" not in avail:
issues.append({
"check": "invalid_property_value", "severity": "medium", "page": url,
"type": block["types"][0] if block["types"] else "(unknown)",
"detail": f"offers.availability = {avail!r}: should be a schema.org URL, e.g. \"https://schema.org/InStock\"",
})
image = get_prop(data, "image")
if isinstance(image, list):
image = image[0] if image else None
if isinstance(image, dict):
image = image.get("url")
if isinstance(image, str) and image and not image.startswith(("http://", "https://")):
issues.append({
"check": "invalid_property_value", "severity": "medium", "page": url,
"type": block["types"][0] if block["types"] else "(unknown)",
"detail": f"image = {image!r}: should be an absolute HTTPS URL",
})
def audit(inventory):
issues = []
pages = inventory.get("pages", {})
type_counts = Counter()
homepage_types = set()
homepage_url = None
for url in pages:
path = re.sub(r"^https?://[^/]+", "", url) or "/"
if path in ("/", "/index.html", "index.html"):
homepage_url = url
for url, page in pages.items():
blocks = page.get("schema_blocks", [])
# 1. Invalid JSON-LD
for err in page.get("jsonld_errors", []):
issues.append({
"check": "invalid_jsonld", "severity": "high", "page": url,
"type": "(unparseable)",
"detail": f"JSON-LD block {err['block_index']}: {err['error']}",
})
seen_types_on_page = Counter()
formats_per_type = {}
for block in blocks:
primary = block["types"][0] if block["types"] else None
if not primary:
issues.append({
"check": "missing_type", "severity": "high", "page": url,
"type": "(none)", "detail": "Schema block has no @type",
})
continue
type_counts[primary] += 1
seen_types_on_page[primary] += 1
formats_per_type.setdefault(primary, set()).add(block["format"])
if url == homepage_url:
homepage_types.update(block["types"])
rules = TYPE_RULES.get(primary)
if rules:
# 2. Missing required properties
for prop in rules["required"]:
if not has_prop(block["data"], prop):
issues.append({
"check": "missing_required_property", "severity": "high",
"page": url, "type": primary,
"detail": f"Missing required property: {prop}",
})
# 4. Missing recommended properties
missing_rec = [p for p in rules["recommended"] if not has_prop(block["data"], p)]
if missing_rec:
issues.append({
"check": "missing_recommended_properties", "severity": "medium",
"page": url, "type": primary,
"detail": "Missing recommended: " + ", ".join(missing_rec),
})
# 7. Deprecated types
for t in block["types"]:
if t in DEPRECATED_TYPES:
issues.append({
"check": "deprecated_type", "severity": "medium", "page": url,
"type": t, "detail": DEPRECATED_TYPES[t],
})
# 9. Invalid property values
check_value_formats(url, block, issues)
# 6. Duplicate blocks of the same type
for t, count in seen_types_on_page.items():
if count > 1 and t in TYPE_RULES and t not in ("Review",):
issues.append({
"check": "duplicate_schema_blocks", "severity": "medium", "page": url,
"type": t, "detail": f"{count} blocks of type {t} on one page — verify they are distinct entities or merge them",
})
# 11. Mixed formats for the same type
for t, formats in formats_per_type.items():
if len(formats) > 1:
issues.append({
"check": "mixed_formats", "severity": "low", "page": url,
"type": t, "detail": f"Type {t} marked up in multiple formats ({', '.join(sorted(formats))}); standardize on JSON-LD",
})
# 8. Homepage Organization / WebSite
if homepage_url is not None:
if not ({"Organization", "LocalBusiness"} & homepage_types):
issues.append({
"check": "missing_homepage_schema", "severity": "medium", "page": homepage_url,
"type": "Organization", "detail": "Homepage has no Organization or LocalBusiness schema",
})
if "WebSite" not in homepage_types:
issues.append({
"check": "missing_homepage_schema", "severity": "medium", "page": homepage_url,
"type": "WebSite", "detail": "Homepage has no WebSite schema",
})
# 3. Pages with no structured data at all (coverage gap candidates)
no_schema = [url for url, p in pages.items()
if not p.get("schema_blocks") and not p.get("jsonld_errors")
and p.get("status", 0) == 200]
for url in no_schema:
issues.append({
"check": "no_structured_data", "severity": "info", "page": url,
"type": "(none)",
"detail": "Page has no structured data; check expected schema for its page type (see references/audit-checks.md)",
})
severity_counts = Counter(i["severity"] for i in issues)
return {
"summary": {
"pages_scanned": len(pages),
"schema_blocks": sum(len(p.get("schema_blocks", [])) for p in pages.values()),
"types_in_use": dict(type_counts),
"issues_by_severity": dict(severity_counts),
},
"issues": issues,
}
def main():
ap = argparse.ArgumentParser(description="Validate a schema inventory and produce an audit report")
ap.add_argument("inventory", help="Path to schema_inventory.json from extract_schema.py")
ap.add_argument("--output", default="audit_report.json")
args = ap.parse_args()
with open(args.inventory, encoding="utf-8") as f:
inventory = json.load(f)
report = audit(inventory)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
s = report["summary"]
print(f"Pages: {s['pages_scanned']} | Blocks: {s['schema_blocks']} | "
f"Issues: {s['issues_by_severity']}")
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()