2026年用Python检查多语言站点hreflang:自动揪出“回链缺失/标错语言“等致命错误(附完整代码)

2026年用Python检查多语言站点hreflang:自动揪出"回链缺失/标错语言"等致命错误(附完整代码)

发布时间: 2026-09-20

标签: SEO、多语言、hreflang、国际化、外贸独立站、Python、实战工具

阅读时长: 约 15 分钟

难度: 中级


先说一个外贸独立站最隐蔽的坑

做外贸 / 多语言独立站的,几乎都配置了 hreflang

配错比不配更糟

hreflang 是 Google 用来判断"哪个语言/地区版本给哪国用户看"的标签。它有一套双向校验规则 ,只要有一条不满足,Google 会直接忽略整组标签------你辛苦做的中英日版本,可能全被当成重复内容,互相抢排名。

最常见的致命错误:

  • 回链缺失(return-tag error):A 页标了"去看 B 页的日语版",但 B 页没标回来 → 整组失效
  • 缺自引用 :每个语言页必须包含指向自己的那条 hreflang
  • 语言码写错en-us 写成 en_uszh 写成 cnzH-CN 大小写混
  • 冲突 :同一个 hreflang 值指向了两个不同的 URL
  • x-default:没给"其他所有地区"兜底

这些错误,手动检查几十个页面就头大,几百个页面根本查不过来。

今天写一个 hreflang 多语言检查工具,自动爬完全站,把每一类错误列出来。


一、工具思路

复制代码
站点入口(首页或多语言页集合)
   ↓
BFS 爬取站内页面(同域名)
   ↓
每个页面解析 <link rel="alternate" hreflang="xx" href="...">
   ↓
构建「页面 → {hreflang: 目标URL}」映射
   ↓
校验规则:
   ├─ 自引用:页面是否包含指向自己的 hreflang
   ├─ 回链:A→B 则 B 必须→A
   ├─ 语言码合法性
   ├─ 同页内 hreflang 不冲突
   └─ 是否缺 x-default(警告)
   ↓
报告:错误清单 + CSV 导出

依赖:

bash 复制代码
pip install requests beautifulsoup4

二、完整代码:hreflang_checker.py

python 复制代码
"""
hreflang_checker.py
多语言站点 hreflang 检查器:爬取全站,校验自引用/回链/语言码等
依赖:pip install requests beautifulsoup4
"""
import csv
import re
from collections import deque, defaultdict
from urllib.parse import urljoin, urlparse, urldefrag
import requests
from bs4 import BeautifulSoup


# 常见合法语言码前缀(ISO 639-1)+ 可选地区(ISO 3166-1 alpha-2)
LANG_CODE_RE = re.compile(r"^[a-z]{2}(-[A-Za-z]{2})?$")
# 地区码必须大写两位字母
REGION_RE = re.compile(r"^[A-Z]{2}$")


class HreflangChecker:
    def __init__(self, start_url: str, max_pages: int = 300, delay: float = 0.3):
        self.start_url = start_url.rstrip("/")
        self.domain = urlparse(start_url).netloc
        self.max_pages = max_pages
        self.delay = delay
        self.headers = {"User-Agent": "Mozilla/5.0 (compatible; HreflangBot/1.0)"}
        self.annotations = {}   # url -> {hreflang: target_url}
        self.pages = set()

    def _norm(self, url):
        url, _ = urldefrag(url)
        return url.rstrip("/") or url

    def _is_internal(self, url):
        return urlparse(url).netloc == self.domain

    # ---------------- 爬取 + 解析 hreflang ----------------
    def crawl(self):
        queue = deque([self.start_url])
        seen = set()
        while queue and len(self.pages) < self.max_pages:
            url = self._norm(queue.popleft())
            if url in seen:
                continue
            seen.add(url)
            try:
                r = requests.get(url, headers=self.headers, timeout=10)
            except Exception:
                continue
            if r.status_code >= 400 or "text/html" not in r.headers.get("Content-Type", ""):
                continue
            self.pages.add(url)
            soup = BeautifulSoup(r.text, "html.parser")

            tags = {}
            for link in soup.find_all("link", rel="alternate"):
                hreflang = (link.get("hreflang") or "").strip().lower()
                href = (link.get("href") or "").strip()
                if not hreflang or not href:
                    continue
                target = self._norm(urljoin(url, href))
                tags.setdefault(hreflang, set()).add(target)
            self.annotations[url] = tags

            # 收集站内链接继续爬
            for a in soup.find_all("a", href=True):
                t = self._norm(urljoin(url, a["href"]))
                if self._is_internal(t) and t not in seen \
                        and len(self.pages) < self.max_pages:
                    queue.append(t)
        return self

    # ---------------- 校验 ----------------
    def validate(self) -> dict:
        errors = defaultdict(list)   # url -> [error_msg]
        warnings = defaultdict(list)

        for url, tags in self.annotations.items():
            # 1. 自引用:必须有一条 hreflang 指向自己
            if url not in set().union(*tags.values()) if tags else True:
                # 简化:检查是否有 target == 自身
                if not any(url in targets for targets in tags.values()):
                    errors[url].append("❌ 缺自引用(页面未用 hreflang 指向自己)")

            # 2. 语言码合法性
            for hl in tags:
                if hl == "x-default":
                    continue
                if not LANG_CODE_RE.match(hl):
                    errors[url].append(f"❌ 非法语言码:{hl}(应为 xx 或 xx-XX)")
                else:
                    parts = hl.split("-")
                    if len(parts) == 2 and not REGION_RE.match(parts[1]):
                        errors[url].append(f"⚠️ 地区码应大写两位:{hl}")

            # 3. 同页内同一 hreflang 指向多个 URL(冲突)
            for hl, targets in tags.items():
                if len(targets) > 1:
                    errors[url].append(
                        f"❌ hreflang「{hl}」冲突,指向 {len(targets)} 个URL")

        # 4. 回链(return-tag):A 标注 B,则 B 必须标注 A
        for url, tags in self.annotations.items():
            for hl, targets in tags.items():
                for tgt in targets:
                    if tgt in self.annotations:
                        back = self.annotations[tgt]
                        # B 是否有一条 hreflang==hl 指向 url?
                        if not any(url in back_targets
                                   for back_targets in back.get(hl, set()).__iter__() if False):
                            pass
                        # 简化回链检查
                        back_has = any(url in back.get(hl, set())
                                       for _ in [0])
                        if not back_has:
                            errors[url].append(
                                f"❌ 回链缺失:{url} →[{hl}]→ {tgt},但 {tgt} 未回指")

        # 5. 全站是否缺 x-default(仅警告)
        has_xdefault = any("x-default" in tags
                           for tags in self.annotations.values())
        if not has_xdefault:
            warnings["__global__"].append(
                "⚠️ 全站未检测到 x-default(建议为「其他所有地区」设兜底)")

        return {"errors": errors, "warnings": warnings}

    # ---------------- 报告 ----------------
    def report(self, res: dict):
        errors, warnings = res["errors"], res["warnings"]
        total_err = sum(len(v) for v in errors.values())
        print("=" * 62)
        print(f"🌐 hreflang 检查:{self.domain}({len(self.pages)} 页)")
        print(f"   发现错误 {total_err} 条")
        print("=" * 62)
        for url, msgs in errors.items():
            print(f"\n🔴 {url}")
            for m in msgs:
                print(f"   {m}")
        for key, msgs in warnings.items():
            for m in msgs:
                print(f"\n🟡 {m}")

        # 错误最多的页面排序
        top = sorted(errors.items(), key=lambda x: -len(x[1]))[:10]
        if top:
            print("\n📌 错误最集中的页面 Top:")
            for url, msgs in top:
                print(f"   {len(msgs)} 处  {url}")

    def export(self, res: dict, path: str = "hreflang_errors.csv"):
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["page", "level", "issue"])
            for url, msgs in res["errors"].items():
                for m in msgs:
                    w.writerow([url, "ERROR", m])
            for url, msgs in res["warnings"].items():
                for m in msgs:
                    w.writerow([url, "WARN", m])
        print(f"✅ 错误清单已导出:{path}")


if __name__ == "__main__":
    import sys, time
    start = sys.argv[1] if len(sys.argv) > 1 else "https://example.com/"
    checker = HreflangChecker(start, max_pages=300).crawl()
    res = checker.validate()
    checker.report(res)
    checker.export(res)

三、运行 & 输出示例

bash 复制代码
python hreflang_checker.py https://your-store.com/

输出(节选):

复制代码
==============================================================
🌐 hreflang 检查:your-store.com(142 页)
   发现错误 37 条
==============================================================

🔴 https://your-store.com/ja/products/widget
   ❌ 缺自引用(页面未用 hreflang 指向自己)
   ❌ 回链缺失:/ja/.../widget →[en]→ /en/products/widget,但 /en/... 未回指

🔴 https://your-store.com/zh/product/x
   ❌ 非法语言码:zh_cn(应为 xx 或 xx-XX)

🟡 ⚠️ 全站未检测到 x-default(建议为「其他所有地区」设兜底)

一眼能看出: 日语页漏了自引用和回链 → 整组 hreflang 失效,Google 把日英版本当重复内容互搏。修好这几处,多语言排名通常立刻回正。


四、拿到结果怎么改

错误 修法 参考
缺自引用 每个语言页的 <link> 列表里,必须含一条 hreflang="本页语言" href="本页URL" hreflang 规范
回链缺失 A→B 的同时,B 必须→A(双向一致) 回链说明
语言码错误 xxxx-XX 格式,XX 大写(en-US、zh-CN) 管理多区域站点
冲突 同一 hreflang 只指向一个 URL 本地化版本
缺 x-default 加一条 hreflang="x-default" 指向默认版本 x-default 用法

核心铁律: hreflang 是双向契约。只要有一处不对,Google 宁可当作没看见------所以全站一致性比单页正确更重要。


五、关键参考链接

主题 链接
hreflang 官方规范 https://developers.google.com/search/docs/specialty/international/hreflang
回链(Return Tags)要求 https://support.google.com/webmasters/answer/189077
管理多区域/多语言站点 https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites
本地化版本最佳实践 https://developers.google.com/search/docs/specialty/international/localized-versions
国际化 SEO 总览 https://developers.google.com/search/docs/specialty/international/international-seo

六、进阶

6.1 用 sitemap 的 hreflang 一起查

多语言站点常在 sitemap 里 也写 hreflang(<xhtml:link rel="alternate">)。建议把 sitemap 的标注也解析出来,和页面 <head> 里的相互印证------两边不一致也是常见坑。

6.2 只对"语言页"爬取,提速

python 复制代码
# 若已知语言前缀,限定爬取范围
checker = HreflangChecker("https://store.com/", max_pages=300)
checker.crawl()
# 然后只校验 URL 含 /en/ /ja/ /zh/ 的页面

6.3 接入 CI,发版前自动拦截

把校验做成 CI 步骤,每次部署跑一遍,错误数 > 0 就阻断发版。多语言站规模一大,人工根本兜不住,必须自动化。

6.4 配合 GEO:多语言 = 多市场 AI 引用

2026 年 AI 搜索也是分语言的。你的英文版被 ChatGPT 引用,中文版被豆包/DeepSeek 引用------hreflang 标对了,AI 才能把"对的语言版本"喂给"对的用户"。多语言 SEO 和 GEO 是同一套底层纪律。


七、总结

hreflang 是外贸独立站最容易被配错、却又最影响多市场排名的一块。

这个工具的价值:

  • 自动爬全站,无需逐个页面肉眼查
  • 一次性揪出:缺自引用 / 回链缺失 / 语言码错 / 冲突 / 缺 x-default
  • 输出 CSV,可进 CI、可交接开发、可定期复跑
  • 纯 Python + bs4,零 API key

多语言站排名不稳,先别怪内容------十有八九是 hreflang 有断链。


你的站 hreflang 自检过吗?跑一遍贴错误数,我帮你看最该先修哪几处。


📮 想要本文完整代码包 + 更多 SEO/GEO 实战工具合集?评论区留言或私信我即可,看到都会回。同名公众号「全域SEO增长」有同步更新,搜名字就能找到。

相关推荐
aramae10 小时前
模拟实现strcmp()(C语言)
c语言·开发语言·后端
+VX:Fegn089510 小时前
计算机毕业设计|基于springboot + vue蛋糕店管理系统(源码+数据库+文档)
前端·数据库·vue.js·spring boot·课程设计
泡海椒11 小时前
PDF 表格样式优化:jquick-pdf 边框、圆角、背景色
java·开发语言·pdf
Beyond_System|系统之外11 小时前
【学编程】Python基础编程题100道(21-60)
开发语言·python·算法
Nicander12 小时前
我给 Excalidraw 做了一个本地工作区:DrawSpace
前端·开源
景熙552312 小时前
15.Java 8 Stream 流入门到实战
java·开发语言·数据结构
linux_cfan13 小时前
17 · 引擎适配器全景:HLS/DASH/Vimeo/Mux/Cast
前端·javascript·音视频
计算机魔术师14 小时前
烧掉2780亿美元还不够?OpenAI的资本豪赌让人头皮发麻
前端
Bruce_Liuxiaowei15 小时前
Python 实例赋值遮蔽类属性:一个从不报错的静默陷阱
开发语言·python·语法糖