Claude AI

Heading Structure Audit Claude Skill

This skill audits a website's heading hierarchy (H1 through H6) and produces an actionable SEO and accessibility report. It crawls pages to find missing or multiple H1s, skipped heading levels, empty headings, headings that do not start at H1, overly long headings, and duplicate heading text, then recommends a clean, logical outline for each page.

What The Skill Checks

  • High-severity structure failures: pages with no H1 at all and pages with no headings whatsoever, each of which leaves search engines and assistive technology without a machine-readable outline of what the page is about.
  • Medium-severity outline problems: multiple H1s that dilute the primary topic signal, skipped heading levels (an H2 jumping straight to an H4) that break the logical outline, and empty heading elements that add confusing entries to the accessibility tree.
  • Low-severity and informational signals: a first heading that is not an H1, headings longer than the 70-character default threshold, duplicate heading text on a single page, title/H1 mismatch, very deep H5/H6 nesting, and CSS-styled fake headings that look like headings but carry no heading markup.

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 extract every heading from pages and audit the resulting inventory. 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 heading-structure-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 (including the long-heading threshold), the audit workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
heading-structure-audit
description
Audit a website's heading structure (H1–H6) for SEO and accessibility. Crawls pages to find missing or multiple H1s, skipped heading levels, empty headings, headings that don't start at H1, overly long headings, and duplicate headings. Use when the user asks to audit, analyze, check, or improve heading tags, the H1, the heading hierarchy, document outline, or content structure of a website.

Heading Structure Audit

Audit the heading hierarchy (H1–H6) of a website and produce an actionable SEO and accessibility report.

When to use this skill

Use this skill when the user asks to:

  • Audit or analyze the heading tags or heading hierarchy of a website
  • Find pages with missing, empty, or multiple H1s
  • Find broken outlines: skipped heading levels (e.g., H2 jumping to H4) or pages that don't start at H1
  • Identify overly long or duplicated headings
  • Improve content structure, document outline, or accessibility of headings
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or local files — a live site root URL (e.g., https://example.com), a sitemap URL, a list of URLs, or a local folder of HTML files.
  2. Scope — full site, a specific section (e.g., /blog/), or a list of URLs.
  3. Crawl limits — max pages (default 500) for live crawls.
  4. Long-heading threshold — character count above which a heading is "too long" (default 70).
Workflow
Step 1: Gather the page set and extract headings
  • If a sitemap is available (/sitemap.xml), it is used to get the canonical page list.
  • Otherwise, the crawler starts from the homepage following only same-host links.
  • For local HTML folders, all .html files are enumerated.

Use scripts/extract_headings.py to build a heading inventory:

python3 scripts/extract_headings.py https://example.com --max-pages 500 --output heading_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_headings.py --from-cache page_cache.json --output heading_inventory.json

For every page the inventory records the <title> and the full list of headings in document order, each with its level (1–6) and trimmed text.

Step 2: Run the audit checks

Use scripts/audit_headings.py to run all audit checks against the inventory:

python3 scripts/audit_headings.py heading_inventory.json --long-heading 70 --output audit_report.json
Step 3: Evaluate the audit checks

Evaluate each check defined in references/audit-checks.md. The core checks are:

CheckSeverity
Missing H1High
No headings at allHigh
Multiple H1sMedium
Skipped heading level (e.g., H2 → H4)Medium
Empty heading elementMedium
First heading is not H1Low
Heading too long (> 70 chars)Low
Duplicate heading text on one pageLow
Title / H1 topical mismatchInformational
Step 4: Produce the report

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

  1. Summary — pages crawled, headings found, issue counts by severity
  2. Issues — one section per failing check, listing the page, the offending heading(s), and a specific fix
  3. Prioritized action list — ordered by severity and estimated impact
Step 5: Recommend fixes

For each issue, give a concrete, copy-pasteable recommendation. When proposing headings:

  • Every page should have exactly one H1 that states the page's primary topic
  • Headings should form a logical, gap-free outline: H1 → H2 → H3, never skipping a level downward
  • Base every suggested heading on the page's actual content; never invent topics the page does not cover
  • Reserve heading tags for true section titles — do not wrap styling-only text in headings, and do not demote real section titles to <div>s for visual reasons (use CSS instead)

Example recommendations:

  • Add an H1 to /pricing: H1: Pricing Plans for Teams of Every Size
  • /blog/post-1 jumps from H2 to H4 under "Methodology" — change the H4 subsections to H3
  • Merge the two H1s on the homepage; demote the tagline H1 to a paragraph and keep the brand-value H1
Optional: export the report
As a Word document (.docx)

If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:

python3 scripts/md_to_docx.py report.md --output report.docx

scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.

As a CSV of findings (.csv)

If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:

python3 scripts/findings_to_csv.py audit_report.json --output findings.csv

scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.

Resources
  • scripts/md_to_docx.py — convert the Markdown report into a Word (.docx) document (standard library only)
  • scripts/findings_to_csv.py — flatten the audit findings JSON into a CSV, one row per finding (standard library only)
  • references/audit-checks.md — full definitions, thresholds, and rationale for every audit check
  • references/report-template.md — report output structure
  • scripts/extract_headings.py — crawl a site and build the heading inventory
  • scripts/audit_headings.py — run audit checks against a heading inventory JSON file

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

Full definitions, severity levels, and rationale for every audit check, from missing and multiple H1s to skipped levels, empty headings, and CSS-styled fake headings.

references/audit-checks.mdView on GitHub

Heading Structure Audit Checks

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

High severity
1. Missing H1
  • Definition: A page with no <h1> element.
  • Why it matters: The H1 is the strongest on-page structural signal of what the page is about — a headline for both users and search engines. Its absence leaves the page's primary topic ambiguous.
  • Fix: Add a single H1 stating the page's primary topic, ideally aligned with the title tag and target query.
2. No headings at all
  • Definition: A page with zero heading elements (H1–H6).
  • Why it matters: A page with no headings has no machine-readable outline; it is hard for screen-reader users to navigate and gives search engines no structural cues. It often indicates content rendered as undifferentiated <div>/<p> soup.
  • Fix: Introduce a proper heading outline — one H1 plus H2/H3 section headings reflecting the content's structure.
Medium severity
3. Multiple H1s
  • Definition: More than one <h1> on a page.
  • Why it matters: While HTML5 technically allows multiple H1s, a single H1 remains the clearest signal of the page's main topic. Multiple H1s usually come from templates (e.g., logo + page title both marked H1) and dilute the signal and the accessibility outline.
  • Fix: Keep one H1 for the page's primary topic; demote the others to H2 or to non-heading elements.
4. Skipped heading level
  • Definition: The outline descends by more than one level between consecutive headings (e.g., an H2 followed directly by an H4).
  • Why it matters: Skipped levels break the logical document outline that assistive technology relies on and obscure the content hierarchy.
  • Fix: Use the next sequential level when nesting (H2 → H3 → H4). Never choose a heading level for its default font size — style with CSS instead.
5. Empty heading
  • Definition: A heading element whose text content is empty after trimming whitespace.
  • Why it matters: Empty headings create confusing "blank" entries in the accessibility outline and waste a structural slot. They usually result from headings used purely for icons/spacing.
  • Fix: Remove the empty heading or give it meaningful text. If it exists only for styling, use a non-heading element.
Low severity
6. First heading is not H1
  • Definition: The first heading encountered on the page is something other than H1 (and an H1 may appear later or not at all).
  • Why it matters: A well-formed outline opens with the H1. Starting at H2+ suggests the H1 is missing, misplaced, or that section headings were mislabeled.
  • Fix: Ensure the page's H1 precedes its subordinate headings in source order.
7. Heading too long
  • Definition: Heading text longer than the threshold (default 70 characters).
  • Why it matters: Very long headings usually mean a sentence or paragraph was marked as a heading. Headings should be concise labels for the section that follows.
  • Fix: Shorten to a scannable label; move the explanatory detail into body copy beneath the heading.
8. Duplicate heading text on one page
  • Definition: The same heading text appears two or more times on a single page.
  • Why it matters: Repeated identical headings make the outline ambiguous ("which 'Overview' did you mean?") and often signal templated sections that need differentiation.
  • Fix: Make each heading specific to its section, or restructure so repeated labels become subheads under a distinguishing parent.
Informational (note, do not count as issues)
  • Title / H1 mismatch — the H1 and the <title> share no significant words. Sometimes intentional (title optimized for the SERP, H1 for the page), but a large divergence is worth confirming.
  • Very deep outlines — frequent use of H5/H6 can indicate over-nesting; review whether the content is genuinely that deep.
  • CSS-styled fake headings — visually bold text that is not marked up as a heading is invisible to this audit and to assistive tech; if the page "looks" structured but reports no headings, suspect this and recommend real heading markup.

Report Template (references/report-template.md)

The output structure the skill follows when writing the final audit report, including the per-page outline listing and prioritized action items.

references/report-template.mdView on GitHub

Heading Structure Audit Report Template

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

# Heading Structure Audit — {site}

**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Headings found:** {n}

## Summary

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

{2–3 sentence overview of the site's heading health and the biggest wins.}

## High-severity issues

### Missing H1 ({n})

| Page | Suggested H1 |
|---|---|
| ... | ... |

### No headings ({n})

| Page | Suggested outline |
|---|---|
| ... | ... |

## Medium-severity issues

### Multiple H1s ({n})

| Page | H1 values | Suggested fix |
|---|---|---|
| ... | ... | ... |

### Skipped levels ({n})

| Page | Jump | Heading | Suggested fix |
|---|---|---|---|
| ... | h2 → h4 | ... | demote to h3 |

### Empty headings ({n})

| Page | Level |
|---|---|
| ... | ... |

## Low-severity issues

{One subsection per failing check: first-not-H1, long heading, duplicate heading. Same table style: page, current value, suggested fix.}

## Informational

{Title/H1 mismatches and deep-outline notes — list for confirmation, not as defects.}

## Prioritized action list

1. {Highest-impact fix, with exact pages and ready-to-apply heading values}
2. ...
Guidance
  • Every issue row must name the exact page URL and the offending heading(s), plus a concrete fix — never generic advice.
  • Suggested headings must reflect the page's actual content (use the existing headings and title from the inventory as context); never invent topics the page does not cover.
  • For skipped-level and multiple-H1 issues, recommend the specific level change (e.g., "change this H4 to H3") rather than restating the rule.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Group templated issues (e.g., every blog post missing an H1 from one template) as a single template-level fix with a few example URLs.
  • Order the action list by severity first, then by traffic impact (high-traffic and high-intent pages first).
  • If a page reports no headings but visibly looks structured, flag the likely cause: section titles styled with CSS instead of real heading tags.

Heading Extractor Script (scripts/extract_headings.py)

Crawls a live site, URL list, or local HTML folder within the configured page limits and collects every heading element with its level and text into a JSON inventory.

scripts/extract_headings.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a website (or scan local HTML files) and build a heading inventory as JSON.

Usage:
    python3 extract_headings.py https://example.com [--max-pages 500] [--output heading_inventory.json]
    python3 extract_headings.py ./site-folder --local [--output heading_inventory.json]
    python3 extract_headings.py urls.txt --url-list [--output heading_inventory.json]

Output JSON schema:
{
  "site": "https://example.com",
  "pages": {
    "<url>": {
      "status": 200,
      "title": "<title text or null>",
      "headings": [ {"level": 1, "text": "..."}, {"level": 2, "text": "..."}, ... ]
    }
  }
}

Headings are captured in document order with their level (1–6) and trimmed text.

Uses only the Python standard library.
"""

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

USER_AGENT = "HeadingAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}


class HeadingParser(HTMLParser):
    """Extract the page title, headings in document order, and same-host links."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.title = None
        self.headings = []
        self.links = []
        self._in_title = False
        self._title_parts = []
        self._heading_level = None
        self._heading_parts = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "title":
            self._in_title = True
            self._title_parts = []
        elif tag in HEADING_TAGS and self._heading_level is None:
            self._heading_level = int(tag[1])
            self._heading_parts = []
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                target = urllib.parse.urljoin(self.base_url, href)
                self.links.append(urllib.parse.urldefrag(target)[0])

    def handle_endtag(self, tag):
        if tag == "title" and self._in_title:
            self._in_title = False
            self.title = " ".join("".join(self._title_parts).split()) or None
        elif tag in HEADING_TAGS and self._heading_level == int(tag[1]):
            text = " ".join("".join(self._heading_parts).split())
            self.headings.append({"level": self._heading_level, "text": text})
            self._heading_level = None

    def handle_data(self, data):
        if self._in_title:
            self._title_parts.append(data)
        if self._heading_level is not None:
            self._heading_parts.append(data)


def fetch(url):
    """Return (status, final_url, html_or_none)."""
    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 = None
            if "html" in ctype or "xml" in ctype:
                body = resp.read(2_000_000).decode("utf-8", errors="replace")
            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):
    host = urllib.parse.urlparse(url).netloc.lower()
    return host[4:] if host.startswith("www.") else host


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


def parse_page(url, html):
    parser = HeadingParser(url)
    try:
        parser.feed(html)
    except Exception:
        pass
    return {"title": parser.title, "headings": parser.headings}, parser.links


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

    No network — parses the cached HTML for exactly the fields this audit needs.
    """
    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)
        if not html:
            pages[url] = {"status": status, "title": None, "headings": []}
            continue
        info, _links = parse_page(page.get("final_url") or url, html)
        info["status"] = status
        pages[url] = info
    return cache.get("site", cache_path), pages


def crawl(site, max_pages):
    start = site.rstrip("/")
    host = norm_host(start)
    seeds = pages_from_sitemap(start) or [start]
    queue = deque(seeds[:max_pages])
    seen = set(queue)
    pages = {}
    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, "title": None, "headings": []}
            continue
        info, links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
        for link in links:
            if norm_host(link) == host and link not in seen:
                seen.add(link)
                queue.append(link)
    return pages


def scan_local(folder):
    pages = {}
    for root, _dirs, files in os.walk(folder):
        for name in files:
            if name.lower().endswith((".html", ".htm")):
                path = os.path.join(root, name)
                with open(path, encoding="utf-8", errors="replace") as f:
                    html = f.read()
                info, _links = parse_page("file://" + os.path.abspath(path), html)
                info["status"] = 200
                pages[os.path.relpath(path, folder)] = info
    return pages


def scan_url_list(path, max_pages):
    pages = {}
    with open(path, encoding="utf-8") as f:
        urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
    for url in urls[:max_pages]:
        status, final_url, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = {"status": status, "title": None, "headings": []}
            continue
        info, _links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
    return pages


def main():
    ap = argparse.ArgumentParser(description="Build a heading inventory for a site.")
    ap.add_argument("source", nargs="?",
                    help="Site root URL, local folder (--local), or URL list file (--url-list)")
    ap.add_argument("--from-cache",
                    help="Extract from a shared page cache produced by fetch_pages.py (no network)")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--output", default="heading_inventory.json")
    ap.add_argument("--local", action="store_true", help="Treat source as a local folder of HTML files")
    ap.add_argument("--url-list", action="store_true", help="Treat source as a text file of URLs")
    args = ap.parse_args()

    if args.from_cache:
        site, pages = extract_from_cache(args.from_cache)
    elif not args.source:
        ap.error("provide a source, or --from-cache page_cache.json")
    elif args.local:
        site, pages = args.source, scan_local(args.source)
    elif args.url_list:
        site, pages = args.source, scan_url_list(args.source, args.max_pages)
    else:
        site, pages = args.source, crawl(args.source, args.max_pages)

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump({"site": site, "pages": pages}, f, indent=2, ensure_ascii=False)
    total = sum(len(p.get("headings", [])) for p in pages.values())
    print(f"Wrote {len(pages)} pages ({total} headings) to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Heading Auditor Script (scripts/audit_headings.py)

Runs the audit checks against the heading inventory, applying the long-heading threshold and severity classifications, and outputs the structured audit report data.

scripts/audit_headings.pyView on GitHub
#!/usr/bin/env python3
"""Audit a heading inventory JSON (from extract_headings.py) and emit audit findings.

Usage:
    python3 audit_headings.py heading_inventory.json [--output audit_report.json]
        [--long-heading 70]

Checks implemented (see references/audit-checks.md):
- missing_h1: page has no <h1> (high)
- no_headings: page has no headings at all (high)
- multiple_h1: page has more than one <h1> (medium)
- skipped_level: heading depth jumps down by more than one (e.g. h2 -> h4) (medium)
- empty_heading: a heading element with no text (medium)
- first_not_h1: the first heading on the page is not an <h1> (low)
- long_heading: heading longer than --long-heading characters (low)
- duplicate_heading: identical heading text repeated on one page (low)
- title_h1_mismatch: <h1> and <title> share no significant words (informational)

Uses only the Python standard library.
"""

import argparse
import json
import re
import sys
from collections import Counter

STOPWORDS = {
    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in",
    "is", "it", "of", "on", "or", "the", "to", "with", "your", "you", "our",
}


def words(text):
    return [w for w in re.findall(r"[a-z0-9']+", (text or "").lower()) if w not in STOPWORDS]


def main():
    ap = argparse.ArgumentParser(description="Run heading-structure audit checks.")
    ap.add_argument("inventory", help="heading_inventory.json from extract_headings.py")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--long-heading", type=int, default=70,
                    help="flag headings longer than this many characters (default 70)")
    args = ap.parse_args()

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

    findings = {
        "missing_h1": [], "no_headings": [], "multiple_h1": [],
        "skipped_level": [], "empty_heading": [], "first_not_h1": [],
        "long_heading": [], "duplicate_heading": [], "title_h1_mismatch": [],
    }

    pages_analyzed = 0
    for url, page in pages.items():
        if page.get("status", 200) != 200:
            continue
        pages_analyzed += 1
        headings = page.get("headings", [])
        levels = [h["level"] for h in headings]
        h1s = [h for h in headings if h["level"] == 1]

        if not headings:
            findings["no_headings"].append({"page": url})
            continue

        if not h1s:
            findings["missing_h1"].append({"page": url})
        if len(h1s) > 1:
            findings["multiple_h1"].append(
                {"page": url, "count": len(h1s), "values": [h["text"] for h in h1s]})

        if headings[0]["level"] != 1:
            findings["first_not_h1"].append(
                {"page": url, "first_level": headings[0]["level"], "first_text": headings[0]["text"]})

        # Skipped levels: a descent of more than one between consecutive headings.
        prev = None
        for h in headings:
            if prev is not None and h["level"] > prev + 1:
                findings["skipped_level"].append(
                    {"page": url, "from": f"h{prev}", "to": f"h{h['level']}", "text": h["text"]})
            prev = h["level"]

        # Empty headings
        for h in headings:
            if not h["text"].strip():
                findings["empty_heading"].append({"page": url, "level": f"h{h['level']}"})

        # Long headings
        for h in headings:
            if len(h["text"]) > args.long_heading:
                findings["long_heading"].append(
                    {"page": url, "level": f"h{h['level']}", "text": h["text"], "length": len(h["text"])})

        # Duplicate heading text on the same page
        counts = Counter(h["text"].strip().lower() for h in headings if h["text"].strip())
        for text, c in counts.items():
            if c > 1:
                findings["duplicate_heading"].append({"page": url, "text": text, "count": c})

        # Title / H1 topical mismatch (informational)
        title = page.get("title")
        if h1s and title:
            h1_words, t_words = set(words(h1s[0]["text"])), set(words(title))
            if h1_words and t_words and not (h1_words & t_words):
                findings["title_h1_mismatch"].append(
                    {"page": url, "h1": h1s[0]["text"], "title": title})

    severity = {
        "missing_h1": "high", "no_headings": "high",
        "multiple_h1": "medium", "skipped_level": "medium", "empty_heading": "medium",
        "first_not_h1": "low", "long_heading": "low", "duplicate_heading": "low",
        "title_h1_mismatch": "info",
    }
    summary = {
        "pages_analyzed": pages_analyzed,
        "headings_found": sum(len(p.get("headings", [])) for p in pages.values()),
        "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

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