知识

使用 hreflang 查找页面的所有语言和国家版本

hreflang 列出页面的所有本地化版本。我们检查了顶级网站如何使用它、哪些地方会出错,以及为什么有些版本只能在特定国家内打开。

James Meadow

James Meadow

2026年10月1日 · 4 分钟阅读

如果你要跨市场比较价格、内容或可用性,第一个问题就是在每个市场中找到同一个页面。猜测URL模式在某些网站上有效,但在大多数网站上会失败:一个网站用/de/,另一个用de.example.com,另一个用查询参数,还有一个每个国家用单独的域名。

许多网站其实已经公开了答案。hreflang标注是网站为搜索引擎添加的,它列出了一个页面的每个语言和国家版本及其所在位置。读取它,一次抓取就能得到整套版本。本指南将说明它的工作原理、我们在检查头部网站如何使用它时发现了什么,以及为什么即使持有正确的URL也不总是足以看到正确的页面。

关键要点

  • 在2026年10月1日,我们抓取的253个头部网站首页中有82个(32%)声明了hreflang替代版本,中位数为每个17.5个版本,最多达157个。
  • 对一个带标注页面的一次抓取,就能获得该页面的所有本地化URL,无需猜测URL模式。
  • 这些标注并不总是干净的:82个网站中有12个使用了至少一个Google不支持的代码,比如en-uk、es-419、带下划线的en_GB,或者国家在语言之前的写法。
  • 我们检查的替代版本中有86%直接链接回原页面,符合Google的要求。其余大部分指向同一页面的不同URL,或发生了重定向。
  • 有些版本只有从正确的位置才能打开。一个新闻网站的美国首页和一个市场平台的德国店面,把我们来自罗马尼亚的连接重定向到了别处,而通过美国和德国的出口节点则能正常加载。

hreflang如何工作

一个页面在其<head>中列出其替代版本:

<link rel="alternate" hreflang="en" href="https://example.com/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/" />
<link rel="alternate" hreflang="pt-BR" href="https://example.com/pt/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />

每个值都是一个语言代码,后面可以选择性地跟上文字系统或地区:de表示任意地方的德语,en-GB表示英国的英语,zh-Hant表示繁体中文。x-default指定了不匹配以上任何版本的访问者的回退版本。Google的文档制定了大多数网站遵循的规则:

  • 语言使用ISO 639-1代码,文字系统使用ISO 15924,地区使用ISO 3166-1 alpha-2。单独的地区代码是无效的,超出这些标准的代码,比如表示拉丁美洲西班牙语的es-419,不受支持。
  • URL必须是完全限定的,包括https://。
  • 每个版本都必须列出自身和其他所有版本,如果两个页面没有互相指向对方,这些标注可能会被忽略。
  • 同样的信息可以在HTML中给出,也可以在HTTP的Link头(对PDF很有用)中给出,或者在站点地图中给出。

对数据采集来说,第三条规则是最有用的。因为每个版本都应该列出其他每个版本,所以其中任何一个都是整套集合的完整映射。

我们在头部网站上的发现

我们抓取了Tranco热门网站排名前500个域名的首页。253个不同的网站返回了HTML首页;其余的是内容网络、API主机和其他没有首页的域名,或者是已经计算过的网站的重复项。

指标结果
带hreflang标注的首页253个中的82个(32%)
每个带标注页面的替代版本数中位数17.5,最大值157
带有x-default的页面82个中的63个
在HTTP Link头中发送hreflang的首页0
至少有一个不支持代码的网站82个中的12个
使用相对URL的网站2
同一代码列出两次的网站6

不受支持的代码呈现出几种模式:

模式示例网站数
国家在语言之前,外加地区标签mx-es、cz-cs、emea_africa-en1(48个代码)
用下划线代替连字符en_GB、de_DE1(25个代码)
非ISO 3166-1地区代码en-uk、en-eu、sq-xk3
拉丁美洲西班牙语es-4192
非ISO 639-1语言代码ceb、skr、已过时的希伯来语代码iw3
自创代码tc、zh-FT、ms-en3

有些网站出现在不止一行中。国家在前的模式对解析器来说最为棘手,因为其中几个代码反过来读恰好也是有效的:ca是加泰罗尼亚语的代码,id是印尼语的代码,th是泰语的代码,所以ca-en会被读成加泰罗尼亚语,而不是加拿大的英语。永远不要仅凭代码本身就推断市场,一定要核实它是否合理。

返回链接

我们从每个带标注的首页中最多取两个替代版本,共155个,抓取它们,并检查每个是否把首页列为回链。

结果替代版本数
直接链接回原页面133(86%)
链接回同一页面的不同URL9
重定向到了别处9
确实没有列出该页面4,分布在两个网站上

第二行是比较微妙的情况。在几个网站上,我们到达的首页位于一个URL,而整套集合却命名了另一个URL:/对/en/,/homepage或/home.html,或者裸URL对带语言参数的URL。标注本身是一致的;只是我们的入口点不是该网站认定的规范URL。采集时,应该按照该集合本身使用的URL来标记每个版本,而不是按照你碰巧到达的那个。

正确的URL并不总是足够

重定向是最具启发性的部分。我们对每个发生重定向的替代版本再次进行抓取,分别直接抓取和通过目标国家的Shifter出口节点抓取,并分别使用英语和本地的Accept-Language头:

网站类型替代版本从罗马尼亚从目标国家由什么决定
新闻网站美国首页重定向到国际版加载成功,通过美国出口节点位置
市场平台德国店面重定向到全球站点加载成功,通过德国出口节点位置
视频通话供应商日语首页仅在使用日语Accept-Language时加载相同,通过日本出口节点语言
游戏发行商阿拉伯语首页重定向重定向,通过沙特出口节点都不是:已列出但无法访问

两个网站由访问者的位置决定,其中市场平台无论语言如何都是如此,所以它们自己的标注指向了来自其他地方的访问者无法打开的页面。一个由语言决定,无论位置如何。还有一个列出了一个在我们尝试的所有组合中都发生重定向的版本,这一点在你用它构建市场比较之前值得了解。

这正是hreflang与出口位置相遇的地方。标注告诉你有哪些版本存在以及它们位于何处;而忠实地采集它们,意味着要像本地访问者那样请求每一个版本,从那个国家出发并使用那种语言,这一点在匹配代理的地理位置、时区和语言环境中有所涉及。否则,你可能会把国际版记录在德国标签下。

代码示例

下面的模块读取页面HTML中的替代版本,标记不受支持的代码和相对URL,并检查返回链接,报告每个失败的替代版本失败的原因:

from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit

import requests
from babel import Locale

# Google accepts ISO 639-1 languages, ISO 15924 scripts and ISO 3166-1 alpha-2 regions.
LANGUAGES = {code for code in Locale("en").languages if len(code) == 2}
NOT_ISO_3166 = {"AC", "CP", "CQ", "DG", "EA", "EU", "EZ", "IC", "QO", "TA", "UN", "XA", "XB", "XK", "ZZ"}
REGIONS = {code for code in Locale("en").territories if code.isalpha()} - NOT_ISO_3166
SCRIPTS = set(Locale("en").scripts)


class AlternateLinks(HTMLParser):
    """Collect <link rel="alternate" hreflang="..."> elements from a page's markup."""

    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "link" and "alternate" in (a.get("rel") or "").lower().split() and a.get("hreflang") and a.get("href"):
            self.links.append((a["hreflang"].strip(), a["href"].strip()))


def valid_hreflang(code):
    """True for x-default, a language, or language-region / language-script(-region) codes."""
    if code.lower() == "x-default":
        return True
    parts = code.split("-")
    if parts[0].lower() not in LANGUAGES:
        return False
    rest = parts[1:]
    if rest and rest[0].title() in SCRIPTS:
        rest = rest[1:]
    if rest and rest[0].upper() in REGIONS:
        rest = rest[1:]
    return not rest


def hreflang_map(url, session=None, timeout=20):
    """Fetch a page and return its declared alternates as {hreflang: absolute URL}, plus any problems found."""
    session = session or requests.Session()
    response = session.get(url, timeout=timeout)
    parser = AlternateLinks()
    parser.feed(response.text)  # link tags are normally ASCII, so the decoding choice rarely matters here
    alternates, problems = {}, []
    for code, href in parser.links:
        if not href.startswith(("http://", "https://")):
            problems.append(f"relative URL for {code}: {href}")
        if not valid_hreflang(code):
            problems.append(f"invalid code: {code}")
        alternates[code] = urljoin(response.url, href)
    return response.url, alternates, problems


def normalise(url):
    """Compare URLs without default ports, host case or a trailing slash getting in the way."""
    parts = urlsplit(url)
    port = f":{parts.port}" if parts.port and parts.port not in (80, 443) else ""
    return urlunsplit((parts.scheme.lower(), (parts.hostname or "") + port, parts.path.rstrip("/") or "/", parts.query, ""))


def check_return_links(url, alternates, session=None, timeout=20):
    """Fetch each alternate and report why it breaks the return-link rule. An empty result means all link back."""
    session = session or requests.Session()
    target = normalise(url)
    problems = {}
    for code, alternate in alternates.items():
        if normalise(alternate) == target:
            continue
        final, back, _ = hreflang_map(alternate, session, timeout)
        if normalise(final) != normalise(alternate):
            problems[code] = f"redirects to {final}"
        elif not back:
            problems[code] = "has no hreflang annotations"
        elif target not in {normalise(u) for u in back.values()}:
            problems[code] = "does not link back"
    return problems

针对我们自己的首页运行:

import requests

from hreflang import hreflang_map, check_return_links

session = requests.Session()
session.headers["User-Agent"] = "ExampleCollector/1.0 (+https://example.com/bot)"

url, alternates, problems = hreflang_map("https://shifter.io/", session)
for code, alternate in sorted(alternates.items()):
    print(code, alternate)
print("problems:", problems)
print("return-link problems:", check_return_links(url, alternates, session))
de https://shifter.io/de
en https://shifter.io/
es https://shifter.io/es
fr https://shifter.io/fr
ja https://shifter.io/ja
ko https://shifter.io/ko
pt-BR https://shifter.io/pt
x-default https://shifter.io/
zh-Hans https://shifter.io/cn
problems: []
return-link problems: {}

有两个限制值得了解。这段代码只读取HTML中的标注;对于在HTTP头或站点地图中发布标注的网站,也需要读取那些内容,而将站点地图作为发现来源涵盖了站点地图方面的内容。另外,check_return_links会抓取给定的每一个替代版本,所以在一个有150个版本的页面上,请抽样检查几个,或者控制好请求的节奏。

实际应用

  • **从每种模板选一个页面开始。**一个产品页、一个分类页和一篇文章页通常共享同一种标注模式,所以对每种各抓取一次,就能看出整个网站如何映射其市场。
  • **按集合自身的URL来标记各个版本。**存储每个替代版本所声明的URL,把重定向当作发现,而不是噪音。
  • **以本地访问者的身份抓取每个版本。**使用目标市场的出口节点和该市场的语言,然后检查你是否被重定向了。在我们的四个重定向案例中,有两个是由位置决定的,正如哪些国家最常遭遇地理封锁中所述。
  • **在信任代码之前先验证它们。**一个颠倒或自创的代码可能会悄悄把一个页面分配给错误的市场。
  • **规范化你采集到的数据。**一旦你拥有了每个版本,价格、日期和数字的格式仍然会有差异,这一点在跨地区规范化价格、数字和日期中有所涉及,而比较本身则是检测基于地理位置的个性化定价的主题。

结论

hreflang是网络上最接近一个页面市场发布索引的东西。三分之一的头部网站提供了它,一次抓取就能揭示整套版本,而且大多数版本确实如应有的那样互相链接。

但它是一张地图,不是保证。代码可能是错的,你到达的URL可能不是该集合所使用的URL,而且有些版本只会对位于正确位置或使用正确语言的访问者打开。阅读这些标注,验证它们,并像本地访问者那样抓取每个版本。

来源与参考资料

  • Google Search Central, 告诉Google你页面的本地化版本。
  • Tranco, 列表 Q2K34,汇总了2026年9月1日至30日的域名排名。
  • Babel,其CLDR数据提供了代码中使用的语言、文字系统和地区列表。
  • 首页及替代版本由Shifter于2026年10月1日抓取,直接抓取以及通过Shifter出口节点抓取,使用了上述代码。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始