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.
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:
- 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. - Scope — how many pages to crawl (default 500, cap with
--max-pages). - Probing — whether to probe each canonical target's status/redirect/noindex
(default on;
--no-probefor a fast structural-only pass).
The indexable contract: a canonical should point at a final, canonical, indexable
200URL — 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:
| Check | Severity |
|---|---|
| Missing canonical on an indexable page | High |
| Multiple conflicting canonicals | High |
| Canonical points to a non-200 URL | High |
| Canonical points to a redirect | High |
| Canonical points to a noindex URL | Medium |
| Relative (non-absolute) canonical | Medium |
| Cross-host canonical | Medium |
| Protocol downgrade (https→http) in canonical | Medium |
| Header / HTML canonical conflict | Medium |
| Canonical contains a fragment | Low |
| 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
200URL. - 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 rationalereferences/report-template.md— report output structurescripts/extract_canonicals.py— crawl pages and probe canonical targetsscripts/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.
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 noLink: 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
200URL (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 orX-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
hrefis 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 anhttp://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.
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
- 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.
#!/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.
#!/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()