I Fetched All 155 Sitemap URLs. Every One Returned 200, but 93% of the lastmod Dates Were the Same Day
A record of fetching every sitemap URL and cross-checking status, canonical, hreflang and lastmod. Full standard-library Python scripts and the mismatch I found.
Does every URL I put in the sitemap really open the way I intend? As an indie developer, I found myself wondering about this on dolice.net, my own site.
Until now, I opened five or six pages in a browser and told myself it looked fine. A spot check covers a few percent of the whole.
So I wrote a small script that reads the sitemap and fetches every URL listed. Even if you hand this kind of audit to an agent, running it yourself once first gives you a way to judge whether the report that comes back is right.
The result first: all 155 URLs returned 200, and the hreflang references were reciprocal with no breaks. I expected nothing to fix. Then I lined up the lastmod values and stopped.
I fixed six checks before writing any code
Decide what to check before you write the script. Adding items afterward makes it easy to bend the criteria around the result.
Check
Where it lives
What goes wrong if it drifts
Status
Result of a GET on the URL
404s and redirects in the sitemap waste crawl budget
canonical
The page's link tag
If the page declares a different URL as canonical, one of the two gets ignored
html lang
The html element
Catches an English path whose lang says ja
noindex
meta robots
Listing a noindex URL in the sitemap is a contradiction
hreflang
Both the sitemap and the page
A one-way declaration can cause the language pairing to be ignored
lastmod
The sitemap and the Last-Modified header
If the date can't be trusted, it is no signal for recrawling
For hreflang only, I fetch the sitemap declaration and the page tags separately and compare them. The same information is written in two places, so there are twice as many ways for it to drift.
The fetch script, in full
It runs on the standard library alone. It reads the sitemap, fetches every URL with six workers, and writes the results to JSON.
I paid attention to two things. First, it does not follow redirects. Python's urllib silently follows 301 and 302 by default, so a report of "200" can hide a redirect behind it. I plug in NoRedirect and count redirects as redirects.
Second, it never discards a URL that failed to fetch. If you keep status: 0 and the reason, you won't spend time later wondering why the counts don't add up.
#!/usr/bin/env python3"""sitemap_audit.py — サイトマップの全 URL を実際に取得し、宣言と実体のずれを数える。使い方: python3 sitemap_audit.py https://example.com/sitemap.xml > report.json"""import json, re, sys, time, urllib.request, urllib.errorimport xml.etree.ElementTree as ETfrom concurrent.futures import ThreadPoolExecutorNS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9", "x": "http://www.w3.org/1999/xhtml"}UA = "sitemap-audit/1.0 (own-site check)"class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None # 301/302 を自分で観測する(追従しない)opener = urllib.request.build_opener(NoRedirect)def fetch(url): req = urllib.request.Request(url, headers={"User-Agent": UA}) t0 = time.time() try: r = opener.open(req, timeout=20) status, headers, body = r.status, r.headers, r.read() except urllib.error.HTTPError as e: status, headers, body = e.code, e.headers, b"" except Exception as e: return {"url": url, "status": 0, "error": str(e)[:80]} ms = int((time.time() - t0) * 1000) html = body.decode("utf-8", "replace") def first(p): m = re.search(p, html, re.I | re.S) return m.group(1).strip() if m else None canon = first(r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)') lang = first(r'<html[^>]+lang=["\']([^"\']+)') robots = first(r'<meta[^>]+name=["\']robots["\'][^>]+content=["\']([^"\']+)') alts = dict(re.findall( r'<link[^>]+rel=["\']alternate["\'][^>]+hreflang=["\']([^"\']+)["\'][^>]+href=["\']([^"\']+)', html)) return {"url": url, "status": status, "ms": ms, "location": headers.get("Location"), "canonical": canon, "html_lang": lang, "robots": robots, "page_hreflang": alts, "bytes": len(body)}def main(sm_url): xml = urllib.request.urlopen( urllib.request.Request(sm_url, headers={"User-Agent": UA}), timeout=20).read() root = ET.fromstring(xml) entries = {} for u in root.findall("s:url", NS): loc = u.find("s:loc", NS).text.strip() alts = {l.get("hreflang"): l.get("href") for l in u.findall("x:link", NS) if l.get("rel") == "alternate"} entries[loc] = {"sm_hreflang": alts, "lastmod": (u.find("s:lastmod", NS).text if u.find("s:lastmod", NS) is not None else None)} with ThreadPoolExecutor(6) as ex: results = {r["url"]: r for r in ex.map(fetch, entries)} out = [] for url, e in entries.items(): r = results[url]; r.update(e); out.append(r) json.dump(out, sys.stdout, ensure_ascii=False, indent=1)if __name__ == "__main__": main(sys.argv[1])
Run it like this. It finished in well under a minute for 155 URLs. Response time had a median of 170ms and a maximum of 267ms, and six workers against my own site caused no visible load.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can cross-check status, canonical and hreflang for every URL in your sitemap with one script, and retire the habit of spot-checking a few pages by hand
✦You can verify hreflang reciprocity and the gap between sitemap and page tags by machine, and prevent language versions from swapping in search results before you publish
✦You can watch for lastmod collapsing onto a single day through an exit code, and notice sooner when your update signal has stopped meaning anything
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I separated fetching from judging. Fetching involves the network, so I do it once; judging runs against a local JSON file that I can rewrite as often as I like.
This script covers every item above except lastmod. It counts mismatches by kind and prints up to three examples of each.
import json, collections, sysd = json.load(open(sys.argv[1])); by = {r["url"]: r for r in d}c = collections.Counter()ex = collections.defaultdict(list)def flag(k, u, note=""): c[k] += 1 if len(ex[k]) < 3: ex[k].append((u, note))for r in d: u = r["url"] if r["canonical"] != u: flag("canonical≠loc", u, str(r["canonical"])) en = "/en/" in u or u.endswith("/en") want = "en" if en else "ja" if (r["html_lang"] or "")[:2] != want: flag("html_lang≠path", u, str(r["html_lang"])) if r["robots"] and "noindex" in r["robots"].lower(): flag("noindex-in-sitemap", u, r["robots"]) if not r["sm_hreflang"]: flag("sm:no-hreflang", u) for lang, href in r["sm_hreflang"].items(): t = by.get(href) if t is None: flag("sm:alt-not-in-sitemap", u, f"{lang}->{href}"); continue back = t["sm_hreflang"].get(lang) if back != href: flag("sm:not-reciprocal", u, f"{lang}->{href} back={back}") if r["sm_hreflang"] and r["page_hreflang"] != r["sm_hreflang"]: flag("sitemap≠page-hreflang", u, f"sm={sorted(r['sm_hreflang'])} page={sorted(r['page_hreflang'])}") if r["sm_hreflang"] and not r["page_hreflang"]: flag("page:no-hreflang-tags", u) if r["ms"] > 1500: flag("slow>1.5s", u, str(r["ms"]))print("URLs", len(d), "ms median", sorted(r["ms"] for r in d)[len(d)//2], "max", max(r["ms"] for r in d))for k, v in c.most_common(): print(k, v, ex[k][:2])print("with-hreflang", sum(1 for r in d if r["sm_hreflang"]), "without", sum(1 for r in d if not r["sm_hreflang"]))
The hreflang reciprocity check asks, inside the sitemap, "if ja declares en, does en declare the same ja URL?" This is the usual entry point for breakage, where someone fixes one side and leaves the other stale.
The result was almost suspiciously clean
Check
Result
Status
All 155 returned 200 (0 redirects, 0 failures)
canonical
Every page points to itself
html lang
Matches the path language everywhere
noindex
0 in the sitemap
hreflang (inside the sitemap)
154 declare it, 0 one-way declarations
hreflang (sitemap vs. page)
0 mismatches
The one URL without hreflang was /privacy/. Its English counterpart /en/privacy/ really does return 404, and the sitemap lists the page without any language alternates. Declaration and reality agree, so it is not a problem.
If my first check had been a visual one, I would have written "no issues" and stopped here. A clean result is also a reason to ask whether the net was too coarse. So I went to look at lastmod, which I had not been auditing.
93% of lastmod values were the same day
lastmod tells crawlers "this URL has changed since your last visit." If almost every entry carries the same date, it stops working as a signal.
Here is a 12-line script that measures that concentration. It prints what share the most common date holds and exits with code 1 when the share exceeds a limit.
#!/usr/bin/env python3"""lastmod_check.py — sitemap の lastmod が 1 日に集中していないかを見る。usage: python3 lastmod_check.py report.json [--max-share 0.5]終了コード 0=問題なし / 1=集中(最多の日付が全体の max-share を超えた)"""import collections, json, sysrows = json.load(open(sys.argv[1]))limit = float(sys.argv[sys.argv.index("--max-share") + 1]) if "--max-share" in sys.argv else 0.5days = collections.Counter((r["lastmod"] or "none")[:10] for r in rows)day, n = days.most_common(1)[0]share = n / len(rows)print(f"{len(rows)} URLs / 最多の日付 {day} が {n} 件 ({share:.0%}) / 日付の種類 {len(days)}")sys.exit(1 if share > limit else 0)
(The output is in Japanese: "most common date 2026-10-28 on 144 URLs (93%), 3 distinct dates".)
There were only three distinct dates. All 144 URLs under /art/ carried the day of my fetch, 2026-10-28, and the remaining 11 (the top pages, the app pages and the privacy policy) were 2026-07-25 or 07-26.
On top of that, the comment at the top of the sitemap still said "all 11 URLs listed." It was left over from the hand-written period and no longer matched the current 155. A comment doesn't affect search, but it makes the maintainer misjudge the real state.
I tried to confirm with Last-Modified, and it didn't work
To find out whether "today" was true, I compared against the Last-Modified header each page returns itself. I thought that if the header were right, it would show whether lastmod was.
#!/usr/bin/env python3"""lastmod_vs_header.py — sitemap の lastmod と、実際の Last-Modified ヘッダを日単位で比べる。usage: python3 lastmod_vs_header.py report.json"""import datetime, email.utils, json, statistics, sys, urllib.requestfrom concurrent.futures import ThreadPoolExecutorrows = json.load(open(sys.argv[1]))def head(row): req = urllib.request.Request(row["url"], method="HEAD", headers={"User-Agent": "sitemap-audit/1.0 (own-site check)"}) try: return row["url"], urllib.request.urlopen(req, timeout=20).headers.get("Last-Modified") except Exception: return row["url"], Nonewith ThreadPoolExecutor(6) as ex: header = dict(ex.map(head, rows))gaps, skipped = [], 0for r in rows: h, lm = header[r["url"]], r["lastmod"] if not h or not lm: skipped += 1 # どちらかが無い URL は比べない continue h_day = email.utils.parsedate_to_datetime(h).date() s_day = datetime.date.fromisoformat(lm[:10]) gaps.append(((h_day - s_day).days, r["url"]))newer = [g for g in gaps if g[0] > 0]print(f"比較 {len(gaps)} 件 / 比べられず {skipped} 件")print(f"ヘッダのほうが新しい: {len(newer)} 件 / 差の中央値: {statistics.median(g[0] for g in gaps)} 日 / 最大: {max(g[0] for g in gaps)} 日")
(In English: "154 compared, 1 skipped; header newer on 10 URLs; median gap 0.0 days, max 95 days.")
For the 144 /art/ URLs the header also said today. Of the 11 hand-written entries, the 10 that could be compared had a header up to 95 days newer than lastmod. lastmod still said July, yet the page was served today.
Here I had been wrong about something. I had read Last-Modified as "the day the content last changed," but on this site it appears to be refreshed on every page at each redeploy. The 11 hand-written pages had not changed since July, yet their headers were new.
So the header is not a baseline for verification. From the outside I could not tell whether the 144 /art/ pages really changed today or simply had the build date stamped on them. Only the source side, wherever each page's update date lives, can tell the difference.
If I fix it, each page will carry its own date
I haven't fixed it on my site yet. I have only decided the direction: stop putting the build date into lastmod and take it from source data that holds each page's own update date.
Git history is the first idea that comes to mind, and it has a trap. CI often uses git clone --depth 1 for speed, and in a shallow history every file's last commit date is the date of the top commit.
git log -1 --format=%cs -- content/articles/ja/tips/mdx-body-link-orphans-vs-related-widget-link-audit.mdx# 2026-10-28 ← the real last change was 10-23; a shallow clone returns the top commit's date
I shallow-cloned this site's repository and tried a different article I wrote on 10/23. It returned the day I cloned, not the day the file was last touched. If you derive lastmod from Git, fetch deep history, or write the update date in frontmatter and treat that as the source of truth.
Don't stop at HEAD. canonical and hreflang live in the body, so a header-only check can't see them. Only the lastmod comparison uses HEAD.
Don't point six workers at someone else's site. This script assumes your own site. Put the audit purpose in the User-Agent and keep concurrency in single digits.
Distrust a clean result. When every check you decided on passes, look for one thing you didn't decide to check. The lastmod finding came from exactly that question.
A request you can hand to an agent
If you have an agent such as Antigravity write the audit script, you can pass the request below as is. The point is to state the checks and the things not to do up front.
Write a Python script that audits every URL in the sitemap (https://example.com/sitemap.xml).- Standard library only. At most 6 workers. Put the audit purpose in the User-Agent- Do not follow redirects; record 3xx as 3xx. Record failures as status 0 with the reason- Checks: status / canonical / html lang / meta robots / hreflang (both sitemap and page) / lastmod- Split fetching and judging into separate scripts; save fetch results as JSON- In the judging step, print a count per kind of violation and up to 3 examples eachDo not: run against any site other than my own, or change the criteria to fit the results
You can use the scripts in this article to check what comes back. Just seeing whether the counts match on the same site, and whether redirects were left unfollowed, catches most of the rework. I wrote up the internal-link side of this kind of recount in recounting articles with no inbound body links.
What to do next
First, run sitemap_audit.py once on your own site. Even at 155 URLs it takes well under a minute.
Then add the exit code of lastmod_check.py as one line in your pre-publish checks. Once I switch the source of lastmod to each page's own update date, I plan to run the same two scripts again to see whether the dates spread out.
Even when every result is clean, don't let it end there. That was the biggest takeaway this time.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.