Claude AI

Sitemap Audit Claude Skill

This skill audits a website's XML sitemaps and produces an actionable report. It discovers sitemaps from robots.txt, follows sitemap index files, and validates every listed URL to find broken or redirecting URLs, noindexed or robots-blocked URLs, URLs canonicalized to a different page, wrong-host or wrong-protocol URLs, duplicates, oversized sitemaps over 50,000 URLs or 50MB, parse errors, and missing or invalid lastmod, priority, and changefreq values.

What The Skill Checks

  • URL indexability: broken (non-200) URLs, redirecting URLs, noindexed or robots-blocked URLs, and URLs canonicalized to a different page, all of which shouldn't appear in a sitemap.
  • Structural and consistency problems: wrong-host or wrong-protocol URLs, duplicate URLs, oversized sitemaps over 50,000 URLs or 50MB, and parse errors in the XML itself.
  • Metadata quality across entries: missing or invalid lastmod, priority, and changefreq values, with discovery that follows sitemap index files from robots.txt.

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 discover and probe sitemap URLs and audit the 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 sitemap-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 five-step workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
sitemap-audit
description
Audit a website's XML sitemaps for SEO. Discovers sitemaps from robots.txt, follows sitemap index files, and validates every listed URL to find broken (non-200) URLs, redirecting URLs, noindexed or robots-blocked URLs, URLs canonicalized to a different page, wrong-host or wrong-protocol URLs, duplicate URLs, oversized sitemaps (over 50,000 URLs or 50MB), parse errors, and missing or invalid lastmod/priority/changefreq values. Use when the user asks to audit, analyze, check, validate, or fix an XML sitemap, sitemap index, sitemap.xml, or sitemap coverage of a website.

Sitemap Audit

Audit a website's XML sitemaps and produce an actionable SEO report.

When to use this skill

Use this skill when the user asks to:

  • Audit, validate, or check an XML sitemap or sitemap index
  • Find sitemap URLs that 404, redirect, are noindexed, or are blocked by robots.txt
  • Find URLs in the sitemap that canonicalize elsewhere or sit on the wrong host/protocol
  • Check that sitemaps are declared in robots.txt and stay under the 50,000-URL / 50MB limits
  • Clean up lastmod, priority, and changefreq metadata
  • Improve the quality and trustworthiness of the URL set submitted to search engines
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or sitemap URL — a live site root (e.g., https://example.com, which auto-discovers sitemaps via robots.txt and common paths) or a specific sitemap URL (--sitemap).
  2. Scope — how many listed URLs to probe (default 2000). Large sitemaps can be sampled with --max-urls.
  3. Probing — whether to fetch each listed URL to check status/canonical/noindex (default on). Use --no-probe for a fast structural-only pass (parse errors, size limits, lastmod hygiene).

Note on the indexable contract: every URL in an XML sitemap should be a final, canonical, indexable 200 page. A URL that redirects, 404s, is noindexed, is blocked by robots.txt, or canonicalizes elsewhere does not belong in the sitemap. Most checks here enforce that contract.

Workflow
Step 1: Discover, parse, and probe the sitemaps

Use scripts/collect_sitemap.py:

python3 scripts/collect_sitemap.py https://example.com --max-urls 2000 --output sitemap_inventory.json

Point directly at a sitemap with --sitemap:

python3 scripts/collect_sitemap.py https://example.com/sitemap_index.xml --sitemap --output sitemap_inventory.json

The script reads robots.txt for Sitemap: directives (falling back to /sitemap.xml and /sitemap_index.xml), follows sitemap index files, handles gzipped sitemaps, and records each sitemap's type, URL count, byte size, and any parse/fetch error. For every listed URL it captures the sitemap's lastmod/changefreq/priority, then probes the URL for status, redirect target, rel=canonical, meta robots, the X-Robots-Tag header, and whether robots.txt disallows it.

Step 2: Run the audit checks

Use scripts/audit_sitemap.py:

python3 scripts/audit_sitemap.py sitemap_inventory.json --output audit_report.json
Step 3: Evaluate the audit checks

Evaluate each check defined in references/audit-checks.md. The core checks are:

CheckSeverity
No parseable sitemap foundHigh
Sitemap parse error / fetch errorHigh
Sitemap exceeds 50,000 URLsHigh
Sitemap exceeds 50MB uncompressedHigh
Listed URL returns 4xx/5xx/errorHigh
Listed URL redirects (not a final 200)High
Listed URL is noindex (meta robots or X-Robots-Tag)High
Listed URL is blocked by robots.txtHigh
Listed URL canonicalizes to a different URLMedium
Listed URL is on the wrong host or protocolMedium
Sitemaps not declared in robots.txtMedium
Duplicate URL across sitemapsLow
Missing / invalid / future lastmodLow
Invalid priority or changefreqLow
Step 4: Produce the report

Write a report following the structure in references/report-template.md:

  1. Summary — sitemaps found, total URLs listed, URLs probed, issue counts by severity
  2. Sitemap files — table of each sitemap: type, URL count, size, gzip, errors
  3. Issues — one section per failing check, listing the URL, the problem, and the fix
  4. Prioritized action list — ordered by severity and impact
Step 5: Recommend fixes

For each issue, give a concrete fix:

  • Non-200, redirecting, noindex, robots-blocked, or canonicalized URLs: remove them from the sitemap (or, for redirects, replace each with its final destination URL). The sitemap should list only final indexable pages.
  • Wrong host/protocol: rewrite URLs to the canonical host and https://.
  • Size limits: split the sitemap into multiple files under 50,000 URLs / 50MB and reference them from a sitemap index.
  • Not in robots.txt: add a Sitemap: line to robots.txt for each sitemap (or the index).
  • lastmod hygiene: emit a valid W3C datetime reflecting the real last-modified date; drop priority/changefreq if they are arbitrary (Google ignores them).
  • Base every recommendation on the observed data; do not invent URLs.
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, thresholds, and rationale for every audit check
  • references/report-template.md — report output structure
  • scripts/collect_sitemap.py — discover, parse, and probe a site's XML sitemaps
  • scripts/audit_sitemap.py — run audit checks against a sitemap inventory JSON file

Audit Checks Reference (references/audit-checks.md)

Full definitions, severities, and rationale for every check across the high, medium, and low tiers, from parse failures to metadata quality.

references/audit-checks.mdView on GitHub

Sitemap Audit Checks

Full definitions, thresholds, and rationale for each audit check.

The guiding principle: an XML sitemap should list only final, canonical, indexable 200 URLs. Most high-severity checks enforce that contract, because a sitemap full of redirects, errors, and noindexed URLs wastes crawl budget and erodes the trust search engines place in the file.

High severity
1. No parseable sitemap found
  • Definition: No sitemap was discovered via robots.txt or the common fallback paths, or every candidate failed to parse.
  • Why it matters: Without a sitemap, search engines rely entirely on link discovery; new and deep pages are found slowly or not at all.
  • Fix: Generate an XML sitemap of canonical indexable URLs and declare it in robots.txt.
2. Sitemap parse error / fetch error
  • Definition: A sitemap file returned non-200 (fetch error) or is not valid XML / not a valid urlset/sitemapindex (parse error).
  • Why it matters: A broken sitemap may be ignored entirely, taking all its URLs with it.
  • Fix: Repair the XML (well-formed, correct namespace, valid encoding) and ensure the file returns 200 with an XML content type.
3. Sitemap exceeds 50,000 URLs
  • Definition: A single urlset lists more than 50,000 URLs.
  • Why it matters: The sitemaps protocol caps a single file at 50,000 URLs; anything beyond may be truncated or rejected.
  • Fix: Split into multiple sitemaps under the limit and list them in a sitemap index.
4. Sitemap exceeds 50MB uncompressed
  • Definition: A single sitemap file exceeds 50MB uncompressed.
  • Why it matters: The protocol caps uncompressed size at 50MB; oversized files may be ignored.
  • Fix: Split the sitemap and/or serve it gzipped, keeping each file under 50MB uncompressed.
5. Listed URL returns 4xx/5xx/error
  • Definition: A URL in the sitemap returns a 4xx, 5xx, or connection error.
  • Why it matters: Submitting dead URLs wastes crawl budget and signals a stale, low-quality sitemap.
  • Fix: Remove the URL, or fix the page so it returns 200 if it should exist.
6. Listed URL redirects
  • Definition: A sitemap URL returns a 3xx redirect instead of being a final 200.
  • Why it matters: Sitemaps should contain destination URLs, not redirects. Redirecting entries waste a hop and suggest the sitemap was not regenerated after a move.
  • Fix: Replace the URL with its final destination (or remove it if the destination is already listed).
7. Listed URL is noindex
  • Definition: A sitemap URL is marked noindex (or none) via a meta robots tag or the X-Robots-Tag HTTP header.
  • Why it matters: Including a page in the sitemap says "please index this", while noindex says the opposite — a direct conflict that confuses crawlers and wastes budget.
  • Fix: Decide the page's fate. If it should be indexed, remove noindex; if not, remove it from the sitemap.
8. Listed URL is blocked by robots.txt
  • Definition: A sitemap URL's path is disallowed for the * user-agent in robots.txt.
  • Why it matters: Crawlers cannot fetch the page to honor the sitemap, and a blocked-but-submitted URL is a contradictory signal.
  • Fix: Either unblock the path in robots.txt (if it should be crawled) or remove the URL from the sitemap.
Medium severity
9. Listed URL canonicalizes to a different URL
  • Definition: A sitemap URL returns 200 but its rel="canonical" points to a different URL.
  • Why it matters: The sitemap is nominating a non-canonical URL; search engines will prefer the canonical and may distrust the sitemap.
  • Fix: List the canonical URL in the sitemap instead of the variant.
10. Listed URL is on the wrong host or protocol
  • Definition: A sitemap URL is on a different host (e.g., www vs. non-www, a different subdomain) or uses http when the site is https.
  • Why it matters: Sitemaps should reference the canonical host and protocol; mismatches create redirects (see check 6) and split signals. Cross-domain URLs are also generally ignored unless cross-submission is configured.
  • Fix: Rewrite all URLs to the canonical host and https://.
11. Sitemaps not declared in robots.txt
  • Definition: Sitemaps exist but none are referenced by a Sitemap: directive in robots.txt.
  • Why it matters: robots.txt is a primary discovery path; without it, search engines depend on manual submission alone.
  • Fix: Add a Sitemap: line (absolute URL) to robots.txt for each sitemap, or for the sitemap index.
Low severity
12. Duplicate URL across sitemaps
  • Definition: The same URL appears in more than one sitemap file.
  • Why it matters: Harmless but messy; usually indicates overlapping generation rules and inflates reported counts.
  • Fix: Partition URLs so each appears in exactly one sitemap.
13. Missing / invalid / future lastmod
  • Definition: A <lastmod> is absent, not a valid W3C datetime, or set in the future.
  • Why it matters: Google uses lastmod as a recrawl hint only when it is consistent and trustworthy; missing or bogus values (e.g., every URL "modified today", or a future date) cause it to be ignored.
  • Fix: Emit an accurate W3C datetime reflecting the real last content change. If you cannot maintain it accurately, omit it rather than faking it.
14. Invalid priority or changefreq
  • Definition: <priority> outside 0.0–1.0, or <changefreq> not one of the allowed values (always, hourly, daily, weekly, monthly, yearly, never).
  • Why it matters: Invalid values are ignored. Note that Google ignores priority and changefreq entirely regardless of validity.
  • Fix: Correct or, more simply, drop priority/changefreq — they add bytes without SEO benefit for Google.

Report Template (references/report-template.md)

The output structure the skill follows, including sitemaps found, URLs probed, and issues grouped into sections.

references/report-template.mdView on GitHub

Sitemap Audit Report Template

Use this structure for the final report delivered to the user.

# Sitemap Audit — {site}

**Date:** {date}
**Sitemaps found:** {n} | **URLs listed:** {n} | **URLs probed:** {n}

## Summary

| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |

{2–3 sentence overview of sitemap health and the biggest wins.}

## Sitemap files

| Sitemap | Type | URLs | Size | Gzip | Error |
|---|---|---|---|---|---|
| /sitemap.xml | urlset | 1,240 | 210 KB | no | — |
| ... | ... | ... | ... | ... | ... |

## High-severity issues

### URLs returning errors ({n})

| URL | Status | Action |
|---|---|---|
| ... | 404 | Remove from sitemap |

### Redirecting URLs ({n})

| URL | Status | Final URL | Action |
|---|---|---|---|
| ... | 301 | ... | Replace with final URL |

### Noindex URLs ({n})

| URL | Source | Action |
|---|---|---|
| ... | meta robots / X-Robots-Tag | Remove from sitemap or remove noindex |

### Robots-blocked URLs ({n})

| URL | Action |
|---|---|
| ... | Unblock in robots.txt or remove from sitemap |

### Sitemap size / structure ({n})

{Note any sitemap over 50,000 URLs or 50MB, plus parse/fetch errors, with the fix.}

## Medium-severity issues

{One subsection per failing check: canonicalized-away URLs, wrong host/protocol, sitemaps not in robots.txt. Same table style: URL, problem, fix.}

## Low-severity issues

{Brief tables: duplicate URLs, lastmod problems, invalid priority/changefreq.}

## Prioritized action list

1. {Highest-impact fix, with exact URLs or the rule to change}
2. ...
Guidance
  • Every issue row must name the exact URL and the concrete action ("remove from sitemap", "replace with {final URL}", "unblock {path} in robots.txt").
  • Reinforce the core rule in the summary: sitemaps should list only final, canonical, indexable 200 URLs.
  • Group templated problems (e.g., an entire /tag/ section that is noindexed but listed) into one fix with a few example URLs and the count.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • For lastmod, recommend dropping rather than faking it when accurate values cannot be maintained; note that Google ignores priority and changefreq.
  • Order the action list by severity first, then by how many URLs each fix cleans up.

Sitemap Collector Script (scripts/collect_sitemap.py)

Discovers sitemaps from robots.txt, follows sitemap index files, and probes each listed URL for HTTP status, redirects, canonical tags, noindex signals, and robots.txt blocking.

scripts/collect_sitemap.pyView on GitHub
#!/usr/bin/env python3
"""Discover and parse a site's XML sitemaps, then probe every listed URL into a JSON inventory.

Usage:
    python3 collect_sitemap.py https://example.com [--max-urls 2000] [--output sitemap_inventory.json]
    python3 collect_sitemap.py https://example.com/sitemap.xml --sitemap [--max-urls 2000] [--output sitemap_inventory.json]

Discovery order (for the site-root form):
1. Sitemap: directives in /robots.txt
2. /sitemap.xml and /sitemap_index.xml fallbacks

For each sitemap file it records type (index/urlset), URL count, byte size, gzip,
HTTP status, and XML parse errors. For each listed URL it records lastmod /
changefreq / priority from the sitemap, then fetches the URL (without auto-follow
beyond one resolution) to capture status, redirect target, canonical, meta robots,
and the X-Robots-Tag header — everything needed to flag URLs that should not be in
a sitemap (non-200, redirecting, noindex, canonicalized away, blocked by robots).

Output JSON schema:
{
  "site": "https://example.com",
  "robots_txt": {"found": true, "status": 200, "sitemaps": [...], "rules": [...]},
  "sitemaps": [{"url": "...", "type": "urlset|sitemapindex", "status": 200,
                "url_count": 120, "bytes": 34567, "gzip": false, "error": null}],
  "url_count": 120,
  "duplicate_urls": ["..."],
  "urls": {
    "<loc>": {
      "lastmod": "...|null", "changefreq": "...|null", "priority": "...|null",
      "in_sitemaps": ["..."],
      "status": 200, "redirected": false, "final_url": "...",
      "canonical": "...|null", "meta_robots": "...|null", "x_robots_tag": "...|null",
      "robots_disallowed": false
    }
  }
}

Uses only the Python standard library.
"""

import argparse
import gzip
import io
import json
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 = "SitemapAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
MAX_SITEMAPS = 100


class PageHeadParser(HTMLParser):
    """Extract canonical and meta robots only."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.canonical = None
        self.meta_robots = None
        self._body_started = False

    def handle_starttag(self, tag, attrs):
        if tag == "body":
            self._body_started = True
        if self._body_started:
            return
        a = dict(attrs)
        if tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
            self.canonical = urllib.parse.urljoin(self.base_url, a["href"].strip())
        elif tag == "meta" and (a.get("name") or "").lower() == "robots":
            self.meta_robots = a.get("content")


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def raw_get(url, max_bytes=10_000_000):
    """Plain GET (auto-follows redirects). Returns (status, final_url, raw_bytes, headers)."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            return resp.status, resp.geturl(), resp.read(max_bytes), resp.headers
    except urllib.error.HTTPError as e:
        return e.code, url, b"", (e.headers if e.headers else None)
    except Exception:
        return 0, url, b"", None


def fetch_no_follow(url):
    """Fetch without following redirects. Returns (status, location, body, headers)."""

    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None

    opener = urllib.request.build_opener(NoRedirect)
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with opener.open(req, timeout=TIMEOUT) as resp:
            ctype = resp.headers.get("Content-Type", "")
            body = resp.read(2_000_000).decode("utf-8", errors="replace") if "html" in ctype else None
            return resp.status, None, body, resp.headers
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location") if e.headers else None
        return e.code, loc, None, (e.headers if e.headers else None)
    except Exception:
        return 0, None, None, None


def decode_body(raw, url, headers):
    """Return decoded text, handling gzip (by header, .gz extension, or magic bytes)."""
    is_gzip = url.lower().endswith(".gz")
    if headers is not None and "gzip" in (headers.get("Content-Encoding", "") or "").lower():
        is_gzip = True
    if raw[:2] == b"\x1f\x8b":
        is_gzip = True
    if is_gzip:
        try:
            raw = gzip.GzipFile(fileobj=io.BytesIO(raw)).read()
        except Exception:
            pass
    return raw.decode("utf-8", errors="replace"), is_gzip


def parse_robots(text):
    groups, sitemaps, current, last_directive = [], [], None, False
    for raw_line in text.splitlines():
        line = raw_line.split("#", 1)[0].strip()
        if not line or ":" not in line:
            continue
        field, _, value = line.partition(":")
        field, value = field.strip().lower(), value.strip()
        if field == "sitemap":
            if value:
                sitemaps.append(value)
        elif field == "user-agent":
            if current is None or last_directive:
                current = {"agents": [], "disallow": [], "allow": []}
                groups.append(current)
            current["agents"].append(value)
            last_directive = False
        elif field in ("disallow", "allow") and current is not None:
            current[field].append(value)
            last_directive = True
    flat = []
    for g in groups:
        for agent in g["agents"]:
            flat.append({"agent": agent, "disallow": list(g["disallow"]), "allow": list(g["allow"])})
    return flat, sitemaps


def fetch_robots(site_root):
    status, _f, raw, _h = raw_get(site_root.rstrip("/") + "/robots.txt", max_bytes=1_000_000)
    if status != 200:
        return {"found": False, "status": status, "sitemaps": [], "rules": []}
    rules, sitemaps = parse_robots(raw.decode("utf-8", errors="replace"))
    return {"found": True, "status": status, "sitemaps": sitemaps, "rules": rules}


def robots_disallowed(path, rules):
    """True if the '*' user-agent group disallows the path (longest-match wins)."""
    star = [r for r in rules if r["agent"] == "*"]
    if not star:
        return False
    best_len, decision = -1, False
    for r in star:
        for rule, allow in [(d, False) for d in r["disallow"]] + [(a, True) for a in r["allow"]]:
            if rule == "":
                continue
            if path.startswith(rule) and len(rule) > best_len:
                best_len, decision = len(rule), allow
    return decision is False and best_len >= 0


def collect_sitemaps(seeds, max_sitemaps):
    """Follow sitemap indexes; return (sitemap_meta, {loc: {lastmod,changefreq,priority,in_sitemaps}})."""
    sitemap_meta = []
    url_entries = {}
    queue = deque(seeds)
    seen = set()
    while queue and len(seen) < max_sitemaps:
        sm = queue.popleft()
        if sm in seen:
            continue
        seen.add(sm)
        status, _f, raw, headers = raw_get(sm)
        meta = {"url": sm, "type": None, "status": status, "url_count": 0,
                "bytes": len(raw), "gzip": False, "error": None}
        if status != 200 or not raw:
            meta["error"] = f"status {status}"
            sitemap_meta.append(meta)
            continue
        text, is_gzip = decode_body(raw, sm, headers)
        meta["gzip"] = is_gzip
        try:
            root = ET.fromstring(text)
        except ET.ParseError as e:
            meta["error"] = f"parse error: {e}"
            sitemap_meta.append(meta)
            continue
        if root.tag.lower().endswith("sitemapindex"):
            meta["type"] = "sitemapindex"
            children = [loc.text.strip() for loc in root.iter(SM_NS + "loc") if loc.text]
            meta["url_count"] = len(children)
            for c in children:
                queue.append(c)
        else:
            meta["type"] = "urlset"
            count = 0
            for url_el in root.iter(SM_NS + "url"):
                loc_el = url_el.find(SM_NS + "loc")
                if loc_el is None or not loc_el.text:
                    continue
                loc = loc_el.text.strip()
                count += 1
                entry = url_entries.setdefault(
                    loc, {"lastmod": None, "changefreq": None, "priority": None, "in_sitemaps": []})
                entry["in_sitemaps"].append(sm)
                for field in ("lastmod", "changefreq", "priority"):
                    el = url_el.find(SM_NS + field)
                    if el is not None and el.text:
                        entry[field] = el.text.strip()
            meta["url_count"] = count
        sitemap_meta.append(meta)
    return sitemap_meta, url_entries


def probe_url(loc, rules):
    status, location, body, headers = fetch_no_follow(loc)
    redirected = 300 <= status < 400
    final_url = urllib.parse.urljoin(loc, location) if (redirected and location) else loc
    x_robots = headers.get("X-Robots-Tag") if headers else None
    canonical = meta_robots = None
    if body:
        parser = PageHeadParser(loc)
        try:
            parser.feed(body)
        except Exception:
            pass
        canonical, meta_robots = parser.canonical, parser.meta_robots
    path = urllib.parse.urlparse(loc).path or "/"
    return {
        "status": status, "redirected": redirected, "final_url": final_url,
        "canonical": canonical, "meta_robots": meta_robots, "x_robots_tag": x_robots,
        "robots_disallowed": robots_disallowed(path, rules),
    }


def main():
    ap = argparse.ArgumentParser(description="Discover, parse, and probe a site's XML sitemaps.")
    ap.add_argument("source", help="Site root URL, or a sitemap URL with --sitemap")
    ap.add_argument("--sitemap", action="store_true", help="Treat source as a sitemap URL directly")
    ap.add_argument("--max-urls", type=int, default=2000, help="max listed URLs to probe (default 2000)")
    ap.add_argument("--no-probe", action="store_true", help="parse sitemaps but skip per-URL probing")
    ap.add_argument("--output", default="sitemap_inventory.json")
    args = ap.parse_args()

    if args.sitemap:
        site_root = "{0.scheme}://{0.netloc}".format(urllib.parse.urlparse(args.source))
        robots = fetch_robots(site_root)
        seeds = [args.source]
    else:
        site_root = args.source.rstrip("/")
        robots = fetch_robots(site_root)
        seeds = list(robots.get("sitemaps", [])) or [
            site_root + "/sitemap.xml", site_root + "/sitemap_index.xml"]

    print(f"Sitemap seeds: {seeds}", file=sys.stderr)
    sitemap_meta, url_entries = collect_sitemaps(seeds, MAX_SITEMAPS)

    # Duplicate URLs = listed in more than one sitemap.
    duplicate_urls = sorted(loc for loc, e in url_entries.items() if len(e["in_sitemaps"]) > 1)

    urls = {}
    rules = robots.get("rules", [])
    locs = list(url_entries.keys())[: args.max_urls]
    for i, loc in enumerate(locs, 1):
        entry = dict(url_entries[loc])
        if not args.no_probe:
            entry.update(probe_url(loc, rules))
            print(f"[{entry.get('status')}] ({i}/{len(locs)}) {loc}", file=sys.stderr)
        urls[loc] = entry

    out = {
        "site": site_root,
        "robots_txt": robots,
        "sitemaps": sitemap_meta,
        "url_count": len(url_entries),
        "duplicate_urls": duplicate_urls,
        "urls": urls,
    }
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)
    print(f"Wrote {len(sitemap_meta)} sitemaps, {len(url_entries)} URLs "
          f"({len(urls)} probed) to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Sitemap Auditor Script (scripts/audit_sitemap.py)

Runs the audit checks against the collected sitemap inventory and outputs the structured report data classified into high, medium, and low severity tiers.

scripts/audit_sitemap.pyView on GitHub
#!/usr/bin/env python3
"""Audit a sitemap inventory JSON (from collect_sitemap.py) and emit findings.

Usage:
    python3 audit_sitemap.py sitemap_inventory.json [--output audit_report.json]

Checks implemented (see references/audit-checks.md):
- no_sitemap_found: no parseable sitemap discovered (high)
- sitemap_not_in_robots: sitemaps exist but none are declared in robots.txt (medium)
- sitemap_parse_error / sitemap_fetch_error: a sitemap file is broken or unreachable (high)
- sitemap_too_many_urls (> 50,000) / sitemap_too_large (> 50MB uncompressed) (high)
- url_error: a listed URL returns 4xx/5xx/0 (high)
- url_redirects: a listed URL 3xx-redirects instead of being a final 200 (high)
- url_noindex: a listed URL is noindex via meta robots or X-Robots-Tag (high)
- url_robots_disallowed: a listed URL is blocked by robots.txt (high)
- url_canonicalized_away: a listed URL canonicalizes to a different URL (medium)
- url_wrong_host: a listed URL is on a different host/scheme than the site (medium)
- duplicate_urls: the same URL appears in more than one sitemap (low)
- missing_lastmod / invalid_lastmod / future_lastmod (low)
- invalid_priority / invalid_changefreq (low)

Uses only the Python standard library.
"""

import argparse
import datetime as dt
import json
import re
import sys
import urllib.parse

MAX_URLS_PER_SITEMAP = 50_000
MAX_BYTES_PER_SITEMAP = 50 * 1024 * 1024  # 50 MB uncompressed
VALID_CHANGEFREQ = {"always", "hourly", "daily", "weekly", "monthly", "yearly", "never"}
W3C_DATE_RE = re.compile(
    r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$")


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def norm(url):
    return urllib.parse.urldefrag((url or "").strip())[0].rstrip("/").lower()


def is_noindex(page):
    blob = ((page.get("meta_robots") or "") + " " + (page.get("x_robots_tag") or "")).lower()
    return "noindex" in blob or "none" in blob


def parse_lastmod(value):
    if not W3C_DATE_RE.match(value):
        return None, "invalid"
    try:
        date_part = value[:10]
        d = dt.date.fromisoformat(date_part)
        return d, None
    except ValueError:
        return None, "invalid"


def main():
    ap = argparse.ArgumentParser(description="Run sitemap audit checks.")
    ap.add_argument("inventory", help="sitemap_inventory.json from collect_sitemap.py")
    ap.add_argument("--output", default="audit_report.json")
    args = ap.parse_args()

    with open(args.inventory, encoding="utf-8") as f:
        data = json.load(f)

    site = data.get("site", "")
    site_host = norm_host(urllib.parse.urlparse(site).netloc)
    site_scheme = urllib.parse.urlparse(site).scheme or "https"
    robots = data.get("robots_txt", {})
    sitemaps = data.get("sitemaps", [])
    urls = data.get("urls", {})
    today = dt.date.today()

    findings = {
        "no_sitemap_found": [], "sitemap_not_in_robots": [],
        "sitemap_parse_error": [], "sitemap_fetch_error": [],
        "sitemap_too_many_urls": [], "sitemap_too_large": [],
        "url_error": [], "url_redirects": [], "url_noindex": [],
        "url_robots_disallowed": [], "url_canonicalized_away": [],
        "url_wrong_host": [], "duplicate_urls": [],
        "missing_lastmod": [], "invalid_lastmod": [], "future_lastmod": [],
        "invalid_priority": [], "invalid_changefreq": [],
    }

    parseable = [s for s in sitemaps if s.get("type") and not s.get("error")]
    if not parseable:
        findings["no_sitemap_found"].append({"checked": [s.get("url") for s in sitemaps] or "none"})

    if parseable and not robots.get("sitemaps"):
        findings["sitemap_not_in_robots"].append(
            {"robots_status": robots.get("status"),
             "sitemaps_found": [s["url"] for s in parseable]})

    for s in sitemaps:
        err = s.get("error")
        if err:
            if "parse" in err:
                findings["sitemap_parse_error"].append({"sitemap": s["url"], "error": err})
            else:
                findings["sitemap_fetch_error"].append({"sitemap": s["url"], "error": err})
        if s.get("type") == "urlset" and s.get("url_count", 0) > MAX_URLS_PER_SITEMAP:
            findings["sitemap_too_many_urls"].append(
                {"sitemap": s["url"], "url_count": s["url_count"]})
        if s.get("bytes", 0) > MAX_BYTES_PER_SITEMAP and not s.get("gzip"):
            findings["sitemap_too_large"].append({"sitemap": s["url"], "bytes": s["bytes"]})

    for loc, p in urls.items():
        status = p.get("status")
        if status is not None:  # probed
            if status == 0 or (status and status >= 400):
                findings["url_error"].append({"url": loc, "status": status})
            elif p.get("redirected"):
                findings["url_redirects"].append(
                    {"url": loc, "status": status, "final_url": p.get("final_url")})
            else:
                if is_noindex(p):
                    findings["url_noindex"].append(
                        {"url": loc, "meta_robots": p.get("meta_robots"),
                         "x_robots_tag": p.get("x_robots_tag")})
                canonical = p.get("canonical")
                if canonical and norm(canonical) != norm(loc):
                    findings["url_canonicalized_away"].append(
                        {"url": loc, "canonical": canonical})
            if p.get("robots_disallowed"):
                findings["url_robots_disallowed"].append({"url": loc})

        # Host / scheme correctness (independent of probing)
        pu = urllib.parse.urlparse(loc)
        if pu.netloc and norm_host(pu.netloc) != site_host:
            findings["url_wrong_host"].append({"url": loc, "expected_host": site_host})
        elif pu.scheme and pu.scheme != site_scheme:
            findings["url_wrong_host"].append(
                {"url": loc, "issue": f"scheme {pu.scheme}, expected {site_scheme}"})

        # lastmod / priority / changefreq hygiene
        lastmod = p.get("lastmod")
        if not lastmod:
            findings["missing_lastmod"].append({"url": loc})
        else:
            d, err = parse_lastmod(lastmod)
            if err:
                findings["invalid_lastmod"].append({"url": loc, "lastmod": lastmod})
            elif d and d > today:
                findings["future_lastmod"].append({"url": loc, "lastmod": lastmod})

        prio = p.get("priority")
        if prio is not None:
            try:
                if not (0.0 <= float(prio) <= 1.0):
                    raise ValueError
            except ValueError:
                findings["invalid_priority"].append({"url": loc, "priority": prio})

        cf = p.get("changefreq")
        if cf is not None and cf.lower() not in VALID_CHANGEFREQ:
            findings["invalid_changefreq"].append({"url": loc, "changefreq": cf})

    for loc in data.get("duplicate_urls", []):
        findings["duplicate_urls"].append(
            {"url": loc, "in_sitemaps": urls.get(loc, {}).get("in_sitemaps", [])})

    severity = {
        "no_sitemap_found": "high", "sitemap_parse_error": "high", "sitemap_fetch_error": "high",
        "sitemap_too_many_urls": "high", "sitemap_too_large": "high",
        "url_error": "high", "url_redirects": "high", "url_noindex": "high",
        "url_robots_disallowed": "high",
        "sitemap_not_in_robots": "medium", "url_canonicalized_away": "medium",
        "url_wrong_host": "medium",
        "duplicate_urls": "low", "missing_lastmod": "low", "invalid_lastmod": "low",
        "future_lastmod": "low", "invalid_priority": "low", "invalid_changefreq": "low",
    }
    probed = sum(1 for p in urls.values() if p.get("status") is not None)
    summary = {
        "sitemaps_found": len([s for s in sitemaps if s.get("type")]),
        "total_urls_listed": data.get("url_count", len(urls)),
        "urls_probed": probed,
        "issues_by_check": {k: len(v) for k, v in findings.items()},
        "issues_by_severity": {
            level: sum(len(findings[k]) for k, s in severity.items() if s == level)
            for level in ("high", "medium", "low")
        },
    }
    report = {"summary": summary, "severity": severity, "findings": findings}

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    print(json.dumps(summary, indent=2))
    print(f"Full report written to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Explore Other Claude SEO Skills

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 Schema Markup Audit Audits a site's structured data: extracting JSON-LD, Microdata, and RDFa, then validating it against schema.org and Google rich result requirements. schema-markup-audit Open 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