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.
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:
- 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. - 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.
- Crawl limits — max pages (default 500) and max hops to follow (default 10).
- 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:
| Check | Severity |
|---|---|
| Redirect loop | High |
| Redirect chain ends in a 4xx/5xx/error | High |
| Chain exceeds the hop limit (likely a loop) | High |
| Chain longer than allowed (> max-chain hops) | High |
| HTTPS → HTTP downgrade in the chain | High |
| Temporary (302/303/307) redirect for a permanent move | Medium |
| Meta-refresh or JavaScript redirect instead of a 301 | Medium |
| Mixed permanent and temporary codes in one chain | Medium |
| Canonical points at a redirecting URL | Medium |
| Redirect silently drops the query string | Low |
| Final page's canonical differs from its own URL | Low |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — URLs checked, how many redirect, hop-count distribution, issue counts by severity
- Hop distribution — how many URLs resolve in 0/1/2/3+ hops
- Issues — one section per failing check, listing the source URL, the full hop-by-hop chain, and the fix
- 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 → CbecomesA → 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 checkreferences/report-template.md— report output structurescripts/collect_redirects.py— resolve the full redirect chain for a crawl, sitemap, or URL listscripts/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.
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-hopsceiling 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 → CbecomesA → 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 anhttp://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-sidelocationassignment 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 0against 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-listso 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.
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.
#!/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.
#!/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()