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-Hansandzh-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-brinstead of thept-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:
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_check.py https://yourdomain.com/de/pageThe 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:
python3 ~/.codex/skills/codex-seo-hreflang/scripts/hreflang_parity.py locales.tsvwhere 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 |
| 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:
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 WARNINGSRead 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:
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:
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:
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:
---
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.
#!/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()#!/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()
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
PASSverdict (or warnings you understand with a fix), - no
FAILlines, - 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
- Code validity first: any
engorpt-brorzh-hansduplicate
value becomes a corrected, canonical-cased entry. Run the checker again before touching anything else; most problems collapse here.
- Conflicts next: one URL, one value. Decide per page which value is
canonical, then patch the renderer that produced the duplicate.
- 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.
- 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 |
| Check the URL; if it's a new page, the set entry may predate it |
Page refuses bots (403/WAF) |
| Fetch the page in a browser and paste the HTML, or whitelist user agents |
No hreflang, but site clearly is multi-language |
| Choose HTML/sitemap method, generate, deploy |
Parity file unreadable |
| Check the file: one |
Word ratio outside window |
| 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:
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).



