如果你要跨市场比较价格、内容或可用性,第一个问题就是在每个市场中找到同一个页面。猜测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-en | 1(48个代码) |
| 用下划线代替连字符 | en_GB、de_DE | 1(25个代码) |
| 非ISO 3166-1地区代码 | en-uk、en-eu、sq-xk | 3 |
| 拉丁美洲西班牙语 | es-419 | 2 |
| 非ISO 639-1语言代码 | ceb、skr、已过时的希伯来语代码iw | 3 |
| 自创代码 | tc、zh-FT、ms-en | 3 |
有些网站出现在不止一行中。国家在前的模式对解析器来说最为棘手,因为其中几个代码反过来读恰好也是有效的:ca是加泰罗尼亚语的代码,id是印尼语的代码,th是泰语的代码,所以ca-en会被读成加泰罗尼亚语,而不是加拿大的英语。永远不要仅凭代码本身就推断市场,一定要核实它是否合理。
返回链接
我们从每个带标注的首页中最多取两个替代版本,共155个,抓取它们,并检查每个是否把首页列为回链。
| 结果 | 替代版本数 |
|---|---|
| 直接链接回原页面 | 133(86%) |
| 链接回同一页面的不同URL | 9 |
| 重定向到了别处 | 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出口节点抓取,使用了上述代码。