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 defaultUser-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.
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:
- 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). - Resource check — whether to fetch the homepage and test its CSS/JS/image references against the rules (default on). Use
--no-resourcesto skip it.
What robots.txt does and does not do: robots.txt controls crawling, not indexing. A
Disallowstops 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 anoindexmeta tag orX-Robots-Tagheader instead —noindexinside 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:
| Check | Severity |
|---|---|
| robots.txt returns 5xx (read as "block everything") | High |
| robots.txt redirects instead of returning the file | High |
| robots.txt served as HTML, not plain text | High |
Sitewide Disallow: / blocks the whole site | High |
| Syntax error / directive before any User-agent | High |
| File exceeds 500 KiB | High |
| Render-critical CSS/JS blocked from crawling | Medium |
No Sitemap: directive | Medium |
Declared Sitemap: URL unreachable or not absolute | Medium |
No default User-agent: * group | Medium |
| Unsupported directive (noindex, nofollow, crawl-delay) | Medium |
| robots.txt missing entirely (404) | Low |
Duplicate User-agent: * group | Low |
| UTF-8 BOM precedes the file | Low |
Empty Disallow: alongside real rules | Low |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — fetch status, group count, sitemaps declared, issue counts by severity
- Fetch & file — status, content type, size, redirect/HTML/BOM flags
- Issues — one section per failing check, with the offending directive/line and the fix
- 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/plainreturning200directly (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, andDisallow/Allowmust follow aUser-agentline. - Blocked resources: add
Allow:rules (or remove theDisallow) 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/nofollowfrom robots.txt and enforce them via meta robots orX-Robots-Tag; dropcrawl-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 checkreferences/report-template.md— report output structurescripts/collect_robots.py— fetch, parse, and probe a site's robots.txtscripts/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.
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.txtreturns 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(or404, 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.txtreturns 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.txtwith a200, 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/plainrobots.txt content; return a genuine404if there is no file.
4. Sitewide Disallow: /
- Definition: A user-agent group contains
Disallow: /with noAllow: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: valuecolon, aDisallow/Allowappearing before anyUser-agent, or an emptyUser-agent/Sitemapvalue. - 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 everyDisallow/Allowfollows aUser-agentdeclaration.
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 theDisallow) 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 returns200.
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
noindexandnofollow, pluscrawl-delay,host,request-rate,clean-param. - Why it matters: These are silently ignored by Google. Relying on
noindexin robots.txt to deindex a page does not work — the page stays indexable.crawl-delaygives a false sense of rate control. - Fix: Remove them. Enforce
noindex/nofollowvia meta robots orX-Robots-Tagon crawlable pages; manage crawl rate in Search Console.
Low severity
12. robots.txt missing entirely (404)
- Definition:
/robots.txtreturns 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-emptyDisallowrules. - 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.
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: /", "addAllow: /assets/", "addSitemap: 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.
#!/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.
#!/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()