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.

  • Growth & SEO
  • $hreflang-international-seo
  • 289 lines
  • 3 files
  • ~3,600 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- hreflang-international-seo

Installs to ~/.agents/skills/hreflang-international-seo/ for every project on this machine.

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.

~/.agents/skills/hreflang-international-seo/SKILL.md

--- name: hreflang-international-seo description: Plans and implements international SEO - choosing ccTLDs, subfolders or subdomains, defining the locale set, and writing hreflang annotations in HTML… license: MIT ---

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

  1. Read AGENTS.md for the framework, build and deploy commands.
  2. Find where locales are defined: i18n config (next.config.*, astro.config.*, nuxt.config.*, Rails/Django locale settings), routing middleware, CMS market settings.
  3. 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.
  4. Check sitemaps (public/sitemap*.xml, generated sitemap.ts, CDN or plugin output) for xhtml:link entries.
  5. Check edge and middleware redirects: geo-IP, Accept-Language or cookie-based redirects decide what Googlebot can reach.
  6. 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

OptionUse whenCosts
Subfolders example.com/de/Default. One domain, shared authority, simplest opsWeaker country signal than a ccTLD; needs hreflang and local content to target
ccTLDs example.deSeparate legal entities, strong local brand, country-specific catalogueEach domain builds authority alone; more domains, certs, Search Console properties
Subdomains de.example.comInfrastructure forces separate hosting per marketAuthority sharing is less certain than subfolders; no targeting advantage
URL parameters ?lang=deAvoid for new buildsEasy to drop in links and canonicals; harder to crawl and report on
Same URL, content by cookie, IP or headerNever for indexable contentGoogle 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, .me and .tv are 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.

ValueStatus
de, en-GB, fr-CA, pt-BR, zh-Hant, zh-Hans-SGValid
x-defaultValid; the fallback for unmatched users
en-UKInvalid: the country is GB
en-EU, es-419, en-UNInvalid: reserved or UN M.49 codes are ignored; list the countries
gb, us, ca aloneInvalid or wrong: a value must start with a language (ca is Catalan)
be meaning BelgiumWrong: be is Belarusian; use nl-BE, fr-BE, de-BE
en_USInvalid: hyphen, not underscore
iw, in, jiDeprecated; 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 (en and x-default on 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, noindex or parameter variants.
  • Use the exact final URL form: protocol, host, trailing slash and case must match the canonical. https://example.com/de and https://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.

MethodUse when
HTML <link> in <head>Default for HTML pages; under roughly 20 locales
XML sitemap xhtml:linkMany locales (head bloat), or templates you cannot edit
HTTP Link headerNon-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, then alternates: { canonical, languages: { 'en': '/pricing', 'de': '/de/preise', 'x-default': '/pricing' } } in generateMetadata; sitemap.ts entries accept alternates.languages too. 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 (/preise vs /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-GB vs en-US with 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-Language on 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 set x-default to /. Every locale URL must stay reachable without the redirect firing.
  • Google detects language from visible content, not from hreflang or <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 on lang and the content-language meta 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-AT market 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:

SymptomLikely causeCheck
Wrong country version ranksMissing return link, wrong region code, near-duplicate contentChecker on that cluster; compare page content
Locale URL “Duplicate, Google chose different canonical”Cross-locale canonical, or same-language variants too similarCanonical tags; differentiate content
Locale URLs “Discovered, currently not indexed”No crawl path: geo-redirect, no switcher links, missing from sitemapFetch as US visitor without cookies
Annotations seem ignoredTags after <head> closed early, relative URLs, JS-only injectionView raw HTML; checker HTML mode
Rankings dropped after i18n launchURLs changed without redirectsTreat 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 (/pricing to /en/pricing) after the tags were written against unprefixed URLs.
  • Don’t combine hreflang with media attributes 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.yamlgenerated from this page

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.py401 lines, 18.1 KB
#!/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.

esc