Claude AI

Soft 404 Audit Claude Skill

This skill audits a website for soft 404s and produces an actionable report. A soft 404 is a page that returns 200 OK but is really a not-found or empty page, it wastes crawl budget and can get real URLs dropped from the index. The skill crawls pages and sends a missing-page probe to find error pages served with a 200 status, a server that returns 200 for every unknown URL instead of a real 404 or 410, thin pages matching the site's error template, and empty or near-empty pages.

What The Skill Checks

  • Error template detection: a missing-page probe that reveals whether the server returns a real 404/410 or a 200 for every unknown URL.
  • Disguised errors: error pages served with a 200 status and thin pages matching the site's error template.
  • Empty pages: empty or near-empty 200 pages below the thin-word threshold, raised for content-heavy sites.

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 crawl the site and send a missing-page probe to extract the error template, then audit each live page against 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 soft-404-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 workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
soft-404-audit
description
Audit a website for soft 404s — pages that return HTTP 200 but are really "not found", empty, or error pages. Crawls pages and sends a missing-page probe to find error pages served with a 200 status, a server that returns 200 for every unknown URL instead of a real 404/410, thin pages matching the site's error template, and empty/near-empty pages. Use when the user asks to audit, check, or fix soft 404s, soft 404 errors in Search Console, pages that should be 404 but return 200, fake/empty pages, or missing pages that don't return the right status code.

Soft 404 Audit

Audit a website for soft 404s and produce an actionable report. A soft 404 is a page that returns 200 OK but is really a "not found"/empty page — it wastes crawl budget and can get real URLs dropped from the index.

When to use this skill

Use this skill when the user asks to:

  • Audit or fix soft 404s (including Search Console "Soft 404" reports)
  • Find pages that return 200 but should return 404/410
  • Check whether the site returns a real 404 for missing URLs
  • Find empty or error-template pages served as live pages
Inputs to collect
  1. Site URL or URL list — a live site root to crawl (auto-seeds from /sitemap.xml), or a text file with --url-list. A URL list of the pages Search Console flagged works well.
  2. Scope — pages to crawl (default 500, --max-pages).
  3. Thin threshold — word count below which a 200 page is "thin" (default 50, --thin-words); raise it for content-heavy sites.
Workflow
Step 1: Crawl and probe

Use scripts/extract_pages.py:

python3 scripts/extract_pages.py https://example.com --max-pages 500 --output soft404_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_pages.py --from-cache page_cache.json --output soft404_inventory.json

It records each page's status, title, first H1, visible word count, and whether error phrases appear in the title/H1, then sends one request to a deliberately non-existent URL to learn how the site handles missing pages.

Step 2: Run the audit checks
python3 scripts/audit_soft404.py soft404_inventory.json --output audit_report.json
Step 3: Evaluate the audit checks

Evaluate each check in references/audit-checks.md. Core checks:

CheckSeverity
Soft 404 (error page served as 200)High
Missing URLs return 200 (site-wide)High
Thin page matching the error templateMedium
Empty / near-empty pageLow
Step 4: Produce the report

Write a report following references/report-template.md: lead with the site-wide status-code behavior, then confirmed soft 404s, then suspected ones, and a prioritized action list.

Step 5: Recommend fixes
  • Missing URLs return 200: configure the server/framework to return a real 404 for unmatched routes (render the custom 404 page with a 404 status). This single fix clears the whole class.
  • Confirmed soft 404: return 404 (or 410 if permanently gone), or restore the page if the URL should resolve.
  • Thin/empty pages: verify they aren't JavaScript-rendered (this crawler doesn't run JS); add content or noindex genuine placeholders.
  • Base every recommendation on observed signals; never invent page content.
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
  • references/report-template.md — report output structure
  • scripts/extract_pages.py — crawl pages and probe missing-page behavior
  • scripts/audit_soft404.py — run soft-404 audit checks

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

Full definitions, severities, and rationale for every check across the high, medium, and low tiers.

references/audit-checks.mdView on GitHub

Soft 404 Audit Checks

Definitions and rationale. A soft 404 is a page that returns HTTP 200 OK but is really a "not found", empty, or error page. Google flags these in Search Console because they waste crawl budget and can get otherwise-fine URLs dropped; users hit dead ends that look like real pages.

High severity
1. Soft 404 (error page served as 200)
  • Definition: A page returning HTTP 200 whose title or H1 contains a "not found" / error phrase ("404", "page not found", "no longer exists", "sorry, we couldn't find…", etc.).
  • Why it matters: Search engines may index the error page, or (more often) distrust the site's status codes and drop real URLs. Users think the link works until they read the page.
  • Fix: Return a real 404 (or 410 if permanently gone) status for missing content. If the URL should resolve, fix whatever makes it render the error.
2. Missing pages return 200
  • Definition: A request to a deliberately non-existent URL returns 200 instead of 404/410 (detected via the missing-page probe).
  • Why it matters: The whole site serves a soft 404 for every unknown URL, so typos, old links, and crawler guesses all become indexable 200 "pages". This is a server/router misconfiguration affecting the entire domain.
  • Fix: Configure the server/framework to return 404 for unmatched routes and render the custom 404 page with a 404 status.
Medium severity
3. Thin page matching the error template
  • Definition: A 200 page that is very thin (few words) and matches the missing-page probe's fingerprint (same title, similarly empty) without an obvious error phrase.
  • Why it matters: Strong sign the page is the site's error/empty template served under a real URL — a soft 404 that phrase-matching alone misses.
  • Fix: Confirm the URL should exist; if not, return 404. If it should, ensure the real content renders server-side.
Low severity
4. Empty / near-empty page
  • Definition: A 200 page with almost no visible text and no error phrase.
  • Why it matters: May be a legitimate page that renders content via JavaScript (which this crawler doesn't execute), a placeholder, or a thin page. Worth a human look, not necessarily a soft 404.
  • Fix: If it's a placeholder, add content or noindex it; if it's JS-rendered, verify search engines can see the content. Cross-check the content-quality-audit skill.
Notes
  • This crawler does not execute JavaScript. A page that's empty in raw HTML but populated by a client-side framework will look thin here — verify before treating it as a soft 404.
  • Tune --thin-words to the site; content-heavy sites may warrant a higher threshold than the default of 50.

Report Template (references/report-template.md)

The output structure the skill follows, including the sections issues are grouped into.

references/report-template.mdView on GitHub

Soft 404 Audit Report Template

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

# Soft 404 Audit — {site}

**Date:** {date}
**Scope:** {pages crawled}
**Pages analyzed:** {n} | **Missing-page probe returned:** {status}

## Summary

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

{2–3 sentences. State the headline first: does the site return a real 404 for
missing URLs, or 200 for everything? Then the count of specific soft 404s found.}

## High-severity issues

### Site-wide: missing URLs return 200 (if applicable)
{If the probe returned 200, explain that every unknown URL becomes an indexable
soft 404, and that this is a single server/router fix.}

### Soft 404s (error pages served as 200) ({n})
| Page | Title / H1 | Words | Fix |
|---|---|---|---|
| ... | "Page not found" | 12 | Return 404, or restore the page |

## Medium-severity issues

### Thin pages matching the error template ({n})
| Page | Title | Words | Fix |
|---|---|---|---|
| ... | ... | 8 | Confirm should exist; else return 404 |

## Low-severity issues

### Empty / near-empty pages ({n})
{Brief table. Note which may be JavaScript-rendered and need manual verification.}

## Prioritized action list

1. {If the probe returned 200: fix the server/router to return real 404s — this is
   the top fix and clears the whole class. Otherwise lead with the confirmed soft
   404s.}
2. ...
Guidance
  • Lead with the status-code behavior. "Missing URLs return 200" is a single root-cause fix that outranks any individual page.
  • Distinguish confirmed soft 404s (error phrase) from suspected ones (thin / template-matching) so the user knows what's certain.
  • Flag that JavaScript-rendered pages can look thin to this crawler; recommend manual verification before returning 404 on a borderline page.
  • Every row names the exact URL, the observed title/word count, and the fix.
  • Base everything on the crawled signals; never invent page content.

Page Extractor Script (scripts/extract_pages.py)

Crawls pages and sends a missing-page probe to capture the site's 404/error template and each live page's content signals.

scripts/extract_pages.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a site and capture per-page signals used to detect soft 404s.

Usage:
    python3 extract_pages.py https://example.com [--max-pages 500] [--output soft404_inventory.json]
    python3 extract_pages.py urls.txt --url-list [--output soft404_inventory.json]

A "soft 404" is a page that returns HTTP 200 but is really a "not found" / empty /
error page. For each page this records status, final_url, title, first H1, visible
word count, and whether known error phrases appear in the title/H1/body. It also
sends one request to a deliberately non-existent URL to learn how the site handles
missing pages (a proper 404, or a 200 "soft" page).

Uses only the Python standard library.
"""

import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser

USER_AGENT = "Soft404AuditBot/1.0 (+claude-skill)"
TIMEOUT = 15

ERROR_PHRASES = [
    "404", "not found", "page not found", "page doesn't exist",
    "page does not exist", "cannot be found", "can't be found",
    "no longer available", "no longer exists", "nothing found",
    "page you requested", "page you were looking for", "oops",
    "sorry, we couldn", "this page isn", "doesn't exist",
]
SKIP_TEXT_TAGS = {"script", "style", "noscript", "template", "svg"}


class PageParser(HTMLParser):
    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.title = None
        self.h1 = None
        self.text_words = 0
        self.links = []
        self._in_title = False
        self._title = []
        self._in_h1 = False
        self._h1 = []
        self._h1_done = False
        self._skip_depth = 0

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag in SKIP_TEXT_TAGS:
            self._skip_depth += 1
        if tag == "title":
            self._in_title = True
            self._title = []
        elif tag == "h1" and not self._h1_done:
            self._in_h1 = True
            self._h1 = []
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                self.links.append(
                    urllib.parse.urldefrag(urllib.parse.urljoin(self.base_url, href))[0])

    def handle_endtag(self, tag):
        if tag in SKIP_TEXT_TAGS and self._skip_depth > 0:
            self._skip_depth -= 1
        if tag == "title":
            self._in_title = False
            self.title = " ".join("".join(self._title).split()) or None
        elif tag == "h1" and self._in_h1:
            self._in_h1 = False
            self._h1_done = True
            self.h1 = " ".join("".join(self._h1).split()) or None

    def handle_data(self, data):
        if self._in_title:
            self._title.append(data)
        if self._in_h1:
            self._h1.append(data)
        if self._skip_depth == 0:
            self.text_words += len(data.split())


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(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, resp.geturl(), body
    except urllib.error.HTTPError as e:
        return e.code, url, None
    except Exception:
        return 0, url, None


def norm_host(url):
    h = urllib.parse.urlparse(url).netloc.lower()
    return h[4:] if h.startswith("www.") else h


def pages_from_sitemap(site):
    status, _f, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
    if status != 200 or not body:
        return []
    try:
        root = ET.fromstring(body)
        return [loc.text.strip() for loc in
                root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc") if loc.text]
    except ET.ParseError:
        return []


def analyze(url, html):
    p = PageParser(url)
    try:
        p.feed(html)
    except Exception:
        pass
    haystack = " ".join(filter(None, [p.title, p.h1])).lower()
    title_h1_error = any(ph in haystack for ph in ERROR_PHRASES)
    return {"title": p.title, "h1": p.h1, "word_count": p.text_words,
            "error_phrase_in_title_or_h1": title_h1_error}, p.links


def probe_missing(site):
    """Request a definitely-missing URL to learn the site's not-found behavior."""
    test = urllib.parse.urljoin(site.rstrip("/") + "/",
                                "claude-soft404-probe-" + uuid.uuid4().hex[:12])
    status, final_url, html = fetch(test)
    info = {"url": test, "status": status, "final_url": final_url,
            "title": None, "word_count": 0}
    if html:
        a, _l = analyze(final_url, html)
        info["title"] = a["title"]
        info["word_count"] = a["word_count"]
    print(f"  missing-page probe [{status}] {test}", file=sys.stderr)
    return info


def extract_from_cache(cache_path):
    """Pure: turn a shared page cache (from fetch_pages.py) into a soft-404 inventory.

    Parses the cached HTML for the page signals this audit needs. The single
    missing-page probe is left to the caller (it is one request, not a crawl).
    """
    with open(cache_path, encoding="utf-8") as f:
        cache = json.load(f)
    pages = {}
    for url, page in cache.get("pages", {}).items():
        html = page.get("html")
        status = page.get("status", 0)
        final_url = page.get("final_url") or url
        if not html:
            pages[url] = {"status": status, "final_url": final_url, "title": None,
                          "h1": None, "word_count": 0,
                          "error_phrase_in_title_or_h1": False}
            continue
        info, _links = analyze(final_url, html)
        info["status"] = status
        info["final_url"] = final_url
        pages[url] = info
    return cache.get("site", cache_path), pages


def collect(urls, max_pages, crawl_host=None):
    pages, queue, seen = {}, deque(urls), set(urls)
    while queue and len(pages) < max_pages:
        url = queue.popleft()
        status, final_url, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = {"status": status, "final_url": final_url, "title": None,
                          "h1": None, "word_count": 0,
                          "error_phrase_in_title_or_h1": False}
            continue
        info, links = analyze(final_url, html)
        info["status"] = status
        info["final_url"] = final_url
        pages[url] = info
        if crawl_host:
            for link in links:
                if norm_host(link) == crawl_host and link not in seen:
                    seen.add(link)
                    queue.append(link)
    return pages


def main():
    ap = argparse.ArgumentParser(description="Capture page signals to detect soft 404s.")
    ap.add_argument("source", nargs="?")
    ap.add_argument("--from-cache",
                    help="Extract from a shared page cache produced by fetch_pages.py (no crawl)")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--output", default="soft404_inventory.json")
    ap.add_argument("--url-list", action="store_true")
    args = ap.parse_args()

    probe = None
    if args.from_cache:
        site, pages = extract_from_cache(args.from_cache)
        if site.startswith(("http://", "https://")):
            origin = "{0.scheme}://{0.netloc}".format(urllib.parse.urlparse(site))
            probe = probe_missing(origin)
    elif not args.source:
        ap.error("provide a source, or --from-cache page_cache.json")
    elif args.url_list:
        with open(args.source, encoding="utf-8") as f:
            urls = [l.strip() for l in f if l.strip() and not l.startswith("#")]
        site, pages = args.source, collect(urls[: args.max_pages], args.max_pages)
        if urls:
            origin = "{0.scheme}://{0.netloc}".format(urllib.parse.urlparse(urls[0]))
            probe = probe_missing(origin)
    else:
        start = args.source.rstrip("/")
        seeds = pages_from_sitemap(start) or [start]
        site, pages = args.source, collect(seeds[: args.max_pages], args.max_pages,
                                           crawl_host=norm_host(start))
        probe = probe_missing(start)

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump({"site": site, "pages": pages, "missing_probe": probe},
                  f, indent=2, ensure_ascii=False)
    print(f"Wrote {len(pages)} pages to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Soft 404 Auditor Script (scripts/audit_soft404.py)

Compares each live page against the error template and thin-content threshold, then outputs the structured report data.

scripts/audit_soft404.pyView on GitHub
#!/usr/bin/env python3
"""Audit a soft-404 inventory (from extract_pages.py) and emit findings.

Usage:
    python3 audit_soft404.py soft404_inventory.json [--output audit_report.json]
        [--thin-words 50]

Checks (see references/audit-checks.md):
- soft_404 (high): HTTP 200 page whose title/H1 contains a "not found"/error phrase
- missing_returns_200 (high): the missing-page probe returned 200 instead of 404/410
  (the site serves a soft 404 for every unknown URL)
- thin_error_page (medium): HTTP 200 page that is both very thin AND matches the
  missing-page probe's fingerprint (title/word count) — likely a masked error page
- empty_page (low): HTTP 200 page with almost no visible text and no error phrase

Uses only the Python standard library.
"""

import argparse
import json
import sys


def main():
    ap = argparse.ArgumentParser(description="Run soft-404 audit checks.")
    ap.add_argument("inventory")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--thin-words", type=int, default=50,
                    help="word count below which a 200 page counts as thin")
    args = ap.parse_args()

    with open(args.inventory, encoding="utf-8") as f:
        data = json.load(f)
    pages = data.get("pages", {})
    probe = data.get("missing_probe") or {}

    findings = {k: [] for k in (
        "soft_404", "missing_returns_200", "thin_error_page", "empty_page")}

    probe_is_soft = probe.get("status") == 200
    probe_title = (probe.get("title") or "").strip().lower()
    probe_words = probe.get("word_count", 0)

    if probe_is_soft:
        findings["missing_returns_200"].append({
            "probe_url": probe.get("url"),
            "status": probe.get("status"),
            "note": "A non-existent URL returned 200 — the site serves a soft 404 "
                    "for unknown URLs instead of a real 404/410."})

    for url, page in pages.items():
        if page.get("status", 0) != 200:
            continue
        title = (page.get("title") or "").strip().lower()
        words = page.get("word_count", 0)
        has_error_phrase = page.get("error_phrase_in_title_or_h1", False)

        if has_error_phrase:
            findings["soft_404"].append(
                {"page": url, "title": page.get("title"), "h1": page.get("h1"),
                 "word_count": words})
            continue

        # Matches the missing-page probe's fingerprint (same title, similarly thin).
        if probe_is_soft and probe_title and title == probe_title and words <= max(probe_words, args.thin_words):
            findings["thin_error_page"].append(
                {"page": url, "title": page.get("title"), "word_count": words,
                 "matches": "missing-page probe"})
            continue

        if words < args.thin_words:
            findings["empty_page"].append(
                {"page": url, "title": page.get("title"), "word_count": words})

    severity = {
        "soft_404": "high", "missing_returns_200": "high",
        "thin_error_page": "medium", "empty_page": "low",
    }
    summary = {
        "pages_analyzed": sum(1 for p in pages.values() if p.get("status", 0) == 200),
        "missing_probe_status": probe.get("status"),
        "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", "info")},
    }
    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

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 Llms.txt Audit Audits a site's /llms.txt for AI and LLM discoverability: missing files, HTML served instead of markdown, a missing H1 or summary, broken or redirecting links, links missing descriptions, wrong-host or relative URLs, and a companion /llms-full.txt. llms-txt-audit Open Canonical Tag Audit Audits a site's rel=canonical tags for missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200, redirecting, or noindexed URLs, relative or cross-host canonicals, https-to-http downgrades, and Link-header conflicts. canonical-tag-audit Open Content Quality Audit Audits a site's content quality for thin content, exact-duplicate body text across pages, near-duplicate (templated) pages, low text-to-HTML ratio, and pages missing an H1. content-quality-audit Open Keyword Cannibalization Audit Audits a site for keyword cannibalization: pages with duplicate title targets, clusters of pages whose title, H1, and keyword signatures overlap, and pages sharing the same primary keyword phrase, with a recommended hierarchy. keyword-cannibalization-audit Open Mixed Content Audit Audits an HTTPS site for mixed content: active mixed content (http scripts, styles, iframes browsers block), insecure form actions, passive mixed content (http images and media), protocol-relative URLs, and pages still served over http. mixed-content-audit Open Open Graph Audit Audits a site's Open Graph and Twitter Card metadata: missing og:title, og:description, og:image, or og:url, broken or relative og:image URLs, og:url that mismatches the canonical, missing twitter:card, over-length social titles, and reused default images. open-graph-audit Open Pagination Audit Audits a site's pagination: paginated pages canonicalizing to page 1 and hiding deep content, noindexed component pages, paginated pages missing a self-canonical, broken or inconsistent rel=next/prev, and redundant ?page=1 first pages. pagination-audit Open