打造结构保真的HTML翻译流水线:DOM解析与文本节点提取实战 在 Hacker News 上有一个很典型的提问Ask HN: A translation pipeline that doesnt chew up long HTML pages。标题里的 chew up 非常形象它说的不是翻译质量差而是很多人在做网页翻译的时候直接把整段 HTML 塞给大模型或在线翻译接口结果返回的内容里标签被吃掉、样式错乱、长文章从中间截断甚至a href/about这种链接地址都被当成正文翻译成了别的样子。HTML 不是纯文本它是由标签、属性、文本节点、脚本、样式组成的结构化文档翻译工具真正需要的只是正文文本节点和少量可翻译属性其余内容必须原样保留。这篇文章给出一条可落地的 HTML 翻译流水线方案专门处理长页面场景下的结构保真问题。整体流程是DOM 解析 - 提取文本节点 - 按段落边界分块 - 调用翻译引擎 - 回填 DOM - 序列化输出。我会用 Python BeautifulSoup 和 Node.js cheerio 两套实现把流程跑通再补上接口 API、批量目录任务、性能观察和常见排错清单。如果你手上正好有 PHP 站、Docs 站点或者 SEO 多语言页面要批量翻译这篇文章可以直接参考。1. 核心能力速览能力项说明方案类型HTML 页面结构化翻译流水线输入单个 HTML 文件、网页 URL、目录批量 HTML 文件核心技术DOM 解析、文本节点提取、段落分块、翻译回填结构保真标签、class、id、href、src、内联样式全部保留长页面处理按空行段落切分多段分批请求避免上下文超限脚本保护script、style、code、pre、svg、math 默认不翻译可翻译属性title、alt、placeholder、aria-label 等白名单属性批量任务支持目录批量处理可并发控制接口 API可封装为 HTTP 服务返回翻译后的 HTML运行环境Python 3.9 或 Node.js 18无 GPU 要求适合场景网站国际化、多语言落地页、技术文档批量翻译不适合场景需要人工审校的专业法律/医疗文件、实时交互翻译这个方案的核心价值不是“翻译得多好”而是“翻译完之后 HTML 还能用”。翻译质量取决于你接的翻译引擎结构保真则取决于流水线本身的设计。2. 适用场景与使用边界先明确适合谁。最常见的是做多语言网站的团队手头有一批静态 HTML 页面或由框架渲染出的整页 HTML需要批量翻译成中文、日文、西班牙文等目标语言。这类场景下你不能让翻译工具改掉链接、样式和页面布局所以流水线必须把“可翻译文本”和“不可动结构”分开。另一个典型场景是技术文档站点内容通常有几万字单次请求塞不进大模型上下文窗口需要拆分成多段并且拆分时不能从句子中间切断。还有人会把“HTML 转 Markdown - 翻译 Markdown - 再转回 HTML”当成快捷方案。这里不建议这么做HTML 转 Markdown 会丢失内联标签、表格结构、自定义属性、注释和语义化标签转回去之后排版大概率对不上。直接操作 DOM 树是更稳的路径。使用边界要提前想清楚。首先是版权问题抓取网站 HTML 并翻译必须确认你有权处理这些内容尤其不能绕过付费内容、登录墙或违反 robots 协议。其次是机器翻译质量问题英文长文档翻译成中文后术语、语气、技术名词需要人工复核不能直接上生产环境。再次是隐私问题如果页面里包含用户数据、个人信息、内部注释不应直接送入第三方翻译 API。最后是反爬和恶意样本问题测试脚本要放在本地环境不要对线上站点发起大规模高频抓取。3. 整体设计思路这条流水线需要拆成五个阶段每个阶段职责单一方便替换和调试。第一阶段是页面获取。本地文件直接读取线上页面用 HTTP 请求或 Playwright 渲染后拿最终 HTML。注意不要拿压缩后的 HTML 就结束很多页面内容靠 JavaScript 动态渲染可能需要无头浏览器先执行脚本。第二阶段是 DOM 解析。Python 推荐 BeautifulSoup lxml 解析器Node.js 推荐 cheerio二者都是轻量级选择。解析后把 HTML 变成可遍历、可修改的节点树。第三阶段是提取可翻译内容。只提取两类内容一类是文本节点也就是页面里真正显示出来的文字另一类是白名单属性值比如title、alt、placeholder、aria-label。其他属性比如class、id、href、src、>html-translate-pipeline/ ├── pages/ │ ├── en/ # 原始 HTML │ └── zh/ # 翻译输出 ├── translate_pipeline.py ├── translate_fn.py ├── server.py ├── batch.py └── requirements.txtrequirements.txt 示例beautifulsoup44.12 lxml5.0 requests2.31 fastapi0.110 uvicorn0.27 openai1.30翻译引擎部分你可以选择大模型 API、DeepL、Google Translate 或任何能返回纯文本的接口。为了把这篇文章里的示例跑通我用一个 OpenAI 兼容的占位函数实际项目里需要替换成你自己的 key、base_url 和模型名。5. 代码实现Python 版 HTML 翻译流水线5.1 核心解析与回填模块先写核心模块translate_pipeline.py负责提取文本节点、分块、翻译和回填。# translate_pipeline.py import re from bs4 import BeautifulSoup, NavigableString, Tag # 黑名单标签内部文本不翻译 BLOCK_TAGS {script, style, code, pre, noscript, svg, math} # 属性白名单只允许翻译这些属性值 ATTR_WHITELIST {title, alt, placeholder, aria-label} def split_text_keep_paragraph(text: str, max_chars: int 1500) - list[str]: 按空行切分文本保留段落边界避免从句子中间切断。 parts re.split(r(\n{2,}), text) chunks [] current for part in parts: if len(current) len(part) max_chars: current part else: if current.strip(): chunks.append(current.strip()) current part if current.strip(): chunks.append(current.strip()) return chunks def translate_with_chunk(text: str, translate_fn, max_chars: int 1500) - str: 长文本分段翻译再把结果合并回原文的空行格式。 chunks split_text_keep_paragraph(text, max_chars) translated_chunks [translate_fn(chunk) for chunk in chunks] return \n\n.join(translated_chunks) def extract_text_nodes(soup): 提取需要翻译的文本节点过滤空白节点和黑名单标签内部节点。 nodes [] for node in soup.find_all(stringTrue): if not node.strip(): continue parent node.parent if isinstance(parent, Tag) and parent.name in BLOCK_TAGS: continue nodes.append(node) return nodes def translate_html(html: str, translate_fn, attr_translate_fnNone) - str: 核心入口解析 HTML - 翻译文本节点和属性 - 序列化输出。 if attr_translate_fn is None: attr_translate_fn translate_fn soup BeautifulSoup(html, html.parser) # 翻译文本节点 for node in extract_text_nodes(soup): text node.string or stripped text.strip() if not stripped: continue # 保留原文的首尾空白避免影响排版 prefix text[: len(text) - len(text.lstrip())] suffix text[len(text.rstrip()):] translated translate_with_chunk(stripped, translate_fn) node.replace_with(NavigableString(prefix translated suffix)) # 翻译白名单属性 for tag in soup.find_all(True): if tag.name in BLOCK_TAGS: continue for attr in ATTR_WHITELIST: if attr not in tag.attrs: continue value tag[attr] if isinstance(value, list): value .join(value) if value.strip(): tag[attr] attr_translate_fn(value.strip()) # 更新 html 标签的 lang 属性 html_tag soup.find(html) if html_tag and html_tag.get(lang): html_tag[lang] zh-CN return str(soup)这段代码的关键点有三个第一split_text_keep_paragraph用\n{2,}作为段落分隔符保证切分出来的每个 chunk 都是完整段落翻译引擎不会看到“一段话只翻译了一半”的情况。第二extract_text_nodes使用find_all(stringTrue)拿到的所有文本节点再通过父级标签名过滤黑名单这样script里的 JSON 数据不会进入翻译队列。第三回填时构造NavigableString而不是node.replace_with(translated)避免翻译结果里出现 HTML 特殊字符时被二次解析。5.2 翻译函数占位实现translate_fn.py是翻译引擎适配层。下面的代码是 OpenAI 兼容接口的模板调用方需要在自己的环境里配置LLM_API_KEY、LLM_BASE_URL和LLM_MODEL三个环境变量。# translate_fn.py import os from openai import OpenAI _client None def translate_text(text: str) - str: global _client if _client is None: _client OpenAI( api_keyos.environ.get(LLM_API_KEY), base_urlos.environ.get(LLM_BASE_URL), ) resp _client.chat.completions.create( modelos.environ.get(LLM_MODEL, gpt-4o-mini), messages[ { role: system, content: Translate to Simplified Chinese. Keep technical terms, product names, code, and proper nouns in original English. Output only the translation., }, {role: user, content: text}, ], temperature0.3, ) return resp.choices[0].message.content.strip()关键点是 system prompt 里明确“只输出翻译结果”并且“保留专有名词”。否则翻译模型会在译文后面加解释或者把 API、GPT、Redis 这类词也音译一遍。如果你用的是 DeepL 或 Google Translate只需要把translate_text内部实现换掉流水线主体不用动。5.3 单文件命令行测试先用一个最简单的命令行入口验证流程能不能跑通。python -c from translate_pipeline import translate_html from translate_fn import translate_text html h2Welcome/h2 pThis is strongthe best/strong tool for a href\/about\web developers/a./p out translate_html(html, translate_text) print(out) 预期输出应该保留h2、strong、a href/about这些标签只替换其中的文字内容。如果这里结构被破坏优先排查解析器和回填逻辑不要直接去调翻译引擎。6. 代码实现Node.js 版与接口 API 封装6.1 Node.js cheerio 实现有些项目技术栈是 Node.js可以把同一个流水线移植过去。cheerio 的语法接近 jQuery操作 DOM 比较方便。// translate-pipeline.js const cheerio require(cheerio); const BLOCK_TAGS new Set([ script, style, code, pre, noscript, svg, math, ]); const ATTR_WHITELIST new Set([ title, alt, placeholder, aria-label, ]); function isBlank(text) { return text.replace(/\s/g, ).length 0; } function splitText(text, maxChars 1500) { const parts text.split(/\n{2,}/); const chunks []; let current ; for (const part of parts) { if (current.length part.length maxChars) { current (current ? \n\n : ) part; } else { if (current.trim()) chunks.push(current.trim()); current part; } } if (current.trim()) chunks.push(current.trim()); return chunks; } async function translateHtml(html, translateFn, attrTranslateFn) { const $ cheerio.load(html); const attrFn attrTranslateFn || translateFn; // 收集所有文本节点 const textNodes []; $(body *) .contents() .each(function () { if (this.type ! text) return; const parent this.parent; if (parent BLOCK_TAGS.has(parent.tagName)) return; if (isBlank(this.data)) return; textNodes.push(this); }); // 批量翻译提升并发度 await Promise.all( textNodes.map(async (node) { const text node.data; const prefix text.slice(0, text.length - text.trimStart().length); const suffix text.slice(text.trimEnd().length); const chunks splitText(text.trim()); const translated await Promise.all(chunks.map((c) translateFn(c))); node.data prefix translated.join(\n\n) suffix; }) ); // 翻译白名单属性 $(body *).each(function () { if (BLOCK_TAGS.has(this.tagName)) return; for (const attr of ATTR_WHITELIST) { const value $(this).attr(attr); if (value value.trim()) { $(this).attr(attr, attrFn(value.trim())); } } }); $(html).attr(lang, zh-CN); return $.html(); } module.exports { translateHtml, splitText };Node.js 版本强调异步并发把一批文本节点用Promise.all处理能明显提高请求效率。如果翻译 API 有并发限制可以把Promise.all换成批量限流每批只发 3 到 5 个请求。6.2 FastAPI 接口服务封装把 Python 流水线封装成 HTTP 服务方便前端调用或接入自动化发布系统。# server.py from fastapi import FastAPI, Request from pydantic import BaseModel from translate_pipeline import translate_html from translate_fn import translate_text app FastAPI() class TranslateRequest(BaseModel): html: str target_lang: str zh-CN class TranslateResponse(BaseModel): html: str lang: str app.post(/translate/html, response_modelTranslateResponse) async def translate_html_api(req: TranslateRequest): translated translate_html(req.html, translate_text) return TranslateResponse(htmltranslated, langreq.target_lang) if __name__ __main__: import uvicorn uvicorn.run(app, host127.0.0.1, port8000)启动服务uvicorn server:app --host 127.0.0.1 --port 8000调用接口curl -X POST http://127.0.0.1:8000/translate/html \ -H Content-Type: application/json \ -d {html: h2Hello/h2pThis is a test./p}Python 的translate_html是同步函数FastAPI 会把它放到线程池执行不会阻塞事件循环。如果并发上来了建议用def而不是async def声明路由函数让 FastAPI 自行调度线程池。6.3 批量目录任务批量翻译是网站国际化的刚需。下面的batch.py会把pages/en下所有.html文件翻译后输出到pages/zh通过ThreadPoolExecutor控制并发。# batch.py import glob import json import os from concurrent.futures import ThreadPoolExecutor from translate_pipeline import translate_html from translate_fn import translate_text def translate_file(input_path: str, output_dir: str) - dict: with open(input_path, r, encodingutf-8) as f: html f.read() translated translate_html(html, translate_text) name os.path.basename(input_path) out_path os.path.join(output_dir, name) with open(out_path, w, encodingutf-8) as f: f.write(translated) print(f[ok] {input_path} - {out_path}) return {input: input_path, output: out_path, status: ok} def run_batch( input_dir: str, output_dir: str, pattern: str *.html, max_workers: int 4, ) - None: os.makedirs(output_dir, exist_okTrue) files glob.glob(os.path.join(input_dir, pattern)) if not files: print(no files matched) return with ThreadPoolExecutor(max_workersmax_workers) as pool: results list( pool.map(lambda f: translate_file(f, output_dir), files) ) print(json.dumps(results, ensure_asciiFalse, indent2)) if __name__ __main__: run_batch(../pages/en, ../pages/zh)运行批量任务python batch.py批量任务里容易忽略的一个点如果不同页面之间有公共导航、页脚文案翻译引擎会把同样的内容重复翻译既浪费 token 又可能产生不一致的译文。工程上可以加一层缓存以原文哈希为 key把翻译结果存到本地 JSON 或 Redis后面重复出现就直接读缓存。7. 功能测试与效果验证一个 HTML 翻译流水线能不能用不能只看翻译后的文字通不通顺还要验证结构有没有被破坏。下面给出一套可复用的测试流程。7.1 测试用例基础标签保真输入一段带内联标签的 HTMLh2Welcome to our product/h2 pThis is strongthe best/strong tool for a href/aboutweb developers/a./p期望结果里h2、strong、a href/about原样存在只是文字变成中文。判断标准是标签数量和属性列表和原文一致href没有被翻译成别的字符串。7.2 测试用例长文档分块生成长文本测试分块逻辑。用 Python 快速生成一段超过 3000 字符的英文段落集合text \n\n.join( fParagraph {i}: This is a long paragraph used to test the chunking behavior fof the translation pipeline. It needs to be split safely. for i in range(30) ) print(len(text))然后调用split_text_keep_paragraph检查每个 chunk 长度不超过设定的max_chars并且 chunk 之间没有出现半句话。判断成功标准所有原文段落都能在输出中找到对应的完整译文段落顺序不变。7.3 测试用例脚本与样式不被翻译输入一段带script的页面script const config { api: https://example.com, version: v2 }; /script pHello world/p翻译后检查script内部的 JSON 键名和值没有被改动。这类问题最常见的原因是解析器没有正确识别脚本标签或者用innerHTML直接替换了整段内容。如果脚本里的内容被翻译基本可以判断是黑名单逻辑没有生效。7.4 测试用例结构化一致性校验这是最重要的回归测试。写一个函数把原始 HTML 和翻译后 HTML 的标签列表、属性键名抽出来对比from bs4 import BeautifulSoup def tag_signature(html: str): soup BeautifulSoup(html, html.parser) return [ (tag.name, sorted(tag.attrs.keys())) for tag in soup.find_all(True) ] original h2Welcome/h2pHello a href/aboutworld/a/p translated h2欢迎/h2p你好 a href/about世界/a/p assert tag_signature(original) tag_signature(translated)只要标签名和属性键集合一致说明结构没有被破坏。这个断言适合接入 CI批量翻译之后跑一遍任何结构变化都会立刻失败。7.5 测试用例接口 API 返回启动 FastAPI 服务后用 Python 调用接口确认响应里包含翻译后的 HTML 和 lang 字段import requests url http://127.0.0.1:8000/translate/html payload { html: h3Contact us/h3pEmail: supportexample.com/p, target_lang: zh-CN, } resp requests.post(url, jsonpayload, timeout60) data resp.json() print(data[lang]) print(data[html])如果lang返回的不是zh-CN或者接口返回结构不符合预期优先查看服务端日志和请求体格式。8. 资源占用与性能观察这条流水线不消耗 GPU 显存主要资源是 CPU、内存和外部 API 请求。内存方面BeautifulSoup 或 cheerio 会把整个 HTML 构建成 DOM 树。一个 1MB 的 HTML 页面解析后内存占用可能上升到几十 MB页面数量多时会成为瓶颈。处理大批量页面时不要把所有页面读进内存再统一翻译应该一个文件一个文件处理处理完就释放引用。Python 写批量任务时特别注意如果每个文件都保存了全局变量引用进程内存会不断增加。CPU 方面解析 HTML 属于 CPU 密集型操作但对于普通文档页影响不大。真正的耗时在翻译 API 请求上属于 IO 密集型。批量任务应该用线程池而不是多进程因为线程等待网络响应时能释放 GIL多进程反而会增加内存开销。API 并发是另一个需要重点观察的指标。不同翻译服务有不同限流策略有的限制每秒请求数有的限制每分钟 token 数。批量翻译时不要一上来就开 32 个 worker建议从 4 到 8 个开始观察错误率。如果出现大量 429 或超时说明限流了应该减小max_workers并在翻译函数里加指数退避重试。长页面分块大小也直接影响性能。max_chars设置越大单次请求翻译的内容越多但超过模型或接口限制就会报错设置太小请求次数变多上下文丢失风险上升。比较稳妥的起点是 1500 字符约等于中英文 500 到 800 个 token然后根据你的模型上下文窗口调整。9. 常见问题与排查方法问题现象可能原因排查方式解决方案输出 HTML 标签错乱文本回填用了 innerHTML 而不是文本节点替换查看替换代码是否传入 HTML 字符串改用 NavigableString 或 text node 赋值script/style 内容被翻译黑名单过滤没生效检查 extract_text_nodes 的父级判断补充 BLOCK_TAGS确认解析器正确识别标签href/src 被翻译属性翻译范围过宽检查属性白名单只翻译 title/alt/placeholder/aria-label中文变成了 HTML 实体序列化时 Unicode 转义查看输出源码中的#字符调整 BeautifulSoup formatter 或对输出做 unescape长文本被截断chunk 在句子中间被切断打印分段结果观察每个 chunk 边界使用空行分块并保留段落分隔符API 返回 429 或超时并发太高或单个 chunk 过大查看接口状态码和耗时日志降低 worker 数缩小 max_chars加重试批量任务中途卡住没有超时设置请求一直挂起检查线程池日志给 HTTP 请求加 timeout设置任务总超时翻译后段落顺序错乱回填时没有按原索引赋值检查文本节点数组顺序保证节点数组顺序与 DOM 顺序一致页面里数字、邮箱被翻译翻译引擎把机器可读内容当成普通文本抽查实体内容在翻译前用正则保护邮箱、URL 和版本号这里说明一下“中文变成了 HTML 实体”这个坑。BeautifulSoup 在序列化时可能把非 ASCII 字符输出成实体比如把中输出为#20013;浏览器里显示正常但源码可读性很差。遇到这种情况可以用html.unescape()处理输出或者调整序列化 formatter。10. 最佳实践与合规提醒第一条最佳实践不要翻译黑名单之外的所有属性。有些人会图省事把所有属性值都丢给翻译引擎结果classcol-md-6被翻译成别的字符串页面布局直接崩溃。正确的做法是明确属性白名单只翻译用户可见的文本属性。第二条最佳实践保留原文副本。批量翻译前先把原始 HTML 备份到一个目录翻译完成后用tag_signature做结构一致性校验发现结构破坏立即报警。不要直接覆盖原文件输出目录和输入目录分开。第三条最佳实践保护机器可读内容。页面里的邮箱、电话号码、版本号、JSON 字符串、技术域名默认不应该翻译。可以在提取文本节点之前先用特殊占位符替换这些内容翻译完成后再还原。例如把supportexample.com替换成__EMAIL_0__避免翻译引擎把它变成“支持示例.com”。第四条最佳实践维护术语表。多语言站点的产品名词、品牌名、API 名称要保持一致。如果你的翻译引擎支持 glossary 或术语库尽量启用如果不支持可以在 system prompt 里把术语表拼进去。对于需要严格一致的文档项目建议翻译后加一道人工审校流程。第五条合规提醒抓取页面、翻译站内内容、将结果部署到公网之前需要确认内容版权和使用授权。企业项目要检查合同里是否允许内容被第三方翻译服务处理个人项目不要抓取有明确版权声明的站点做公开部署。涉及用户上传内容、个人信息的页面翻译前要做脱敏不能把真实用户数据直接送入外部 API。涉及人脸、声音、品牌形象的素材以及受版权保护的文档都必须先获得授权。11. 总结与下一步这条 HTML 翻译流水线的核心不是“调用哪个翻译服务”而是“如何让翻译发生在正确的位置”。把 HTML 当成结构化文档处理提取文本节点、按段落分块、回填 DOM才能保证长页面翻译后还能正常展示。项目里最容易踩的坑是属性翻译范围过大和文本回填方式错误这两个问题会在上线后被浏览器放大成页面布局崩坏所以结构一致性校验必须纳入测试流程。下一步建议这样推进先拿一个真实业务页面跑通单文件流水线用tag_signature确认结构和原页面一致然后接上你实际使用的翻译引擎调优max_chars和并发数最后把批量任务和 API 服务拆成独立模块方便接入 CI 或发布系统。如果真的在用 HTML 页面做多语言站点这套方案可以先用起来。