Claude AI

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.txt file 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.

SKILL.mdView on GitHub
name
llms-txt-audit
description
Audit a website's /llms.txt for AI/LLM discoverability. Fetches and parses the llms.txt markdown file (per llmstxt.org) to find a missing file, a file served as HTML instead of markdown, a missing required H1 title, a missing blockquote summary, broken (non-200) or redirecting links, links missing descriptions, malformed list items that aren't proper markdown links, links to the wrong host, links that are noindexed or blocked by robots.txt, relative instead of absolute URLs, duplicate links, an empty file with no sections, the wrong content type, and whether a companion /llms-full.txt exists. Use when the user asks to audit, analyze, check, validate, create, or fix an llms.txt or llms-full.txt file, or to make a site discoverable and usable by AI assistants and LLMs.

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.txt or llms-full.txt file
  • Make a site discoverable and usable by AI assistants, chatbots, and LLMs
  • Find broken, redirecting, or blocked links inside llms.txt
  • Confirm llms.txt follows the llmstxt.org structure (H1 title, summary, link sections)
  • Decide what should go into llms.txt vs. 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:

  1. 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).
  2. Scope — how many referenced links to probe (default 300, capped with --max-urls).
  3. Probing — whether to fetch each link to check status/redirect/noindex (default on). Use --no-probe for 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:

CheckSeverity
/llms.txt missing (non-200)High
Served as HTML, not markdown/plain textHigh
Missing required H1 titleHigh
Referenced link returns 4xx/5xx/errorHigh
/llms.txt itself redirectsMedium
Referenced link redirects (not a final 200)Medium
Missing blockquote summaryMedium
Link list item missing a descriptionMedium
Malformed list item (not a markdown link)Medium
Link on the wrong hostMedium
Linked page is noindex or robots-blockedMedium
Wrong content typeMedium
Relative URL instead of absoluteLow
No sections / no linksLow
Duplicate linkLow
/llms-full.txt missingLow
Step 4: Produce the report

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

  1. Summary — file status, H1, sections, links found/probed, issue counts by severity
  2. File structure — H1, summary present, section list, llms-full.txt presence
  3. Issues — one section per failing check, listing the link/item, the problem, and the fix
  4. Prioritized action list — ordered by severity and impact
Step 5: Recommend fixes

For each issue, give a concrete fix:

  • Missing file: create /llms.txt with at minimum an H1 title, a one-line > summary, and one or more ## sections of curated - [name](url): description links. Serve it as text/markdown or text/plain at the root with a 200.
  • Served as HTML / wrong content type: serve the raw markdown file, not a rendered page; set Content-Type: text/markdown (or text/plain).
  • Missing H1: add a single top-level # Project Name heading — it is the only required element.
  • Broken / redirecting / blocked links: replace each with its final, canonical, indexable 200 URL (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 description so 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.txt with 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 check
  • references/report-template.md — report output structure
  • scripts/collect_llms.py — fetch, parse, and probe a site's llms.txt
  • scripts/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.

references/audit-checks.mdView on GitHub

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.txt returns 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.txt with 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 404 if there is no file.
3. Missing H1 title
  • Definition: The file has no single top-level # Title heading.
  • 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 Name heading 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.txt returns 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.txt with a 200 at 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 description to 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, www mismatch, 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-Type other than text/markdown or text/plain.
  • Why it matters: Some consumers check the content type; an unexpected type (e.g., application/octet-stream or text/html) can cause the file to be skipped or downloaded instead of read.
  • Fix: Serve it as text/markdown; charset=utf-8 (or text/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.txt is 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.txt with 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.

references/report-template.mdView on GitHub

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.txt is 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 200 pages, each with a clear description.
  • Provide a corrected or starter markdown block so the user can copy-paste the fix.
  • Treat /llms-full.txt as 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.

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

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

Explore Other Claude SEO Skills

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