Claude AI

Robots.txt Audit Claude Skill

This skill audits a website's robots.txt and produces an actionable report. It fetches and parses the file to find a redirected or HTML-served file, a 5xx that Google reads as block-everything, a sitewide Disallow: / that blocks the whole site, syntax errors, CSS and JS blocked from rendering, missing or unreachable Sitemap directives, a missing default User-agent group, unsupported directives that Google ignores, files over the 500 KiB limit, and BOM or encoding issues.

What The Skill Checks

  • Delivery problems that change how the file is read: a redirected or HTML-served file, a 5xx that Google interprets as block-everything, files over the 500 KiB limit, and BOM or encoding issues.
  • Blocking mistakes: a sitewide Disallow: / that blocks the whole site, CSS and JS resources blocked from rendering, a missing default User-agent: * group, and syntax errors or directives that appear before any User-agent line.
  • Directive hygiene: missing or unreachable Sitemap directives, unsupported directives like noindex, nofollow, and crawl-delay that Google ignores in robots.txt, and duplicate or empty rules.

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 fetch and parse robots.txt and audit it. 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 robots-txt-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 four-step workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
robots-txt-audit
description
Audit a website's robots.txt for SEO. Fetches and parses robots.txt to find a redirected or HTML-served file, a 5xx that Google reads as "block everything", a sitewide Disallow: / that blocks the whole site, syntax errors and directives before any User-agent, CSS/JS resources blocked from rendering, missing or unreachable Sitemap: directives, a missing default User-agent: * group, unsupported directives (noindex, nofollow, crawl-delay) that Google ignores, files over the 500 KiB limit, BOM/encoding issues, and duplicate or empty rules. Use when the user asks to audit, analyze, check, validate, debug, or fix a robots.txt file, crawl directives, or what search engines are allowed to crawl.

Robots.txt Audit

Audit a website's robots.txt file and produce an actionable SEO report.

When to use this skill

Use this skill when the user asks to:

  • Audit, validate, or check a robots.txt file or crawl directives
  • Find out why pages are not being crawled or why Google says a URL is "blocked by robots.txt"
  • Check that important pages aren't blocked and that CSS/JS resources can be rendered
  • Confirm robots.txt declares the XML sitemap and returns 200 as plain text
  • Catch a stray Disallow: / that took the whole site out of search
  • Clean up syntax errors and unsupported directives (noindex, crawl-delay, etc.)
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or robots.txt URL — a live site root (e.g., https://example.com, which fetches /robots.txt) or a specific robots.txt URL (--robots).
  2. Resource check — whether to fetch the homepage and test its CSS/JS/image references against the rules (default on). Use --no-resources to skip it.

What robots.txt does and does not do: robots.txt controls crawling, not indexing. A Disallow stops compliant crawlers from fetching a page, but a blocked URL can still be indexed (without a snippet) if other pages link to it. To keep a page out of the index, allow crawling and use a noindex meta tag or X-Robots-Tag header instead — noindex inside robots.txt is unsupported and ignored. Most checks here protect crawl access and rendering rather than indexing.

Workflow
Step 1: Fetch and parse robots.txt

Use scripts/collect_robots.py:

python3 scripts/collect_robots.py https://example.com --output robots_inventory.json

Point directly at a robots.txt with --robots:

python3 scripts/collect_robots.py https://example.com/robots.txt --robots --output robots_inventory.json

The script fetches /robots.txt without auto-following redirects (so a redirected or HTML-served file is visible), records HTTP status, content type, byte size, gzip, and a BOM flag, then parses user-agent groups, Sitemap: directives, comments, unknown/unsupported directives, and per-line syntax errors. It probes every declared sitemap URL for status and (unless --no-resources) fetches the homepage to test same-host CSS/JS/image references against the * group's rules.

Step 2: Run the audit checks

Use scripts/audit_robots.py:

python3 scripts/audit_robots.py robots_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
robots.txt returns 5xx (read as "block everything")High
robots.txt redirects instead of returning the fileHigh
robots.txt served as HTML, not plain textHigh
Sitewide Disallow: / blocks the whole siteHigh
Syntax error / directive before any User-agentHigh
File exceeds 500 KiBHigh
Render-critical CSS/JS blocked from crawlingMedium
No Sitemap: directiveMedium
Declared Sitemap: URL unreachable or not absoluteMedium
No default User-agent: * groupMedium
Unsupported directive (noindex, nofollow, crawl-delay)Medium
robots.txt missing entirely (404)Low
Duplicate User-agent: * groupLow
UTF-8 BOM precedes the fileLow
Empty Disallow: alongside real rulesLow
Step 4: Produce the report

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

  1. Summary — fetch status, group count, sitemaps declared, issue counts by severity
  2. Fetch & file — status, content type, size, redirect/HTML/BOM flags
  3. Issues — one section per failing check, with the offending directive/line and the fix
  4. Prioritized action list — ordered by severity and impact
Step 5: Recommend fixes

For each issue, give a concrete fix:

  • 5xx / redirect / HTML: serve robots.txt from the root path as text/plain returning 200 directly (no redirect, no HTML error page). A persistent 5xx will pause crawling site-wide.
  • Sitewide Disallow: /: confirm it is intentional (it usually is not on a production site) and remove or scope it; this single line can deindex an entire domain.
  • Syntax errors: fix malformed lines — every directive needs field: value, and Disallow/Allow must follow a User-agent line.
  • Blocked resources: add Allow: rules (or remove the Disallow) so Googlebot can fetch the CSS/JS it needs to render the page.
  • Sitemap directive: add an absolute Sitemap: line for each sitemap (or the index); fix any that 404.
  • Unsupported directives: remove noindex/nofollow from robots.txt and enforce them via meta robots or X-Robots-Tag; drop crawl-delay (Google ignores it — set the rate in Search Console if needed).
  • Base every recommendation on the observed file; quote the exact line to change and do not invent rules.
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_robots.py — fetch, parse, and probe a site's robots.txt
  • scripts/audit_robots.py — run audit checks against a robots.txt inventory JSON file

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

Full definitions, severities, and rationale for all fourteen checks, spanning HTTP errors, file format problems, blocking rules, and missing directives.

references/audit-checks.mdView on GitHub

Robots.txt Audit Checks

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

The guiding principle: robots.txt should return a small, valid 200 plain-text file from the site root that lets crawlers reach every page and resource you want indexed, declares your sitemap, and blocks only what you deliberately want kept out of the crawl. Remember that robots.txt governs crawling, not indexing — a disallowed URL can still appear in search results without a snippet.

High severity
1. robots.txt returns 5xx
  • Definition: /robots.txt returns a 5xx server error.
  • Why it matters: Google treats a persistent 5xx on robots.txt as "disallow everything" and will pause crawling of the whole site until it recovers. This can quietly drop a site out of the index.
  • Fix: Make robots.txt return 200 (or 404, which is read as "allow all"). Never let it 5xx; if the file is generated dynamically, add a static fallback.
2. robots.txt redirects
  • Definition: /robots.txt returns a 3xx redirect instead of the file itself.
  • Why it matters: Crawlers follow one redirect for robots.txt, but redirect chains, cross-host redirects, or redirects to an HTML page lead to the file being misread or ignored. Each host/subdomain/protocol should serve its own robots.txt.
  • Fix: Serve robots.txt directly at https://<host>/robots.txt with a 200, no redirect.
3. robots.txt served as HTML
  • Definition: The response body is an HTML document (e.g., a soft-404 or a CMS page) rather than plain text.
  • Why it matters: An HTML "file not found" page is not a valid robots.txt; rules are not parsed and discovery breaks. It often masks a misconfigured route.
  • Fix: Serve real text/plain robots.txt content; return a genuine 404 if there is no file.
4. Sitewide Disallow: /
  • Definition: A user-agent group contains Disallow: / with no Allow: re-opening the root.
  • Why it matters: This blocks the entire site from crawling. When it targets User-agent: * it is the single most damaging robots.txt mistake — commonly left over from a staging environment and pushed to production.
  • Fix: Remove the line, or scope the block to the specific paths you mean to exclude. Confirm intent before keeping any sitewide block on a live site.
5. Syntax error
  • Definition: A line is malformed — missing the field: value colon, a Disallow/Allow appearing before any User-agent, or an empty User-agent/Sitemap value.
  • Why it matters: Crawlers skip lines they cannot parse, so a typo can silently drop a rule or attach rules to the wrong group, producing crawl behavior you never intended.
  • Fix: Correct each line to field: value, and make sure every Disallow/Allow follows a User-agent declaration.
6. File exceeds 500 KiB
  • Definition: robots.txt is larger than 500 KiB.
  • Why it matters: Google only parses the first 500 KiB; any rules past that point are ignored, which can unexpectedly expose or block paths.
  • Fix: Trim the file — consolidate rules with wildcards (*, $) and remove dead entries so all directives fit under the limit.
Medium severity
7. Render-critical resource blocked
  • Definition: A CSS or JS file referenced by the homepage is disallowed for User-agent: *.
  • Why it matters: Google renders pages before ranking them. Blocking the CSS/JS it needs can make pages look broken to the renderer, hurting mobile-friendliness and content evaluation.
  • Fix: Add Allow: rules (or remove the Disallow) so the rendering resources are crawlable. Blocking truly private scripts is fine; blocking theme/app bundles is not.
8. No Sitemap: directive
  • Definition: robots.txt contains no Sitemap: line.
  • Why it matters: robots.txt is a primary sitemap-discovery path; without it, search engines rely on manual submission alone.
  • Fix: Add an absolute Sitemap: line for each sitemap, or for the sitemap index.
9. Declared Sitemap: URL unreachable
  • Definition: A Sitemap: value is not an absolute URL, or the absolute URL it points to returns non-200.
  • Why it matters: A broken or relative sitemap reference cannot be fetched, defeating the purpose of declaring it. The Sitemap: directive must be a full absolute URL.
  • Fix: Use an absolute https:// URL and ensure the sitemap returns 200.
10. No default User-agent: * group
  • Definition: robots.txt has user-agent groups but none for *.
  • Why it matters: A crawler uses only the most specific group that matches its name; with no * fallback, any agent you did not name gets no rules at all, which may be unintended.
  • Fix: Add a User-agent: * group expressing the default crawl policy.
11. Unsupported directive
  • Definition: A directive Google does not support appears in the file — most importantly noindex and nofollow, plus crawl-delay, host, request-rate, clean-param.
  • Why it matters: These are silently ignored by Google. Relying on noindex in robots.txt to deindex a page does not work — the page stays indexable. crawl-delay gives a false sense of rate control.
  • Fix: Remove them. Enforce noindex/nofollow via meta robots or X-Robots-Tag on crawlable pages; manage crawl rate in Search Console.
Low severity
12. robots.txt missing entirely (404)
  • Definition: /robots.txt returns 404 (or is unreachable).
  • Why it matters: A 404 is valid and means "allow all", so this is not harmful. But a real file lets you declare the sitemap and document intended crawl policy in one place.
  • Fix: Add a minimal robots.txt with a Sitemap: line and any intended rules.
13. Duplicate User-agent: * group
  • Definition: More than one User-agent: * group exists.
  • Why it matters: Rules may be merged or, in some parsers, only one group honored — making behavior ambiguous and hard to maintain.
  • Fix: Consolidate all * rules into a single group.
14. UTF-8 BOM present
  • Definition: A UTF-8 byte-order mark precedes the file.
  • Why it matters: Most parsers tolerate a leading BOM, but some may misread the first directive. It is harmless to remove and safer without.
  • Fix: Save robots.txt as UTF-8 without a BOM.
15. Empty Disallow: alongside real rules
  • Definition: A group contains an empty Disallow: (which allows everything) together with non-empty Disallow rules.
  • Why it matters: Usually a leftover or copy-paste artifact. It is a no-op next to real rules but signals the file may not say what the author thinks.
  • Fix: Remove the empty line unless it is intentionally the only rule in a deliberately open group.

Report Template (references/report-template.md)

The output structure the skill follows, with prioritized fixes for each failure.

references/report-template.mdView on GitHub

Robots.txt Audit Report Template

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

# Robots.txt Audit — {site}

**Date:** {date}
**Fetch:** {status} {content_type} | **Groups:** {n} | **Sitemaps declared:** {n}

## Summary

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

{2–3 sentence overview of robots.txt health and the biggest wins. Lead with any
sitewide block, 5xx, redirect, or HTML-served file — those are emergencies.}

## Fetch & file

| Property | Value |
|---|---|
| URL | https://example.com/robots.txt |
| Status | 200 |
| Content-Type | text/plain |
| Size | 1.2 KB |
| Redirected | no |
| Served as HTML | no |
| BOM | no |

## High-severity issues

### Sitewide / availability problems ({n})

{Call out Disallow: / , 5xx, redirect, or HTML-served file with the exact agent
or line, and the fix. These can deindex the whole site.}

### Syntax errors ({n})

| Line | Text | Problem | Fix |
|---|---|---|---|
| 4 | Disallow /admin | missing colon | Use `Disallow: /admin` |

### File size ({n})

{Note if robots.txt exceeds 500 KiB and how to trim it.}

## Medium-severity issues

### Blocked render resources ({n})

| Resource | Type | Blocking rule | Fix |
|---|---|---|---|
| /assets/app.css | css | Disallow: /assets | Allow: /assets/ for CSS/JS |

### Sitemap directives ({n})

| Issue | Detail | Fix |
|---|---|---|
| No Sitemap: line | — | Add `Sitemap: https://example.com/sitemap.xml` |
| Unreachable sitemap | https://… → 404 | Fix the sitemap or update the URL |

### Other ({n})

{One row per failing check: no User-agent: * group, unsupported directives
(noindex/nofollow/crawl-delay), etc. Same style: problem, detail, fix.}

## Low-severity issues

{Brief list: 404 (allow-all), duplicate * group, BOM, empty Disallow alongside
real rules.}

## Current robots.txt

\`\`\`
{paste the file, or the relevant excerpt, so the user can see what changes against what}
\`\`\`

## Prioritized action list

1. {Highest-impact fix, with the exact line to add/remove/change}
2. ...
Guidance
  • Lead with availability and sitewide blocks: a Disallow: /, a 5xx, a redirect, or an HTML-served file can take a whole site out of search and dwarf every other finding.
  • Reinforce the crawl-vs-index distinction in the summary: robots.txt blocks crawling, not indexing; use noindex (meta/header) on crawlable pages to remove a page from the index.
  • Every issue row must quote the exact directive or line and give a concrete action ("remove Disallow: /", "add Allow: /assets/", "add Sitemap: https://…").
  • Show the current file (or the relevant excerpt) so recommended edits are unambiguous.
  • For unsupported directives, name the supported alternative (meta robots / X-Robots-Tag for noindex; Search Console for crawl rate).
  • Order the action list by severity first, then by blast radius (how much of the site each fix unblocks).

Robots Collector Script (scripts/collect_robots.py)

Fetches robots.txt without following redirects, recording HTTP status, content type, file size, and gzip flags, and parses user-agent groups, sitemap declarations, and syntax issues.

scripts/collect_robots.pyView on GitHub
#!/usr/bin/env python3
"""Fetch and parse a site's robots.txt, then probe its sitemap and resource references into a JSON inventory.

Usage:
    python3 collect_robots.py https://example.com [--output robots_inventory.json]
    python3 collect_robots.py https://example.com/robots.txt --robots [--output robots_inventory.json]

It fetches /robots.txt (without auto-following redirects, so a redirected or
HTML-served file is visible), records HTTP status, content type, byte size, gzip,
and a BOM flag, then parses it into user-agent groups, Sitemap: directives,
comments, unknown/unsupported directives, and per-line syntax errors. It probes
every declared Sitemap: URL for status, and fetches the homepage to extract
same-host CSS/JS/image resources and test each one against the '*' group's rules
so resources blocked from crawling can be flagged.

Output JSON schema:
{
  "site": "https://example.com",
  "robots_url": "https://example.com/robots.txt",
  "fetch": {"found": true, "status": 200, "content_type": "text/plain",
            "bytes": 1234, "gzip": false, "has_bom": false,
            "redirected": false, "final_url": "...", "looks_like_html": false},
  "raw": "<full robots.txt text>",
  "groups": [{"agents": ["*"], "disallow": ["/admin"], "allow": ["/admin/public"],
              "crawl_delay": null, "other": []}],
  "sitemaps": [{"url": "...", "absolute": true, "status": 200}],
  "comments": 3,
  "unknown_directives": [{"line": 12, "field": "noindex", "value": "/x"}],
  "line_errors": [{"line": 4, "text": "...", "error": "missing colon"}],
  "has_star_group": true,
  "sitewide_block_agents": ["*"],
  "resources": [{"url": "...", "type": "css", "path": "/a.css", "blocked": 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
from html.parser import HTMLParser

USER_AGENT = "RobotsAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
KNOWN_FIELDS = {"user-agent", "disallow", "allow", "sitemap", "crawl-delay", "host", "clean-param"}


class ResourceParser(HTMLParser):
    """Extract same-host CSS / JS / image resource URLs from a page."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.resources = []

    def _add(self, kind, href):
        if href:
            self.resources.append((kind, urllib.parse.urljoin(self.base_url, href.strip())))

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and "stylesheet" in (a.get("rel") or "").lower():
            self._add("css", a.get("href"))
        elif tag == "script" and a.get("src"):
            self._add("js", a.get("src"))
        elif tag == "img" and a.get("src"):
            self._add("img", a.get("src"))


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, max_bytes=2_000_000):
    """Fetch without following redirects. Returns (status, location, raw_bytes, 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:
            return resp.status, None, resp.read(max_bytes), resp.headers
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location") if e.headers else None
        return e.code, loc, b"", (e.headers if e.headers else None)
    except Exception:
        return 0, None, b"", None


def decode_body(raw, url, headers):
    """Return (text, is_gzip), 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):
    """Parse robots.txt into groups, sitemaps, comments, unknown directives, and line errors."""
    groups, sitemaps, comments = [], [], 0
    unknown, errors = [], []
    current, last_directive = None, False
    for i, raw_line in enumerate(text.splitlines(), 1):
        stripped = raw_line.strip()
        if not stripped:
            continue
        if stripped.startswith("#"):
            comments += 1
            continue
        line = raw_line.split("#", 1)[0].strip()
        if not line:
            comments += 1
            continue
        if ":" not in line:
            errors.append({"line": i, "text": raw_line, "error": "missing colon"})
            continue
        field, _, value = line.partition(":")
        field, value = field.strip().lower(), value.strip()
        if field == "sitemap":
            if value:
                sitemaps.append(value)
            else:
                errors.append({"line": i, "text": raw_line, "error": "empty Sitemap value"})
        elif field == "user-agent":
            if current is None or last_directive:
                current = {"agents": [], "disallow": [], "allow": [],
                           "crawl_delay": None, "other": []}
                groups.append(current)
            if value:
                current["agents"].append(value)
            else:
                errors.append({"line": i, "text": raw_line, "error": "empty User-agent value"})
            last_directive = False
        elif field in ("disallow", "allow"):
            if current is None:
                errors.append({"line": i, "text": raw_line,
                               "error": f"{field} before any User-agent"})
            else:
                current[field].append(value)
                last_directive = True
        elif field == "crawl-delay":
            if current is not None:
                current["crawl_delay"] = value
                last_directive = True
        elif field in KNOWN_FIELDS:
            if current is not None:
                current["other"].append({"field": field, "value": value})
                last_directive = True
        else:
            unknown.append({"line": i, "field": field, "value": value})
    return {"groups": groups, "sitemaps": sitemaps, "comments": comments,
            "unknown": unknown, "errors": errors}


def robots_disallowed(path, groups):
    """True if the '*' user-agent group disallows the path (longest-match wins)."""
    star = [g for g in groups if "*" in g["agents"]]
    if not star:
        return False
    best_len, decision = -1, False
    for g in star:
        for rule, allow in [(d, False) for d in g["disallow"]] + [(a, True) for a in g["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 is_sitewide_block(group):
    """A group blocks the whole site if it disallows '/' and has no allow that re-opens root."""
    if "/" not in group["disallow"]:
        return False
    return not any(a == "/" for a in group["allow"])


def main():
    ap = argparse.ArgumentParser(description="Fetch, parse, and probe a site's robots.txt.")
    ap.add_argument("source", help="Site root URL, or a robots.txt URL with --robots")
    ap.add_argument("--robots", action="store_true", help="Treat source as a robots.txt URL directly")
    ap.add_argument("--no-resources", action="store_true",
                    help="skip homepage resource extraction / blocked-resource check")
    ap.add_argument("--output", default="robots_inventory.json")
    args = ap.parse_args()

    if args.robots:
        pu = urllib.parse.urlparse(args.source)
        site_root = f"{pu.scheme}://{pu.netloc}"
        robots_url = args.source
    else:
        site_root = args.source.rstrip("/")
        robots_url = site_root + "/robots.txt"

    status, location, raw, headers = fetch_no_follow(robots_url)
    redirected = 300 <= status < 400
    final_url = urllib.parse.urljoin(robots_url, location) if (redirected and location) else robots_url
    content_type = (headers.get("Content-Type", "") if headers else "") or ""
    has_bom = raw[:3] == b"\xef\xbb\xbf"
    text, is_gzip = decode_body(raw, robots_url, headers)
    if has_bom:
        text = text.lstrip("")
    looks_like_html = "<html" in text[:2000].lower() or "<!doctype html" in text[:2000].lower()

    fetch = {
        "found": status == 200, "status": status, "content_type": content_type,
        "bytes": len(raw), "gzip": is_gzip, "has_bom": has_bom,
        "redirected": redirected, "final_url": final_url, "looks_like_html": looks_like_html,
    }
    print(f"robots.txt: [{status}] {robots_url} ({len(raw)} bytes)", file=sys.stderr)

    parsed = parse_robots(text) if (status == 200 and not looks_like_html) else {
        "groups": [], "sitemaps": [], "comments": 0, "unknown": [], "errors": []}

    # Probe declared sitemaps.
    site_host = norm_host(urllib.parse.urlparse(site_root).netloc)
    sitemaps = []
    for sm in parsed["sitemaps"]:
        absolute = bool(urllib.parse.urlparse(sm).scheme)
        entry = {"url": sm, "absolute": absolute, "status": None}
        if absolute:
            st, _f, _r, _h = raw_get(sm, max_bytes=2_000_000)
            entry["status"] = st
            print(f"  Sitemap [{st}] {sm}", file=sys.stderr)
        sitemaps.append(entry)

    groups = parsed["groups"]
    sitewide = [a for g in groups if is_sitewide_block(g) for a in g["agents"]]

    # Extract and test homepage resources.
    resources = []
    if not args.no_resources and status == 200 and not looks_like_html:
        hp_status, _f, hp_raw, hp_headers = raw_get(site_root, max_bytes=3_000_000)
        if hp_status == 200 and hp_raw:
            body, _g = decode_body(hp_raw, site_root, hp_headers)
            parser = ResourceParser(site_root)
            try:
                parser.feed(body)
            except Exception:
                pass
            seen = set()
            for kind, url in parser.resources:
                if url in seen:
                    continue
                seen.add(url)
                pu = urllib.parse.urlparse(url)
                if pu.netloc and norm_host(pu.netloc) != site_host:
                    continue  # only test same-host resources
                path = pu.path or "/"
                resources.append({"url": url, "type": kind, "path": path,
                                  "blocked": robots_disallowed(path, groups)})
            print(f"  Resources extracted: {len(resources)}", file=sys.stderr)

    out = {
        "site": site_root,
        "robots_url": robots_url,
        "fetch": fetch,
        "raw": text,
        "groups": groups,
        "sitemaps": sitemaps,
        "comments": parsed["comments"],
        "unknown_directives": parsed["unknown"],
        "line_errors": parsed["errors"],
        "has_star_group": any("*" in g["agents"] for g in groups),
        "sitewide_block_agents": sitewide,
        "resources": resources,
    }
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)
    print(f"Wrote {len(groups)} groups, {len(sitemaps)} sitemaps, "
          f"{len(resources)} resources to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Robots Auditor Script (scripts/audit_robots.py)

Runs the audit checks against the parsed robots.txt data and outputs the structured report data grouped by severity.

scripts/audit_robots.pyView on GitHub
#!/usr/bin/env python3
"""Audit a robots.txt inventory JSON (from collect_robots.py) and emit findings.

Usage:
    python3 audit_robots.py robots_inventory.json [--output audit_report.json]

Checks implemented (see references/audit-checks.md):
- robots_5xx: robots.txt returns 5xx (Google treats this as "disallow all") (high)
- robots_redirect: robots.txt redirects instead of returning the file directly (high)
- robots_served_as_html: robots.txt returns an HTML page, not plain text (high)
- sitewide_block: a User-agent group disallows the entire site with Disallow: / (high)
- syntax_error: malformed lines (missing colon, directive before User-agent, etc.) (high)
- file_too_large: robots.txt exceeds 500 KiB (Google ignores content past it) (high)
- blocked_resource: a CSS/JS/image needed to render the page is disallowed (medium)
- no_sitemap_directive: no Sitemap: directive present (medium)
- sitemap_unreachable: a declared Sitemap: URL is non-200 or not absolute (medium)
- no_star_group: no default User-agent: * group (medium)
- unsupported_directive: noindex/nofollow/crawl-delay etc. that Google ignores (medium/low)
- robots_404: robots.txt missing entirely (low — means "allow all", but worth a real file)
- duplicate_star_group: more than one User-agent: * group (low)
- has_bom: a UTF-8 BOM precedes the file (low)
- empty_disallow_confusion: Disallow: with no value (allows everything — often a mistake) (low)

Uses only the Python standard library.
"""

import argparse
import json
import sys

MAX_BYTES = 500 * 1024  # Google ignores robots.txt content past 500 KiB
RENDER_BLOCKING_TYPES = {"css", "js"}
# Directives that are not part of the robots.txt standard / are ignored by Google.
UNSUPPORTED_FIELDS = {"noindex", "nofollow", "crawl-delay", "host", "request-rate", "clean-param"}


def main():
    ap = argparse.ArgumentParser(description="Run robots.txt audit checks.")
    ap.add_argument("inventory", help="robots_inventory.json from collect_robots.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)

    fetch = data.get("fetch", {})
    groups = data.get("groups", [])
    sitemaps = data.get("sitemaps", [])
    resources = data.get("resources", [])

    findings = {
        "robots_5xx": [], "robots_redirect": [], "robots_served_as_html": [],
        "sitewide_block": [], "syntax_error": [], "file_too_large": [],
        "blocked_resource": [], "no_sitemap_directive": [], "sitemap_unreachable": [],
        "no_star_group": [], "unsupported_directive": [],
        "robots_404": [], "duplicate_star_group": [], "has_bom": [],
        "empty_disallow_confusion": [],
    }

    status = fetch.get("status")
    if status and 500 <= status < 600:
        findings["robots_5xx"].append({"status": status, "url": data.get("robots_url")})
    if fetch.get("redirected"):
        findings["robots_redirect"].append(
            {"status": status, "final_url": fetch.get("final_url")})
    if fetch.get("looks_like_html"):
        findings["robots_served_as_html"].append(
            {"content_type": fetch.get("content_type")})
    if status == 404 or (status == 0):
        findings["robots_404"].append({"status": status, "url": data.get("robots_url")})

    if fetch.get("bytes", 0) > MAX_BYTES:
        findings["file_too_large"].append({"bytes": fetch.get("bytes"), "limit": MAX_BYTES})
    if fetch.get("has_bom"):
        findings["has_bom"].append({"note": "UTF-8 BOM precedes the file"})

    for agent in data.get("sitewide_block_agents", []):
        findings["sitewide_block"].append({"agent": agent, "rule": "Disallow: /"})

    for err in data.get("line_errors", []):
        findings["syntax_error"].append(err)

    # Resource blocking.
    for r in resources:
        if r.get("blocked") and r.get("type") in RENDER_BLOCKING_TYPES:
            findings["blocked_resource"].append(
                {"url": r["url"], "type": r["type"], "path": r["path"]})

    # Sitemap directives.
    if not sitemaps:
        findings["no_sitemap_directive"].append({"note": "no Sitemap: directive in robots.txt"})
    for sm in sitemaps:
        if not sm.get("absolute"):
            findings["sitemap_unreachable"].append(
                {"url": sm["url"], "issue": "not an absolute URL"})
        elif sm.get("status") not in (200, None):
            findings["sitemap_unreachable"].append(
                {"url": sm["url"], "status": sm.get("status")})

    # Group structure.
    star_groups = [g for g in groups if "*" in g.get("agents", [])]
    if groups and not star_groups:
        findings["no_star_group"].append(
            {"note": "no default User-agent: * group; unlisted crawlers get no rules"})
    if len(star_groups) > 1:
        findings["duplicate_star_group"].append({"count": len(star_groups)})

    # Unsupported directives + empty Disallow.
    for u in data.get("unknown_directives", []):
        if u.get("field") in UNSUPPORTED_FIELDS:
            findings["unsupported_directive"].append(u)
    for g in groups:
        if g.get("crawl_delay") is not None:
            findings["unsupported_directive"].append(
                {"field": "crawl-delay", "value": g["crawl_delay"], "agents": g["agents"]})
        for o in g.get("other", []):
            if o.get("field") in UNSUPPORTED_FIELDS:
                findings["unsupported_directive"].append({**o, "agents": g["agents"]})
        # An explicit empty Disallow allows everything; flag only when it coexists with
        # real Disallow rules in the same group (a likely mistake / no-op leftover).
        if "" in g.get("disallow", []) and any(d for d in g.get("disallow", [])):
            findings["empty_disallow_confusion"].append({"agents": g["agents"]})

    severity = {
        "robots_5xx": "high", "robots_redirect": "high", "robots_served_as_html": "high",
        "sitewide_block": "high", "syntax_error": "high", "file_too_large": "high",
        "blocked_resource": "medium", "no_sitemap_directive": "medium",
        "sitemap_unreachable": "medium", "no_star_group": "medium",
        "unsupported_directive": "medium",
        "robots_404": "low", "duplicate_star_group": "low", "has_bom": "low",
        "empty_disallow_confusion": "low",
    }
    summary = {
        "robots_status": status,
        "groups": len(groups),
        "sitemaps_declared": len(sitemaps),
        "resources_checked": len(resources),
        "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

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 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