本站自己就是案例:中文 134 个页面,扩成 10 个语种共 1340 个 URL。翻译不是逐页粘进翻译器,而是「源站只维护一份中文,其余全是生成物」。这套管线跑完,质检脚本给出 0 error / 0 warning。下面把这套流程完整拆开,包括每一步为什么这么做、以及真正踩过的坑。
一、先看结果:改造前后对比
先给结论,省得你以为是「找了个翻译公司翻了 1000 页」。
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 语种 | 1(zh-CN) | 10(zh-CN + en / ja / ko / zh-TW / es / de / fr / vi / th) |
| 页面 | 134 个中文页 | 1340 个 URL(134 × 10),与源站严格同构 |
| hreflang | 无 | 每页 11 条 alternate(10 语种 + x-default) |
| sitemap | 1 份中文 sitemap | sitemap.xml(中文)+ sitemap-i18n.xml(1340 条)+ sitemap_index.xml |
| 站内搜索 | 1 份中文索引 | 10 份(按 <html lang> 自动切换,取不到回退中文) |
| 质检 | 人工翻着看 | 脚本 5 道闸,逐页逐语种跑,0 error / 0 warning |
| 日常维护 | — | 只改中文源页,跑一条命令,9 个语种同步更新 |
需要说明的是:这套东西不是运行时 i18n,是构建期生成本地文件。服务器上就是一堆静态 HTML,没有 i18n 框架、没有语言中间件、没有数据库切语种。好处是部署简单(rsync 即可)、页面快、缓存友好;代价是必须有条管线,否则 1340 个文件迟早失控。
① 定架构 ② 盘点可译面 ③ 抽取器 ④ 翻译 + 缓存
/<lang>/ 子目录 翻 / 不翻 清单 文本指纹 sha1 术语表 + 三级补丁
│ │ │ │
└──────────────────┴────────────────────┴────────────────────┘
│
⑤ 生成器(head / 导航 / 页脚 / 正文)
│
⑥ 质检 5 道闸 → 分批上线 → 回滚预案
二、第一步 · 定架构(最不该拍脑袋的一步)
很多人一上来就翻译,翻完了才发现 URL 结构错了——这时候要重做的是 hreflang、sitemap、内链、甚至已收录的地址。所以先定四件事。
决策 1:URL 策略选哪一个
| 方案 | 样子 | 优点 | 代价 |
|---|---|---|---|
| 子目录(本站选的) | /en/… | 同一份部署、同一套证书、权重集中在主域、日志与 CDN 规则通用 | 没有地域信号,只能靠 hreflang 声明 |
| 子域名 | en.site.com | 可独立部署/独立团队,地域信号略强 | 每个子域要单独证书、Search Console、分析、缓存策略 |
| 独立域名 | site.de | 本地品牌感最强,合规隔离好 | 成本最高,权重从零开始,运营是 N 个站 |
| 参数 | ?lang=en | 改动最小 | 收录最差、分享易丢参数、缓存不友好,不建议 |
选子目录的核心理由很朴素:一套静态文件、一条部署链路、一个证书。对一个内容型站点来说,权重集中比地域信号更值钱。
决策 2:路径要不要翻译 slug
本站的决定是不翻:/100wen/what-is-geo/ 对应 /en/100wen/what-is-geo/,路径完全镜像。
- 镜像的好处:源页新增一页,生成器直接按同一路径落盘;内链不用逐条改;hreflang 两边一一对应,出错概率接近零。
- 翻译 slug 的代价:需要一张长期的别名映射表,每次新增页面都要维护,映射一错就是死链;收益(关键词出现在 URL 里)在这个权重级别上微乎其微。
决策 3:要不要自动跳转
决策 4:源语言页要不要动
不动。中文页保持在根目录原路径,这样既有的收录、外链、分享链接全部不受影响。新增的 9 个语种是"增量目录",出问题删掉目录就回滚,中文站毫发无损。
/ ← 中文源站(路径不变) ├── index.html ├── 100wen/… ← 134 个源页面 ├── en/ ja/ ko/ zh-TW/ ← 生成物:与源站同构 ├── es/ de/ fr/ vi/ th/ ├── assets/js/i18n/ ← 工具页 JS 文案字典(en.js 等) ├── search-index-en.json ← 每语种一份站内搜索索引 ├── sitemap.xml ← 只收中文页 ├── sitemap-i18n.xml ← 收全部 1340 条 └── sitemap_index.xml ← 只提交这一个给搜索引擎
/en/index.html 占位页,确认相对路径、静态资源、导航都正常,再开始批量生成。架构错了改一次是灾难,架构对了后面全是流水线。三、第二步 · 盘点可翻译面
「翻译页面」这句话太粗。一个页面里能被爬虫和用户看到的中文有十几个来源,逐类过一遍,产出清单:
| 位置 | 翻不翻 | 说明 |
|---|---|---|
<title> / meta description | 必翻 | 搜索结果里的门面 |
og:* / twitter:* | 必翻 | 分享卡片,含 og:site_name 这种容易漏的 |
| H1–H4 / 正文 / 列表 / 表格 | 必翻 | 主体 |
图片 alt、title、aria-label、placeholder | 必翻 | 属性和正文一样会被读出来 |
<pre> / <code> 里的中文散文 | 必翻 | 提示词示例、检查清单、ASCII 结构图——读者要照抄的正是这些 |
| 导航菜单 / 页脚 / 免责声明 | 必翻 | 由生成器统一注入,不靠人写 |
JSON-LD 里的 headline / description / inLanguage | 必翻 | 否则富媒体卡片露中文 |
| 工具页的 JS 文案(按钮、提示) | 必翻 | 在 inline <script> 里,要单独处理 |
| 品牌名与平台名(百度 / 抖音 / 微信 / 小红书…) | 不翻 | 进术语表白名单,永不送进模型 |
| ICP 备案号 | 不翻 | 法规要求保持中文原文 |
| 纯 ASCII 代码、URL、slug | 不翻 | 翻译只会搞坏它们 |
<pre> 当成"代码块"跳过。但本站的 <pre> 里装的是「AI 提示词示例」「上线检查清单」「目录结构图」——这些是给人看的散文。所以抽取器的跳过名单最终收窄成 script / style / textarea 三个,其余只要含中文就抽。判据不是标签,是这段文字有没有人读。四、第三步 · 搭管线:抽取 → 翻译 → 生成 → 质检
为什么不用现成的 i18n 框架
因为本站是手写 HTML,不是模板渲染。任何"解析成 DOM 再序列化输出"的方案,都会把内联样式、注释、未加引号的属性、缩进风格打散——生成页和源页长得不一样,后面就再也无法用 diff 判断改动是否安全。
所以整套管线的第一原则是:只改字节区间,不重排结构。
① 抽取器:给每一段文字发一个身份证
- 用 quote-aware 的 tokenizer 扫出四类 token:
tag/text/comment/raw(script、style)。 - 只抽"含中文字符"的文本节点,以及白名单属性(
alt/title/aria-label/placeholder/ meta 的content)。 - 每段算一个文本指纹
key = sha1(text)[:16],作为翻译缓存的键。 - 回填时按
(start, end)从后往前替换,光标不会错位。
② 翻译层:术语表 + 三级补丁
调 OpenAI 兼容接口(本站用 DeepSeek),一次请求批量返回 JSON 数组。真正决定质量的是三件事:
| 机制 | 解决的现实问题 |
|---|---|
| 术语表 keep | 品牌名不一定翻。百度 在英文页就该是 Baidu,小红书 保留原文;替换时必须按长度倒序,否则「大黄蜂」会先匹配并吃掉「大黄蜂跑车」 |
| 术语表 terms | 同一概念全站统一:非付费营销 = organic marketing、投流 = paid media buying、私域 = private domain |
| zh-TW 用词偏好 | 繁中不是简体转码:优化→優化、视频→影片、软件→軟體、设置→設定 |
模型偶尔会把中文原样吐回来(尤其是三个字以内的短标签)。所以回填前要跑三级补丁:
- strict 重问:明确要求"每个汉字都要翻译",再来一轮;
- 按行拆:FAQ、JSON-LD 这类超长单段按换行拆成多行,一次批量翻完再拼回;
- 纯文本重问:混合引号会让 JSON 协议崩掉,就退化成"只回译文,不要 JSON"逐段问。
③ 生成器:把公共部分交给代码,不要手写
每个生成页由四块拼成,全部用可识别的注释围栏包住,方便幂等重跑:
<!-- i18n:assets:start --> assets/i18n.css 的引用 <!-- i18n:meta:start --> canonical / og:url / og:locale(每语种指向自己) <!-- i18n:hreflang:start --> 10 条 alternate + x-default <!-- i18n:header:start --> 导航 + 语言选择弹窗(按语种生成) <div class="footer-legal"> ICP 备案号(中文)+ 该语种免责声明
重跑时第一步永远是先删旧的围栏块,再从源页重新生成。不删就会叠加——这个坑踩过一次,页面里会出现两套 hreflang。
④ 质检:五道闸,缺一不可
| 闸门 | 判据 | 拦住的典型问题 |
|---|---|---|
| 残留文字 | 非中文页的可见文本里不该有中文字符 | 某段漏译、按钮回退中文 |
| 死链 | 每个 href / src 都能解析到真实文件 | 导航相对路径算错、语种目录缺页 |
| hreflang 互指 | 每个 alternate 都能回指自己,缺语种即报错 | 新增语种后老页面没补 alternate |
| 标签结构一致 | 生成页的标签序列与源页逐项相等 | 少一个 </div>、属性被引号截断 |
| 字典完整 | 工具页 JS 字典键全齐、无中文残留值 | 新增按钮没配 9 个语种的文案 |
第 4 道闸是最容易被忽略、也最值钱的一道。翻译错了你一眼能看出来,标签结构崩了往往只有某个设备上某个宽度才看得出来——而用"标签名序列比对"这种笨办法,能 100% 拦住它。
五、第四步 · 翻译质量:从 2140 条残留到 0
第一次全量跑完,质检脚本报了 2140 条残留。这不是"翻译得不好",是纯粹的漏网——每一条都对应一个具体的机制缺陷。收敛过程大致是:
| 现象 | 根因 | 处置 |
|---|---|---|
| 部分中文原样留在页面上 | 那一段是超长段落,模型整段回吐 | 超过 600 字的段落按句拆开,每 8 句一批重译再拼回 |
| 术语误替换(把长词拆坏) | keep 按数组顺序匹配,短词先命中 | 匹配前按长度倒序排序 |
| 结构化数据里仍是中文 | JSON-LD 的 articleBody 是超长单字段 | 用该页面已本地化的 description 兜底 |
| 残余碎片(半句、单个词) | 模型输出被截断 | 碎片补丁:只对剩余的中文片段再问一次 |
| 日文页大面积误报 | 用"含汉字"判断日文未翻译,日语本来就有汉字 | 改用简体字特征集合(这个们说时发…)判定 |
| 人工校对结果下次被覆盖 | 直接改生成页 | 统一写进 manual.json(按中文源文精确匹配),渲染时覆盖缓存 |
最后一步是把脚本清零,而不是把报错注释掉:2140 → 0 error / 0 warning。这之后每次改内容,跑一遍闸门就知道有没有回归。
六、第五步 · SEO 收尾:hreflang、sitemap、站内搜索
hreflang:每页 11 条,互相指认
<link rel="alternate" hreflang="zh-CN" href="https://www.songbing.com.cn/100wen/what-is-geo/"> <link rel="alternate" hreflang="en" href="https://www.songbing.com.cn/en/100wen/what-is-geo/"> <link rel="alternate" hreflang="ja" href="https://www.songbing.com.cn/ja/100wen/what-is-geo/"> … 共 10 个语种 … <link rel="alternate" hreflang="x-default" href="https://www.songbing.com.cn/100wen/what-is-geo/"> <link rel="canonical" href="https://www.songbing.com.cn/en/100wen/what-is-geo/">
三条硬规则:
- canonical 指向自己,不是指向中文页。所有语种都指向中文页,等于告诉引擎"这些页都别收录"。
- 只输出目标页真实存在的语种。生成前先检查文件在不在,否则会产生指向 404 的 hreflang——这比不写更糟。
- 必须能回指。质检闸会逐个 alternate 反向验证,缺一条就报错。
sitemap:三层,只提交最外层
sitemap_index.xml ← 只把这一个提交到 Search Console / 百度站长平台 ├── sitemap.xml 中文源站(不含多语言目录) └── sitemap-i18n.xml 全部 1340 条(10 语种 × 134 页)
站内搜索:按 <html lang> 选索引
本站的《100 问》知识库有全文搜索。多语言后如果还只喂中文索引,英文读者搜 "geo" 会搜不到东西。做法是每语种生成一份 search-index-<lang>.json(正文取页面的 .content 容器,截前 4000 字),前端按 document.documentElement.lang 选文件,取不到就回退中文。
search-index-en.json?v=20261021 这样的地址加版本号,结果因为属性没加引号,抽取器的属性正则把 = 当成了新的属性边界,把标签拆坏。要么统一给属性加引号,要么别在这种地址上加参数。七、第六步 · 分批上线与回滚
1340 个文件一次性推上去是赌博。本站的做法是按语种目录分批:
| 批次 | 内容 | 放量理由 |
|---|---|---|
| 第 1 批 | /en/ | 灰度:英文是最大的目标市场,也最容易人工校验 |
| 第 2 批 | /ja/ /ko/ /zh-TW/ | 东亚语种,结构与英文同批验证过 |
| 第 3 批 | /es/ /de/ /fr/ /vi/ /th/ | 其余语种 |
| 第 4 批 | 导航入口 + sitemap-i18n.xml + robots | 最后开门,前面都验证过才让爬虫进来 |
git add en/ && git commit -m "i18n: 英文版上线(134 页)" && git push # 观察 24h:日志无 404、抽查 5 页无残留中文、hreflang 互指通过 git add ja/ ko/ zh-TW/ && git commit -m "i18n: 日/韩/繁中上线" git add es/ de/ fr/ vi/ th/ && git commit -m "i18n: 其余五语种上线" git add sitemap-i18n.xml sitemap_index.xml robots.txt git commit -m "seo: 多语言 sitemap 与入口开门" && git push
回滚就是一个 rm -rf:删掉出问题的语种目录,中文站完全不受影响。这是"增量目录"架构最大的好处——风险被物理隔离了。
孤儿页不要翻译
站里总有几个零入链的历史页面(本站是 3 个:article.html、douyin_estimator2.html、tianjin-transfer.html)。它们的处理方式是:不翻译、保留可访问、从 sitemap 与 llms.txt 里摘掉。省下来的不是翻译费,是 3 × 10 = 30 个"有内容但没人需要"的 URL 带来的抓取预算浪费。
八、避坑清单:13 条真实踩过的坑
- inline
<script>里的文案抽不到。 教程页有「复制 / 已复制」按钮,文案写在脚本里,页面一加载就变回中文。解法:单独一张 JS UI 文案表,按语种替换脚本里的字符串。 <script>里的 JSON 数据也是中文。 目录页的「热门文章」标题存在var POP_TITLES={…}里,抽取器按约定跳过 script。解法:用各语种文章自己的 H1 回填。og:site_name这类 meta 容易漏。 默认只抽name="description",property 形式的要显式加进白名单。- 引号冲突会把页面拆坏。 模型返回的直引号落进单引号属性里,标签当场截断。回填前必须按属性引号种类转义。
- 页脚合规块不能只写在源页。 重跑生成器就冲掉,必须由生成器注入;ICP 号永久保留中文。
- 术语替换要按长度倒序。 否则「大黄蜂」先命中,「大黄蜂跑车」就被拆成两半。
- 未加引号的属性会被正则拆坏。
src=foo.json?v=1里的=是隐患,要么加引号,要么别加参数。 - 生成器必须幂等。 先删旧围栏块再生成,否则重跑一次就多一套 hreflang。
- 标签结构漂移是最隐蔽的错。 文字全对、页面在某些设备上错位——只有标签序列比对能拦住。
- 日文不能按"含汉字"判未翻译。 用简体字特征集合,否则满屏误报,最后你会把闸门关掉。
- hreflang 不要指向不存在的页面。 生成前先验证文件存在,缺语种就不输出那一条。
- 语言变长要留白,不要缩短文案。 同一句德语常比中文长 1.5–2 倍。按钮用 CSS 弹性布局,别靠删字。
- "万 / 亿"不要直译。 用
Intl.NumberFormat(lang, {notation:'compact'}):中文显示 8.5万,英文 85K,韩文 만。
九、成本、维护与日常命令
成本结构
| 项 | 量级 | 说明 |
|---|---|---|
| 一次性全量翻译 | 每语种约 1.1 万条文本片段 | 按 token 计费,用便宜的中文强模型即可;质量靠术语表 + 人工抽检兜底 |
| 日常增量 | 新增 1 页 ≈ 该页段数 × 9 | 缓存命中,不改动的段落零成本 |
| 人力 | 1 人 + 一套脚本 | 主要花在清单盘点与终审抽检 |
| 服务器 | 0 增量 | 纯静态文件,nginx 直接吐 |
日常命令
# 1. 看看还有多少段要翻(不写文件、不调模型) python3 tools/i18n/build.py --dry-run # 2. 只跑某个语种 / 只跑某一页(增量) python3 tools/i18n/build.py --locales en python3 tools/i18n/build.py --pages index.html # 3. 不调模型,只按缓存重新渲染(改了模板/导航时用) python3 tools/i18n/build.py --render-only # 4. 五道闸质检:目标是 0 error / 0 warning python3 tools/i18n/check.py # 5. 重建多语言站内搜索索引 python3 tools/i18n/build_search_index.py
维护心法三条
- 只改中文源页,外语页永远由生成器覆盖。手改生成页 = 下次构建就丢。
- 人工校对的成果写进
manual.json,按中文源文精确匹配,渲染时覆盖缓存——这是唯一允许手工干预的入口。 - 新增页面 = 加一行配置。把页面加进管线清单,跑一次 build,9 个语种的对应文件、hreflang、sitemap 全部自动就位。
十、附:改造流程 SKILL.md
上面这套流程已经固化成一份可直接复用的技能文档。把下面这段 markdown 存成 SKILL.md 放进你的技能目录(或直接丢给 AI 当作业指导书),它就能按同样的六个阶段带你把一个单语站扩成多语站,包含决策表、五道闸判据和踩坑清单。
下载后把文件名改回 SKILL.md 即可(站点的源码防护规则会拦截 .md 直链,因此提供 .txt 副本,内容与仓库里的 SKILL.md 完全一致)。
---
name: multilingual-site-revamp
display_name: 多语言站点改造
description: 把已有的单语种站点扩成多语种(子目录镜像 + hreflang + 本地生成),覆盖架构选型、字符串抽取、术语表、增量翻译缓存、质检与分批上线。适合静态站、半静态站与手写 HTML 站点。
icon: "🌐"
trigger: 多语言 多语种 国际化 i18n 本地化 hreflang 站点改造 出海建站
inputs:
- name: site_root
description: 站点源码根目录(静态站即 HTML 所在目录)
required: true
- name: target_locales
description: 目标语种代码,如 en,ja,ko,zh-TW,es,de,fr,vi,th
required: true
- name: url_strategy
description: subdir(默认)/ subdomain / ccTLD,决定目录结构与 hreflang 写法
required: false
scripts: [extract.py, llm.py, build.py, check.py]
---
# 多语言站点改造
## Overview
把一个已经在运营的单语种站点,扩成 N 个语种、且机器可读层面(hreflang / sitemap / 结构化数据)全部正确的站点。核心不是"翻译文字",而是**建立一条可重复执行的生成管线**:源语言只维护一份,其余语种全部是生成物。
参考实现:songbing.com.cn(134 页 × 10 语种 = 1340 URL,全静态,check 脚本 0 error / 0 warning)。
## 什么时候用 / 不用
用:
- 站点已有稳定内容与收录,不想为 i18n 重构框架
- 页面是手写 HTML / 模板生成,但结构相对统一(每页都有标题、正文、页脚)
- 目标语种 ≥ 3,且预期会持续更新内容(增量翻译价值才体现出来)
不用:
- 站点还没定型、页面结构天天变 → 先定型再翻译,否则每次改结构都要重译
- 只有 1-2 个页面要翻 → 手工翻更快
- 需要按用户语言自动 301 跳转 → 这是另一件事,且多数搜索引擎不推荐
## Workflow
### 阶段 1 · 定架构(deterministic)
先定三件事,写进决策表再动手:
| 决策 | 选项 | 判据 |
| --- | --- | --- |
| URL 策略 | 子目录 `/en/` | 默认选它:同一份部署、权重集中、证书与日志通用 |
| | 子域名 `en.site.com` | 只有需要独立 CDN/独立团队/独立合规时才选;每个子域要单独配证书、GSC、分析 |
| | 独立域名 `site.de` | 有本地化品牌与本地合规需求时才选;成本最高 |
| 路径是否翻译 slug | 不翻译,镜像原路径 | 默认不翻:别名映射是长期维护负担,hreflang 已足够表达对应关系 |
| 是否自动跳转 | 不自动跳 | 语言选择器给建议,不强制 301。自动跳转会干扰爬虫与回访用户 |
验证:手写一个 `/en/index.html` 占位页,确认目录结构、相对路径、静态资源都能正常访问,再开始批量。
### 阶段 2 · 盘点可翻译面(deterministic)
逐类过一遍,产出"翻 / 不翻"清单:
必翻:`<title>`、meta description、`og:*` 与 `twitter:*`、H1-H4、正文、列表、表格、图片 `alt`、`title`、`aria-label`、`placeholder`、JSON-LD 里的 `headline` / `description` / `inLanguage`、导航菜单、页脚免责声明、**`<pre>` / `<code>` 里的中文散文**(提示词、检查清单、ASCII 结构图——读者要照抄,必须翻)。
不翻:品牌名与平台名(百度、抖音、微信、小红书…)、ICP 备案号(法规要求保留中文)、纯 ASCII 代码与 URL、URL slug、统计数据里的数字格式(用 `Intl.NumberFormat` 处理,别直译"万/亿")。
验证:把清单写成术语表 `glossary.json` 的 `keep` 数组,后面所有语种共用。
### 阶段 3 · 写抽取器(deterministic,难度最高的 20%)
不要用 DOM 解析再序列化——内联样式、注释、未加引号的属性都会被打散。正确做法是**只改字节区间**:
1. 用 quote-aware 的 tokenizer 扫 `tag / text / comment / raw` 四类 token
2. 只抽取"含目标语言字符"的文本节点与指定属性
3. 每段生成一个**文本指纹** `key = sha1(text)[:16]`,作为翻译缓存的键
4. 回填时按 `(start, end)` 从后往前替换,源页与生成页除文字外逐字节一致
验证:对同一页面连续跑两次,第二次应无 diff(幂等)。回填后标签序列必须与源页完全一致。
### 阶段 4 · 接翻译(agentic 判断 + deterministic 调用)
- 用 OpenAI 兼容接口,按"语种 + 术语表 + 语境"拼 system prompt,一次请求批量返回 JSON 数组
- 缓存按 `key` 存 `cache/<lang>.json`,改一段只重译那一段;全量重翻用 `--force`
- 术语表 `keep` 按**长度倒序**替换,否则短词先吃掉长词
- 失败三级补丁:strict 重问 → 含换行的长段按行拆开重问 → 纯文本逐段重问
- 人工校对结果写 `manual.json`(按源文精确匹配),渲染时覆盖缓存——这是唯一允许手改的地方
验证:`--dry-run` 打印每语种待译段数,新增页面时该数字应等于新页面段数而不是全站段数。
### 阶段 5 · 生成器(deterministic)
每页按顺序拼装,全部用可识别的注释块包裹,便于幂等重跑:
1. `strip` 掉旧的生成块(否则重跑会叠加)
2. head 注入:canonical(指向自己)、`og:url`、`og:locale`、hreflang 集群 + `x-default`
3. 导航头按语种生成(菜单文案走字典,链接用相对路径计算,不要硬编码)
4. 页脚合规块按语种注入(备案号保留中文,免责声明翻译)
5. 回填正文,写出 `/<lang>/<page>`
hreflang 只输出**目标页真实存在**的语种——指向 404 的 hreflang 比不写更糟。
### 阶段 6 · 质检与上线(deterministic)
五道闸全部为 0 才算完成:
| 闸门 | 判据 |
| --- | --- |
| 残留文字 | 非中文页的可见文本里不应有源语言字符(日文用简体字特征表判定,不能用"含汉字") |
| 死链 | 每页每语种的 `href` / `src` 都能解析到真实文件 |
| hreflang 互指 | 每个 alternate 都能回指自己,缺语种即报错 |
| 标签结构 | 生成页的标签序列与源页逐项相等(最隐蔽的错就是这里) |
| 字典完整 | 工具页 JS 字典的键全齐,且没有回吐源语言的值 |
上线:按语种目录**分批** commit + push,先灰度 1 个语种看日志与收录,再放其余;`sitemap-i18n.xml` 与导航入口放在最后一批。回滚 = 删除该语种目录。
## Lessons Learned
### Do
- 先定架构与清单,再写代码。80% 的返工来自架构反复。
- 用文本指纹做缓存键:内容不变就不重译,这是成本控制的全部。
- 生成物全部加注释围栏,保证幂等可重跑。
- 把"校验"做成脚本而不是人工抽查:1330 页人工是看不完的。
- 品牌名、法规文本(ICP)走白名单,永不进模型。
### Don't
- 不要重排 DOM 再输出(内联样式/注释/引号风格会被毁)。
- 不要让 hreflang 指向不存在的页面。
- 不要把所有语种的 canonical 都指向源语言页——那是自废收录。
- 不要用"整站重翻"解决问题:先看是缓存键错、术语表顺序错,还是抽取器漏了区域。
- 不要在生成页上手改文字:下次生成就没了,改源语言页或 `manual.json`。
### Common Failures
- **按钮文案回退成源语言**:文案在 inline `<script>` 里(复制/已复制),抽取器按约定跳过 script → 需要单独的 JS UI 文案表按语种替换。
- **"热门文章"列表仍是中文**:标题在 `<script>` 里的 JSON 对象中 → 用各语种文章自己的 H1 回填。
- **分享卡片标题露中文**:`og:site_name` 这类 `meta property` 不在默认白名单里 → 要显式加入抽取属性集。
- **属性被截断、页面布局崩**:模型返回的直引号与属性引号冲突 → 回填前按属性引号种类转义。
- **术语替换错位**:短词先匹配吃掉长词("大黄蜂" 吃掉 "大黄蜂跑车")→ `keep` 按长度倒序。
- **未加引号的属性被拆坏**:`src=foo.js?v=1` 里的 `=` 会被属性正则误判 → 静态资源不加版本参数,或统一加引号。
- **日文页大面积误报**:用"含汉字"判定日文未翻译必然失败 → 改用简体字特征集合。
- **重跑后备案号消失**:页脚合规块必须由生成器注入,不能只写在源页。
### When to Ask the User
- URL 策略要不要动老 URL(涉及 301 与收录损失,必须人决策)
- 哪些品牌词必须保留原文(不同公司口径不同)
- 某个语种的译文质量是否达标(机器能测"有没有中文",测不了"像不像人话")
- 上线节奏(先灰度哪几个语种)
## Output
交付物固定为四样:
1. `<lang>/…` 全套生成页(与源站同构)
2. `sitemap-i18n.xml` + `sitemap_index.xml` + 更新后的 `robots.txt`
3. 质检报告:五道闸的 error / warning 计数(目标 0 / 0)
4. 术语表 `glossary.json` 与人工覆盖 `manual.json`(下一轮维护的唯一入口)
## 参考实现
songbing.com.cn 的管线(本地保留,不入公开仓库):
```
tools/i18n/
├── config.json 站点、语种、页面清单、资源路径
├── extract.py 字节级抽取/回填 + 未翻译检测
├── llm.py OpenAI 兼容客户端 + 占位符保护
├── build.py 翻译调度 + 页面生成 + 页脚/hreflang 注入
├── check.py 五道闸
├── fix_residual.py 残留清理(长段拆句 → 三级补丁)
├── glossary.json keep / terms / zh-TW 用词偏好
├── manual.json 人工校对覆盖
└── cache/<lang>.json 文本指纹 → 译文
```
日常命令:
```bash
python3 tools/i18n/build.py --dry-run # 还要翻多少段
python3 tools/i18n/build.py --locales en # 只跑一个语种
python3 tools/i18n/build.py --pages index.html # 只跑一页(增量)
python3 tools/i18n/build.py --render-only # 不调模型,只重渲染
python3 tools/i18n/check.py # 五道闸
```
结语
多语言站点的难点从来不在"翻译",而在把翻译变成一条可重复执行、可验证、可回滚的流水线。一旦这条线搭好,第 11 个语种的成本接近于零;搭不好,第 2 个语种就会把你拖进"每次改内容都要重新翻 9 个语种"的泥潭。
先用真实页面跑通一个语种,再谈规模化。这是这套流程里最值钱的一句话。