DEV Community

linkbruno.com
linkbruno.com

Posted on Edited on

How to check every URL in your sitemap.xml for 404s, redirect chains and timeouts (a Python script for CI)

A sitemap is a list of URLs you're telling the world are live, canonical and worth visiting. In practice, sitemaps drift. A page gets deleted but the generator still lists it from a stale cache. A slug changes and the sitemap keeps the old one, which now redirects. A section moves behind a login. Nobody notices, because nobody reads sitemaps except crawlers.

Checking a sitemap is also one of the easiest link checks you can automate. You don't need to crawl anything: the list of URLs is already there. This post builds a small Python script that reads a sitemap (including sitemap indexes), fetches every URL with sensible limits, and reports four things:

  • URLs that return an error (4xx, 5xx).
  • URLs that redirect at all (a sitemap should list final URLs).
  • Redirect chains of more than one hop.
  • URLs that time out or fail to connect.

It exits non-zero when it finds problems, so you can run it in CI after a deploy.

Reading the sitemap

Sitemaps come in two shapes: a <urlset> with <url><loc> entries, or a <sitemapindex> that points to more sitemaps. Large sites nearly always use an index. The XML namespace trips people up, so handle it explicitly:

import xml.etree.ElementTree as ET
import gzip, urllib.request

NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}

def fetch_bytes(url, timeout=20):
    req = urllib.request.Request(url, headers={"User-Agent": "sitemap-check/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        data = r.read()
    return gzip.decompress(data) if url.endswith(".gz") else data

def sitemap_urls(url, seen=None):
    seen = seen or set()
    if url in seen:
        return []
    seen.add(url)
    root = ET.fromstring(fetch_bytes(url))
    if root.tag.endswith("sitemapindex"):
        out = []
        for loc in root.findall("sm:sitemap/sm:loc", NS):
            out += sitemap_urls(loc.text.strip(), seen)
        return out
    return [loc.text.strip() for loc in root.findall("sm:url/sm:loc", NS)]
Enter fullscreen mode Exit fullscreen mode

The seen set protects you from an index that accidentally lists itself, which happens more often than you'd think with plugins.

Checking each URL without following redirects blindly

Most HTTP libraries follow redirects silently. For a sitemap check, you want to see every hop, so follow them yourself. This uses requests because it makes that easy:

import requests

def check(url, max_hops=5, timeout=10):
    hops, current = [], url
    s = requests.Session()
    s.headers["User-Agent"] = "sitemap-check/1.0 (+your contact URL)"
    for _ in range(max_hops + 1):
        try:
            r = s.get(current, allow_redirects=False, timeout=timeout, stream=True)
            r.close()
        except requests.Timeout:
            return {"url": url, "status": "timeout", "hops": hops}
        except requests.ConnectionError as e:
            return {"url": url, "status": "connect-error", "hops": hops, "detail": str(e)[:120]}
        if r.is_redirect or r.status_code in (301, 302, 303, 307, 308):
            nxt = requests.compat.urljoin(current, r.headers.get("Location", ""))
            hops.append((r.status_code, nxt))
            current = nxt
            continue
        return {"url": url, "status": r.status_code, "hops": hops, "final": current}
    return {"url": url, "status": "too-many-redirects", "hops": hops}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. stream=True plus close() means you don't download a 40 MB PDF just to learn it exists. And using GET rather than HEAD avoids the servers that answer HEAD differently, or not at all.

Running it in parallel, politely

A few thousand URLs one at a time takes a while. A small thread pool is enough, and keeping it small stops your own check from looking like an attack on your own site:

from concurrent.futures import ThreadPoolExecutor
import sys, time

def main(sitemap, workers=8):
    urls = sorted(set(sitemap_urls(sitemap)))
    print(f"{len(urls)} URLs in sitemap")
    t0 = time.time()
    with ThreadPoolExecutor(max_workers=workers) as pool:
        results = list(pool.map(check, urls))
    problems = 0
    for r in results:
        st, hops = r["status"], r["hops"]
        if isinstance(st, int) and st >= 400:
            print(f"ERROR {st}  {r['url']}"); problems += 1
        elif not isinstance(st, int):
            print(f"FAIL  {st}  {r['url']}"); problems += 1
        elif len(hops) > 1:
            print(f"CHAIN {len(hops)} hops  {r['url']} -> {r['final']}"); problems += 1
        elif hops:
            print(f"REDIR {hops[0][0]}  {r['url']} -> {r['final']}"); problems += 1
    print(f"checked {len(urls)} in {time.time()-t0:.1f}s, {problems} problems")
    return 1 if problems else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1]))
Enter fullscreen mode Exit fullscreen mode

Run it with python sitemap_check.py https://example.com/sitemap.xml.

What the results usually show

On real sites, the first run tends to surface the same categories:

  • Single redirects. By far the most common. http:// URLs in an otherwise HTTPS sitemap, missing or extra trailing slashes, and old slugs. These aren't broken for users, but a sitemap that lists redirecting URLs is telling crawlers the wrong canonical. Fix the generator, not the redirect.
  • Chains. Usually http://example.com/old to https://example.com/old to https://example.com/new/. Each migration added a hop. Point the first rule straight at the final URL.
  • 404s for deleted content. The generator is reading from a cache, a search index or a table that doesn't respect the "deleted" flag.
  • Timeouts on a handful of heavy pages. Often the slowest pages on the site, and worth knowing about regardless of the sitemap.

Putting it in CI

Run it after deploy against production or a preview environment, not before, because you're testing what's actually served:

sitemap-check:
  runs-on: ubuntu-latest
  needs: deploy
  steps:
    - uses: actions/checkout@v4
    - run: pip install requests
    - run: python scripts/sitemap_check.py https://example.com/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

If your sitemap is large, check a random sample on every deploy and the whole thing nightly. If single redirects are noisy at first, treat them as warnings for a week while you fix the generator, then make them fail the build.

Limits of this approach

A sitemap check only sees URLs you've listed. It won't find broken links inside your pages, links to pages missing from the sitemap, or broken outbound links. It's one useful slice. For the rest you still need a crawl or a dedicated tool; checking the links inside pages, and the external ones, is the part linkbruno.com focuses on.

Related reading

Have you ever run a check like this against your own sitemap? What share of the URLs redirected on the first run?

Top comments (1)

Collapse
 
acemeridian profile image
Ace Meridian •

timeout=10 in check() bounds each socket read, not the URL. A server that sends its headers slowly gets reported as healthy, and one that never finishes them can hang the whole run.

I ran your check() unchanged (requests 2.34.2, urllib3 2.6.3, Python 3.14) in an 8-worker thread pool, like main() does, against a local server with four paths:

  • /ok: 200 at 0.0 s.
  • /silent (reads the request, sends nothing): timeout at 10.0 s, as intended.
  • /slowheaders (status line, then one header byte every 3 s for 12 bytes): reported as 200 at 36.0 s. Each byte restarts the 10 s read timer, so a URL that took 36 s shows up as fine.
  • /tarpit (one header byte every 3 s, without end): still blocked when my 50 s cap killed the process. With list(pool.map(check, urls)), no result line is printed until every URL returns, so CI hits its job-level timeout and you get no report saying which URL hung.

Only the header phase matters here. With stream=True and r.close() your check never reads the body, so a slow body can't hold it.

requests has no total timeout, and a timeout on the future wouldn't help: the with ThreadPoolExecutor block still waits for the stuck thread when it exits. So I moved the fetch to httpx 0.28.1 and gave each URL a wall-clock deadline:

async def check_one(client, url, deadline=15):
    async with asyncio.timeout(deadline):
        async with client.stream("GET", url) as r:
            return r.status_code

async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
    ...
Enter fullscreen mode Exit fullscreen mode

Results on the same four paths: /ok returned 200, /silent raised ReadTimeout at 10.1 s, /slowheaders and /tarpit both raised TimeoutError at 15.1 s, and the run finished at 15.1 s. Your manual hop loop carries over unchanged, since follow_redirects=False keeps every hop visible.

Do you also put a job-level timeout on the CI step, or is the per-request limit the only bound right now?