Hreflang and International SEO skill for Codex
The right country version in every market. Codex plans the locale set before writing a tag: URL structure, language versus language-plus-region codes, a generic fallback and x-default. It builds a page-equivalence map, generates hreflang from it in one method (head, sitemap or Link header), keeps every locale self-canonical, and runs a bundled checker that catches bad codes, missing return links and split clusters.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- hreflang-international-seo Installs to ~/.agents/skills/hreflang-international-seo/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install hreflang-international-seo The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project hreflang-international-seo Installs to .agents/skills/hreflang-international-seo/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/hreflang-international-seo.zipunzip -o hreflang-international-seo.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) hreflang-international-seo PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $hreflang-international-seo, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Plans and implements international SEO - choosing ccTLDs, subfolders or subdomains, defining the locale set, and writing hreflang annotations in HTML head, HTTP Link headers or XML sitemaps with x-default, self-references, return links and matching canonicals - then validates every cluster with a bundled checker. Use when launching a site in new countries or languages, adding hreflang, fixing wrong-country rankings or missing return links, or reviewing an i18n routing change.
Use it when
- A site is adding languages or country versions
- The wrong country or language version ranks in a market
- A crawler reports missing return links or invalid hreflang codes
- An i18n routing, CMS market or sitemap change needs review
Use something else when
- Locale URLs are changing and need a redirect map: use seo-migrations
- You are ranking one business in local map results: use local-seo
- The issue is Next.js metadata in general rather than locales: use nextjs-seo
How to check the result
hreflang_check.py exits 0 on the built sitemaps or staging crawl, and URL Inspection shows each locale indexed with its own URL as the Google-selected canonical.
The mistake it prevents
Canonicalizing every translation to the English page, which tells Google to drop the localized URLs and voids their hreflang.
Done well means: every localized page is crawlable at its own stable URL, self-canonical, and listed in one hreflang cluster whose members all declare the identical set of alternates, including themselves and an x-default. Searchers in each market land on their version, and no locale is lost to duplicate clustering or a geo-redirect.
Before changing anything
- Read
AGENTS.mdfor the framework, build and deploy commands. - Find where locales are defined: i18n config (
next.config.*,astro.config.*,nuxt.config.*, Rails/Django locale settings), routing middleware, CMS market settings. - Find where
<head>is rendered and whether hreflang already exists. Platforms often emit it on their own (Shopify Markets, WPML, Polylang, Yoast, headless CMS plugins). Two generators produce two conflicting sets; keep exactly one source of truth. - Check sitemaps (
public/sitemap*.xml, generatedsitemap.ts, CDN or plugin output) forxhtml:linkentries. - Check edge and middleware redirects: geo-IP,
Accept-Languageor cookie-based redirects decide what Googlebot can reach. - Ask for the business intent if unclear: which countries are sold to, which languages are actually translated, whether prices, currency, shipping or legal terms differ by country.
Workflow
- [ ] Inventory locales and the markets they serve (language only vs language+region)
- [ ] Confirm or choose URL structure (ccTLD / subfolder / subdomain)
- [ ] Define the code for each version and the x-default target
- [ ] Map equivalent pages across locales (one row per page, one column per locale)
- [ ] Pick one annotation method and implement it from that map
- [ ] Make each localized page self-canonical; remove cross-locale canonicals
- [ ] Remove or soften geo/language auto-redirects
- [ ] Run scripts/hreflang_check.py on built output or staging; fix until clean
- [ ] After launch: URL Inspection per locale, Page indexing, country performance
Choose the URL structure
| Option | Use when | Costs |
|---|---|---|
Subfolders example.com/de/ | Default. One domain, shared authority, simplest ops | Weaker country signal than a ccTLD; needs hreflang and local content to target |
ccTLDs example.de | Separate legal entities, strong local brand, country-specific catalogue | Each domain builds authority alone; more domains, certs, Search Console properties |
Subdomains de.example.com | Infrastructure forces separate hosting per market | Authority sharing is less certain than subfolders; no targeting advantage |
URL parameters ?lang=de | Avoid for new builds | Easy to drop in links and canonicals; harder to crawl and report on |
| Same URL, content by cookie, IP or header | Never for indexable content | Google crawls mostly from US IPs without cookies; other versions are invisible |
Rules:
- Search Console no longer offers a country-targeting setting (retired in 2022), so a gTLD subfolder cannot be geotargeted by configuration; hreflang plus genuinely local content (currency, prices, addresses, phone numbers, shipping terms) carry the signal.
- Generic ccTLDs such as
.io,.co,.meand.tvare treated as generic, not as country domains. - Never change structure without a migration plan: changing URLs is a site migration.
Define the locale set and codes
Format: language[-Script][-REGION]. Language is ISO 639-1, region is ISO 3166-1
alpha-2, script is ISO 15924. Values are case-insensitive; write en-GB by convention.
| Value | Status |
|---|---|
de, en-GB, fr-CA, pt-BR, zh-Hant, zh-Hans-SG | Valid |
x-default | Valid; the fallback for unmatched users |
en-UK | Invalid: the country is GB |
en-EU, es-419, en-UN | Invalid: reserved or UN M.49 codes are ignored; list the countries |
gb, us, ca alone | Invalid or wrong: a value must start with a language (ca is Catalan) |
be meaning Belgium | Wrong: be is Belarusian; use nl-BE, fr-BE, de-BE |
en_US | Invalid: hyphen, not underscore |
iw, in, ji | Deprecated; use he, id, yi |
Decisions:
- Language only (
de) when one translation serves every country that speaks it. - Language + region only when content differs by country: prices, currency, availability, spelling, legal terms. A region code on identical content adds nothing.
- Add a generic fallback when a language has regional variants:
en-GB+en-US+en(pointing at the variant best for everyone else). Without it, an Australian English searcher gets whichever version Google picks. - x-default points at a language selector or the version you want for everyone unmatched, usually the global English page. One x-default per cluster.
- Several codes may share one URL (
enandx-defaulton the same page is normal). One code must never point at two URLs within a cluster.
Map equivalents before writing tags
hreflang links equivalent pages, not sections. Build the map as data, then generate tags:
key,en,de,fr-CA,x-default
pricing,https://example.com/pricing,https://example.com/de/preise,https://example.com/fr-ca/tarifs,https://example.com/pricing
blog/launch,https://example.com/blog/launch,https://example.com/de/blog/start,,https://example.com/blog/launch
- A blank cell means no equivalent: omit that locale for that page. Never point at the other locale’s homepage as a stand-in.
- Only include URLs that return 200, are indexable and self-canonical. No redirects,
4xx,
noindexor parameter variants. - Use the exact final URL form: protocol, host, trailing slash and case must match the
canonical.
https://example.com/deandhttps://example.com/de/are different URLs.
Implement with one method
All three are equivalent for Google. Pick one per page type; mixing gives no benefit and makes conflicts likely.
| Method | Use when |
|---|---|
HTML <link> in <head> | Default for HTML pages; under roughly 20 locales |
XML sitemap xhtml:link | Many locales (head bloat), or templates you cannot edit |
HTTP Link header | Non-HTML files such as PDFs |
HTML, emitted identically on every member of the cluster:
<link rel="canonical" href="https://example.com/de/preise">
<link rel="alternate" hreflang="en" href="https://example.com/pricing">
<link rel="alternate" hreflang="de" href="https://example.com/de/preise">
<link rel="alternate" hreflang="fr-CA" href="https://example.com/fr-ca/tarifs">
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing">
Sitemap: one <url> per page, each listing the full set including itself:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/pricing</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/pricing"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/preise"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/pricing"/>
</url>
<url>
<loc>https://example.com/de/preise</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/pricing"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/preise"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/pricing"/>
</url>
</urlset>
Sitemap limits still apply (50,000 URLs and 50 MB uncompressed per file); hreflang inflates file size fast, so split by locale or section and list the files in a sitemap index. Cross-domain clusters (ccTLDs) are fine; to list another host’s URLs in one sitemap, verify both hosts in Search Console.
HTTP header for a PDF:
Link: <https://example.com/guide.pdf>; rel="alternate"; hreflang="en",
<https://example.com/de/leitfaden.pdf>; rel="alternate"; hreflang="de"
Framework notes:
- Next.js App Router: set
metadataBase, thenalternates: { canonical, languages: { 'en': '/pricing', 'de': '/de/preise', 'x-default': '/pricing' } }ingenerateMetadata;sitemap.tsentries acceptalternates.languagestoo. For the rest of Next.js metadata, use the nextjs-seo skill. - Generate tags from the route map, never from “the current path with the prefix
swapped”: translated slugs (
/preisevs/pricing) break that shortcut. - Render hreflang server-side. Tags injected by client JavaScript depend on rendering and are missed by most validators.
Canonical interplay
- Every localized page canonicalizes to itself. A German page canonical to the English page tells Google to drop the German URL, and its hreflang is then ignored.
- hreflang targets must be the canonical URLs. A target that canonicalizes elsewhere, redirects or is noindexed breaks the pair.
- Translated pages are not duplicates. Same-language regional pages with near-identical
content (
en-GBvsen-USwith only a currency symbol changed) may be clustered by Google, which picks one canonical. Make them differ where it matters to the user: prices, currency, spelling, shipping, contact details, legal copy. - Paginated and filtered pages: hreflang only between true equivalents (page 2 to page 2), each with its own self-canonical.
Redirects and language detection
- Do not auto-redirect by IP or
Accept-Languageon content URLs. Show a dismissible banner suggesting the better locale and remember the choice in a cookie. - If the root
/must route users, make it a selector or a 302 redirect and setx-defaultto/. Every locale URL must stay reachable without the redirect firing. - Google detects language from visible content, not from
hreflangor<html lang>. Translate the whole page: title, meta description, navigation, footer, alt text, structured data. Half-translated templates send mixed signals. - Still set
<html lang="de">correctly; it matters for accessibility, and Bing leans onlangand thecontent-languagemeta more than on hreflang. - Link between locales with plain
<a href>links (a language switcher) so crawlers discover every version.
Verification
Run the bundled checker (standard library only; paths relative to this skill folder):
# Built or exported sitemaps (works offline)
python3 scripts/hreflang_check.py dist/sitemap-0.xml dist/sitemap-1.xml
# Rendered HTML files; the page URL comes from rel=canonical or file@URL
python3 scripts/hreflang_check.py out/de/index.html "out/index.html@https://example.com/"
# Crawler export or the route map as CSV with columns page,hreflang,href
python3 scripts/hreflang_check.py --csv hreflang-export.csv
# Live staging or production; needs network, so ask for approval in a sandbox
python3 scripts/hreflang_check.py https://staging.example.com/de/ --follow --max-fetch 500
It reports invalid codes, relative URLs, missing self-references, missing or mismatched
return links (with near misses such as a trailing slash), one code pointing to two URLs,
hreflang on non-canonical pages, alternates that redirect or fail (live mode), links
placed after an element that implicitly closes <head>, clusters whose members disagree,
and clusters without x-default. Exit code 1 means errors; add --strict to fail on
warnings, --json for machine output. Fix and rerun until it exits 0.
Spot checks:
curl -s https://example.com/de/preise | grep -io '<link[^>]*hreflang[^>]*>'
curl -sI https://example.com/guide.pdf | grep -i '^link:'
After launch, in Search Console:
- URL Inspection on one page per locale: Google-selected canonical equals the user-declared one, and the page is indexed.
- Page indexing report: watch “Duplicate, Google chose different canonical than user” for regional variants.
- Performance report filtered by page prefix and country: each market should show its
own locale’s URLs. A German-language URL ranking in Austria for a
de-ATmarket you built means the pair is broken or the pages are too similar.
Search Console symptoms
The International Targeting report that listed “no return tags” and “unknown language code” was retired in 2022. Find those errors with the checker or a crawler with hreflang reporting, and read these symptoms instead:
| Symptom | Likely cause | Check |
|---|---|---|
| Wrong country version ranks | Missing return link, wrong region code, near-duplicate content | Checker on that cluster; compare page content |
| Locale URL “Duplicate, Google chose different canonical” | Cross-locale canonical, or same-language variants too similar | Canonical tags; differentiate content |
| Locale URLs “Discovered, currently not indexed” | No crawl path: geo-redirect, no switcher links, missing from sitemap | Fetch as US visitor without cookies |
| Annotations seem ignored | Tags after <head> closed early, relative URLs, JS-only injection | View raw HTML; checker HTML mode |
| Rankings dropped after i18n launch | URLs changed without redirects | Treat as a migration; use seo-migrations |
Gotchas
- An
<img>,<iframe>,<div>or stray text inside<head>(often from a tag manager or consent snippet) ends the head in the parser; every hreflang link after it is ignored. Put hreflang directly after the canonical, early in<head>. - Return links must use the exact URL form of the target’s own self-reference.
- Updating one locale’s template but not the others creates partial clusters. Generate every member from the same map.
- Adding a locale means adding it to every existing member, not just the new pages.
- Hreflang to a staging host, to
http://, or to URLs with tracking parameters. - CDN or middleware adding a locale prefix redirect (
/pricingto/en/pricing) after the tags were written against unprefixed URLs. - Don’t combine hreflang with
mediaattributes in the same<link>.
Output contract
When reporting an audit or implementation, use:
## International SEO: <site>
Structure: <subfolders | ccTLDs | subdomains> Method: <HTML | sitemap | Link header>
Locales: <code -> market, x-default target>
### Findings (checker: N errors, N warnings)
| Severity | Page / cluster | Issue | Fix |
### Changes made
- <file>: <what changed>
### Verification
- hreflang_check.py result, URL Inspection results per locale, open items
Changes to production redirects, CDN rules or live sitemaps go out only after the user confirms the deploy.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Hreflang and International SEO"
short_description: "The right country version in every market"
default_prompt: "$hreflang-international-seo We're launching German and French versions under /de and /fr. Add hreflang and tell me what else needs to change" scripts/hreflang_check.py
#!/usr/bin/env python3
"""Validate hreflang clusters from XML sitemaps, HTML files, a CSV export or live URLs.
Checks: language/region/script codes, fully qualified URLs, self-reference, return
links, conflicting codes, x-default, hreflang on non-canonical pages, cluster
consistency and (live mode) alternates that redirect or do not return 200.
Usage:
python3 hreflang_check.py sitemap.xml [more.xml | sitemap.xml.gz ...]
python3 hreflang_check.py page.html [other.html@https://example.com/de/ ...]
python3 hreflang_check.py --csv annotations.csv # columns: page,hreflang,href
python3 hreflang_check.py https://example.com/ --follow # live; needs network
Options: --json, --strict (warnings also fail), --follow, --max-fetch N, --limit N
Exit codes: 0 clean, 1 errors (or warnings with --strict), 2 bad input.
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import csv
import gzip
import json
import re
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit
ISO_639_1 = set("""
aa ab ae af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu
cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi
ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks
ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl
nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl
sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve
vi vo wa wo xh yi yo za zh zu
""".split())
ISO_3166_1 = set("""
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO
BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ
DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP
GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG
KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML
MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE
PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL
SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM
US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
""".split())
REGION_FIXES = {"UK": "use GB", "EU": "list each country", "UN": "list each country",
"419": "list each country (UN M.49 codes are not supported)"}
DEPRECATED_LANG = {"iw": "he", "in": "id", "ji": "yi"}
HEAD_OK = {"title", "base", "link", "meta", "style", "script", "noscript", "template"}
SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
XHTML_NS = "{http://www.w3.org/1999/xhtml}"
def norm(url: str) -> str:
"""Lowercase scheme and host, drop fragment; keep path and query exactly."""
p = urlsplit(url.strip())
return urlunsplit((p.scheme.lower(), p.netloc.lower(), p.path, p.query, ""))
def loose(url: str) -> str:
"""Key used only to explain near misses (protocol, www, trailing slash)."""
p = urlsplit(url)
host = p.netloc.lower().removeprefix("www.")
return host + (p.path.rstrip("/") or "/") + ("?" + p.query if p.query else "")
def check_code(code: str) -> tuple[str, str] | None:
"""Return (severity, message) for a bad hreflang value, else None."""
if code.lower() == "x-default":
return None
if "_" in code:
return ("error", f"'{code}' uses an underscore; use a hyphen ({code.replace('_', '-')})")
parts = code.split("-")
lang = parts[0].lower()
if lang.upper() in ISO_3166_1 and lang not in ISO_639_1 and len(parts) == 1:
return ("error", f"'{code}' looks like a region alone; hreflang must start with a language")
if lang in DEPRECATED_LANG:
return ("warn", f"'{code}' uses deprecated language code '{lang}'; use '{DEPRECATED_LANG[lang]}'")
if lang not in ISO_639_1:
return ("error", f"'{code}': '{lang}' is not an ISO 639-1 language code")
rest = parts[1:]
if rest and len(rest[0]) == 4 and rest[0].isalpha():
rest = rest[1:] # ISO 15924 script, e.g. zh-Hant
if len(rest) > 1:
return ("error", f"'{code}' has too many subtags (language[-Script][-REGION])")
if rest:
region = rest[0].upper()
if region in REGION_FIXES:
return ("error", f"'{code}': region '{rest[0]}' is not supported; {REGION_FIXES[region]}")
if region not in ISO_3166_1:
return ("error", f"'{code}': '{rest[0]}' is not an ISO 3166-1 alpha-2 region")
if len(parts) == 1 and lang in {"be"}:
return ("warn", f"'{code}' is Belarusian; for Belgium use nl-BE, fr-BE or de-BE")
return None
class HeadParser(HTMLParser):
"""Collect hreflang links and canonical, noting links after <head> implicitly closed."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.alts: list[tuple[str, str]] = []
self.late: list[str] = []
self.canonical: str | None = None
self.in_head = True
self.head_broken_by: str | None = None
self.done = False
self.raw: str | None = None # inside title/script/style etc.
def handle_starttag(self, tag, attrs):
if self.done:
return
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "body":
self.done = True
return
if tag in {"html", "head"}:
return
if self.in_head and tag not in HEAD_OK:
self.in_head = False
self.head_broken_by = f"<{tag}>"
if tag in HEAD_OK - {"link", "meta", "base"}:
self.raw = tag
if tag == "link":
rel = a.get("rel", "").lower().split()
if "alternate" in rel and "hreflang" in a:
if self.in_head:
self.alts.append((a["hreflang"].strip(), a.get("href", "").strip()))
else:
self.late.append(a["hreflang"].strip())
elif "canonical" in rel and self.canonical is None:
self.canonical = a.get("href", "").strip()
def handle_endtag(self, tag):
if tag == self.raw:
self.raw = None
if tag == "head":
self.done = True
def handle_data(self, data):
if not self.done and self.in_head and self.raw is None and data.strip():
self.in_head = False
self.head_broken_by = "text content"
def parse_link_header(value: str) -> list[tuple[str, str]]:
out = []
for m in re.finditer(r'<([^>]+)>\s*((?:;\s*[^;,]+)*)', value or ""):
params = dict((k.strip().lower(), v.strip().strip('"'))
for k, _, v in (p.partition("=") for p in m.group(2).split(";") if "=" in p))
if params.get("rel", "").lower() == "alternate" and "hreflang" in params:
out.append((params["hreflang"], m.group(1)))
return out
class Store:
def __init__(self) -> None:
self.pages: dict[str, dict] = {}
self.issues: list[dict] = []
def page(self, url: str, source: str) -> dict:
return self.pages.setdefault(norm(url), {"url": url, "source": source, "alts": [],
"canonical": None, "status": None, "final": None})
def add(self, sev: str, url: str, msg: str) -> None:
self.issues.append({"severity": sev, "page": url, "message": msg})
def load_sitemap(path: str, st: Store) -> None:
raw = gzip.open(path).read() if path.endswith(".gz") else open(path, "rb").read()
root = ET.fromstring(raw)
if root.tag == SITEMAP_NS + "sitemapindex":
raise SystemExit(f"{path} is a sitemap index; pass the child sitemaps instead")
for u in root.iter(SITEMAP_NS + "url"):
loc = (u.findtext(SITEMAP_NS + "loc") or "").strip()
if not loc:
continue
p = st.page(loc, path)
for link in u.findall(XHTML_NS + "link"):
if link.get("rel") == "alternate" and link.get("hreflang"):
p["alts"].append((link.get("hreflang").strip(), (link.get("href") or "").strip()))
def load_html(arg: str, st: Store) -> None:
path, _, url = arg.partition("@")
hp = HeadParser()
hp.feed(open(path, encoding="utf-8", errors="replace").read())
url = url or hp.canonical
if not url:
st.add("error", path, "no canonical found; pass the page URL as file.html@https://...")
return
p = st.page(url, path)
p["alts"] += hp.alts
p["canonical"] = hp.canonical
if hp.late:
st.add("error", url, f"{len(hp.late)} hreflang link(s) appear after {hp.head_broken_by} "
"closed <head>; Google ignores them")
def load_csv(path: str, st: Store) -> None:
with open(path, newline="", encoding="utf-8-sig") as fh:
for row in csv.DictReader(fh):
row = {k.strip().lower(): (v or "").strip() for k, v in row.items() if k}
if row.get("page"):
st.page(row["page"], path)["alts"].append((row.get("hreflang", ""), row.get("href", "")))
def fetch(url: str, st: Store) -> None:
p = st.page(url, "live")
req = urllib.request.Request(url, headers={"User-Agent": "hreflang-check/1.0"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
p["status"], p["final"] = resp.status, resp.geturl()
p["alts"] += parse_link_header(resp.headers.get("Link", ""))
ctype = resp.headers.get("Content-Type", "")
body = resp.read(2_000_000).decode("utf-8", "replace") if "html" in ctype else ""
except urllib.error.HTTPError as exc:
p["status"], p["final"] = exc.code, url
return
except (urllib.error.URLError, TimeoutError, OSError) as exc:
st.add("error", url, f"fetch failed: {exc}")
p["status"] = 0
return
if body:
hp = HeadParser()
hp.feed(body)
p["alts"] += [(c, urljoin(url, h) if h and not urlsplit(h).scheme else h) for c, h in hp.alts]
if any(not urlsplit(h).scheme for _, h in hp.alts):
st.add("error", url, "relative hreflang href(s) in HTML; use fully qualified URLs")
p["canonical"] = urljoin(url, hp.canonical) if hp.canonical else None
if hp.late:
st.add("error", url, f"{len(hp.late)} hreflang link(s) appear after {hp.head_broken_by} "
"closed <head>; Google ignores them")
def analyse(st: Store, limit: int) -> None:
unverified: list[str] = []
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for key, p in st.pages.items():
url, alts = p["url"], p["alts"]
if not alts:
if p["source"] == "live" and p["status"] not in (0, 200):
st.add("error", url, f"returned HTTP {p['status']}")
elif p["source"] == "live" and p["status"] == 200:
st.add("warn", url, "no hreflang found in <head> or the Link header")
continue
if p["status"] not in (None, 200):
st.add("error", url, f"page returns HTTP {p['status']}")
if p["final"] and norm(p["final"]) != key:
st.add("error", url, f"redirects to {p['final']}; hreflang must use final URLs")
if p["canonical"] and norm(p["canonical"]) != key:
st.add("error", url, f"carries hreflang but canonicalizes to {p['canonical']}")
by_code: dict[str, set[str]] = {}
for code, href in alts:
bad = check_code(code)
if bad:
st.add(bad[0], url, bad[1])
if not href or not urlsplit(href).scheme or not urlsplit(href).netloc:
st.add("error", url, f"hreflang '{code}' href '{href}' is not a fully qualified URL")
continue
by_code.setdefault(code.lower(), set()).add(norm(href))
find(key), find(norm(href))
parent[find(norm(href))] = find(key)
for code, hrefs in by_code.items():
if len(hrefs) > 1:
st.add("error", url, f"hreflang '{code}' points to {len(hrefs)} different URLs: "
+ ", ".join(sorted(hrefs)))
targets = {h for hs in by_code.values() for h in hs}
if key not in targets:
near = [t for t in targets if loose(t) == loose(key)]
hint = f" (near miss: {near[0]})" if near else ""
st.add("error", url, "missing self-referencing hreflang" + hint)
for code, hrefs in by_code.items():
for t in hrefs:
if t == key:
continue
other = st.pages.get(t)
if other is not None and (other["status"] not in (None, 200)
or (other["final"] and norm(other["final"]) != t)):
st.add("error", url, f"'{code}' -> {t} is not a 200 final URL")
continue
if other is None or not other["alts"]:
if other is None:
unverified.append(t)
else:
st.add("error", url, f"'{code}' -> {t} has no hreflang annotations (no return link)")
continue
back = {norm(h): c.lower() for c, h in other["alts"] if h}
if key not in back:
near = [b for b in back if loose(b) == loose(key)]
hint = f"; it links to {near[0]} instead" if near else ""
st.add("error", url, f"no return link from {t} ('{code}'){hint}")
if code != "x-default":
own = {c.lower() for c, h in other["alts"] if h and norm(h) == t} - {"x-default"}
if own and code not in own:
st.add("warn", url, f"labels {t} as '{code}' but that page labels itself "
f"{', '.join(sorted(own))}")
if other["canonical"] and norm(other["canonical"]) != t:
st.add("error", url, f"'{code}' -> {t}, which canonicalizes to {other['canonical']}")
clusters: dict[str, list[str]] = {}
for key, p in st.pages.items():
if p["alts"]:
clusters.setdefault(find(key), []).append(key)
for members in clusters.values():
sets = {m: frozenset((c.lower(), norm(h)) for c, h in st.pages[m]["alts"] if h) for m in members}
if len(set(sets.values())) > 1:
st.add("warn", st.pages[members[0]]["url"],
f"cluster of {len(members)} pages declares {len(set(sets.values()))} different "
"hreflang sets; every member should list the identical set")
codes = {c for s in sets.values() for c, _ in s}
if "x-default" not in codes:
st.add("warn", st.pages[members[0]]["url"], "cluster has no x-default annotation")
langs = {c.split("-")[0] for c in codes if "-" in c}
if "x-default" not in codes:
for lang in sorted(langs):
if lang not in codes:
st.add("info", st.pages[members[0]]["url"],
f"'{lang}' has only regional variants and no '{lang}' or x-default fallback")
if unverified:
uniq = sorted(set(unverified))
st.add("warn", "(input)", f"{len(uniq)} alternate URL(s) not in the input, return links unverified: "
+ ", ".join(uniq[:limit]) + (" ..." if len(uniq) > limit else ""))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("inputs", nargs="*", help="sitemap .xml/.xml.gz, .html files, or http(s) URLs")
ap.add_argument("--csv", action="append", default=[], help="CSV with page,hreflang,href columns")
ap.add_argument("--follow", action="store_true", help="live mode: also fetch every alternate URL")
ap.add_argument("--max-fetch", type=int, default=200)
ap.add_argument("--limit", type=int, default=20, help="max URLs listed per aggregated message")
ap.add_argument("--json", action="store_true")
ap.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
args = ap.parse_args()
if not args.inputs and not args.csv:
ap.print_usage(sys.stderr)
return 2
st = Store()
live: list[str] = []
try:
for item in args.inputs:
if re.match(r"https?://", item):
live.append(item)
elif re.search(r"\.html?(@|$)", item):
load_html(item, st)
else:
load_sitemap(item, st)
for path in args.csv:
load_csv(path, st)
except (OSError, ET.ParseError, csv.Error) as exc:
print(f"input error: {exc}", file=sys.stderr)
return 2
fetched: set[str] = set()
while live and len(fetched) < args.max_fetch:
url = live.pop(0)
if norm(url) in fetched:
continue
fetched.add(norm(url))
fetch(url, st)
if args.follow:
live += [h for _, h in st.pages[norm(url)]["alts"] if h and norm(h) not in fetched]
if live and args.follow:
st.add("warn", "(input)", f"stopped after --max-fetch {args.max_fetch}; {len(live)} URL(s) not fetched")
analyse(st, args.limit)
order = {"error": 0, "warn": 1, "info": 2}
st.issues.sort(key=lambda i: (order[i["severity"]], i["page"]))
errors = sum(i["severity"] == "error" for i in st.issues)
warns = sum(i["severity"] == "warn" for i in st.issues)
annotated = sum(1 for p in st.pages.values() if p["alts"])
if args.json:
print(json.dumps({"pages": len(st.pages), "annotated": annotated, "errors": errors,
"warnings": warns, "issues": st.issues}, indent=2))
else:
for i in st.issues:
print(f"{i['severity'].upper():5} {i['page']}: {i['message']}")
print(f"\n{annotated} annotated page(s), {errors} error(s), {warns} warning(s)")
return 1 if errors or (args.strict and warns) else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.