打开 EN 文章再打开对应的 ZH——两边几乎不像翻译。EN 有 5 个 ## 章节、3 个代码块;ZH 只有 3 个章节、1 个代码块。EN 六周前加了 FAQ 段——ZH 根本没收到。EN 把”Step 1: Audit”改名成”Step 1: Inventory”——ZH 还是旧措辞。这对共享一个 translationKey,但内容结构已经走散。
这和单纯的字数漂移(中文更紧凑)不同——这是结构漂移:章节数不同、代码块数不同、链接指向不同、小标题在翻译已不存在的概念。通过 hreflang 落地的读者会觉得被骗,Google 看到 alternate 不匹配会同时降两端的信任度。修法有三条腿:按结构审计(不只看时间戳)、PR 层强制 translate-as-you-edit、接受”标单语”是低价值页的合法选择。
常见原因
1. 单边编辑没触发翻译工单
你编辑 en/foo.mdx 加了新章节,提交。没东西提醒你 zh/foo.mdx 现在缺这段。半年 200 篇文章这样重复——结构鸿沟就拉开了。
如何判断:按文件统计 ## 标题数并按对 diff。
for f in src/content/articles/en/troubleshooting/*.mdx; do
key=$(basename "$f")
zh="src/content/articles/zh/troubleshooting/$key"
[ -f "$zh" ] || continue
en_sec=$(grep -c '^## ' "$f")
zh_sec=$(grep -c '^## ' "$zh")
if [ "$en_sec" != "$zh_sec" ]; then
echo "$key: en=$en_sec zh=$zh_sec"
fi
done
en 和 zh 相差 2+ 的对,就是结构漂移而非语言精简。
2. 编辑只往一个方向走——通常 EN -> ZH 卡住
多数内容站有主作者先写 EN,ZH 几周后才翻(如果翻的话)。后续 EN 修订基本不会反向回到 ZH——一开始对齐,每个 PR 都漂一点。
如何判断:列出 EN mtime 比 ZH 新 30 天以上的对。
3. 改了 translationKey 或移动文件——悄悄断对
EN 改了 slug,translationKey 指向缺失的 ZH 文件(或带旧 key 的过期文件)。hreflang 发出悬空对,build 不报错。
如何判断:导出两端 translationKey 后 diff:
diff \
<(grep -h "^translationKey:" src/content/articles/en/**/*.mdx | sort -u) \
<(grep -h "^translationKey:" src/content/articles/zh/**/*.mdx | sort -u)
4. EN 新加的代码示例没复制到 ZH
EN 加了一段带新 shell 脚本的代码块,ZH 还是旧版(或根本没代码块)。代码块本身语言无关,但前后散文不是——ZH 页现在引用一段不在页面上的脚本。
如何判断:按对统计三反引号数并 diff。
5. FAQ 段只加在一边
EN 加了 ## FAQ 带 3 个 ### Question? 条目,ZH 没收到。FAQ JSON-LD 只在 EN 发出——ZH 页失去一次 rich result 机会、看起来更薄。
最短修复路径
Step 1:跑一次全量结构 diff
写一个比结构(不只比 mtime)的脚本:
# scripts/audit-pair-structure.mjs
import fs from "node:fs";
import path from "node:path";
const EN_DIR = "src/content/articles/en/troubleshooting";
const ZH_DIR = "src/content/articles/zh/troubleshooting";
function metrics(file) {
const txt = fs.readFileSync(file, "utf8");
return {
h2: (txt.match(/^## /gm) || []).length,
h3: (txt.match(/^### /gm) || []).length,
code: (txt.match(/^```/gm) || []).length / 2,
lines: txt.split("\n").length,
};
}
for (const f of fs.readdirSync(EN_DIR)) {
const en = path.join(EN_DIR, f);
const zh = path.join(ZH_DIR, f);
if (!fs.existsSync(zh)) continue;
const a = metrics(en), b = metrics(zh);
if (Math.abs(a.h2 - b.h2) >= 2 || Math.abs(a.code - b.code) >= 2) {
console.log(`DRIFT ${f}: h2 en=${a.h2} zh=${b.h2}, code en=${a.code} zh=${b.code}`);
}
}
输出按漂移程度排序,先同步最差的几对。
Step 2:按对决定——同步 / 拆分 / 标单语
3 个合法选项:
- 同步:把落后那侧补到对齐结构
- 拆分:内容已合理分叉——去掉 translationKey 配对、当两篇独立文章对待
- 标单语:低流量 ZH——去掉 ZH 的 translationKey、从 EN 的 hreflang alternate 中移除
不要”机翻补全缺失章节”。烂 MT 比缺失更糟——要么认真翻,要么拆对。
Step 3:PR 层强制 translate-as-you-edit
加一个 CI 步骤——任何动 en/*.mdx 但没动对应 zh/*.mdx 的 PR 都被标记:
# .github/workflows/translation-sync.yml 片段
- name: Check translation parity
run: |
CHANGED_EN=$(git diff --name-only origin/main -- 'src/content/articles/en/' | grep '\.mdx$' || true)
for f in $CHANGED_EN; do
zh=$(echo "$f" | sed 's|/en/|/zh/|')
if [ -f "$zh" ] && ! git diff --name-only origin/main | grep -q "$zh"; then
echo "::warning::EN changed: $f -- but ZH not updated: $zh"
fi
done
这是 warning 不是 fail——作者要么同 PR 更新 ZH,要么明确开一张 i18n 工单承认漂移。
Step 4:按价值手工回填最差的
在 GSC 用 /zh/articles/* 过滤、按印象排序,挑 top 20 漂移对手工同步。长尾别管,等流量上来再说。
Step 5:验证 hreflang 仍然干净配对
同步后重查 sitemap 中的 hreflang。每对都应该发:
<xhtml:link rel="alternate" hreflang="en" href="https://site.com/en/articles/slug/" />
<xhtml:link rel="alternate" hreflang="zh" href="https://site.com/zh/articles/slug/" />
如果某页标了单语——完全去掉 alternate。半发的 hreflang 比没有还糟。
预防
- CI warning:EN/ZH 单独变更——作者必须回应
- 每周 prebuild 跑结构审计(h2/h3/code-block 计数)
- 新增
## FAQ只在一边需要先开 i18n 工单才能 merge - 改 slug 必须同 PR 改两端——lint 规则强制
- 低流量页明确标单语而不是留着漂
- 季度回顾:top 20 漂移对纳入排期同步