Localized pages can answer the same search intent for different language or regional audiences, but each version needs a reciprocal map to the others. A missing return link can cause search engines to ignore part of that map even when the tags on the first page look complete.
The HTML method places rel=“alternate” links inside the document head. Every localized page should list itself and the same fully qualified alternate URLs; the Python checker starts from one page, follows every valid head link, and compares the live sets without requiring a third-party package.
Matching reciprocal sets do not prove that an unusual language code is assigned, that Google selected each intended canonical, or that every page is indexable. Locale values still need to match current search-engine guidance, x-default needs a real fallback destination, and sitemap or HTTP-header implementations need their own audit when HTML tags are not the maintained source.
Steps to check hreflang tags on live HTML pages:
- Create hreflang_check.py with the HTML parser and HTTPS fetch layer.
from html.parser import HTMLParser from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen import sys class HreflangParser(HTMLParser): def __init__(self): super().__init__() self.in_head = False self.alternates = [] self.misplaced_alternates = [] def handle_starttag(self, tag, attrs): tag = tag.lower() if tag == "head": self.in_head = True return if tag == "body": self.in_head = False values = {name.lower(): value for name, value in attrs if value is not None} rel = values.get("rel", "").lower().split() if tag == "link" and "alternate" in rel: alternate = (values.get("hreflang"), values.get("href")) if self.in_head: self.alternates.append(alternate) else: self.misplaced_alternates.append(alternate) def handle_endtag(self, tag): if tag.lower() == "head": self.in_head = False def fetch_alternates(url): request = Request(url, headers={"User-Agent": "hreflang-check/1.0"}) with urlopen(request, timeout=15) as response: if response.headers.get_content_type() != "text/html": raise ValueError(f"{url} did not return HTML") charset = response.headers.get_content_charset() or "utf-8" markup = response.read().decode(charset, errors="replace") final_url = response.geturl() parser = HreflangParser() parser.feed(markup) return final_url, parser.alternates, parser.misplaced_alternates
The parser reads server-returned HTML rather than a browser-modified DOM. It records hreflang links outside the document head as errors and follows normal HTTPS verification and redirects. Redirected alternate URLs appear as errors in the page result.
- Append the per-page inspection function to hreflang_check.py.
def inspect_page(url): final_url, alternates, misplaced_alternates = fetch_alternates(url) mapping = {} problems = [] if final_url != url: problems.append(f"{url} redirects to {final_url}") for locale, href in misplaced_alternates: problems.append(f"{url} has hreflang={locale} outside <head>: {href or '[missing href]'}") for locale, href in alternates: if not locale or not href: problems.append(f"{url} has an incomplete alternate link") continue parsed = urlparse(href) if parsed.scheme not in {"http", "https"} or not parsed.netloc: problems.append(f"{url} uses a non-absolute URL for {locale}: {href}") continue locale = locale.lower() if locale in mapping: problems.append(f"{url} repeats hreflang={locale}") continue mapping[locale] = href if final_url not in mapping.values(): problems.append(f"{url} has no self-reference") return final_url, mapping, problems
A self-reference means the requested page's final URL appears in its own alternate set. An alternate URL that redirects away from its declaration indicates a stale cluster entry.
- Append the reciprocal cluster comparison to hreflang_check.py.
def check_cluster(seed_url): _, expected, problems = inspect_page(seed_url) if not expected: problems.append(f"{seed_url} has no hreflang links") return problems for page_url in sorted(set(expected.values())): try: _, actual, page_problems = inspect_page(page_url) except (HTTPError, URLError, ValueError) as error: problems.append(f"{page_url} could not be checked: {error}") continue problems.extend(page_problems) missing = set(expected.items()) - set(actual.items()) extra = set(actual.items()) - set(expected.items()) for locale, href in sorted(missing): problems.append(f"{page_url} is missing {locale} -> {href}") for locale, href in sorted(extra): problems.append(f"{page_url} has unexpected {locale} -> {href}") if not page_problems and not missing and not extra: locales = ", ".join(sorted(actual)) print(f"PASS {page_url} [{locales}]") return problems
The seed page defines the expected locale-to-URL map. Every unique destination must return the same map, including its own URL, for the cluster-wide result to pass.
- Append the command-line result handling to hreflang_check.py.
def main(): if len(sys.argv) != 2: print("Usage: python3 hreflang_check.py https://www.example.net/page/") return 2 try: problems = check_cluster(sys.argv[1]) except (HTTPError, URLError, ValueError) as error: print(f"FAIL: seed URL could not be checked: {error}") return 1 if problems: print(f"FAIL: {len(problems)} problem(s) found") for problem in problems: print(f"- {problem}") return 1 print("PASS: every page exposes the same reciprocal hreflang cluster in <head>") return 0 if __name__ == "__main__": raise SystemExit(main())
The script exits with status 1 for an unreachable page, redirect, hreflang link outside the document head, relative alternate URL, duplicate locale, missing self-reference, or mismatched return set. Status 0 requires every fetched HTML page to expose the same reciprocal cluster inside its head.
- Compare hreflang_check.py with the consolidated source.
- hreflang_check.py
from html.parser import HTMLParser from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen import sys class HreflangParser(HTMLParser): def __init__(self): super().__init__() self.in_head = False self.alternates = [] self.misplaced_alternates = [] def handle_starttag(self, tag, attrs): tag = tag.lower() if tag == "head": self.in_head = True return if tag == "body": self.in_head = False values = {name.lower(): value for name, value in attrs if value is not None} rel = values.get("rel", "").lower().split() if tag == "link" and "alternate" in rel: alternate = (values.get("hreflang"), values.get("href")) if self.in_head: self.alternates.append(alternate) else: self.misplaced_alternates.append(alternate) def handle_endtag(self, tag): if tag.lower() == "head": self.in_head = False def fetch_alternates(url): request = Request(url, headers={"User-Agent": "hreflang-check/1.0"}) with urlopen(request, timeout=15) as response: if response.headers.get_content_type() != "text/html": raise ValueError(f"{url} did not return HTML") charset = response.headers.get_content_charset() or "utf-8" markup = response.read().decode(charset, errors="replace") final_url = response.geturl() parser = HreflangParser() parser.feed(markup) return final_url, parser.alternates, parser.misplaced_alternates def inspect_page(url): final_url, alternates, misplaced_alternates = fetch_alternates(url) mapping = {} problems = [] if final_url != url: problems.append(f"{url} redirects to {final_url}") for locale, href in misplaced_alternates: problems.append(f"{url} has hreflang={locale} outside <head>: {href or '[missing href]'}") for locale, href in alternates: if not locale or not href: problems.append(f"{url} has an incomplete alternate link") continue parsed = urlparse(href) if parsed.scheme not in {"http", "https"} or not parsed.netloc: problems.append(f"{url} uses a non-absolute URL for {locale}: {href}") continue locale = locale.lower() if locale in mapping: problems.append(f"{url} repeats hreflang={locale}") continue mapping[locale] = href if final_url not in mapping.values(): problems.append(f"{url} has no self-reference") return final_url, mapping, problems def check_cluster(seed_url): _, expected, problems = inspect_page(seed_url) if not expected: problems.append(f"{seed_url} has no hreflang links") return problems for page_url in sorted(set(expected.values())): try: _, actual, page_problems = inspect_page(page_url) except (HTTPError, URLError, ValueError) as error: problems.append(f"{page_url} could not be checked: {error}") continue problems.extend(page_problems) missing = set(expected.items()) - set(actual.items()) extra = set(actual.items()) - set(expected.items()) for locale, href in sorted(missing): problems.append(f"{page_url} is missing {locale} -> {href}") for locale, href in sorted(extra): problems.append(f"{page_url} has unexpected {locale} -> {href}") if not page_problems and not missing and not extra: locales = ", ".join(sorted(actual)) print(f"PASS {page_url} [{locales}]") return problems def main(): if len(sys.argv) != 2: print("Usage: python3 hreflang_check.py https://www.example.net/page/") return 2 try: problems = check_cluster(sys.argv[1]) except (HTTPError, URLError, ValueError) as error: print(f"FAIL: seed URL could not be checked: {error}") return 1 if problems: print(f"FAIL: {len(problems)} problem(s) found") for problem in problems: print(f"- {problem}") return 1 print("PASS: every page exposes the same reciprocal hreflang cluster in <head>") return 0 if __name__ == "__main__": raise SystemExit(main())
- Run the checker against one canonical localized URL to crawl its declared cluster.
$ python3 hreflang_check.py https://www.example.net/en/product/ PASS https://www.example.net/de/produkt/ [de, en, x-default] PASS https://www.example.net/en/product/ [de, en, x-default] PASS https://www.example.net/language/ [de, en, x-default] PASS: every page exposes the same reciprocal hreflang cluster in <head>
A final PASS means every fetched page returned HTML, stayed on its declared URL, kept every hreflang link inside the document head, included a self-reference, and exposed the same locale-to-URL map. A final FAIL lists the exact page and misplaced, missing, unexpected, duplicate, relative, redirected, or unreachable entry and returns status 1.