A sitemap is a list of URLs you say you want in search results. A canonical tag says which URL is the real one. A noindex rule says "keep this out of search." On a healthy site those three agree. On most sites, somewhere, they don't: a thank-you page with noindex sits in the sitemap, a blog template points every post's canonical at the trailing-slash version that returns 404, a staging copy carries noindex and a canonical to the home page, or a PDF is quietly blocked by an X-Robots-Tag header that nobody remembers adding.
None of this shows up in a browser. This post builds a small auditor in Python using only the standard library (urllib, urllib.robotparser, html.parser, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, fetches each URL, reads the robots <meta> tags, the X-Robots-Tag and Link response headers and the <link rel="canonical"> elements, and reports where they disagree with each other or with the sitemap. Output is a CSV plus a console summary, and the exit code works in CI.
Google Search Central's What is canonicalization (showing "Last updated 2026-08-20 UTC"), How to specify a canonical URL (2026-07-10 UTC), Block Search indexing with noindex (2025-12-10 UTC), Robots meta tag, data-nosnippet, and X-Robots-Tag specifications (2026-03-24 UTC) and Build and submit a sitemap (2026-07-08 UTC) were all opened on 10 Nov 2026. Appie free-tool descriptions are as of 10 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site and a live public sitemap; the output below is real.
Disclosure: Appie.run publishes this article. Two free tools mentioned near the end are ours, and one of the test runs below used our own sitemap. The script does not depend on any of them.
Where this fits
This is the next script in a small standard-library series. Earlier posts covered generating a sitemap that skips noindex pages and finding broken links and redirect chains in a sitemap. This one checks the other direction: given the sitemap you already ship, do the pages in it say the same thing the sitemap does?
What Google's documentation actually says
Five Google pages cover this, and a few lines in them do most of the work.
Canonicals are hints. The canonicalization overview says you can indicate a preference, "but Google may choose a different page as canonical than you do," and calls it "a hint, not a rule." It also says some duplicate content is normal and is not a spam policy violation.
Signals have different strengths, and they should agree. The "specify a canonical" page ranks the methods by how strongly they influence canonicalization: redirects and rel="canonical" annotations are strong signals, sitemap inclusion is a weak one. Its best practices include:
- Don't specify different URLs as canonical for the same page with different techniques, "for example, don't specify one URL in a sitemap, but a different URL for that same page using
rel="canonical"." - Do include a self-referential canonical on the canonical page itself.
- Don't use a URL fragment as the canonical.
- Use absolute URLs. Relative ones are supported but "can cause problems in the long run."
- Don't use
noindexto choose a canonical within a single site, because it "will completely block the page from Search." - Pick either the HTML
<link>element or theLinkHTTP header. Both are supported, but using both "is more error prone." -
rel="canonical"annotations withhreflang,lang,mediaortypeattributes are not used for canonicalization. - The canonical
<link>element is only accepted in the<head>.
The same page also says none of these methods are required, and that your site "will likely do just fine without specifying a canonical preference." That is why a missing canonical is informational in this script, not an error.
Sitemaps should list canonical URLs. The sitemap page says to include the URLs you want to see in Google's results, and that if the same content is reachable at several URLs you should "choose the URL you prefer and include that in the sitemap instead of all URLs that lead to the same content."
noindex needs to be crawlable to work. The noindex page says the rule can be a <meta> tag or an X-Robots-Tag response header with the same effect, and that noindex in robots.txt is not supported. Its key warning: if robots.txt blocks the page, "the crawler will never see the noindex rule, and the page can still appear in search results."
Robots meta details. The specification page says Google reads the generic robots name plus two user agent tokens, googlebot and googlebot-news, and ignores other names. With multiple tags Google uses "the sum of the negative rules," so robots: nofollow plus googlebot: noindex means noindex, nofollow for Googlebot. Robots meta tags in the body are respected too. X-Robots-Tag values can carry a user agent prefix (googlebot: nofollow); without one they apply to all crawlers. Old rules such as noarchive are listed as no longer used.
Notice what none of these pages say. They do not say a self-referential canonical improves rankings, and they do not promise that fixing a conflict changes which URL Google picks. The script reports where your own signals disagree, so you can make them consistent. It is not a ranking audit, and Search Console's URL Inspection tool is still the place to see which canonical Google actually chose.
What the script flags
-
noindex: Google-applicablenoindexornone, from<meta name="robots">,<meta name="googlebot">orX-Robots-Tag. If you want it indexed, remove the rule; if not, remove it from the sitemap. -
blocked_by_robots: robots.txt disallows the URL for Googlebot. Google cannot see anynoindexor canonical on it; decide which you want. -
noindex_with_canonical:noindexplus a canonical pointing to another URL. Mixed signals; Google recommends canonicals, notnoindex, for duplicates. -
noindex_other_agent:noindexaimed at another crawler (bingbot: noindex). Probably intentional; confirm. -
canonical_elsewhere: Canonical points to a different URL than the one in the sitemap. The sitemap and page disagree; list the canonical instead. -
canonical_not_in_sitemap: That canonical target is not in the sitemap at all. Often the real cause: the sitemap lists the duplicate. -
redirected: The sitemap URL redirects. List the final URL in the sitemap. -
multiple_canonicals: Two or more different canonicals in the HTML (or in the headers). Keep one. -
canonical_header_conflict:Link: rel="canonical"header and HTML element disagree. Pick one method. -
relative_canonical:hrefis a relative path. Use an absolute URL. -
canonical_fragment: Canonical contains#.... Remove the fragment. -
canonical_to_http: HTTPS page with an HTTP canonical. Point to the HTTPS URL. -
canonical_ignored_attr: Canonical carrieshreflang,lang,mediaortype. Google ignores it for canonicalization. -
canonical_in_body:<link rel="canonical">after<body>. Move it into<head>. -
canonical_target_404(etc.),canonical_target_redirects,canonical_target_noindex: With--check-targets, the canonical URL itself is broken, redirects or isnoindex. Fix the target or the tag. -
no_canonical: No canonical found (informational). Fine per Google; a self-referential one is still recommended. -
unrecognized_rule: A robots rule not in Google's current list (informational). Old or custom rule; Google ignores it. -
http_error/fetch_failed: The page did not load. Fix the page or remove it from the sitemap.
As in the earlier scripts, markup inside <script>, <style>, <template> and <noscript> is ignored, so a canonical or robots tag that only exists after JavaScript runs will not be seen. Google's canonical page asks you to put the canonical in the HTML source and not let JavaScript change it, so a page that only gets its canonical from JavaScript is worth knowing about anyway.
The script
Save as canon_audit.py. No pip install needed.
#!/usr/bin/env python3
"""Audit canonical tags and noindex directives for every URL in a sitemap.
Standard library only. Usage:
python3 canon_audit.py https://example.com/sitemap.xml --out canon_report.csv
"""
import argparse
import csv
import re
import sys
import urllib.error
import urllib.request
import urllib.robotparser
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit
UA = "canon-audit/1.0 (+stdlib python)"
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
GOOGLE_NAMES = {"robots", "googlebot"} # meta names Google web search reads
RULES_WITH_COLON = {"unavailable_after", "max-snippet",
"max-image-preview", "max-video-preview"}
IGNORED_CANON_ATTRS = ("hreflang", "lang", "media", "type")
# rules from Google's robots meta tag page, plus the common index/follow defaults
KNOWN_RULES = {"all", "noindex", "nofollow", "none", "nosnippet", "indexifembedded",
"notranslate", "noimageindex", "index", "follow"}
INFO_ONLY = {"no_canonical", "unrecognized_rule"} # reported, but not a failure
def fetch(url, timeout=15):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
return {"status": resp.status, "final_url": resp.geturl(),
"headers": resp.headers,
"body": resp.read().decode(charset, "replace")}
def sitemap_urls(sitemap_url, seen=None):
"""Return page URLs from a urlset, following sitemap indexes."""
seen = seen if seen is not None else set()
if sitemap_url in seen:
return []
seen.add(sitemap_url)
root = ET.fromstring(fetch(sitemap_url)["body"].encode("utf-8"))
if root.tag == SM_NS + "sitemapindex":
urls = []
for sm in root.findall(SM_NS + "sitemap"):
loc = sm.find(SM_NS + "loc")
if loc is not None and loc.text:
urls.extend(sitemap_urls(loc.text.strip(), seen))
return urls
return [u.find(SM_NS + "loc").text.strip()
for u in root.findall(SM_NS + "url")
if u.find(SM_NS + "loc") is not None and u.find(SM_NS + "loc").text]
def norm(url):
"""Lowercase scheme and host, drop default ports. Keep path and query as-is."""
p = urlsplit(url)
host = (p.hostname or "").lower()
if p.port and not ((p.scheme == "http" and p.port == 80)
or (p.scheme == "https" and p.port == 443)):
host += f":{p.port}"
return urlunsplit((p.scheme.lower(), host, p.path or "/", p.query, ""))
class HeadParser(HTMLParser):
"""Collect <link rel=canonical> and robots <meta> tags."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.canonicals = [] # dicts: href, in_head, ignored_attr
self.robots = [] # (meta name, content)
self._skip = 0
self._in_head = True # until </head> or <body>
def handle_starttag(self, tag, attrs):
a = {k: (v or "") for k, v in attrs}
if tag in ("script", "style", "template", "noscript"):
self._skip += 1
return
if self._skip:
return
if tag == "body":
self._in_head = False
elif tag == "link" and "canonical" in a.get("rel", "").lower().split():
self.canonicals.append({
"href": a.get("href", "").strip(),
"in_head": self._in_head,
"ignored_attr": [k for k in IGNORED_CANON_ATTRS if k in a]})
elif tag == "meta" and a.get("name", "").lower() in GOOGLE_NAMES | {"googlebot-news", "bingbot"}:
self.robots.append((a["name"].lower(), a.get("content", "")))
def handle_startendtag(self, tag, attrs):
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag):
if tag in ("script", "style", "template", "noscript"):
self._skip = max(0, self._skip - 1)
elif tag == "head":
self._in_head = False
def split_rules(text):
return {r.strip().lower() for r in text.split(",") if r.strip()}
def xrobots(values):
"""Split X-Robots-Tag values into (google_rules, {other_agent: rules})."""
google, other = set(), {}
for v in values:
m = re.match(r"\s*([A-Za-z][\w-]*)\s*:\s*(.*)$", v)
if m and m.group(1).lower() not in RULES_WITH_COLON:
agent, rules = m.group(1).lower(), split_rules(m.group(2))
if agent == "googlebot":
google |= rules
else:
other.setdefault(agent, set()).update(rules)
else:
google |= split_rules(v)
return google, other
def link_canonicals(values):
"""Return URLs from Link headers with rel=canonical."""
out = []
for v in values:
for m in re.finditer(r"<([^>]*)>\s*((?:;[^,]*)*)", v):
if re.search(r'rel\s*=\s*"?canonical"?', m.group(2), re.I):
out.append(m.group(1).strip())
return out
def is_noindex(rules):
return bool(rules & {"noindex", "none"})
def audit(url, robots, sitemap_set):
row = {"page": url, "status": "", "final_url": "", "google_robots": "",
"canonical": "", "issues": []}
issues = row["issues"]
if robots is not None and not robots.can_fetch("Googlebot", url):
issues.append("blocked_by_robots")
try:
r = fetch(url)
except urllib.error.HTTPError as e:
row["status"] = e.code
issues.append("http_error")
return row
except (urllib.error.URLError, TimeoutError) as e:
row["status"] = "error"
issues.append(f"fetch_failed:{e}")
return row
row["status"], row["final_url"] = r["status"], r["final_url"]
if norm(r["final_url"]) != norm(url):
issues.append("redirected")
# robots rules from headers and (for HTML) meta tags
google, other = xrobots(r["headers"].get_all("X-Robots-Tag") or [])
header_canon = [urljoin(r["final_url"], h)
for h in link_canonicals(r["headers"].get_all("Link") or [])]
p = HeadParser()
if "html" in (r["headers"].get_content_type() or ""):
p.feed(r["body"])
p.close()
for name, content in p.robots:
if name in GOOGLE_NAMES:
google |= split_rules(content)
else:
other.setdefault(name, set()).update(split_rules(content))
row["google_robots"] = ",".join(sorted(google))
if any(r not in KNOWN_RULES and r.split(":")[0] not in RULES_WITH_COLON
for r in google):
issues.append("unrecognized_rule")
if is_noindex(google):
issues.append("noindex")
if any(is_noindex(rules) for rules in other.values()):
issues.append("noindex_other_agent")
# canonical checks
used = [c for c in p.canonicals if not c["ignored_attr"]]
if any(c["ignored_attr"] for c in p.canonicals):
issues.append("canonical_ignored_attr")
if any(not c["in_head"] for c in used):
issues.append("canonical_in_body")
html_canon = [urljoin(r["final_url"], c["href"]) for c in used
if c["in_head"] and c["href"]]
if any(not urlsplit(c["href"]).scheme for c in used if c["in_head"]):
issues.append("relative_canonical")
if len({norm(c) for c in html_canon}) > 1 or len({norm(c) for c in header_canon}) > 1:
issues.append("multiple_canonicals")
if html_canon and header_canon and norm(html_canon[0]) != norm(header_canon[0]):
issues.append("canonical_header_conflict")
canon = (html_canon or header_canon or [""])[0]
row["canonical"] = canon
if not canon:
issues.append("no_canonical")
return row
if urlsplit(canon).fragment:
issues.append("canonical_fragment")
if urlsplit(url).scheme == "https" and urlsplit(canon).scheme == "http":
issues.append("canonical_to_http")
if norm(canon) != norm(url):
issues.append("canonical_elsewhere")
if norm(canon) not in sitemap_set:
issues.append("canonical_not_in_sitemap")
if is_noindex(google):
issues.append("noindex_with_canonical")
return row
def check_target(url):
"""Fetch a canonical target once; return issue codes for it."""
try:
r = fetch(url)
except urllib.error.HTTPError as e:
return [f"canonical_target_{e.code}"]
except (urllib.error.URLError, TimeoutError):
return ["canonical_target_unreachable"]
out = []
if norm(r["final_url"]) != norm(url):
out.append("canonical_target_redirects")
google, _ = xrobots(r["headers"].get_all("X-Robots-Tag") or [])
p = HeadParser()
p.feed(r["body"])
for name, content in p.robots:
if name in GOOGLE_NAMES:
google |= split_rules(content)
if is_noindex(google):
out.append("canonical_target_noindex")
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("sitemap")
ap.add_argument("--out", default="canon_report.csv")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--limit", type=int, default=0, help="check only the first N URLs")
ap.add_argument("--check-targets", action="store_true",
help="also fetch canonical targets that differ from the page URL")
ap.add_argument("--no-robots", action="store_true", help="skip the robots.txt check")
args = ap.parse_args()
try:
urls = sitemap_urls(args.sitemap)
except (urllib.error.URLError, ET.ParseError) as e:
print(f"Could not read sitemap: {e}", file=sys.stderr)
return 2
if args.limit:
urls = urls[:args.limit]
robots = None
if not args.no_robots:
robots = urllib.robotparser.RobotFileParser(urljoin(args.sitemap, "/robots.txt"))
try:
robots.read()
except (urllib.error.URLError, TimeoutError):
robots = None
sitemap_set = {norm(u) for u in urls}
print(f"Checking {len(urls)} URLs from {args.sitemap}")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
rows = list(pool.map(lambda u: audit(u, robots, sitemap_set), urls))
if args.check_targets:
targets = sorted({r["canonical"] for r in rows
if "canonical_elsewhere" in r["issues"]})
found = dict(zip(targets, pool.map(check_target, targets)))
for r in rows:
r["issues"] += found.get(r["canonical"], [])
counts = {}
for r in rows:
for code in r["issues"]:
counts[code] = counts.get(code, 0) + 1
r["issues"] = ";".join(r["issues"])
with open(args.out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=["page", "status", "final_url",
"google_robots", "canonical", "issues"])
w.writeheader()
w.writerows(rows)
flagged = [r for r in rows if set(r["issues"].split(";")) - INFO_ONLY - {""}]
print(f"{len(rows)} pages checked; {len(flagged)} with problems")
for code, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
print(f" {code:<28} {n}")
print(f"Report: {args.out}")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main())
A few design choices worth knowing:
-
Only Google web search agents count as
noindex. Meta namesrobotsandgooglebot, andX-Robots-Tagvalues with no prefix or agooglebot:prefix. Everything else (bingbot,googlebot-news) goes tonoindex_other_agent. ChangeGOOGLE_NAMESif you care about other engines. -
Colon rules are not user agents.
X-Robots-Tag: unavailable_after: 25 Jun 2010 ...andmax-snippet:50contain colons too, so the parser keeps a small list of rules that take a value and does not mistake them for crawler names. -
Headers are read for every URL, HTML only for HTML. A PDF can still be
noindexthroughX-Robots-Tag, and Google's docs suggest theLinkheader for non-HTML canonicals. -
Comparison is light.
norm()lowercases the scheme and host and drops default ports. It does not strip trailing slashes or query strings, because/postand/post/are different URLs. -
The robots.txt check uses
urllib.robotparser, which applies rules in file order. Google uses the most specific matching rule. For simple files the answer is the same; if you mixAllowandDisallowon overlapping paths, confirm anyblocked_by_robotsrow with Search Console's robots.txt report. -
"Head" means before
<body>or</head>. HTML parsers also close the head early when they hit a body-only element such as an<img>inside<head>, and Google's canonical page asks for at least a valid<head>. The script does not model that, so a canonical placed after such an element can look fine here and still be ignored. -
Exit codes:
0when only informational codes appear,1when anything else is flagged,2when the sitemap cannot be read.
Testing it against a local fixture
python3 -m http.server cannot send custom headers or redirects, so the fixture uses a 34-line server that adds them for a few paths:
"""Static server that can add response headers and redirects, for testing."""
import http.server
import sys
HEADERS = {
"/docs/guide.pdf": [("X-Robots-Tag", "noindex")],
"/search.html": [("X-Robots-Tag", "bingbot: noindex"),
("X-Robots-Tag", "googlebot: nosnippet")],
"/landing.html": [("Link", '<http://127.0.0.1:8767/landing-a.html>; rel="canonical"')],
}
REDIRECTS = {"/old-page.html": "/new-page.html"}
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
path = self.path.split("?")[0]
if path in REDIRECTS:
self.send_response(301)
self.send_header("Location", REDIRECTS[path])
self.end_headers()
return
super().do_GET()
def end_headers(self):
for k, v in HEADERS.get(self.path.split("?")[0], []):
self.send_header(k, v)
super().end_headers()
def log_message(self, *args):
pass
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8767
http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
The site has a sitemap index with two child sitemaps (17 URLs) and a robots.txt that disallows /private/. The pages cover one case each: a clean home page with a self-referential canonical, a pricing page with no canonical and a leftover noarchive, a noindex, nofollow thank-you page, a redirected URL, a page with a relative canonical and googlebot: noindex, two conflicting canonicals, a canonical with hreflang, a Link header that disagrees with the HTML, a staging copy with noindex and a canonical to the home page, a bingbot-only noindex, a canonical with #top, a canonical in the body, a page whose noindex is only added by JavaScript and whose canonical points at a noindex page, a noindex page under the blocked /private/ path, a PDF with an X-Robots-Tag: noindex header, a 404, and a blog post whose canonical points at a trailing-slash URL that does not exist.
cd site
python3 ../serve_fixture.py 8767
Then from another terminal:
python3 canon_audit.py http://127.0.0.1:8767/sitemap.xml --out canon_report.csv
echo "exit code: $?"
Real output on Python 3.13.5:
Checking 17 URLs from http://127.0.0.1:8767/sitemap.xml
17 pages checked; 15 with problems
canonical_elsewhere 5
no_canonical 5
noindex 5
canonical_not_in_sitemap 4
blocked_by_robots 1
canonical_fragment 1
canonical_header_conflict 1
canonical_ignored_attr 1
canonical_in_body 1
http_error 1
multiple_canonicals 1
noindex_other_agent 1
noindex_with_canonical 1
redirected 1
relative_canonical 1
unrecognized_rule 1
Report: canon_report.csv
exit code: 1
And the CSV:
page,status,final_url,google_robots,canonical,issues
http://127.0.0.1:8767/,200,http://127.0.0.1:8767/,,http://127.0.0.1:8767/,
http://127.0.0.1:8767/pricing.html,200,http://127.0.0.1:8767/pricing.html,noarchive,,unrecognized_rule;no_canonical
http://127.0.0.1:8767/thanks.html,200,http://127.0.0.1:8767/thanks.html,"nofollow,noindex",http://127.0.0.1:8767/thanks.html,noindex
http://127.0.0.1:8767/old-page.html,200,http://127.0.0.1:8767/new-page.html,,http://127.0.0.1:8767/new-page.html,redirected;canonical_elsewhere;canonical_not_in_sitemap
http://127.0.0.1:8767/features.html,200,http://127.0.0.1:8767/features.html,"max-snippet:120,noindex",http://127.0.0.1:8767/features.html,noindex;relative_canonical
http://127.0.0.1:8767/compare.html,200,http://127.0.0.1:8767/compare.html,,http://127.0.0.1:8767/compare.html,multiple_canonicals
http://127.0.0.1:8767/de.html,200,http://127.0.0.1:8767/de.html,,,canonical_ignored_attr;no_canonical
http://127.0.0.1:8767/landing.html,200,http://127.0.0.1:8767/landing.html,,http://127.0.0.1:8767/landing-b.html,canonical_header_conflict;canonical_elsewhere;canonical_not_in_sitemap
http://127.0.0.1:8767/staging-copy.html,200,http://127.0.0.1:8767/staging-copy.html,noindex,http://127.0.0.1:8767/,noindex;canonical_elsewhere;noindex_with_canonical
http://127.0.0.1:8767/search.html,200,http://127.0.0.1:8767/search.html,nosnippet,http://127.0.0.1:8767/search.html,noindex_other_agent
http://127.0.0.1:8767/faq.html,200,http://127.0.0.1:8767/faq.html,,http://127.0.0.1:8767/faq.html#top,canonical_fragment
http://127.0.0.1:8767/body-canon.html,200,http://127.0.0.1:8767/body-canon.html,,,canonical_in_body;no_canonical
http://127.0.0.1:8767/promo.html,200,http://127.0.0.1:8767/promo.html,,http://127.0.0.1:8767/promo-old.html,canonical_elsewhere;canonical_not_in_sitemap
http://127.0.0.1:8767/private/report.html,200,http://127.0.0.1:8767/private/report.html,noindex,,blocked_by_robots;noindex;no_canonical
http://127.0.0.1:8767/docs/guide.pdf,200,http://127.0.0.1:8767/docs/guide.pdf,noindex,,noindex;no_canonical
http://127.0.0.1:8767/missing.html,404,,,,http_error
http://127.0.0.1:8767/blog/post-a.html,200,http://127.0.0.1:8767/blog/post-a.html,,http://127.0.0.1:8767/blog/post-a/,canonical_elsewhere;canonical_not_in_sitemap
Notes on that result:
-
15 of 17 pages flagged. Only the home page is clean. The pricing page has two informational codes (
unrecognized_rulefornoarchive, which Google lists as no longer used, andno_canonical), so it does not count toward the exit code. -
private/report.htmlis the case Google's noindex page warns about. The script can read itsnoindexbecause it ignores robots.txt when fetching, but Googlebot is disallowed and would never see the rule. If the goal is "not in Search," allow crawling and keep thenoindex; if the goal is "don't crawl," drop it from the sitemap. -
features.htmlshows the "sum of negative rules": therobotstag only setsmax-snippet:120, thegooglebottag addsnoindex, and the merged rules for Google aremax-snippet:120,noindex. -
landing.htmlsendslanding-a.htmlin theLinkheader andlanding-b.htmlin the HTML. The report shows the HTML one in thecanonicalcolumn, but the real fix is to pick one method. -
staging-copy.htmlhasnoindexand a canonical to the home page. Google's canonical page recommendsrel="canonical"overnoindexfor duplicates within a site; here, the staging copy should probably not be in the sitemap at all. -
de.htmlgetscanonical_ignored_attrandno_canonical: its only canonical carrieshreflang, so Google would not use it. -
promo.htmllooks fine in the first pass. Itsnoindexis only added by a<script>, which the script ignores on purpose. -
The PDF is caught through its
X-Robots-Tagheader alone.
With --check-targets, the script also fetches each canonical that points somewhere else (once per unique target) and adds two rows of information:
Checking 17 URLs from http://127.0.0.1:8767/sitemap.xml
17 pages checked; 15 with problems
canonical_elsewhere 5
no_canonical 5
noindex 5
canonical_not_in_sitemap 4
blocked_by_robots 1
canonical_fragment 1
canonical_header_conflict 1
canonical_ignored_attr 1
canonical_in_body 1
canonical_target_404 1
canonical_target_noindex 1
http_error 1
multiple_canonicals 1
noindex_other_agent 1
noindex_with_canonical 1
redirected 1
relative_canonical 1
unrecognized_rule 1
Report: canon_targets.csv
exit code: 1
$ grep canonical_target canon_targets.csv
http://127.0.0.1:8767/promo.html,200,http://127.0.0.1:8767/promo.html,,http://127.0.0.1:8767/promo-old.html,canonical_elsewhere;canonical_not_in_sitemap;canonical_target_noindex
http://127.0.0.1:8767/blog/post-a.html,200,http://127.0.0.1:8767/blog/post-a.html,,http://127.0.0.1:8767/blog/post-a/,canonical_elsewhere;canonical_not_in_sitemap;canonical_target_404
The blog post is the template bug from the intro: every post points its canonical at a trailing-slash URL that returns 404. promo.html points at promo-old.html, which is itself noindex. Both are cases where the canonical sends Google to a page that cannot be the canonical.
When the sitemap URL is wrong, the script stops early:
Could not read sitemap: HTTP Error 404: File not found
exit code: 2
Running it on a real sitemap
I also ran it against a live public sitemap, our own https://appie.run/sitemap.xml, with two workers and --check-targets:
python3 canon_audit.py https://appie.run/sitemap.xml --out appie_canon.csv --workers 2 --check-targets
Checking 74 URLs from https://appie.run/sitemap.xml
74 pages checked; 0 with problems
no_canonical 1
unrecognized_rule 1
Report: appie_canon.csv
exit code: 0
The two informational rows are the same URL, https://appie.run/llms.txt. It is a plain text file, so it has no HTML canonical, and the server sends X-Robots-Tag: llms-txt, which is not a rule in Google's list. The other 73 URLs returned 200, did not redirect, had a self-referential absolute canonical and index, follow. A sample:
page,status,final_url,google_robots,canonical,issues
https://appie.run/,200,https://appie.run/,"follow,index",https://appie.run/,
https://appie.run/pricing,200,https://appie.run/pricing,"follow,index",https://appie.run/pricing,
https://appie.run/llms.txt,200,https://appie.run/llms.txt,llms-txt,,unrecognized_rule;no_canonical
A clean run means your own signals agree with each other. It does not tell you which canonical Google picked; the canonicalization page is clear that it may choose differently. Use URL Inspection in Search Console for that, and the Page Indexing report to see which pages Google found a noindex on.
On sites you do not own, keep --workers low and use --limit for a first pass:
python3 canon_audit.py https://example.com/sitemap.xml --out canon_report.csv --workers 2 --limit 50
If you do not know where the sitemap lives, the free Sitemap Finder and Checker reads robots.txt Sitemap lines and the usual sitemap paths, then reports which files load and how many URLs they list. If you want to look at the URL list in a browser first, the Sitemap URL Extractor takes a sitemap URL or pasted XML and returns one URL per line; for a sitemap index, it lists the child sitemaps so you can run each one. Both ask for a captcha before they run.
Fixing what it finds
A rough order that works for most sites:
-
noindexpages in the sitemap. Decide per URL. If the page should be in Search, remove the rule. If it should not, take it out of the sitemap, which per Google should list the URLs you want in results. Check templates first: one wrong setting on a post type can noindex hundreds of URLs. -
blocked_by_robotswithnoindex. Google cannot see thenoindex. Allow crawling if you need the page out of Search. -
Broken canonical targets (
canonical_target_404,canonical_target_noindex,canonical_target_redirects). These almost always come from a template or a URL rule, so fix the source and rerun. -
Sitemap and canonical disagree (
canonical_elsewhere,canonical_not_in_sitemap,redirected). Usually the sitemap lists the duplicate. Make the sitemap generator emit the final, canonical URL. -
Conflicting or malformed canonicals (
multiple_canonicals,canonical_header_conflict,relative_canonical,canonical_fragment,canonical_in_body,canonical_ignored_attr). Often a theme and an SEO plugin both write a tag. Keep one source. - Informational rows as a review pass.
Extending it
-
Diff runs. Save the CSV per deploy and diff it. A sudden jump in
noindexrows is a cheap regression check. -
hreflang reciprocity. Collect
<link rel="alternate" hreflang>and check that each alternate points back. Google's canonical page notes it prefers URLs inhreflangclusters. - Internal links. Google recommends linking to the canonical URL within your site. Combine this with the link checker from the earlier post and flag internal links to URLs whose canonical points elsewhere.
FAQ
Does a self-referential canonical help rankings?
Google does not say that. Its canonical page recommends adding one, and says sites "will likely do just fine" without specifying a canonical at all. Treat it as a way to make your preference clear, not a ranking factor.
Will Google always use my canonical?
No. Google calls a canonical preference "a hint, not a rule" and may choose a different page. Consistent signals (redirects, canonicals, sitemap and internal links all pointing the same way) make your preference clearer.
Can I put noindex in robots.txt?
No. Google's noindex page says specifying noindex in robots.txt is not supported. Use the <meta> tag or the X-Robots-Tag header, and make sure robots.txt does not block the URL.
Should I use noindex on duplicate pages instead of a canonical?
Google does not recommend it for choosing a canonical within one site, because noindex removes the page from Search entirely. Use rel="canonical" or a redirect.
Can I run it in CI?
Yes. It exits 1 when anything other than informational codes is flagged and 2 when the sitemap cannot be read. To fail only on noindex, filter the CSV's issues column before deciding.
Wrap-up
Canonical and noindex mistakes are quiet. The page loads, the sitemap validates, and the only symptom is a URL that never shows up in Search or a duplicate that does. Run this after template changes, CMS or plugin updates and migrations, fix the noindex and broken-target rows first, and then check URL Inspection for the pages that matter most. The script tells you where your signals disagree; deciding which URL should win is still your call.
RG, Founder, Appie.run
Top comments (0)