Llms.txt Audit Claude Skill
This skill audits a website's /llms.txt file for AI and LLM discoverability and produces an actionable report. It fetches and parses the file against the llmstxt.org specification to find a missing file, a file served as HTML instead of markdown, a missing required H1 title or blockquote summary, broken or redirecting links, links missing descriptions, malformed list items, wrong-host or relative URLs, duplicates, and whether a companion /llms-full.txt exists.
What The Skill Checks
- File-level problems: a missing
/llms.txt, a file served as HTML instead of markdown, the wrong content type, an empty file with no sections, and a missing required H1 title or blockquote summary. - Link quality across every entry: broken (non-200) or redirecting links, links missing descriptions, malformed list items that aren't proper markdown links, links to the wrong host, and links that are noindexed or blocked by robots.txt.
- Hygiene and completeness signals: relative instead of absolute URLs, duplicate links, and whether a companion
/llms-full.txtfile exists alongside the main file.
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 the file and audit it against the spec. 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 llms-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 five-step workflow against the llmstxt.org spec, and the resources the skill relies on.
llms.txt Audit
Audit a website's /llms.txt file and produce an actionable report for AI/LLM discoverability.
When to use this skill
Use this skill when the user asks to:
- Audit, validate, or check an
llms.txtorllms-full.txtfile - Make a site discoverable and usable by AI assistants, chatbots, and LLMs
- Find broken, redirecting, or blocked links inside
llms.txt - Confirm
llms.txtfollows the llmstxt.org structure (H1 title, summary, link sections) - Decide what should go into
llms.txtvs.llms-full.txt
Background: what llms.txt is
llms.txt is a proposed standard (https://llmstxt.org) for a markdown file at the site root (/llms.txt) that gives LLMs a concise, curated map of the site's most useful content. Unlike robots.txt (which controls crawling) or sitemap.xml (which lists every URL), llms.txt is hand-curated and human-readable. Its structure:
# Project Name ← required: the only mandatory element
> A short summary. ← recommended blockquote summary
Optional free-form markdown with key context (no headings).
## Docs ← H2 section of curated links
- [Quickstart](https://example.com/quickstart): How to get started.
- [API reference](https://example.com/api): Full API docs.
## Optional ← an "Optional" section may be skipped by LLMs with limited context
- [Changelog](https://example.com/changelog): Release history.
A companion /llms-full.txt may contain the full expanded content in one file. This skill audits structure and link health against that spec.
Inputs to collect
Before starting, confirm with the user:
- Site URL or llms.txt URL — a live site root (e.g.,
https://example.com, which fetches/llms.txt) or a specific llms.txt URL (--llms). - Scope — how many referenced links to probe (default 300, capped with
--max-urls). - Probing — whether to fetch each link to check status/redirect/noindex (default on). Use
--no-probefor a fast structure-only pass.
Workflow
Step 1: Fetch and parse llms.txt
Use scripts/collect_llms.py:
python3 scripts/collect_llms.py https://example.com --max-urls 300 --output llms_inventory.json
Point directly at an llms.txt with --llms:
python3 scripts/collect_llms.py https://example.com/llms.txt --llms --output llms_inventory.json
The script fetches /llms.txt without auto-following redirects (so a redirected or HTML-served file is visible), records status / content type / size, parses the H1 title, blockquote summary, and H2 sections with their list items, then probes every referenced link (status, redirect target, canonical, meta robots, X-Robots-Tag, robots.txt disallow). It also checks whether /llms-full.txt exists.
Step 2: Run the audit checks
Use scripts/audit_llms.py:
python3 scripts/audit_llms.py llms_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 |
|---|---|
| /llms.txt missing (non-200) | High |
| Served as HTML, not markdown/plain text | High |
| Missing required H1 title | High |
| Referenced link returns 4xx/5xx/error | High |
| /llms.txt itself redirects | Medium |
| Referenced link redirects (not a final 200) | Medium |
| Missing blockquote summary | Medium |
| Link list item missing a description | Medium |
| Malformed list item (not a markdown link) | Medium |
| Link on the wrong host | Medium |
| Linked page is noindex or robots-blocked | Medium |
| Wrong content type | Medium |
| Relative URL instead of absolute | Low |
| No sections / no links | Low |
| Duplicate link | Low |
| /llms-full.txt missing | Low |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — file status, H1, sections, links found/probed, issue counts by severity
- File structure — H1, summary present, section list, llms-full.txt presence
- Issues — one section per failing check, listing the link/item, the problem, and the fix
- Prioritized action list — ordered by severity and impact
Step 5: Recommend fixes
For each issue, give a concrete fix:
- Missing file: create
/llms.txtwith at minimum an H1 title, a one-line>summary, and one or more##sections of curated- [name](url): descriptionlinks. Serve it astext/markdownortext/plainat the root with a200. - Served as HTML / wrong content type: serve the raw markdown file, not a rendered page; set
Content-Type: text/markdown(ortext/plain). - Missing H1: add a single top-level
# Project Nameheading — it is the only required element. - Broken / redirecting / blocked links: replace each with its final, canonical, indexable
200URL (or remove it). Links in llms.txt should point straight to the live destination. - Missing descriptions / malformed items: rewrite each item as
- [Descriptive title](absolute-url): one-line descriptionso an LLM can tell what each link is for. - Relative URLs: use absolute
https://URLs so the file works when fetched out of context. - llms-full.txt: if the user wants full-content ingestion, generate
/llms-full.txtwith the expanded page contents. - Base every recommendation on the observed file; quote the exact line to change and 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 and rationale for every audit checkreferences/report-template.md— report output structurescripts/collect_llms.py— fetch, parse, and probe a site's llms.txtscripts/audit_llms.py— run audit checks against an llms.txt inventory JSON file
Audit Checks Reference (references/audit-checks.md)
Full definitions, severities, and rationale for all fourteen checks, from a missing file and HTML serving to relative URLs and a missing companion file.
llms.txt Audit Checks
Full definitions, thresholds, and rationale for each audit check.
The guiding principle: /llms.txt should be a valid markdown file, served as plain text/markdown from the site root, that opens with an H1 title and a one-line summary and then offers curated sections of well-described links — each pointing to a final, canonical, reachable 200 page. It is a human- and LLM-readable map of your best content, so structure and link health are everything. The spec lives at https://llmstxt.org.
High severity
1. /llms.txt missing
- Definition:
/llms.txtreturns a non-200 status (404, error, etc.). - Why it matters: Without the file, AI assistants have no curated entry point to the site and fall back to crawling or guessing. For a site that wants strong LLM discoverability, the absence of the file is the headline finding.
- Fix: Create
/llms.txtwith an H1 title, a>summary, and at least one##section of curated links. (Note: the standard is emerging and optional — if the user does not want one, this is informational rather than a defect.)
2. Served as HTML
- Definition: The response body is an HTML document rather than markdown/plain text.
- Why it matters: A rendered HTML page (or a soft-404) is not a parseable llms.txt; LLM tooling expects raw markdown. It usually means a catch-all route is serving the path.
- Fix: Serve the raw markdown file directly; return a genuine
404if there is no file.
3. Missing H1 title
- Definition: The file has no single top-level
# Titleheading. - Why it matters: The H1 project/site name is the only required element in the spec. Without it the file is not a valid llms.txt and consumers may reject or misparse it.
- Fix: Add one
# Project Nameheading as the first line.
4. Referenced link returns an error
- Definition: A link listed in llms.txt returns 4xx, 5xx, or a connection error.
- Why it matters: The whole point of the file is to send LLMs to useful pages; dead links waste that and signal a stale, untrusted file.
- Fix: Replace the link with the correct live URL, or remove it.
Medium severity
5. /llms.txt redirects
- Definition:
/llms.txtreturns a 3xx redirect instead of the file directly. - Why it matters: Consumers may not follow redirects for this file. It should live at the canonical root path and return the content directly.
- Fix: Serve
/llms.txtwith a200at the root, no redirect.
6. Referenced link redirects
- Definition: A listed link returns a 3xx redirect rather than a final 200.
- Why it matters: llms.txt should point straight to destinations. Redirecting entries add a hop and suggest the file was not updated after content moved.
- Fix: Replace the link with its final destination URL.
7. Missing summary
- Definition: There is no
>blockquote summary immediately after the H1. - Why it matters: The summary is the spec's recommended one-line orientation for an LLM with limited context; without it the file is less useful at a glance.
- Fix: Add a single
> One-sentence description of the project.line under the H1.
8. Link missing a description
- Definition: A list link has no trailing description (
- [title](url)with nothing after it). - Why it matters: Descriptions tell an LLM what each link is for and when to use it. Bare links force guessing from the URL alone.
- Fix: Append
: one-line descriptionto each link item.
9. Malformed list item
- Definition: A list item under a section is not a valid markdown link (
- [name](url)). - Why it matters: Parsers extract links by this exact shape; a malformed item is dropped or misread, so the content it pointed to is lost.
- Fix: Rewrite the item as
- [Descriptive title](https://absolute-url): description.
10. Link on the wrong host
- Definition: A listed link is on a different host than the site (different subdomain,
wwwmismatch, or external domain). - Why it matters: llms.txt should map this site's content. Off-host links may be intentional (e.g., docs on a subdomain) but are often mistakes or non-canonical hosts.
- Fix: Point links at the canonical host, or confirm the off-host link is intended.
11. Linked page is noindex or robots-blocked
- Definition: A referenced page is marked
noindex(meta robots / X-Robots-Tag) or its path is disallowed in robots.txt. - Why it matters: Featuring a page in llms.txt while telling crawlers to ignore or not index it is a contradictory signal and a sign the curation is stale.
- Fix: Either promote the page (remove the block/noindex if it should be surfaced) or drop it from llms.txt.
12. Wrong content type
- Definition: The file is served with a
Content-Typeother thantext/markdownortext/plain. - Why it matters: Some consumers check the content type; an unexpected type (e.g.,
application/octet-streamortext/html) can cause the file to be skipped or downloaded instead of read. - Fix: Serve it as
text/markdown; charset=utf-8(ortext/plain).
Low severity
13. Relative URL instead of absolute
- Definition: A link uses a relative path (
/docs) rather than an absolute URL. - Why it matters: llms.txt is frequently fetched and processed out of context, where relative URLs cannot be resolved. The spec recommends absolute URLs.
- Fix: Use full
https://URLs for every link.
14. No sections / no links
- Definition: The file has no
##sections, or sections contain no links. - Why it matters: A title and summary with no curated links provides little value to an LLM.
- Fix: Add
##sections grouping your most important pages as described links.
15. Duplicate link
- Definition: The same URL appears in more than one place in the file.
- Why it matters: Harmless but noisy; usually a curation slip that inflates the list.
- Fix: List each URL once, in its most relevant section.
16. /llms-full.txt missing
- Definition: No
/llms-full.txtis present. - Why it matters: Purely informational.
llms-full.txt(the full expanded content in one file) is optional; some sites offer it for full-content ingestion, but it is not required. - Fix: If full-content ingestion is desired, generate
/llms-full.txtwith the expanded page contents. Otherwise ignore.
Report Template (references/report-template.md)
The output structure the skill follows, including file status, issues grouped by type, and prioritized remediation actions.
llms.txt Audit Report Template
Use this structure for the final report delivered to the user.
# llms.txt Audit — {site}
**Date:** {date}
**File:** {status} | **Sections:** {n} | **Links:** {found} ({probed} probed) | **llms-full.txt:** {yes/no}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentence overview of llms.txt health and the biggest wins. If the file is
missing entirely, say so up front and frame the report as "how to create one".}
## File structure
| Element | Status |
|---|---|
| H1 title | "Project Name" / missing |
| Summary blockquote | present / missing |
| Sections | Docs, Guides, Optional |
| Content-Type | text/markdown |
| /llms-full.txt | present / missing |
## High-severity issues
### Availability / structure ({n})
{Call out a missing file, an HTML-served file, or a missing H1 with the fix.}
### Broken links ({n})
| URL | Status | Section | Action |
|---|---|---|---|
| ... | 404 | Docs | Replace with live URL or remove |
## Medium-severity issues
### Redirecting links ({n})
| URL | Status | Final URL | Action |
|---|---|---|---|
| ... | 301 | ... | Replace with final URL |
### Link descriptions & formatting ({n})
| Item | Problem | Fix |
|---|---|---|
| - [API](…) | no description | Add `: what this link is` |
| - plain text line | not a markdown link | Rewrite as `- [title](url): desc` |
### Other ({n})
{One row per failing check: missing summary, wrong host, noindex/blocked links,
redirecting /llms.txt, wrong content type. Same style: item, problem, fix.}
## Low-severity issues
{Brief list: relative URLs, no sections/links, duplicate links, llms-full.txt absent.}
## Suggested llms.txt
\`\`\`markdown
# {Project Name}
> {One-line summary.}
## {Section}
- [{Title}](https://example.com/{path}): {description}
\`\`\`
{Provide a corrected or starter file when the existing one is missing or weak.}
## Prioritized action list
1. {Highest-impact fix, with the exact link or line to add/remove/change}
2. ...
Guidance
- If
/llms.txtis missing, lead with that and deliver a ready-to-use starter file in the "Suggested llms.txt" block rather than a list of defects. - Every issue row must name the exact URL or list item and a concrete action ("replace with {final URL}", "add
: description", "use absolute URL"). - Reinforce the core rule in the summary: llms.txt links should point to final, canonical, reachable
200pages, each with a clear description. - Provide a corrected or starter markdown block so the user can copy-paste the fix.
- Treat
/llms-full.txtas optional — note its absence, but do not present it as a defect unless the user wants full-content ingestion. - Cap link tables at ~20 rows in the report body; offer the full list (CSV/JSON) if larger.
- Order the action list by severity first, then by how many links each fix repairs.
Llms.txt Collector Script (scripts/collect_llms.py)
Fetches and parses /llms.txt, extracting the H1 title, blockquote summary, H2 sections, and list items while recording HTTP status and content type, and probes each linked URL.
#!/usr/bin/env python3
"""Fetch and parse a site's /llms.txt, then probe every link it references into a JSON inventory.
Usage:
python3 collect_llms.py https://example.com [--max-urls 300] [--output llms_inventory.json]
python3 collect_llms.py https://example.com/llms.txt --llms [--output llms_inventory.json]
The llms.txt proposal (https://llmstxt.org) defines a markdown file at /llms.txt:
an H1 project name (required), an optional blockquote summary, optional free
markdown, then H2 sections whose list items are links of the form
"- [name](url): optional notes". This script fetches /llms.txt without
auto-following redirects, records status / content type / size, parses that
structure, and probes every linked URL (status, redirect target, canonical, meta
robots, X-Robots-Tag, robots.txt disallow). It also checks whether /llms-full.txt
exists.
Output JSON schema:
{
"site": "https://example.com",
"llms_url": "https://example.com/llms.txt",
"fetch": {"found": true, "status": 200, "content_type": "text/markdown",
"bytes": 1234, "redirected": false, "final_url": "...",
"looks_like_html": false},
"raw": "<full llms.txt text>",
"h1": "Project Name|null",
"summary": "blockquote text|null",
"sections": [{"heading": "Docs", "optional": false,
"items": [{"raw": "...", "is_link": true, "title": "...",
"url": "...", "description": "...", "relative": false}]}],
"links": {"<url>": {"sections": ["Docs"], "status": 200, "redirected": false,
"final_url": "...", "canonical": "...|null",
"meta_robots": "...|null", "x_robots_tag": "...|null",
"robots_disallowed": false}},
"llms_full_txt": {"checked": "https://…/llms-full.txt", "status": 200, "found": true}
}
Uses only the Python standard library.
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
USER_AGENT = "LlmsTxtAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
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=3_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_text_or_none, 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 fetch_text_no_follow(url):
"""Fetch a text file without following redirects. Returns (status, location, text, 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(2_000_000).decode("utf-8", errors="replace"), resp.headers
except urllib.error.HTTPError as e:
loc = e.headers.get("Location") if e.headers else None
return e.code, loc, "", (e.headers if e.headers else None)
except Exception:
return 0, None, "", None
def parse_robots_disallow(site_root):
"""Fetch robots.txt and return the list of Disallow paths for User-agent: *."""
status, _f, raw, _h = raw_get(site_root.rstrip("/") + "/robots.txt", max_bytes=1_000_000)
if status != 200:
return []
text = raw.decode("utf-8", errors="replace")
disallow, in_star, after_directive = [], False, 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 == "user-agent":
# A new group starts after the previous group's directives ended.
if after_directive:
in_star = False
in_star = in_star or (value == "*")
after_directive = False
elif field == "disallow":
after_directive = True
if in_star and value:
disallow.append(value)
elif field == "allow":
after_directive = True
return disallow
def robots_disallowed(path, disallow):
return any(path.startswith(rule) for rule in disallow)
def parse_llms(text):
"""Parse llms.txt markdown into h1, summary blockquote, and H2 sections of list items."""
h1 = None
summary = None
sections = []
current = None
seen_h1 = False
for raw_line in text.splitlines():
line = raw_line.rstrip()
stripped = line.strip()
if stripped.startswith("# ") and not stripped.startswith("##"):
if h1 is None:
h1 = stripped[2:].strip()
seen_h1 = True
continue
if stripped.startswith("## "):
heading = stripped[3:].strip()
current = {"heading": heading, "optional": heading.lower() == "optional", "items": []}
sections.append(current)
continue
if summary is None and seen_h1 and current is None and stripped.startswith(">"):
summary = stripped.lstrip(">").strip()
continue
if current is not None and re.match(r"^[-*+]\s+", stripped):
item_text = re.sub(r"^[-*+]\s+", "", stripped)
m = LINK_RE.search(item_text)
if m:
title, url = m.group(1).strip(), m.group(2).strip()
# Description is whatever follows the link, after an optional ": ".
tail = item_text[m.end():].lstrip()
if tail.startswith(":"):
tail = tail[1:].strip()
relative = not bool(urllib.parse.urlparse(url).scheme) and not url.startswith("//")
current["items"].append({
"raw": item_text, "is_link": True, "title": title, "url": url,
"description": tail or None, "relative": relative,
})
else:
current["items"].append({
"raw": item_text, "is_link": False, "title": None, "url": None,
"description": None, "relative": False,
})
return h1, summary, sections
def probe_url(loc, disallow):
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, disallow),
}
def main():
ap = argparse.ArgumentParser(description="Fetch, parse, and probe a site's llms.txt.")
ap.add_argument("source", help="Site root URL, or an llms.txt URL with --llms")
ap.add_argument("--llms", action="store_true", help="Treat source as an llms.txt URL directly")
ap.add_argument("--max-urls", type=int, default=300, help="max linked URLs to probe (default 300)")
ap.add_argument("--no-probe", action="store_true", help="parse but skip per-link probing")
ap.add_argument("--output", default="llms_inventory.json")
args = ap.parse_args()
if args.llms:
pu = urllib.parse.urlparse(args.source)
site_root = f"{pu.scheme}://{pu.netloc}"
llms_url = args.source
else:
site_root = args.source.rstrip("/")
llms_url = site_root + "/llms.txt"
status, location, text, headers = fetch_text_no_follow(llms_url)
redirected = 300 <= status < 400
final_url = urllib.parse.urljoin(llms_url, location) if (redirected and location) else llms_url
content_type = (headers.get("Content-Type", "") if headers else "") or ""
if redirected and location:
# Follow the redirect once so a moved file is still parsed.
_st, _l, text, _h = fetch_text_no_follow(final_url)
text = text or ""
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(text.encode("utf-8")), "redirected": redirected,
"final_url": final_url, "looks_like_html": looks_like_html,
}
print(f"llms.txt: [{status}] {llms_url} ({fetch['bytes']} bytes)", file=sys.stderr)
h1 = summary = None
sections = []
if status == 200 and not looks_like_html:
h1, summary, sections = parse_llms(text)
# Gather links (absolute-resolved) and which sections they appear in.
disallow = parse_robots_disallow(site_root)
link_sections = {}
for sec in sections:
for item in sec["items"]:
if item["is_link"] and item["url"]:
resolved = urllib.parse.urljoin(llms_url, item["url"])
link_sections.setdefault(resolved, []).append(sec["heading"])
links = {}
locs = list(link_sections.keys())[: args.max_urls]
for i, loc in enumerate(locs, 1):
entry = {"sections": link_sections[loc]}
if not args.no_probe:
entry.update(probe_url(loc, disallow))
print(f" [{entry.get('status')}] ({i}/{len(locs)}) {loc}", file=sys.stderr)
links[loc] = entry
# Check llms-full.txt.
full_url = site_root + "/llms-full.txt"
fst, _fl, _ft, _fh = fetch_text_no_follow(full_url)
llms_full = {"checked": full_url, "status": fst, "found": fst == 200}
out = {
"site": site_root,
"llms_url": llms_url,
"fetch": fetch,
"raw": text,
"h1": h1,
"summary": summary,
"sections": sections,
"links": links,
"llms_full_txt": llms_full,
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
print(f"Wrote {len(sections)} sections, {len(link_sections)} links "
f"({len(links)} probed) to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
Llms.txt Auditor Script (scripts/audit_llms.py)
Runs the audit checks against the collected inventory to validate it against the llmstxt.org specification and outputs the structured report data.
#!/usr/bin/env python3
"""Audit an llms.txt inventory JSON (from collect_llms.py) and emit findings.
Usage:
python3 audit_llms.py llms_inventory.json [--output audit_report.json]
Checks implemented (see references/audit-checks.md):
- no_llms_txt: /llms.txt is missing (non-200) (high)
- served_as_html: /llms.txt returns an HTML page, not markdown/plain text (high)
- missing_h1: no top-level H1 title — the one required element (high)
- link_error: a referenced link returns 4xx/5xx/error (high)
- llms_redirect: /llms.txt itself redirects instead of returning the file (medium)
- link_redirects: a referenced link 3xx-redirects instead of being a final 200 (medium)
- missing_summary: no blockquote summary after the H1 (medium)
- link_missing_description: a list link has no description (medium)
- malformed_list_item: a list item under a section is not a proper markdown link (medium)
- link_wrong_host: a referenced link is on a different host (medium)
- link_noindex_or_blocked: a referenced page is noindex or blocked by robots.txt (medium)
- wrong_content_type: not served as text/markdown or text/plain (medium)
- relative_link: a link uses a relative URL instead of an absolute one (low)
- no_sections: no H2 sections / no links at all (low)
- duplicate_link: the same URL is listed more than once (low)
- llms_full_txt_missing: /llms-full.txt is not present (low, informational)
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.parse
MARKDOWN_TYPES = ("text/markdown", "text/plain", "text/x-markdown")
def norm_host(host):
host = host.lower()
return host[4:] if host.startswith("www.") else host
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 main():
ap = argparse.ArgumentParser(description="Run llms.txt audit checks.")
ap.add_argument("inventory", help="llms_inventory.json from collect_llms.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)
fetch = data.get("fetch", {})
sections = data.get("sections", [])
links = data.get("links", {})
findings = {
"no_llms_txt": [], "served_as_html": [], "missing_h1": [], "link_error": [],
"llms_redirect": [], "link_redirects": [], "missing_summary": [],
"link_missing_description": [], "malformed_list_item": [], "link_wrong_host": [],
"link_noindex_or_blocked": [], "wrong_content_type": [],
"relative_link": [], "no_sections": [], "duplicate_link": [],
"llms_full_txt_missing": [],
}
status = fetch.get("status")
available = status == 200 and not fetch.get("looks_like_html")
if status != 200:
findings["no_llms_txt"].append({"status": status, "url": data.get("llms_url")})
if fetch.get("looks_like_html"):
findings["served_as_html"].append({"content_type": fetch.get("content_type")})
if fetch.get("redirected"):
findings["llms_redirect"].append(
{"status": status, "final_url": fetch.get("final_url")})
if available:
ctype = (fetch.get("content_type") or "").lower()
if ctype and not any(t in ctype for t in MARKDOWN_TYPES):
findings["wrong_content_type"].append({"content_type": fetch.get("content_type")})
if not data.get("h1"):
findings["missing_h1"].append({"note": "no top-level '# Title' heading"})
if not data.get("summary"):
findings["missing_summary"].append({"note": "no '> summary' blockquote after the H1"})
has_links = any(item.get("is_link") for s in sections for item in s["items"])
if not sections or not has_links:
findings["no_sections"].append(
{"sections": len(sections), "note": "no H2 sections with links"})
# List-item structure.
for sec in sections:
for item in sec["items"]:
if not item.get("is_link"):
findings["malformed_list_item"].append(
{"section": sec["heading"], "text": item.get("raw")})
continue
if not item.get("description"):
findings["link_missing_description"].append(
{"section": sec["heading"], "url": item.get("url"),
"title": item.get("title")})
if item.get("relative"):
findings["relative_link"].append(
{"section": sec["heading"], "url": item.get("url")})
# Per-link probe results.
for url, p in links.items():
st = p.get("status")
if st is not None:
if st == 0 or (st and st >= 400):
findings["link_error"].append({"url": url, "status": st})
elif p.get("redirected"):
findings["link_redirects"].append(
{"url": url, "status": st, "final_url": p.get("final_url")})
else:
if is_noindex(p) or p.get("robots_disallowed"):
findings["link_noindex_or_blocked"].append(
{"url": url, "noindex": is_noindex(p),
"robots_disallowed": p.get("robots_disallowed")})
pu = urllib.parse.urlparse(url)
if pu.netloc and norm_host(pu.netloc) != site_host:
findings["link_wrong_host"].append({"url": url, "expected_host": site_host})
# Duplicate links across sections.
for url, p in links.items():
if len(p.get("sections", [])) > 1:
findings["duplicate_link"].append({"url": url, "sections": p["sections"]})
full = data.get("llms_full_txt", {})
if not full.get("found"):
findings["llms_full_txt_missing"].append(
{"checked": full.get("checked"), "status": full.get("status")})
severity = {
"no_llms_txt": "high", "served_as_html": "high", "missing_h1": "high",
"link_error": "high",
"llms_redirect": "medium", "link_redirects": "medium", "missing_summary": "medium",
"link_missing_description": "medium", "malformed_list_item": "medium",
"link_wrong_host": "medium", "link_noindex_or_blocked": "medium",
"wrong_content_type": "medium",
"relative_link": "low", "no_sections": "low", "duplicate_link": "low",
"llms_full_txt_missing": "low",
}
probed = sum(1 for p in links.values() if p.get("status") is not None)
summary = {
"llms_txt_status": status,
"h1": data.get("h1"),
"sections": len(sections),
"links_found": len(links),
"links_probed": probed,
"llms_full_txt_found": full.get("found", False),
"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()