Claude AI

Redirect Audit Claude Skill

This skill audits a website's redirects and produces an actionable report. It resolves the full redirect chain for every URL to find redirect chains and loops, redirects that end in 404s or errors, temporary 302 redirects used for permanent moves, HTTPS-to-HTTP downgrades, client-side meta-refresh and JavaScript redirects, dropped query strings, and canonicals that point at redirecting URLs.

What The Skill Checks

  • Chain integrity: multi-hop redirect chains, redirect loops, chains that exceed the hop limit, and redirects that end in a 4xx, 5xx, or error instead of a live page.
  • Protocol and status problems: HTTPS-to-HTTP downgrades and temporary 302 redirects used where a permanent 301 belongs, plus client-side meta-refresh and JavaScript redirects that search engines treat differently from server redirects.
  • Equity and consistency signals: redirects that silently drop the query string and canonical tags that point at a redirecting URL instead of the final destination.

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 resolve redirect chains and audit them. 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 redirect-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 hop limits), the four-step workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
redirect-audit
description
Audit a website's redirects for SEO. Resolves the full redirect chain for every URL to find redirect chains and loops, redirects that end in 404s or errors, temporary (302) redirects used for permanent moves, HTTPS-to-HTTP downgrades, client-side meta-refresh and JavaScript redirects, dropped query strings, and canonicals that point at redirecting URLs. Use when the user asks to audit, analyze, check, or fix redirects, redirect chains, 301s, 302s, redirect loops, or HTTP-to-HTTPS migration issues on a website.

Redirect Audit

Resolve and audit the redirects of a website and produce an actionable SEO report.

When to use this skill

Use this skill when the user asks to:

  • Audit or analyze a site's redirects, 301s, 302s, or redirect rules
  • Find redirect chains, redirect loops, or redirects that end in 404s/errors
  • Verify an HTTP→HTTPS or domain/www migration redirects cleanly in one hop
  • Find temporary (302/307) redirects that should be permanent (301/308)
  • Detect meta-refresh or JavaScript redirects that should be server-side 301s
  • Check that canonical tags and sitemap URLs don't point at redirecting URLs
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or URL set — a live site root URL (e.g., https://example.com), a sitemap URL (--sitemap), or a text file of specific URLs (--url-list). Sitemap and URL-list modes are best for verifying a known migration map.
  2. Scope — full crawl, a sitemap, or an explicit list. For a migration, supply the list of old URLs so each is checked for a clean single hop.
  3. Crawl limits — max pages (default 500) and max hops to follow (default 10).
  4. Chain tolerance — the maximum acceptable number of redirect hops before flagging (default 1; every redirect should reach its destination in a single hop).
Workflow
Step 1: Resolve redirect chains

Use scripts/collect_redirects.py to follow each URL one hop at a time and record the full chain:

python3 scripts/collect_redirects.py https://example.com --max-pages 500 --output redirect_inventory.json

Other input modes:

python3 scripts/collect_redirects.py https://example.com/sitemap.xml --sitemap --output redirect_inventory.json
python3 scripts/collect_redirects.py old_urls.txt --url-list --output redirect_inventory.json

For every URL the inventory records each hop (URL, status code, target), the chain length, the final URL and status, whether the chain loops or exceeds the hop limit, scheme/host changes, HTTPS→HTTP downgrades, dropped query strings, the final page's canonical, and any meta-refresh or JavaScript redirect on the final page.

Step 2: Run the audit checks

Use scripts/audit_redirects.py to evaluate the inventory:

python3 scripts/audit_redirects.py redirect_inventory.json --output audit_report.json

Use --max-chain 0 to flag every redirect (useful when checking that sitemap/canonical URLs are all direct 200s), or a higher number to tolerate known multi-hop paths.

Step 3: Evaluate the audit checks

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

CheckSeverity
Redirect loopHigh
Redirect chain ends in a 4xx/5xx/errorHigh
Chain exceeds the hop limit (likely a loop)High
Chain longer than allowed (> max-chain hops)High
HTTPS → HTTP downgrade in the chainHigh
Temporary (302/303/307) redirect for a permanent moveMedium
Meta-refresh or JavaScript redirect instead of a 301Medium
Mixed permanent and temporary codes in one chainMedium
Canonical points at a redirecting URLMedium
Redirect silently drops the query stringLow
Final page's canonical differs from its own URLLow
Step 4: Produce the report

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

  1. Summary — URLs checked, how many redirect, hop-count distribution, issue counts by severity
  2. Hop distribution — how many URLs resolve in 0/1/2/3+ hops
  3. Issues — one section per failing check, listing the source URL, the full hop-by-hop chain, and the fix
  4. Prioritized action list — ordered by severity and traffic impact
Step 5: Recommend fixes

For each issue, give a concrete fix:

  • Chains: collapse every multi-hop chain to a single 301 from the original URL straight to the final destination. Show the exact rewrite (e.g., A → B → C becomes A → C).
  • Loops: identify the two rules fighting each other (commonly a trailing-slash rule plus a lowercase rule, or a www rule plus an HTTPS rule) and state which to change.
  • Redirects to errors: repoint the redirect to a live, relevant page (not the homepage unless nothing better exists).
  • Temporary redirects: change 302/307 to 301/308 for moves intended to be permanent; keep 302/307 only for genuinely temporary cases (A/B tests, geo, maintenance).
  • Meta/JS redirects: replace with a server-side 301.
  • Never invent the destination — base every recommended target on the final URL the chain already reaches or on the user's migration map.
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/collect_redirects.py — resolve the full redirect chain for a crawl, sitemap, or URL list
  • scripts/audit_redirects.py — run audit checks against a redirect inventory JSON file

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

Full definitions, severities, and rationale for all ten checks, from a chain ending in a 4xx/5xx to a silently dropped query string.

references/audit-checks.mdView on GitHub

Redirect Audit Checks

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

High severity
1. Redirect loop
  • Definition: The chain returns to a URL it has already visited (A → B → A, or a URL redirecting to itself).
  • Why it matters: The page never resolves. Browsers show "too many redirects", and crawlers abandon the URL, so it cannot rank or pass equity.
  • Fix: Find the two rules in conflict — most loops come from a pair of competing normalizations (trailing slash vs. no slash, www vs. non-www, HTTP vs. HTTPS, lowercase vs. mixed case). Keep one canonical form and make the other redirect to it exactly once.
2. Redirect chain ends in a 4xx/5xx/error
  • Definition: A URL that redirects, but whose final destination returns 4xx, 5xx, or a connection error (status 0).
  • Why it matters: The redirect actively sends users and crawlers to a dead end, wasting the link equity the redirect was meant to preserve.
  • Fix: Repoint the redirect to a live, topically relevant page. Avoid defaulting everything to the homepage ("soft 404" behavior); map each old URL to its closest current equivalent.
3. Chain exceeds the hop limit
  • Definition: The resolver hit its --max-hops ceiling without reaching a final status.
  • Why it matters: Either an extremely long chain or an undetected loop; in both cases the destination is effectively unreachable for crawlers, which cap redirect following.
  • Fix: Trace the chain manually, find where it fails to terminate, and replace it with a single direct 301.
4. Chain longer than allowed
  • Definition: More redirect hops than --max-chain (default 1). Every redirect should reach its destination in one hop.
  • Why it matters: Each hop adds latency and dilutes/risks the signals passed along. Google follows a limited number of hops and may stop consolidating signals on long chains.
  • Fix: Update the redirect rules so the original URL points straight to the final destination (A → B → C becomes A → C). Keep the intermediate rules only if those intermediate URLs are themselves still requested directly.
5. HTTPS → HTTP downgrade
  • Definition: A hop redirects from an https:// URL to an http:// URL.
  • Why it matters: Downgrades break the secure chain, trigger browser warnings, and can strip security; on a migrated site it usually signals a misordered rule (the HTTPS redirect runs before the canonicalization).
  • Fix: Reorder rules so HTTPS is enforced last (or first and consistently), ensuring no hop ever lands on http://.
Medium severity
6. Temporary redirect for a permanent move
  • Definition: A 302, 303, or 307 used where the move is permanent.
  • Why it matters: Temporary redirects tell search engines to keep indexing the old URL and may not consolidate signals to the new one as reliably as a 301/308.
  • Fix: Change to 301 (or 308 to preserve the request method). Reserve 302/307 for genuinely temporary situations: A/B tests, geolocation, maintenance pages, and cart/login flows.
7. Meta-refresh or JavaScript redirect
  • Definition: The final document redirects via <meta http-equiv="refresh"> or a client-side location assignment instead of an HTTP status redirect.
  • Why it matters: Client-side redirects are slower, depend on rendering, and are interpreted inconsistently by crawlers. A 0-second meta refresh is treated like a 301 by Google but is still discouraged; delayed refreshes are treated as soft 404s.
  • Fix: Replace with a server-side 301.
8. Mixed permanent and temporary codes in one chain
  • Definition: A single chain contains both a permanent (301/308) and a temporary (302/303/307) code.
  • Why it matters: Inconsistent signals about whether the move is permanent; the temporary hop can prevent full consolidation even if the chain also contains a 301.
  • Fix: Make every hop in the chain permanent (and collapse the chain to one hop while you are there).
9. Canonical points at a redirecting URL
  • Definition: A page returns 200 but its rel="canonical" points to a URL that itself redirects.
  • Why it matters: Canonicals should always point to the final, indexable 200 URL. Pointing at a redirect forces another hop and weakens the canonical signal.
  • Fix: Update the canonical to the redirect's final destination.
Low severity
10. Redirect drops the query string
  • Definition: The requested URL carried a query string, and the final URL has none.
  • Why it matters: Dropping parameters can lose tracking, pagination, filtering, or campaign attribution. Sometimes intentional (stripping junk params), sometimes a bug.
  • Fix: Confirm intent. Preserve meaningful parameters through the redirect; strip only known-noise parameters deliberately.
11. Final page's canonical differs from its own URL
  • Definition: A 200 page's canonical points somewhere other than its own final URL, and that target was not itself observed in the crawl.
  • Why it matters: May be a legitimate cross-page canonical, or a sign the page is canonicalizing to a URL that no longer resolves. Worth a manual check.
  • Fix: Verify the canonical target is a live 200 page and is the intended canonical; correct it if not.
Notes
  • Run with --max-chain 0 against a sitemap or a list of canonical URLs to assert that every "indexable" URL is a direct 200 with no redirect at all.
  • For migrations, feed the list of old URLs via --url-list so each legacy URL is verified to reach its new home in a single 301.

Report Template (references/report-template.md)

The output structure the skill follows, including the summary, hop distribution, issues grouped by severity, and prioritized fixes with exact rewrite rules.

references/report-template.mdView on GitHub

Redirect Audit Report Template

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

# Redirect Audit — {site}

**Date:** {date}
**Scope:** {scope description}
**URLs checked:** {n} | **Redirecting:** {n} | **Chain tolerance:** {max-chain} hop(s)

## Summary

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

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

## Hop distribution

| Hops | URLs |
|---|---|
| 0 (direct 200) | {n} |
| 1 | {n} |
| 2 | {n} |
| 3+ | {n} |

## High-severity issues

### Redirect loops ({n})

| Start URL | Chain | Fix |
|---|---|---|
| ... | A → B → A | ... |

### Redirects ending in errors ({n})

| Start URL | Final URL | Final status | Suggested target |
|---|---|---|---|
| ... | ... | 404 | ... |

### Redirect chains > {max-chain} hop(s) ({n})

| Start URL | Chain (codes) | Final URL | Collapsed redirect |
|---|---|---|---|
| ... | 301 → 301 → 200 | ... | A → C (single 301) |

### HTTPS → HTTP downgrades ({n})

| Start URL | Offending hop |
|---|---|
| ... | https://… → http://… |

## Medium-severity issues

{One subsection per failing check: temporary redirects, meta/JS redirects, mixed codes, canonical-to-redirect. Same table style: source URL, observed chain, fix.}

## Low-severity issues

{Brief list or table: dropped query strings, canonical mismatches.}

## Prioritized action list

1. {Highest-impact fix, with exact rules to change and before/after chains}
2. ...
Guidance
  • Every issue row must show the exact hop-by-hop chain (URL and status code per hop), not just "redirects".
  • For chains, always state the collapsed single-hop replacement (A → C) so the fix is copy-pasteable into the redirect map.
  • Base every suggested redirect target on the destination the chain already reaches or the user's migration map; never invent URLs.
  • Group templated redirects (e.g., an entire /old-blog/* path redirecting through two hops) into one rule-level fix with a few example URLs.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Order the action list by severity first, then by traffic impact (redirects on high-traffic legacy URLs and on canonical/sitemap URLs first).

Redirect Collector Script (scripts/collect_redirects.py)

Resolves the full redirect chain for a site root, sitemap, or URL list, recording each hop's status code, target, and final destination details into a JSON inventory.

scripts/collect_redirects.pyView on GitHub
#!/usr/bin/env python3
"""Resolve the full redirect chain for a set of URLs and record it as JSON.

Usage:
    python3 collect_redirects.py https://example.com [--max-pages 500] [--max-hops 10] [--output redirect_inventory.json]
    python3 collect_redirects.py sitemap.xml --sitemap [--output redirect_inventory.json]
    python3 collect_redirects.py urls.txt --url-list [--output redirect_inventory.json]

For every requested URL the script follows redirects one hop at a time (it never
lets urllib auto-follow) so it can record each individual hop, detect loops, and
flag downgrades to HTTP. It also inspects the final document for meta-refresh and
client-side (JavaScript) redirects, which search engines treat far worse than a
server 301.

Output JSON schema:
{
  "site": "<source>",
  "canonical_host": "https://example.com",   # only for crawl mode
  "pages": {
    "<requested url>": {
      "requested_url": "...",
      "hops": [{"url": "...", "status": 301, "location": "...", "scheme": "https", "host": "example.com"}],
      "chain_length": 1,                       # number of redirect hops
      "final_url": "...",
      "final_status": 200,
      "status_codes": [301],                   # distinct redirect codes in the chain
      "loop": false,
      "exceeded_max_hops": false,
      "scheme_changes": false,
      "host_changes": false,
      "downgrade_to_http": false,
      "drops_query": false,
      "meta_refresh": {"found": false, "delay": null, "url": null},
      "js_redirect": false,
      "final_canonical": "...|null"
    }
  }
}

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 = "RedirectAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"

META_REFRESH_RE = re.compile(r"""url\s*=\s*['"]?([^'">\s]+)""", re.IGNORECASE)
JS_REDIRECT_RE = re.compile(
    r"""(?:window\.)?location(?:\.href|\.replace\s*\(|\.assign\s*\(|\s*=)\s*['"]([^'"]+)""",
    re.IGNORECASE,
)


class FinalPageParser(HTMLParser):
    """Extract canonical and meta-refresh from a final (200) document."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.canonical = None
        self.meta_refresh = None  # (delay, url)
        self.links = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and (a.get("rel") or "").lower() == "canonical" and a.get("href"):
            self.canonical = urllib.parse.urljoin(self.base_url, a["href"].strip())
        elif tag == "meta" and (a.get("http-equiv") or "").lower() == "refresh":
            content = a.get("content") or ""
            delay = content.split(";", 1)[0].strip()
            m = META_REFRESH_RE.search(content)
            url = urllib.parse.urljoin(self.base_url, m.group(1)) if m else None
            self.meta_refresh = (delay, url)
        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 raw_fetch(url):
    """Fetch a single URL WITHOUT following redirects.

    Returns (status, location_or_none, body_or_none, content_type).
    """

    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            return None  # never follow

    opener = urllib.request.build_opener(NoRedirect)
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with opener.open(req, timeout=TIMEOUT) as resp:
            ctype = resp.headers.get("Content-Type", "")
            body = None
            if "html" in ctype:
                body = resp.read(1_000_000).decode("utf-8", errors="replace")
            return resp.status, None, body, ctype
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location") if e.headers else None
        return e.code, loc, None, (e.headers.get("Content-Type", "") if e.headers else "")
    except Exception:
        return 0, None, None, ""  # 0 = network/connection error


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def follow_chain(start_url, max_hops):
    hops = []
    current = start_url
    seen = {start_url}
    loop = False
    exceeded = False
    final_body = None
    final_status = 0
    final_url = start_url

    for _ in range(max_hops + 1):
        status, location, body, _ctype = raw_fetch(current)
        p = urllib.parse.urlparse(current)
        if 300 <= status < 400 and location:
            target = urllib.parse.urljoin(current, location.strip())
            hops.append({"url": current, "status": status, "location": target,
                         "scheme": p.scheme, "host": p.netloc.lower()})
            if target in seen:
                loop = True
                final_url, final_status = target, status
                break
            seen.add(target)
            current = target
        else:
            final_url, final_status, final_body = current, status, body
            break
    else:
        exceeded = True
        final_url, final_status = current, 0

    return hops, final_url, final_status, final_body, loop, exceeded


def analyze(start_url, max_hops):
    hops, final_url, final_status, final_body, loop, exceeded = follow_chain(start_url, max_hops)

    start_p = urllib.parse.urlparse(start_url)
    final_p = urllib.parse.urlparse(final_url)
    schemes = {h["scheme"] for h in hops} | {start_p.scheme, final_p.scheme}
    hosts = {norm_host(h["host"]) for h in hops} | {norm_host(start_p.netloc), norm_host(final_p.netloc)}
    downgrade = any(
        urllib.parse.urlparse(h["url"]).scheme == "https"
        and urllib.parse.urlparse(h["location"]).scheme == "http"
        for h in hops
    )
    drops_query = bool(start_p.query) and not final_p.query

    canonical = None
    meta_refresh = {"found": False, "delay": None, "url": None}
    js_redirect = False
    if final_body:
        parser = FinalPageParser(final_url)
        try:
            parser.feed(final_body)
        except Exception:
            pass
        canonical = parser.canonical
        if parser.meta_refresh:
            meta_refresh = {"found": True, "delay": parser.meta_refresh[0], "url": parser.meta_refresh[1]}
        js_redirect = bool(JS_REDIRECT_RE.search(final_body[:200_000]))

    return {
        "requested_url": start_url,
        "hops": hops,
        "chain_length": len(hops),
        "final_url": final_url,
        "final_status": final_status,
        "status_codes": sorted({h["status"] for h in hops}),
        "loop": loop,
        "exceeded_max_hops": exceeded,
        "scheme_changes": len(schemes) > 1,
        "host_changes": len(hosts) > 1,
        "downgrade_to_http": downgrade,
        "drops_query": drops_query,
        "meta_refresh": meta_refresh,
        "js_redirect": js_redirect,
        "final_canonical": canonical,
    }


def urls_from_sitemap(source):
    status, loc, body, _ = raw_fetch(source)
    if 300 <= status < 400 and loc:
        status, loc, body, _ = raw_fetch(urllib.parse.urljoin(source, loc))
    if status != 200 or not body:
        # raw_fetch only returns body for text/html; refetch xml plainly
        try:
            req = urllib.request.Request(source, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                body = resp.read(10_000_000).decode("utf-8", errors="replace")
        except Exception:
            return []
    urls, queue, seen = [], deque([body]), set()
    try:
        root = ET.fromstring(body)
    except ET.ParseError:
        return []
    if root.tag.lower().endswith("sitemapindex"):
        children = [loc.text.strip() for loc in root.iter(SM_NS + "loc") if loc.text]
        for child in children:
            if child in seen:
                continue
            seen.add(child)
            try:
                req = urllib.request.Request(child, headers={"User-Agent": USER_AGENT})
                with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                    sub = ET.fromstring(resp.read(10_000_000).decode("utf-8", errors="replace"))
                urls += [loc.text.strip() for loc in sub.iter(SM_NS + "loc") if loc.text]
            except Exception:
                continue
    else:
        urls = [loc.text.strip() for loc in root.iter(SM_NS + "loc") if loc.text]
    return urls


def crawl_urls(start, max_pages, max_hops):
    host = norm_host(urllib.parse.urlparse(start).netloc)
    pages = {}
    queue = deque([start])
    seen = {start}
    while queue and len(pages) < max_pages:
        url = queue.popleft()
        result = analyze(url, max_hops)
        pages[url] = result
        print(f"[{result['final_status']}] {result['chain_length']} hop(s) {url}", file=sys.stderr)
        # Discover more same-host links from the final page only when it resolved 200.
        if result["final_status"] == 200:
            _h, _f, _s, body, _l, _e = follow_chain(url, max_hops)
            if body:
                parser = FinalPageParser(result["final_url"])
                try:
                    parser.feed(body)
                except Exception:
                    pass
                for target in parser.links:
                    if not target.startswith(("http://", "https://")):
                        continue
                    if norm_host(urllib.parse.urlparse(target).netloc) == host and target not in seen:
                        seen.add(target)
                        queue.append(target)
    return pages


def main():
    ap = argparse.ArgumentParser(description="Resolve redirect chains for a set of URLs.")
    ap.add_argument("source", help="Site root URL, sitemap (--sitemap), or URL list file (--url-list)")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--max-hops", type=int, default=10)
    ap.add_argument("--output", default="redirect_inventory.json")
    ap.add_argument("--sitemap", action="store_true", help="Treat source as a sitemap URL")
    ap.add_argument("--url-list", action="store_true", help="Treat source as a text file of URLs")
    args = ap.parse_args()

    canonical_host = None
    if args.url_list:
        with open(args.source, encoding="utf-8") as f:
            urls = [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]
        pages = {}
        for url in urls[: args.max_pages]:
            pages[url] = analyze(url, args.max_hops)
            print(f"[{pages[url]['final_status']}] {pages[url]['chain_length']} hop(s) {url}", file=sys.stderr)
    elif args.sitemap:
        urls = urls_from_sitemap(args.source)
        pages = {}
        for url in urls[: args.max_pages]:
            pages[url] = analyze(url, args.max_hops)
            print(f"[{pages[url]['final_status']}] {pages[url]['chain_length']} hop(s) {url}", file=sys.stderr)
    else:
        canonical_host = args.source.rstrip("/")
        pages = crawl_urls(canonical_host, args.max_pages, args.max_hops)

    out = {"site": args.source, "canonical_host": canonical_host, "pages": pages}
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)
    redirecting = sum(1 for p in pages.values() if p["chain_length"] > 0)
    print(f"Wrote {len(pages)} URLs ({redirecting} redirecting) to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Redirect Auditor Script (scripts/audit_redirects.py)

Runs the audit checks against the redirect inventory and outputs the structured report data, including chain length distributions and flagged issues.

scripts/audit_redirects.pyView on GitHub
#!/usr/bin/env python3
"""Audit a redirect inventory JSON (from collect_redirects.py) and emit findings.

Usage:
    python3 audit_redirects.py redirect_inventory.json [--output audit_report.json] [--max-chain 1]

Checks implemented (see references/audit-checks.md):
- redirect_loop: the chain returns to a URL already visited (high)
- redirect_to_error: the chain ends on a 4xx/5xx/0 status (high)
- exceeded_max_hops: chain longer than the resolver limit; likely a loop (high)
- chain_too_long: more redirect hops than --max-chain (high)
- temporary_redirect: 302/303/307 used where a permanent move is implied (medium)
- meta_or_js_redirect: client-side meta-refresh or JS redirect instead of a 301 (medium)
- mixed_redirect_types: a single chain mixes permanent and temporary codes (medium)
- downgrade_to_http: a hop redirects from HTTPS to HTTP (high)
- drops_query: redirect target silently drops the query string (low)
- canonical_to_redirect: a 200 page's canonical points at a URL that itself redirects (medium)
- redirecting_canonical_target: the final page's canonical differs from its own final URL (low)

Uses only the Python standard library.
"""

import argparse
import json
import sys
import urllib.parse

PERMANENT = {301, 308}
TEMPORARY = {302, 303, 307}


def norm(url):
    return urllib.parse.urldefrag((url or "").strip())[0].rstrip("/").lower()


def main():
    ap = argparse.ArgumentParser(description="Run redirect audit checks.")
    ap.add_argument("inventory", help="redirect_inventory.json from collect_redirects.py")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--max-chain", type=int, default=1,
                    help="max acceptable redirect hops before flagging chain_too_long (default 1)")
    args = ap.parse_args()

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

    findings = {
        "redirect_loop": [], "redirect_to_error": [], "exceeded_max_hops": [],
        "chain_too_long": [], "temporary_redirect": [], "meta_or_js_redirect": [],
        "mixed_redirect_types": [], "downgrade_to_http": [], "drops_query": [],
        "canonical_to_redirect": [], "redirecting_canonical_target": [],
    }

    # Map every requested URL to its final status so canonical-to-redirect can be checked.
    final_status_by_url = {norm(u): p.get("final_status") for u, p in pages.items()}
    redirecting_urls = {norm(u) for u, p in pages.items() if p.get("chain_length", 0) > 0}

    hop_count = {"0": 0, "1": 0, "2": 0, "3+": 0}

    for url, p in pages.items():
        chain = p.get("chain_length", 0)
        if chain == 0:
            hop_count["0"] += 1
        elif chain == 1:
            hop_count["1"] += 1
        elif chain == 2:
            hop_count["2"] += 1
        else:
            hop_count["3+"] += 1

        codes = set(p.get("status_codes", []))
        final = p.get("final_status")

        if p.get("loop"):
            findings["redirect_loop"].append({"page": url, "hops": p.get("hops", [])})
        if p.get("exceeded_max_hops"):
            findings["exceeded_max_hops"].append({"page": url, "chain_length": chain})

        if chain > 0 and (final == 0 or (final and final >= 400)):
            findings["redirect_to_error"].append(
                {"page": url, "final_url": p.get("final_url"), "final_status": final})

        if chain > args.max_chain:
            findings["chain_too_long"].append(
                {"page": url, "chain_length": chain, "final_url": p.get("final_url"),
                 "hops": [h["status"] for h in p.get("hops", [])]})

        if codes & TEMPORARY:
            findings["temporary_redirect"].append(
                {"page": url, "codes": sorted(codes), "final_url": p.get("final_url")})

        if (codes & PERMANENT) and (codes & TEMPORARY):
            findings["mixed_redirect_types"].append(
                {"page": url, "codes": sorted(codes), "final_url": p.get("final_url")})

        if p.get("downgrade_to_http"):
            findings["downgrade_to_http"].append({"page": url, "hops": p.get("hops", [])})

        if p.get("drops_query"):
            findings["drops_query"].append(
                {"page": url, "final_url": p.get("final_url")})

        mr = p.get("meta_refresh", {}) or {}
        if mr.get("found") or p.get("js_redirect"):
            findings["meta_or_js_redirect"].append({
                "page": url,
                "type": "meta-refresh" if mr.get("found") else "javascript",
                "target": mr.get("url"),
            })

        # Canonical pointing at a URL that itself redirects (only meaningful on 200 finals).
        canonical = p.get("final_canonical")
        if final == 200 and canonical:
            c = norm(canonical)
            if c in redirecting_urls and c != norm(p.get("final_url", "")):
                findings["canonical_to_redirect"].append(
                    {"page": url, "canonical": canonical, "canonical_final_status": final_status_by_url.get(c)})
            elif c != norm(p.get("final_url", "")) and c not in final_status_by_url:
                findings["redirecting_canonical_target"].append(
                    {"page": p.get("final_url"), "canonical": canonical})

    severity = {
        "redirect_loop": "high", "redirect_to_error": "high", "exceeded_max_hops": "high",
        "chain_too_long": "high", "downgrade_to_http": "high",
        "temporary_redirect": "medium", "meta_or_js_redirect": "medium",
        "mixed_redirect_types": "medium", "canonical_to_redirect": "medium",
        "drops_query": "low", "redirecting_canonical_target": "low",
    }
    summary = {
        "urls_checked": len(pages),
        "urls_redirecting": sum(1 for p in pages.values() if p.get("chain_length", 0) > 0),
        "hop_distribution": hop_count,
        "issues_by_check": {k: len(v) for k, v in findings.items()},
        "issues_by_severity": {
            level: sum(len(findings[k]) for k, s in severity.items() if s == level)
            for level in ("high", "medium", "low")
        },
    }
    report = {"summary": summary, "severity": severity, "findings": findings}

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    print(json.dumps(summary, indent=2))
    print(f"Full report written to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Explore Other Claude SEO Skills

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