Claude AI

Canonical Tag Audit Claude Skill

This skill audits a website's rel="canonical" tags and produces an actionable report. It crawls pages and probes each canonical target to find missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200, redirecting, or noindexed URLs, relative (non-absolute) canonicals, cross-host canonicals, https-to-http protocol downgrades, conflicts between the HTTP Link header and the in-page canonical, and canonicals that contain fragments.

What The Skill Checks

  • Presence and uniqueness: pages missing a canonical, pages with multiple conflicting canonicals, and conflicts between the HTTP Link header canonical and the in-page tag.
  • Target health: canonicals pointing at non-200, redirecting, or noindexed URLs, all of which break duplicate-content consolidation.
  • Format correctness: relative (non-absolute) canonicals, cross-host canonicals, https-to-http protocol downgrades, and canonicals containing URL fragments.

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 extract canonical signals, then probe each canonical target 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 canonical-tag-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
canonical-tag-audit
description
Audit a website's rel=canonical tags for SEO. Crawls pages and probes each canonical target to find missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200 or redirecting or noindexed URLs, relative (non-absolute) canonicals, cross-host canonicals, https-to-http protocol downgrades, conflicts between the HTTP Link header and the in-page canonical, and canonicals containing fragments. Use when the user asks to audit, check, validate, or fix canonical tags, rel=canonical, duplicate-content consolidation, or canonicalization on a website.

Canonical Tag Audit

Audit a website's rel="canonical" tags and produce an actionable SEO report.

When to use this skill

Use this skill when the user asks to:

  • Audit, validate, or check rel="canonical" tags / canonicalization
  • Find pages with missing, duplicate, or conflicting canonicals
  • Find canonicals that point at dead, redirecting, or noindexed URLs
  • Diagnose duplicate-content consolidation problems
  • Verify canonicals are absolute, same-host, and https://
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or URL list — a live site root to crawl (auto-seeds from /sitemap.xml), or a text file of URLs with --url-list.
  2. Scope — how many pages to crawl (default 500, cap with --max-pages).
  3. Probing — whether to probe each canonical target's status/redirect/noindex (default on; --no-probe for a fast structural-only pass).

The indexable contract: a canonical should point at a final, canonical, indexable 200 URL — ideally the page itself. A canonical that 404s, redirects, or is noindexed breaks consolidation and can drop the page from the index.

Workflow
Step 1: Crawl and probe canonicals

Use scripts/extract_canonicals.py:

python3 scripts/extract_canonicals.py https://example.com --max-pages 500 --output canonical_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_canonicals.py --from-cache page_cache.json --output canonical_inventory.json

It records each page's status, every in-page <link rel="canonical"> (raw href + resolved URL), the Link: rel="canonical" header, meta robots, and X-Robots-Tag, then probes each unique canonical target once for status, redirect, and noindex.

Step 2: Run the audit checks

Use scripts/audit_canonicals.py:

python3 scripts/audit_canonicals.py canonical_inventory.json --output audit_report.json
Step 3: Evaluate the audit checks

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

CheckSeverity
Missing canonical on an indexable pageHigh
Multiple conflicting canonicalsHigh
Canonical points to a non-200 URLHigh
Canonical points to a redirectHigh
Canonical points to a noindex URLMedium
Relative (non-absolute) canonicalMedium
Cross-host canonicalMedium
Protocol downgrade (https→http) in canonicalMedium
Header / HTML canonical conflictMedium
Canonical contains a fragmentLow
Non-self canonical (confirm intent)Info
Step 4: Produce the report

Write a report following references/report-template.md: summary by severity, high-severity issues first, a section of non-self canonicals to confirm, and a prioritized action list.

Step 5: Recommend fixes
  • Missing: add a self-referencing absolute https:// canonical.
  • Non-200 / redirect target: repoint at the final live 200 URL.
  • Multiple / header conflict: emit exactly one canonical (find the duplicate CMS/plugin source).
  • Relative / cross-host / protocol downgrade: rewrite to an absolute, same-host, https:// URL.
  • Non-self canonicals: confirm each consolidation is intentional.
  • Base every recommendation on observed data; do not invent target statuses.
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
  • references/report-template.md — report output structure
  • scripts/extract_canonicals.py — crawl pages and probe canonical targets
  • scripts/audit_canonicals.py — run audit checks against the inventory

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

Canonical Tag Audit Checks

Full definitions, thresholds, and rationale for each check. A rel="canonical" tells search engines which URL is the authoritative version of a page; getting it wrong silently removes pages from the index or splits ranking signals.

High severity
1. Missing canonical
  • Definition: An indexable (200, non-noindex) HTML page with no <link rel="canonical"> and no Link: rel="canonical" header.
  • Why it matters: Without a canonical, parameterized, syndicated, or duplicated variants of the page compete and split signals; the engine picks a canonical for you, sometimes the wrong one.
  • Fix: Add a self-referencing absolute canonical to every indexable page.
2. Multiple conflicting canonicals
  • Definition: A page with more than one distinct canonical URL (across <link> tags and/or the HTTP header).
  • Why it matters: Conflicting canonicals are ambiguous; Google ignores them all and falls back to its own choice.
  • Fix: Emit exactly one canonical. Usually a CMS theme plus an SEO plugin both inject one — remove the duplicate.
3. Canonical points to a non-200 URL
  • Definition: The canonical target returns 4xx/5xx or fails to load.
  • Why it matters: Canonicalizing to a dead URL tells the engine the real page doesn't exist; the page may be dropped from the index.
  • Fix: Point the canonical at a live 200 URL (usually the page itself).
4. Canonical points to a redirect
  • Definition: The canonical target 3xx-redirects to another URL.
  • Why it matters: A canonical should name the final destination, not a hop. Chained canonical→redirect signals dilute and confuse consolidation.
  • Fix: Set the canonical to the redirect's final destination URL.
Medium severity
5. Canonical points to a noindex URL
  • Definition: The canonical target carries noindex (meta or X-Robots-Tag).
  • Why it matters: You're consolidating signals onto a page you've told the engine not to index — contradictory; both URLs can end up dropped.
  • Fix: Canonicalize to an indexable URL, or remove the noindex from the target.
6. Relative canonical
  • Definition: The canonical href is a relative path rather than an absolute URL.
  • Why it matters: Relative canonicals are resolved against the current URL and break under parameters, alternate hosts, or proxying.
  • Fix: Always emit a fully-qualified absolute URL (https://host/path).
7. Cross-host canonical
  • Definition: The canonical points to a different host than the page.
  • Why it matters: Sometimes intentional (syndication), but usually a misconfiguration that hands ranking to the wrong domain or a staging host.
  • Fix: Confirm it's deliberate; otherwise canonicalize within the live host.
8. Protocol downgrade in canonical
  • Definition: An https:// page canonicalizes to an http:// URL.
  • Why it matters: Points the authoritative version at the insecure URL, undermining the HTTPS migration.
  • Fix: Use https:// in the canonical.
9. Header / HTML canonical conflict
  • Definition: The Link: rel="canonical" header and the in-page canonical disagree.
  • Why it matters: Same ambiguity as multiple canonicals; the engine may honor either.
  • Fix: Make them identical, or emit only one.
Low severity
10. Canonical contains a fragment
  • Definition: The canonical URL includes a #fragment.
  • Why it matters: Fragments are ignored for indexing and usually indicate a templating bug.
  • Fix: Drop the fragment from the canonical.
Informational (note, not counted as an issue)
  • Non-self canonical — the page canonicalizes to a different URL. This is legitimate and common (parameter variants, print pages, syndicated copies). It is reported so you can confirm each one is intentional and that the target is the right consolidation point — not because it is inherently wrong.

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

Canonical Tag Audit Report Template

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

# Canonical Tag Audit — {site}

**Date:** {date}
**Scope:** {pages crawled, canonical targets probed}
**Pages analyzed:** {n} | **Canonical targets probed:** {n}

## Summary

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

{2–3 sentence overview: are canonicals present, self-referencing, and pointing at
live indexable URLs? Name the dominant problem.}

## High-severity issues

### Missing canonical ({n})
| Page | Suggested canonical |
|---|---|
| ... | self-referencing absolute URL |

### Canonical points to non-200 / redirect ({n})
| Page | Canonical target | Problem | Fix |
|---|---|---|---|
| ... | ... | 404 / 301→{dest} | Point at the final 200 URL |

### Multiple / conflicting canonicals ({n})
| Page | Canonicals found | Fix |
|---|---|---|
| ... | {url1}, {url2} | Keep one |

## Medium-severity issues

{One subsection per failing check: relative, cross-host, protocol downgrade,
canonical-to-noindex, header/HTML conflict. Table with page, current canonical,
and the corrected value.}

## Low-severity issues

{Fragment-in-canonical and similar hygiene, as a brief list.}

## Non-self canonicals to confirm ({n})

| Page | Canonicalizes to | Looks intentional? |
|---|---|---|
| ... | ... | confirm with the user |

## Prioritized action list

1. {Highest-impact fix with exact pages and the corrected canonical value}
2. ...
Guidance
  • Every row names the exact page, its current canonical, and the corrected absolute URL.
  • Treat non-self canonicals as items to confirm, not defects — list them so the user can verify each consolidation is intended.
  • Group template-level problems (e.g. every paginated page canonicalizing to page
    1. as a single fix with example URLs.
  • Base everything on the probed data in the inventory; never invent target statuses.

Canonical Extractor Script (scripts/extract_canonicals.py)

Crawls pages and extracts every rel=canonical signal, including in-page tags and HTTP Link headers.

scripts/extract_canonicals.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a site and build a canonical-tag inventory, probing each canonical target.

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

For every crawled page it records the page status, every <link rel="canonical">
found (raw href + resolved absolute URL), the HTTP `Link: rel=canonical` header,
meta robots, and the `X-Robots-Tag` header. It then probes each unique canonical
target once for status, redirect, and noindex so the auditor can tell whether a
canonical points at a final, indexable 200 URL.

Output JSON schema:
{
  "site": "...",
  "pages": { "<url>": {
      "status": 200, "final_url": "...",
      "canonicals": [{"raw": "...", "resolved": "..."}],
      "header_canonical": "<url or null>",
      "meta_robots": "<content or null>",
      "x_robots": "<header or null>"
  }},
  "targets": { "<canonical_url>": {
      "status": 200, "redirects_to": "<url or null>", "noindex": false
  }}
}

Uses only the Python standard library.
"""

import argparse
import json
import re
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 = "CanonicalAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15

LINK_HEADER_RE = re.compile(r'<([^>]+)>\s*;\s*rel\s*=\s*"?canonical"?', re.I)


class HeadParser(HTMLParser):
    """Extract canonical link tags, meta robots, and same-host anchor links."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.canonicals = []
        self.meta_robots = None
        self.links = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and (a.get("rel") or "").lower() == "canonical":
            raw = (a.get("href") or "").strip()
            if raw:
                self.canonicals.append(
                    {"raw": raw, "resolved": urllib.parse.urljoin(self.base_url, raw)})
        elif tag == "meta" and (a.get("name") or "").lower() == "robots":
            self.meta_robots = a.get("content")
        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 fetch(url):
    """Return (status, final_url, body, headers)."""
    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, dict(resp.headers)
    except urllib.error.HTTPError as e:
        return e.code, url, None, dict(e.headers or {})
    except Exception:
        return 0, url, None, {}


def head_or_get(url):
    """Probe a canonical target: return (status, redirects_to, noindex)."""
    status, final_url, body, headers = fetch(url)
    redirects_to = final_url if final_url and final_url != url else None
    x_robots = headers.get("X-Robots-Tag", "") or ""
    noindex = "noindex" in x_robots.lower()
    if body and not noindex:
        m = re.search(r'<meta[^>]+name=["\']robots["\'][^>]*>', body, re.I)
        if m and "noindex" in m.group(0).lower():
            noindex = True
    return status, redirects_to, noindex


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, _f, body, _h = 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, headers):
    parser = HeadParser(url)
    try:
        parser.feed(html)
    except Exception:
        pass
    header_canonical = None
    link_header = headers.get("Link", "")
    m = LINK_HEADER_RE.search(link_header or "")
    if m:
        header_canonical = urllib.parse.urljoin(url, m.group(1).strip())
    info = {
        "canonicals": parser.canonicals,
        "header_canonical": header_canonical,
        "meta_robots": parser.meta_robots,
        "x_robots": headers.get("X-Robots-Tag"),
    }
    return info, parser.links


def empty_page(status, final_url):
    return {"status": status, "final_url": final_url, "canonicals": [],
            "header_canonical": None, "meta_robots": None, "x_robots": None}


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

    Reads the cached HTML *and response headers* (the Link: rel=canonical and
    X-Robots-Tag headers this audit needs). The canonical-target probe is left to
    the caller — those targets may sit outside the crawl set.
    """
    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] = empty_page(status, final_url)
            continue
        info, _links = parse_page(final_url, html, page.get("headers") or {})
        info["status"] = status
        info["final_url"] = final_url
        pages[url] = info
    return cache.get("site", cache_path), pages


def collect(urls_iter, max_pages, crawl_host=None):
    pages = {}
    queue = deque(urls_iter)
    seen = set(queue)
    while queue and len(pages) < max_pages:
        url = queue.popleft()
        status, final_url, html, headers = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = empty_page(status, final_url)
            continue
        info, links = parse_page(final_url, html, headers)
        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 probe_targets(pages):
    wanted = set()
    for page in pages.values():
        for c in page.get("canonicals", []):
            wanted.add(c["resolved"])
        if page.get("header_canonical"):
            wanted.add(page["header_canonical"])
    targets = {}
    for url in sorted(wanted):
        status, redirects_to, noindex = head_or_get(url)
        print(f"  canonical target [{status}] {url}", file=sys.stderr)
        targets[url] = {"status": status, "redirects_to": redirects_to, "noindex": noindex}
    return targets


def main():
    ap = argparse.ArgumentParser(description="Build a canonical-tag inventory for a site.")
    ap.add_argument("source", nargs="?", help="Site root URL, or URL list file with --url-list")
    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="canonical_inventory.json")
    ap.add_argument("--url-list", action="store_true")
    ap.add_argument("--no-probe", action="store_true", help="skip probing canonical targets")
    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.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)
    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))

    targets = {} if args.no_probe else probe_targets(pages)

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


if __name__ == "__main__":
    main()

Canonical Auditor Script (scripts/audit_canonicals.py)

Probes each canonical target for status, redirects, and noindex, then runs the audit checks and outputs the structured report data.

scripts/audit_canonicals.pyView on GitHub
#!/usr/bin/env python3
"""Audit a canonical inventory (from extract_canonicals.py) and emit findings.

Usage:
    python3 audit_canonicals.py canonical_inventory.json [--output audit_report.json]

Checks (see references/audit-checks.md):
- missing_canonical (high): indexable 200 HTML page with no canonical at all
- multiple_canonicals (high): more than one distinct canonical URL on a page
- canonical_to_non200 (high): canonical target returns 4xx/5xx/error
- canonical_to_redirect (high): canonical points at a URL that redirects
- canonical_to_noindex (medium): canonical points at a noindexed URL
- relative_canonical (medium): canonical href is not an absolute URL
- cross_host_canonical (medium): canonical points to a different host
- protocol_downgrade_canonical (medium): https page canonicalizes to http
- header_html_canonical_conflict (medium): Link header and HTML canonical disagree
- canonical_with_fragment (low): canonical URL contains a #fragment
- non_self_canonical (info): page canonicalizes to a different URL (note, not error)

Uses only the Python standard library.
"""

import argparse
import json
import sys
import urllib.parse


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


def is_absolute(href):
    return bool(urllib.parse.urlparse(href).scheme)


def main():
    ap = argparse.ArgumentParser(description="Run canonical-tag audit checks.")
    ap.add_argument("inventory")
    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)
    pages = data.get("pages", {})
    targets = data.get("targets", {})

    findings = {k: [] for k in (
        "missing_canonical", "multiple_canonicals", "canonical_to_non200",
        "canonical_to_redirect", "canonical_to_noindex", "relative_canonical",
        "cross_host_canonical", "protocol_downgrade_canonical",
        "header_html_canonical_conflict", "canonical_with_fragment",
        "non_self_canonical")}

    for url, page in pages.items():
        if page.get("status", 0) != 200:
            continue
        is_noindex = "noindex" in (page.get("meta_robots") or "").lower() or \
                     "noindex" in (page.get("x_robots") or "").lower()
        cans = page.get("canonicals", [])
        resolved = sorted({c["resolved"] for c in cans})
        final_url = page.get("final_url", url)

        if not cans and not page.get("header_canonical"):
            if not is_noindex:
                findings["missing_canonical"].append({"page": url})
            continue

        if len(resolved) > 1:
            findings["multiple_canonicals"].append({"page": url, "canonicals": resolved})

        header_can = page.get("header_canonical")
        if header_can and resolved and header_can not in resolved:
            findings["header_html_canonical_conflict"].append(
                {"page": url, "header": header_can, "html": resolved})

        for c in cans:
            raw, target = c["raw"], c["resolved"]
            if not is_absolute(raw):
                findings["relative_canonical"].append({"page": url, "raw": raw, "resolved": target})
            if urllib.parse.urldefrag(target)[1]:
                findings["canonical_with_fragment"].append({"page": url, "canonical": target})
            if host_of(target) != host_of(final_url):
                findings["cross_host_canonical"].append({"page": url, "canonical": target})
            if final_url.startswith("https://") and target.startswith("http://"):
                findings["protocol_downgrade_canonical"].append({"page": url, "canonical": target})
            if urllib.parse.urldefrag(target)[0].rstrip("/") != \
               urllib.parse.urldefrag(final_url)[0].rstrip("/"):
                findings["non_self_canonical"].append({"page": url, "canonical": target})

            t = targets.get(target)
            if t:
                if t["status"] == 0 or t["status"] >= 400:
                    findings["canonical_to_non200"].append(
                        {"page": url, "canonical": target, "status": t["status"]})
                elif t["redirects_to"]:
                    findings["canonical_to_redirect"].append(
                        {"page": url, "canonical": target, "redirects_to": t["redirects_to"]})
                if t["noindex"]:
                    findings["canonical_to_noindex"].append({"page": url, "canonical": target})

    severity = {
        "missing_canonical": "high", "multiple_canonicals": "high",
        "canonical_to_non200": "high", "canonical_to_redirect": "high",
        "canonical_to_noindex": "medium", "relative_canonical": "medium",
        "cross_host_canonical": "medium", "protocol_downgrade_canonical": "medium",
        "header_html_canonical_conflict": "medium",
        "canonical_with_fragment": "low", "non_self_canonical": "info",
    }
    summary = {
        "pages_analyzed": sum(1 for p in pages.values() if p.get("status", 0) == 200),
        "canonical_targets_probed": len(targets),
        "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

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