Claude AI

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 Organization and WebSite schema on the homepage, mixed JSON-LD and Microdata duplicates of the same entity, and missing optional enhancements like sameAs and BreadcrumbList.

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.

SKILL.mdView on GitHub
name
schema-markup-audit
description
Audit a website's structured data (schema markup) for SEO. Extracts JSON-LD, Microdata, and RDFa from pages, validates it against schema.org and Google rich result requirements, and finds missing, invalid, or incomplete markup. Use when the user asks to audit, validate, fix, or improve schema markup, structured data, JSON-LD, or rich result eligibility of a website.

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:

  1. 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.
  2. Scope — full site, a specific section (e.g., /products/), or a list of URLs.
  3. Crawl limits — max pages (default 200) for live crawls.
  4. 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 .html files.
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:

CheckSeverity
Invalid JSON-LD (parse errors)High
Missing required properties for the declared typeHigh
Missing schema on key page types (e.g., Product pages without Product schema)High
Missing recommended properties for rich resultsMedium
Schema/content mismatch (markup describes content not on the page)Medium
Conflicting or duplicate schema blocks on one pageMedium
Deprecated types or properties (e.g., HowTo rich results retired)Medium
Missing Organization / WebSite schema on the homepageMedium
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:

  1. Summary — pages scanned, schema blocks found, types in use, issue counts by severity
  2. Coverage map — page types vs. expected schema types, showing gaps
  3. Issues — one section per failing check, listing affected page, schema type, property, and a specific fix
  4. Opportunities — schema types worth adding for rich result eligibility
  5. 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 offers property to the Product schema on all product pages, including price, priceCurrency, and availability
  • 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 type
  • references/report-template.md — report output structure
  • scripts/extract_schema.py — crawl pages and extract all structured data into a JSON inventory
  • scripts/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.

references/audit-checks.mdView on GitHub

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 without Article, location pages without LocalBusiness).
  • 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 Product blocks 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., HowTo retired, FAQPage limited 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 (or LocalBusiness) and WebSite markup.
  • Why it matters: These feed Google's knowledge panel, sitelinks, and brand entity understanding.
  • Fix: Add Organization with name, url, logo, and sameAs (social profiles), plus WebSite with name and url.
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., availability not a schema.org/ItemAvailability URL).
  • 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, or url values don't match the canonical URL.
  • Why it matters: Weakens entity graph consistency across the site.
  • Fix: Use stable @id values (e.g., https://example.com/#organization) and reference them across pages; align url with 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: BreadcrumbList on inner pages, sameAs social links, ImageObject for logos, inLanguage, author with a Person profile 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 (with price, priceCurrency), image
  • Recommended: offers.availability, offers.priceValidUntil, aggregateRating, review, sku, gtin/mpn, brand, description
Article / BlogPosting / NewsArticle
  • Required: none strictly, but headline and image are needed for rich display
  • Recommended: author (with name and url), 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; add potentialAction (SearchAction) only if the site has internal search.
BreadcrumbList
  • Required: itemListElement with ListItems, each with position, name, and item (URL; omit on the last item)
FAQPage
  • Required: mainEntity of Question items, each with acceptedAnswer
  • Note: Rich results limited to authoritative government/health sites; markup must mirror visible Q&A.
Review / AggregateRating
  • Required: reviewRating/ratingValue, author (for Review), 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 (or applicantLocationRequirements + jobLocationType for 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 typeExpected schema
HomepageOrganization (or LocalBusiness), WebSite
Product pageProduct with offers, BreadcrumbList
Category/listing pageBreadcrumbList; optionally ItemList
Blog post / articleArticle/BlogPosting, BreadcrumbList
Contact / location pageLocalBusiness with address, hours
Event pageEvent
Job listingJobPosting
Recipe pageRecipe
Video pageVideoObject

Report Template (references/report-template.md)

The output structure the skill follows when writing the final audit report.

references/report-template.mdView on GitHub

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.

scripts/extract_schema.pyView on GitHub
#!/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.

scripts/validate_schema.pyView on GitHub
#!/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()

Explore Other Claude SEO Skills

Site Architecture Audit Audits a site's architecture: hostname and protocol canonicalization, robots.txt, XML sitemap parity, crawl and index directive conflicts, URL structure, click depth, and directory taxonomy. site-architecture-audit Open Heading Structure Audit Audits a site's heading hierarchy for missing or multiple H1s, skipped heading levels, empty headings, headings that don't start at H1, overly long headings, and duplicate heading text. heading-structure-audit Open Image SEO Audit Audits a site's images for SEO and Core Web Vitals: missing alt text, absent width/height, missing lazy-loading, oversized files, legacy formats, generic filenames, and broken images. image-seo-audit Open Core Web Vitals Audit Audits a site's Core Web Vitals and page-experience signals (LCP, CLS, INP, TTFB), pulling real CrUX field data from PageSpeed Insights and collecting lab proxies like render-blocking resources, page weight, and image hygiene. core-web-vitals-audit Open Redirect Audit Audits a site's redirects for redirect chains and loops, redirects ending in 404s or errors, 302s used for permanent moves, HTTPS-to-HTTP downgrades, client-side redirects, dropped query strings, and canonicals pointing at redirecting URLs. redirect-audit Open Robots.txt Audit Audits a site's robots.txt for a redirected or HTML-served file, a 5xx read as block-everything, a sitewide Disallow, syntax errors, blocked CSS/JS, missing Sitemap directives, unsupported directives, oversized files, and encoding issues. robots-txt-audit Open Sitemap Audit Audits a site's XML sitemaps: discovering them from robots.txt, following sitemap index files, and validating every URL for broken or redirecting targets, noindexed or canonicalized pages, wrong host or protocol, duplicates, oversized sitemaps, and invalid lastmod values. sitemap-audit Open Llms.txt Audit Audits a site's /llms.txt for AI and LLM discoverability: missing files, HTML served instead of markdown, a missing H1 or summary, broken or redirecting links, links missing descriptions, wrong-host or relative URLs, and a companion /llms-full.txt. llms-txt-audit Open Canonical Tag Audit Audits a site's rel=canonical tags for missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200, redirecting, or noindexed URLs, relative or cross-host canonicals, https-to-http downgrades, and Link-header conflicts. canonical-tag-audit Open Content Quality Audit Audits a site's content quality for thin content, exact-duplicate body text across pages, near-duplicate (templated) pages, low text-to-HTML ratio, and pages missing an H1. content-quality-audit Open Keyword Cannibalization Audit Audits a site for keyword cannibalization: pages with duplicate title targets, clusters of pages whose title, H1, and keyword signatures overlap, and pages sharing the same primary keyword phrase, with a recommended hierarchy. keyword-cannibalization-audit Open Mixed Content Audit Audits an HTTPS site for mixed content: active mixed content (http scripts, styles, iframes browsers block), insecure form actions, passive mixed content (http images and media), protocol-relative URLs, and pages still served over http. mixed-content-audit Open Open Graph Audit Audits a site's Open Graph and Twitter Card metadata: missing og:title, og:description, og:image, or og:url, broken or relative og:image URLs, og:url that mismatches the canonical, missing twitter:card, over-length social titles, and reused default images. open-graph-audit Open Pagination Audit Audits a site's pagination: paginated pages canonicalizing to page 1 and hiding deep content, noindexed component pages, paginated pages missing a self-canonical, broken or inconsistent rel=next/prev, and redundant ?page=1 first pages. pagination-audit Open Soft 404 Audit Audits a site for soft 404s: error pages served with a 200 status, servers that return 200 for every unknown URL, thin pages matching the site's error template, and empty or near-empty pages. soft-404-audit Open Full SEO Audit The orchestrator skill that runs every individual audit below and consolidates the findings into one prioritized, cross-area report ranked by impact, for a complete technical-SEO review in a single pass. full-seo-audit Open Internal Link Audit Audits a site's internal linking structure for broken links, orphan pages, redirect chains, click depth, and anchor text quality. internal-link-audit Open External Link Audit Audits a site's external links for broken targets, redirects, insecure HTTP links, missing rel attributes on affiliate links, and unsafe target="_blank" usage. external-link-audit Open Meta Data Audit Audits a site's title tags and meta descriptions for missing, duplicate, too-long, too-short, or multiple tags, generic boilerplate titles, and keyword stuffing. meta-data-audit Open