Checking hreflang Return Links Automatically With a Small Python Crawler
The most common hreflang bug is not a typo in a language code. It is a missing return link: page A says "my German version is B", but page B never points back to A. Search engines may then ignore the pair. On a site with
The most common hreflang bug is not a typo in a language code. It is a missing return link: page A says "my German version is B", but page B never points back to A. Search engines may then ignore the pair. On a site with a few hundred localised pages this is tedious to check by hand, so here is a small script that does it.
What we check
For every page in a list of URLs:
- Fetch the HTML and read all
<link rel="alternate" hreflang="...">tags. - For each alternate, fetch that URL and confirm it lists the original page back.
- Confirm each page includes a self-referencing hreflang entry.
- Flag alternates that return a non-200 status or redirect.
The script
import requests, re
from collections import defaultdict
from html.parser import HTMLParser
class Alt(HTMLParser):
def __init__(self):
super().__init__(); self.alts = {}
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "link" and a.get("rel") == "alternate" and "hreflang" in a:
self.alts[a["hreflang"].lower()] = a.get("href", "").strip()
cache = {}
def alternates(url):
if url in cache: return cache[url]
r = requests.get(url, timeout=20, allow_redirects=False,
headers={"User-Agent": "hreflang-check/1.0"})
p = Alt()
if r.status_code == 200: p.feed(r.text)
cache[url] = (r.status_code, p.alts)
return cache[url]
def check(urls):
problems = defaultdict(list)
for u in urls:
status, alts = alternates(u)
if status != 200:
problems[u].append(f"status {status}"); continue
if u not in alts.values():
problems[u].append("no self-reference")
for lang, target in alts.items():
if target == u: continue
t_status, t_alts = alternates(target)
if t_status != 200:
problems[u].append(f"{lang} -> {target} returns {t_status}")
elif u not in t_alts.values():
problems[u].append(f"{lang} -> {target} has no return link")
return problems
if __name__ == "__main__":
urls = [l.strip() for l in open("urls.txt") if l.strip()]
for page, issues in check(urls).items():
for i in issues: print(page, "|", i)
Put one URL per line in urls.txt, ideally the canonical URLs from your sitemap, and run it.
Notes from using it
-
Trailing slashes matter.
https://example.com/de/andhttps://example.com/deare different strings. Normalise in your CMS rather than in the checker, so the check reflects what crawlers see. -
Don't follow redirects silently. An alternate that 301s is a real problem worth flagging, which is why
allow_redirects=Falseis set. - x-default should exist once per cluster and point to a sensible fallback, usually a language selector or the main market page.
-
Rate limit if you run this against production: a short
time.sleepbetween requests is polite.
Extending it
The same parser can compare canonical tags with hreflang targets (they should agree), or export a CSV for your content team. For a broader overview of hreflang setup, including URL structure and sitemaps, see this hreflang guide for multilingual websites.
Published by PandaWM, an overseas marketing team based in Hong Kong.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.