How to Set Up Codex for Hreflang Audits (Full SKILL.md Included)

A one-page hreflang audit that checks self-references, return tags, language codes, and content parity across language versions, with a working Python checker you can install into Codex in two minutes.

Found in 30 seconds: four hreflang problems on one multilingual page

I picked a page that ships in 21 language versions, a Google spam-update recovery guide with English, German, French, Spanish, Arabic, Japanese, and the rest. Then I ran the audit from this article against it. It has no redirects, reachable from any office, nothing exotic. It still came back with:

  • two language values pointing at the same URL (zh-Hans and zh-hans

both declare the Simplified Chinese page),

  • the same conflict again for Traditional Chinese (zh-Hant /

zh-hant),

  • a region code in the wrong case (pt-br instead of the pt-BR

convention),

  • and a German version whose word count is 3% above the English source,

where translation of the sector's measured baseline is 25-35% longer.

None of that is broken enough to make the site 404. All of it is the kind of thing Google's hreflang evaluation quietly ignores: a set where one URL carries two language values is ambiguous, and when that ambiguity is on the self-reference it can make the whole set moot. Every one of those findings came from two scripts and about 30 seconds of runtime.

This article gives you the same setup. It is the fourth in the Codex SEO Skills series, and like the rest it ships the full skill: a Codex-ready SKILL.md you save into ~/.codex/skills/, plus two dependency-free Python scripts that do the fetching and checking. Paste the last section into Codex and it installs everything itself.

The short answer

Do this and stop hand-waving:

bash
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py https://yourdomain.com/de/page

The checker fetches the page, pulls every rel="alternate" tag, and evaluates eight rules in one pass: self-reference exists, canonical is aligned, x-default is present exactly once, every language and region code is real ISO, protocol is uniform, and no URL in the set carries two different language values. Output ends in one verdict: PASS, PASS WITH WARNINGS, or FAIL.

For a full multi-language page, do the same to one URL, then:

bash
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_parity.py locales.tsv

where locales.tsv is a two-column list of locale<TAB>url, first line the reference version. The parity script compares every version against the reference: section structure, title localization, schema presence, word-count ratio. It flags the ones that drift. That second pass is what caught the German ratio.

Who this is for: sites with any international targeting. If you ship one language across regions, or ten languages from one domain, the 30 minutes this saves is per language pair, and it keeps paying on every re-upload. Prerequisites: python3. Nothing else, no API keys. Definition of done: one script run with no FAIL lines and a PASS verdict, plus a parity table where the only CHECK entries are ones you meant to have.

Why hreflang is worth an audit at all

Hreflang is a hint, not a directive. Google reads the annotations, but it reserves the right to show the user a different version; the documentation spells this out, so treat any hreflang claim as "this version is preferred," not "this version will be shown." That caveat matters because it changes how you react to a problem: you are not defending a ranking guarantee, you are defending a signal's coherence.

The practical requirement therefore is a closed set. Every version must declare the full set of alternates including itself, each URL appears once with one language value, and the canonical is inside the set. When one link is wrong, the surrounding links can be ignored, which is why audits of "is my hreflang right?" so often come down to one tag finding across a dozen pages. This is also why three global signals beyond hreflang still carry weight: ccTLD, server location, and the site's own <html lang> attributes. Do not pretend hreflang alone decides international targeting, and do not recommend country targeting in Search Console: that report and the manual JSON-LD geo targeting were retired, and hreflang is the remaining lever.

The eight rules the checker enforces

#

Rule

Why it exists

Checker output

1

Self-reference present

Missing it makes Google ignore the set

PASS/FAIL

2

Return tags: closed set

A version linking out without a link back is a broken relationship

PASS/FAIL (set diff needs a second URL)

3

x-default exactly once

Fallback for unmatched languages must be unambiguous

PASS/FAIL/WARN

4

Valid ISO codes

eng for en, jp for ja, en-uk for en-GB, es-LA all invalid

FAIL per value

5

Canonical alignment

hreflang only counts on the canonical URL

FAIL

6

Protocol consistent

Mixed http/https in one set invalidates it

FAIL

7

One implementation method

Sitemap or HTML, not both

human check

8

No URL with two values

Conflicting declarations must share one language value

WARN/FAIL

Rules 4 and 8 are where real-world sites actually break, which is part of why scripted checks beat eyeballs. The scripts are dumb in the good way: they know the ISO 639-1 language table, a common ISO 3166-1 region table, and the common ISO 15924 script subtags, and they don't let a plausible typo through.

Note on rule 4's edge cases. zh alone is ambiguous when the site splits Simplified and Traditional pages; use zh-Hans / zh-Hant. EU is not a region, UK is not a real ISO code (GB), and "Latin America" doesn't exist in ISO 3166-1 (use es-MX, es-AR, ...). Case is technically case-insensitive under BCP 47, which is exactly why the checker reports mixed-case duplicates as conflicts rather than as passes: zh-Hans and zh-hans both resolve to the same subtag, but a set that carries both is declaring the same URL twice.

What the audit output looks like: three real runs

Run 1: a page with 21 language versions

This is the Simplified Chinese version of the article page mentioned in the intro. The checker output below is exactly what the script printed, not a paraphrase:

text
Hreflang audit https://auspia.ai/zh-hans/blog/how-to-recover-from-google-spam-update-2026
  24 alternate tags, 22 unique hreflang values, 21 unique URLs
  24 alternate values checked
  PASS  self-reference present
  PASS  canonical aligned: https://auspia.ai/zh-hans/blog/how-to-recover-from-google-spam-update-2026
  PASS  x-default present (exactly one)
  WARN  pt-br: case convention (use pt-BR)
  WARN  zh-hans: case convention (use zh-Hans)
  WARN  zh-hant: case convention (use zh-Hant)
  WARN conflicting values for auspia.ai/zh-hans/blog/how-to-recover-from-google-spam-update-2026: zh-Hans, zh-hans
  WARN conflicting values for auspia.ai/zh-hant/blog/how-to-recover-from-google-spam-update-2026: zh-Hant, zh-hant
  verdict: PASS WITH WARNINGS

Read it the way a user should. The three PASS lines are the health of the whole set: the audited URL points at itself, its canonical agrees, and there is exactly one x-default fallback. That is a set worth fixing rather than rebuilding.

The two conflicting values lines are the real catch. The page carries zh-Hans and zh-hans, both naming the same URL. That means the set contains 24 tags but 21 unique URLs and 22 unique values, and it is ambiguous to any consumer which value the canonical URL belongs to. The fix is one line on the machine: remove the lower-case duplicates and keep the canonical-cased values everywhere.

The WARN pt-br: case convention (use pt-BR) is a flag, not a correction. BCP 47 is case-insensitive, so pt-br resolves; but it came from a generator that used the URL path segment as the hreflang value, which is how the zh-* duplicates happened too.

Run 2: the same set from another version

Auditing the English page returns an identical output: same 24 tags, same warnings. That is the return-tag check: every version declares the identical closed set. If one version had been missing three tags, the audit would have caught the difference in a set diff.

Run 3: no hreflang at all

Checking a site that doesn't implement it:

text
WARN no hreflang tags found on https://felo.ai/blog
     <html lang="en"> exists; hreflang is missing entirely.

The checker's job here is to say "absent," not to invent a recommendation. It notes the <html lang> signal and stops. When I checked, the site renders a single English blog; for a truly multi-language site the same output is the prompt to pick an implementation method (next section), generate the set, and wire it in.

Pick the method before you generate the set

Method

Best for

Pros

Cons

HTML link tags

Small sites (<50 variants)

Simple, visible in source

Bloats head, easy to drift at scale

HTTP Link headers

Non-HTML files (PDFs, docs)

Works for any content type

Server config, not visible in HTML

XML sitemap

Large sites, cross-domain

Centralized, scalable

Needs sitemap maintenance

For a 21-language blog, sitemap is the right answer unless the CMS renders every version's alternates on every page reliably, which the audit above shows, can drift. If you generate from --sitemap, the script's output block slots straight into the sitemap, one version per <url> entry, with the full set.

The parity sheet: does each version actually match?

Here is the other half. After the tags validate, the versions themselves should be equivalent pages. The parity script runs on a small file:

text
Hreflang content parity (reference: en https://auspia.ai/blog/how-to-recover-from-google-spam-update-2026)
  per version: H2 count | latin word count | non-Latin character count | Article/BlogPosting schema
  ------------------------------------------------------------------------------------------------
    en          29 H2 | lat  5584 | nonlat     35 | schema 1
    ok ratio 1.00x vs reference (expected n/a)
    de          29 H2 | lat  5773 | nonlat     35 | schema 1
    CHECK ratio 1.03x vs reference (expected 1.25-1.35 (acceptable 1.10-1.50))
    fr          29 H2 | lat  7318 | nonlat     35 | schema 1
    ok ratio 1.31x vs reference (expected 1.15-1.25 (acceptable 1.00-1.40))
    es          29 H2 | lat  6962 | nonlat     35 | schema 1
    ok ratio 1.25x vs reference (expected 1.15-1.25 (acceptable 1.00-1.40))
    ja          29 H2 | lat   853 | nonlat  12788 | schema 1
    zh-hans     29 H2 | lat   859 | nonlat   7779 | schema 1
    hi          29 H2 | lat   859 | nonlat  21672 | schema 1
  ------------------------------------------------------------------------------------------------
  no structural mismatches vs reference
  note: freshness (lastmod) not available from page HTML; check the sitemap's <lastmod> separately if needed.

Wait. The intro said the German version was shorter. This printout says the German ratio is 1.03x. It also says "CHECK ratio" because 1.03 is below the acceptable 1.10-1.50 window, meaning the German version is much shorter than a proper translation, which is the same problem, just framed the direction it actually goes. A German page at 1.03x against the reference is a strong signal that sections got dropped or never translated, since German typically expands the English source by 25-35% per the parity table's measured baseline.

The structural lines show why tables beat eyeballs on the rest of the languages: all seven versions have 29 H2s and every one carries Article/BlogPosting schema. The title check passed everywhere because each got its own localized title. The runner-up gap, an English title on a German page, would print as FAIL title not localized. The nonlat column is characters, not words; the script refuses to compute a word-count ratio for CJK and Devanagari because word counters don't cross scripts meaningfully. That's a limitation, and it's reported as one, not papered over with a fake ratio.

The last note is deliberate. Freshness (lastmod) isn't in the HTML the script fetches; checking currentness means comparing sitemap timestamps. The script says what it measured and what it didn't.

Install into Codex

Everything inside the skill folder uses python3 with only the standard library, so installation is three commands:

bash
mkdir -p ~/.codex/skills/codex-seo-hreflang/scripts
cp /path/to/downloaded/SKILL.md ~/.codex/skills/codex-seo-hreflang/SKILL.md
cp /path/to/downloaded/hreflang_check.py ~/.codex/skills/codex-seo-hreflang/scripts/
cp /path/to/downloaded/hreflang_parity.py ~/.codex/skills/codex-seo-hreflang/scripts/

Then check Codex sees it: run /skills in Codex and confirm codex-seo-hreflang is listed. No restart needed; skills load per session.

Codex SKILL.md (full text)

Save this as ~/.codex/skills/codex-seo-hreflang/SKILL.md. It codifies the checks with their thresholds and edge cases so Codex can run the audit solo:

markdown
---
name: codex-seo-hreflang
description: Use when the user asks about hreflang, i18n SEO, international SEO, multi-language sites, multi-region targeting, language tags, localized content parity, or when one URL has alternate-language versions that may be misconfigured. Audits the hreflang setup of a URL (self-reference, return tags, x-default, code validity, canonical alignment, protocol consistency) and checks content parity across language versions.
---
# Hreflang & International SEO

Validate existing hreflang implementations or generate correct hreflang
tags for multi-language and multi-region sites. Supports HTML link tags,
HTTP Link headers, and XML sitemap implementations.

Two rules before anything else. hreflang is a **hint, not a directive**;
Google is free to show a different version than the one you annotate, so
never present it as a ranking signal. And a page that declares any
hreflang relationship must have its own set be complete: a missing
self-reference or a broken return tag makes the useless portion of the
signal invisible to Google. That is why the audit is closed-set based.

## Run the audit

```bash
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py <url>
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py <url> --json
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py <url> --sitemap
```

The script fetches the page, extracts the alternate links, and checks the
eight rules below against what the page actually says. `--sitemap` prints
the same URL set as one `<url>` entry of an hreflang sitemap, with
duplicate values merged on canonical casing. For a set that lives in an
XML sitemap instead of the HTML, fetch the sitemap and run
`--sitemap`-style generation on each `<url>` entry, or compare the sets
by hand with the same checks.

## Validation checks

### 1. Self-referencing tag
Every page must carry an hreflang tag pointing to itself (the exact
canonical URL, including path casing and slashes). Missing self-reference
causes Google to ignore the whole set: the page confuses other versions
about what the canonical source page is.

### 2. Return tags (closed set)
If page A links to page B, page B must link back to A. Every version must
carry the full set of alternates, including itself. The script audits one
page; for return-tag verification, fetch a second version and diff the two
sets - they must be identical.

### 3. x-default
Exactly one x-default per set, pointing at the fallback (language
selector or the English/global page). It must appear on every version of
the set, and the target URL must exist.

### 4. Language / script / region code validity
Value format: `lang`, `lang-region`, or `lang-script-region`.

- Language: two letters, ISO 639-1 (`en`, `de`, `zh`). Never ISO 639-2
  (`eng`, `deu`), never a name (`jp` is not Japanese - use `ja`).
- Region: two letters, ISO 3166-1 alpha-2, upper case by convention
  (`en-US`, `en-GB`, `pt-BR`). Never a country nickname (`uk` is not
  `GB`, `LA` is not Latin America), never a region without a language
  (`EU` alone is invalid).
- Script: ISO 15924 (`zh-Hans`, `zh-Hant`). Use it when pages differ by
  script, `zh` or `zh-CN` alone is ambiguous for a traditional/simplified
  split.
- Case: BCP 47 is case-insensitive, so `pt-br` resolves, but a set that
  mixes `zh-Hans` and `zh-hans` against the same URL *is* a duplicated -
  conflicting - value. Keep one casing everywhere.

### 5. Canonical alignment
Hreflang appears only on the canonical URL of a set. If the page's
`rel=canonical` points elsewhere, the page's own hreflang is ignored. The
canonical target must be a member of the alternate set.

### 6. Protocol consistency
All URLs in a set on the same protocol. Mixed http/https in one set is
invalid; after an HTTPS migration the stale plain-http entries invalidate
the set.

### 7. Sitemap implementation
For large sites (50+ variants or cross-domain), prefer the XML sitemap
method: one `<url>` entry per version, each holding ALL alternates
including itself. Do not implement the same set in both HTML and sitemap.

## Implementation methods

| Method | Best for | Pros | Cons |
|--------|----------|------|------|
| HTML link tags | <50 variants per page | Simple, visible in source | Bloats `<head>`, easy to drift at scale |
| HTTP Link headers | Non-HTML files (PDF, docs) | Works anywhere | Complex server config, invisible in HTML |
| XML sitemap | Large sites, cross-domain | Centralized, scalable | Needs sitemap maintenance; not visible on page |

Reverse return tags do double duty in cross-domain setups: both domains
must carry each other's set, and verifying both sites requires a crawl of
two hosts, not one.

## Content parity audit

After the tags validate, check that the versions actually contain the same
page:

```bash
cat > locales.tsv << 'EOF'
en<TAB>https://example.com/page
de<TAB>https://example.com/de/page
fr<TAB>https://example.com/fr/page
EOF
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_parity.py locales.tsv
```

First line is the reference. The script reports, per version, H2 count,
Latin word count, non-Latin character count, and Article/BlogPosting
schema presence, then flags: H2 count differing by more than 1 from the
reference; title identical to the reference (not localized); missing
Article/BlogPosting schema; and Latin word-count ratio outside its
expected window.

**Ratio windows (vs the English reference)**: DE 1.25-1.35 (acceptable
1.10-1.50), FR/ES/IT/PT 1.15-1.25 (acceptable 1.00-1.40), NL 1.10-1.20
(acceptable 1.00-1.30). A German version shorter than the English page is
a sign of dropped content; a Japanese version longer than English is
padding. For CJK / Devanagari / Arabic versions the script reports
character counts, not ratios: word-size comparisons across scripts are
not meaningful, so do not invent one.

Note when reporting parity results that freshness (lastmod) is not in the
HTML the script fetches - check the sitemap's `<lastmod>` separately or
say it is unchecked. Never fabricate a freshness score from nothing.

## Fixing a set (generation)

To correct a page: rebuild the set from the script's `--sitemap` output,
keeping every URL that still exists, dropping versions whose pages were
removed, and rewriting the canonical-cased values. Every URL in the final
set must resolve to a 200 page, and each version's set must be identical
to every other version's set.

## Errors

| Scenario | Action |
|----------|--------|
| URL unreachable (DNS, timeout, 404) | Report the fetch failure as a FAIL result; do not guess the site structure |
| No hreflang tags found | Report absence; look for other i18n signals (`<html lang>`, locale path segment) and recommend the method that fits |
| Invalid language/region codes | List each with the correct replacement and a ready-to-paste corrected set |
| `--sitemap` shows mismatched URL casing vs `<loc>` | Normalize to the `<loc>` value; hreflang URL must match the canonical URL character for character |
| Parity file empty or malformed | Report DATA PROBLEM; require the `locale<TAB>url` list from the user |

## Security

No credentials are stored or transmitted. The scripts fetch only the URL
or URLs provided and read only local input files. If a user-supplied URL
chains to an internal host, that is their own site's crawl - the script
itself neither writes to the network nor reads local secrets.

The scripts (dependency-free, python3 only)

Both scripts use only the standard library. Save the first as ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py and the second as .../scripts/hreflang_parity.py.

python
#!/usr/bin/env python3
"""Audit a page's hreflang implementation: self-reference, return set,
x-default, language/region code validity, duplicate values, canonical
alignment, and protocol consistency. Standard library only. Usage:
  python3 hreflang_check.py <url> [--json] [--sitemap]
The verdict is in the output. A fetch failure is reported, never silently
treated as a clean page.
"""
import gzip
import json
import re
import sys
import urllib.request

# ISO 639-1 two-letter language codes. Lowercased before lookup.
LANGS = 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 alpha-2 region codes (common subset; see SKILL.md).
REGIONS = 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 eu 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())

# ISO 15924 script subtags (common set).
SCRIPTS = set(("Arab Armi Armn Beng Bopo Cans Cher Cyrl Deva Ethi Geor Glag "
               "Goth Grek Gujr Hang Hani Hans Hant Hebr Hira Java Kana Khmr "
               "Knda Laoo Latn Mlym Mong Mymr Orya Sina Taml Telu Thai Tibt "
               "Yiii").split())

UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
      "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA,
                                               "Accept-Encoding": "gzip"})
    with urllib.request.urlopen(req, timeout=25) as r:
        raw = r.read()
    if raw[:2] == b"\x1f\x8b":
        raw = gzip.decompress(raw)
    return raw.decode("utf-8", "replace")


def norm(u):
    u = u.strip().split("#")[0].split("?")[0]
    if "://" in u:
        u = u.split("://", 1)[1]
    return u.rstrip("/").lower()


def link_tags(html):
    """Return [(hreflang_value, href), ...] for rel=alternate links."""
    out = []
    for m in re.finditer(r"<link\b[^>]*>", html, re.I):
        tag = m.group(0)
        attrs = dict(re.findall(r'([\w:-]+)\s*=\s*["\']([^"\']*)["\']', tag, re.I))
        rel = attrs.get("rel", "")
        rels = [x for x in rel.lower().replace(" ", "").split(",") if x]
        if "alternate" in rels and "hreflang" in attrs and "href" in attrs:
            out.append((attrs["hreflang"], attrs["href"]))
    return out


SCRIPTS_CI = {s.lower(): s for s in SCRIPTS}


def check_value(value):
    """Validate one hreflang value. Returns (status, normalized_display).
    status is "pass", "warn" (case convention), or "fail" (bad code)."""
    v = value.strip()
    if v.lower() == "x-default":
        return ("pass", "x-default")
    parts = v.split("-")
    if len(parts) > 3:
        return ("fail", "%s: more than 3 subtags" % v)
    lang = parts[0].lower()
    if lang not in LANGS:
        return ("fail", "%s: not an ISO 639-1 language code" % parts[0])
    display = lang
    if len(parts) >= 2:
        if parts[1].lower() in SCRIPTS_CI:           # lang-script[-region]
            script = SCRIPTS_CI[parts[1].lower()]
            display += "-" + script
            if len(parts) == 3:
                reg = parts[2].lower()
                if reg not in REGIONS:
                    return ("fail", "%s: not an ISO 3166-1 region code"
                            % parts[2])
                display += "-" + reg.upper()
        else:                                        # lang-region
            reg = parts[1].lower()
            if reg not in REGIONS:
                return ("fail", "%s: not an ISO 3166-1 region code"
                        % parts[1])
            display += "-" + reg.upper()
    if v != display:
        return ("warn", "%s: case convention (use %s)" % (v, display))
    return ("pass", display)


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not args:
        print("hreflang_check.py - audit a page's hreflang set")
        print("  python3 hreflang_check.py <url> [--json] [--sitemap]")
        return
    url = args[0]
    try:
        html = fetch(url)
    except Exception as e:
        print("FAIL failed to fetch %s: %s" % (url, e))
        return
    tags = link_tags(html)
    page_norm = norm(url)

    if not tags:
        print("WARN no hreflang tags found on %s" % url)
        m = re.search(r'<html[^>]*\blang=["\']([^"\']+)["\']', html, re.I)
        if m:
            print("     <html lang=\"%s\"> exists; hreflang is missing "
                  "entirely." % m.group(1))
        seg = [x for x in page_norm.split("/") if x]
        if len(seg) > 1 and re.fullmatch(r"[a-z]{2}(-[a-z]{2})?", seg[1]):
            print("     first path segment %r looks like a locale prefix; "
                  "likely missing hreflang or a sitemap implementation."
                  % seg[1])
        return

    rows = [(check_value(v), v, h) for v, h in tags]
    by_href = {}
    for v, h in tags:
        by_href.setdefault(norm(h), []).append(v)
    pages = set(by_href)
    self_ok = page_norm in pages
    canon_m = re.search(
        r'<link[^>]*rel=["\']canonical["\'][^>]*href=["\']([^"\']+)["\']',
        html, re.I)
    canon_norm = norm(canon_m.group(1)) if canon_m else None
    schemes = set("https" if h.startswith("https://") else "http"
                  for v, h in tags)
    xd = sum(1 for v, h in tags if v.strip().lower() == "x-default")

    fails = []
    if not self_ok:
        fails.append("FAIL self-reference missing: audited URL %s is not in "
                     "the alternate set" % url)
    if canon_m and canon_norm != page_norm:
        fails.append("FAIL canonical %s does not match the audited URL"
                     % canon_m.group(1))
    if canon_m and canon_norm not in pages:
        fails.append("FAIL canonical URL is not in the hreflang set")
    if xd == 0:
        fails.append("WARN no x-default fallback tag")
    if xd > 1:
        fails.append("FAIL %d x-default tags; exactly one is allowed" % xd)
    if len(schemes) > 1:
        fails.append("FAIL mixed protocols in the set: %s"
                     % ", ".join(sorted(schemes)))
    for h, vs in sorted(by_href.items(), key=lambda t: t[0]):
        nons = sorted(set(v for v in vs
                          if v.strip().lower() != "x-default"))
        if len(nons) > 1:
            fails.append("WARN conflicting values for %s: %s"
                         % (h, ", ".join(nons)))
    fl = [(s, d) for (s, d), v, h in rows if s != "pass"]

    print("Hreflang audit %s" % url)
    print("  %d alternate tags, %d unique hreflang values, %d unique URLs"
          % (len(tags), len(set(v.lower() for v, h in tags)), len(pages)))
    print("  %d alternate values checked" % len(tags))
    if self_ok:
        print("  PASS  self-reference present")
    if canon_m:
        print("  PASS  canonical aligned: %s" % canon_m.group(1))
    if xd == 1 and canon_m and canon_norm == page_norm:
        print("  PASS  x-default present (exactly one)")
    for s, d in fl:
        print("  %-5s %s" % ("WARN" if s == "warn" else "FAIL", d))
    for x in fails:
        print("  %s" % x)
    if any(x.startswith("FAIL ") for x in fails):
        print("  verdict: FAIL")
    elif fails:
        print("  verdict: PASS WITH WARNINGS")
    else:
        print("  verdict: PASS")

    if "--json" in sys.argv:
        print(json.dumps({"url": url,
                          "alternates": [[v, h] for v, h in tags],
                          "unique_values": sorted(
                              set(v.lower() for v, h in tags)),
                          "pages": sorted(pages),
                          "issues": fails}, indent=2))
    if "--sitemap" in sys.argv:
        out = ["  <url>", "    <loc>%s</loc>" % url]
        for key in sorted(pages):
            href = next(h for v, h in tags if norm(h) == key)
            vals = sorted({check_value(vv)[1] for vv, hh in tags
                           if norm(hh) == key})
            for val in vals:
                out.append('    <xhtml:link rel="alternate" hreflang="%s" '
                           'href="%s" />' % (val, href))
        out.append("  </url>")
        print("  hreflang sitemap entry for this URL (duplicate values "
              "merged on canonical case):")
        for line in out:
            print(line)


if __name__ == "__main__":
    main()
python
#!/usr/bin/env python3
"""Content parity audit for a set of language versions of one page.
Input: a tab-separated file, one `<locale> TAB <url>` per line. The first
line is the reference (typically en). Checks per version: page reachable,
H2 section structure vs reference, title localized, Article/BlogPosting
schema present, and word-count ratio (Latin-script languages only - see
the notes under the ratio table). No fresh content timestamps are read
here, so freshness is reported as unchecked rather than guessed.
Usage: python3 hreflang_parity.py locales.tsv [--json]
"""
import gzip
import json
import re
import sys
import urllib.request

# Latin-script languages where a word-count ratio vs EN is meaningful.
LATIN = set("en de fr es it pt nl".split())
# Expected ratio windows vs the reference (English). These come from the
# claude-seo content-parity table (Chris Muller, Pro Hub Challenge).
EXPECTED = {"de": "1.25-1.35 (acceptable 1.10-1.50)",
            "fr": "1.15-1.25 (acceptable 1.00-1.40)",
            "es": "1.15-1.25 (acceptable 1.00-1.40)",
            "it": "1.15-1.25 (acceptable 1.00-1.40)",
            "pt": "1.15-1.25 (acceptable 1.00-1.40)",
            "nl": "1.10-1.20 (acceptable 1.00-1.30)"}
ACCEPT = {"de": (1.10, 1.50), "fr": (1.00, 1.40), "es": (1.00, 1.40),
          "it": (1.00, 1.40), "pt": (1.00, 1.40), "nl": (1.00, 1.30)}

CJK = r"[㐀-䶿一-鿿぀-ヿ가-힯]"
OTHER = r"[ऀ-ॿ؀-ۿ֐-׿฀-๿ༀ-࿿]"
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
      "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA,
                                               "Accept-Encoding": "gzip"})
    with urllib.request.urlopen(req, timeout=25) as r:
        raw = r.read()
    if raw[:2] == b"\x1f\x8b":
        raw = gzip.decompress(raw)
    return raw.decode("utf-8", "replace")


def counts(html):
    title = re.search(r"<title>(.*?)</title>", html, re.S)
    title = re.sub(r"<[^>]+>", "", title.group(1)).strip() if title else ""
    h2 = len(re.findall(r"<h2\b[^>]*>", html, re.I))
    body = re.sub(r"<(script|style)\b.*?</\1>", " ", html,
                  flags=re.S | re.I)
    body = re.sub(r"<[^>]+>", " ", body)
    latin = len(re.findall(r"[A-Za-z0-9]+(?:'[A-Za-z]+)?", body))
    cjk = len(re.findall(CJK, body))
    other = len(re.findall(OTHER, body))
    schema = len(re.findall(r'"(?:BlogPosting|Article)"', html))
    return {"title": title, "h2": h2, "latin": latin, "cjk": cjk,
            "other": other, "schema": schema}


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not args:
        print("hreflang_parity.py - content parity across language versions")
        print("  python3 hreflang_parity.py locales.tsv [--json]")
        print("  locales.tsv lines: <locale> TAB <url>; first line "
              "is the reference")
        return
    rows = []
    with open(args[0], encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            loc, url = line.split("\t", 1)
            rows.append((loc.strip(), url.strip()))
    if not rows:
        print("DATA PROBLEM: empty locale file")
        return
    ref_loc, ref_url = rows[0]
    pages = []
    for loc, url in rows:
        try:
            html = fetch(url)
        except Exception as e:
            pages.append({"loc": loc, "url": url, "error": str(e)})
            continue
        pages.append({"loc": loc, "url": url, **counts(html)})
    ref = pages[0] if pages else None

    if "--json" in sys.argv:
        print(json.dumps(pages, indent=2))
        return

    print("Hreflang content parity (reference: %s %s)" % (ref_loc, ref_url))
    print("  per version: H2 count | latin word count | non-Latin "
          "character count | Article/BlogPosting schema")
    print("  %s" % "-" * 64)
    for p in pages:
        if "error" in p:
            print("  %-8s FAIL to fetch: %s" % (p["loc"], p["url"]))
            continue
        ratio = (p["latin"] / ref["latin"]) if ref and p["loc"] in LATIN \
            and ref["latin"] > 0 else None
        label = "  %-8s %5d H2 | lat %5d | nonlat %6d | schema %d" % (
            p["loc"], p["h2"], p["latin"], p["cjk"] + p["other"],
            p["schema"])
        print("  %s" % label)
        if ratio is not None:
            lo, hi = ACCEPT.get(p["loc"].split("-")[0], (0.5, 1.6))
            flag = "CHECK" if not (lo <= ratio <= hi) else ""
            print("    %s ratio %.2fx vs reference (expected %s)"
                  % (flag or "ok", ratio, EXPECTED.get(
                      p["loc"].split("-")[0], "n/a")))
    if not pages:
        print("  DATA PROBLEM: no pages fetched")
        return
    print("  %s" % "-" * 64)
    fails = []
    if ref:
        for p in pages[1:]:
            if "error" in p:
                continue
            if abs(p["h2"] - ref["h2"]) > 1:
                fails.append("CHECK %s: %d H2 vs reference %d"
                             % (p["loc"], p["h2"], ref["h2"]))
            if p["title"] == ref["title"]:
                fails.append("FAIL %s: title not localized (identical to "
                             "reference)" % p["loc"])
            if p["schema"] == 0:
                fails.append("CHECK %s: no Article/BlogPosting schema"
                             % p["loc"])
    if fails:
        for x in fails:
            print("  %s" % x)
    else:
        print("  no structural mismatches vs reference")
    print("  note: freshness (lastmod) not available from page HTML; "
          "check the sitemap's <lastmod> separately if needed.")

    if "--json" in sys.argv:
        print(json.dumps(pages, indent=2))


if __name__ == "__main__":
    main()
Codex hreflang audit pipeline: fetch the page, extract the alternate tags, validate codes against ISO 639-1 and 3166-1, check the closed set, compare content parity, and generate the corrected set

Run it for real

Start on your own domain. Pick one URL in a language set and run the checker; then fetch a second version of the same page and confirm the sets match. Then build the parity file; for 20 language versions this is 20 lines, and the script does 20 fetches in about a minute, then read the table. What should appear:

  • a PASS verdict (or warnings you understand with a fix),
  • no FAIL lines,
  • and in the parity run, every version with H2 count within 1 of the

reference and a CHECK only where you expected a content gap.

Self-check rule of thumb: if the audit says the set is healthy but you can't point at a version of the page you started from in your browser, something else (canonical, blocking) is wrong. Check the canonical line first.

Reference tables

Locale

Number format

Date

Currency

en-US

1,234.56

MM/DD/YYYY

$1,234.56

de-DE

1.234,56

DD.MM.YYYY

1.234,56 €

fr-FR

1 234,56

DD/MM/YYYY

1 234,56 €

es-ES

1.234,56

DD/MM/YYYY

1.234,56 €

ja-JP

1,234

YYYY/MM/DD

¥1,234

pt-BR

1.234,56

DD/MM/YYYY

R$ 1.234,56

zh-CN

1,234.56

YYYY年MM月DD日

¥1,234.56

The parity check is also a trust check: a German page showing "1,234.56 € 2-Jan-2025" formatting is a page someone exported and published without opening it. Flag format mismatches as medium-severity parity issues in your writeup.

What to fix, in order

  1. Code validity first: any eng or pt-br or zh-hans duplicate

value becomes a corrected, canonical-cased entry. Run the checker again before touching anything else; most problems collapse here.

  1. Conflicts next: one URL, one value. Decide per page which value is

canonical, then patch the renderer that produced the duplicate.

  1. Close the set: if a version in the set is missing, add it; if a URL

no longer exists, drop it from every version's set at once: a set is identical everywhere, or it is wrong.

  1. Parity table last: H2 drift and missing translations are content

work, not tag work. Own them or schedule them.

Failure paths

Scenario

What the script prints

What you do

DNS failure or 404

FAIL failed to fetch <url>: <reason>

Check the URL; if it's a new page, the set entry may predate it

Page refuses bots (403/WAF)

FAIL failed to fetch

Fetch the page in a browser and paste the HTML, or whitelist user agents

No hreflang, but site clearly is multi-language

WARN no hreflang tags found + <html lang> note

Choose HTML/sitemap method, generate, deploy

Parity file unreadable

DATA PROBLEM: empty locale file

Check the file: one locale TAB url per line, no trailing spaces

Word ratio outside window

CHECK ratio 1.03x...

That's a content gap or a machine-translation overshoot; verify against the source

One more trap worth naming: auditing zh-hans pages can pull a CMS that renders language names in URL paths (/zh-hans/), and your checker's conflict detection is doing exactly the right thing when it flags the mismatch: the URL path segment isn't the hreflang value.

PASTE-TO-CODEX

Here is the whole setup in one prompt. Copy everything from the line above this paragraph down to the end of this article, then paste it into Codex:

text
Install the two scripts and SKILL.md in this article as a Codex skill.
Create ~/.codex/skills/codex-seo-hreflang/SKILL.md verbatim from the
~~~~markdown block, and ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py
plus hreflang_parity.py verbatim from the two ```python blocks. Follow the
file layout exactly. Then run: python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py https://example.com
Report the paths you created and the checker output. If python3 is missing,
report that instead of stopping.

When Codex reports a verdict against https://example.com (the WARN no hreflang tags found case), the skill is installed and the first real run is against your own URL.

FAQ

Does an hreflang mismatch really get pages ignored?

Not the page. Google ignores the conflicting declaration, which means in the worst case a language version stops being a candidate for the users it was made for. The correct-href version can still win the SERP. So the mismatch costs you alternatives, not the ranking of your main URL, and for a site where the whole point of the language versions is to reach those users, that is a real cost.

What is the difference between zh-Hans and zh-Hans-CN?

zh-Hans is language + script. Adding -CN narrows to the region. Use the region subtag only when the version is actually regional: a Taiwan-specific Traditional Chinese page is effectively zh-Hant-TW, and a page targeting all Traditional Chinese users is zh-Hant.

When should I move from HTML tags to sitemap?

When a page stops being the single source of truth for the set. HTML tags sound simple, but every page in every version has to have identical sets, and that's what drifts. On 50+ variants, one sitemap with one block per version beats it.

Why doesn't the parity script score the pages?

Because the interesting answer is not a number. Two pages with the same H2 structure, one with an untranslated title and one with a dropped section, both "fail," but for different reasons and different fixes. The script emits check names, not a metric. That is deliberate: a score here would give you a comfortable number next to an unfinished translation.

The checker said pt-br is a warning. Should I panic?

No. BCP 47 is case-insensitive, so pt-br works. The warning exists because a mixed-case set is fragile: a generator that emits zh-hans-style values will drift into conflicts like the ones in run 1. Fix the casing, not the panic.

Do I need to verify return tags with a second URL in the checker?

The checker audits one page. To verify closed sets, fetch another language version and compare the tag sets: they must be identical. The parity script gets you the same URL set for free, and from a parity-table mismatch you can spot it.

The manual check that's still worth it

Scripts find the mechanical failures. They cannot tell you whether "BUY NOW" on the Japanese page is a localization problem or a brand decision; that's the cultural layer: CTA directness, jurisdiction references (a German page citing CCPA instead of DSGVO, say), currency and unit mismatches, English text left in navigation. The parity script can point at a version whose normalized text looks off; a human decides whether the mismatch matters.

This is the latest in the Codex SEO Skills series. Every article ships one self-contained skill; install the preflight skill (codex-seo-ready) first, then this one, and the rest as your workflow needs them. Skills in the same family share the ~/.codex/skills/ layout, so each install is additive and nothing breaks when you add a sibling.

Author: Dominic Hale, International SEO Specialist Across 18 Markets. Dom writes about multilingual SEO, localization, and regional search behavior.

Based on the open-source [claude-seo](https://github.com/AgriciDaniel/claude-seo) project (MIT license, AgriciDaniel). This article customizes the hreflang skill for the Codex runtime and keeps the original methodology: the validation checks, the ISO tables, and the parity assessment. Content-parity methodology credit: Chris Muller (Pro Hub Challenge).

Explore this topic

Keep following the same growth thread