差不多两年前,我做了一个让团队同事直呼“真香”的小工具:一个批量文档翻译的爬虫脚本。当时的情况是,某项目组拿到几十份英文技术文档,需要快速翻译成中文用于内部评审。手动复制粘贴到网页翻译,一份几十页的文档能折腾一上午,而且格式全乱。所以我花了一个周末,写了一套自动化的文档翻译脚本:扔进去一批 PDF、Word、TXT,自动抽取文本、调用谷歌翻译的免费接口、把翻译结果回填到原文档里,全程无人值守。
这篇文章就把这套工具的完整思路、关键代码、踩坑记录都整理出来。不搞花架子,从接口原理到代码实现,再到并发优化和故障排查,每一步都讲清楚“为什么这么做”。如果你也需要批量翻译文档,或者想做一个依赖外部翻译服务的自动化工具,这篇可以直接照着抄。
1. 项目整体设计与需求拆解
1.1 核心需求:批量、格式保持、可控成本
先说需求。表面上看就是“翻译文档”,但拆开来看有三个硬性约束,直接影响技术方案选型。
第一个约束是批量。不是三份五份,而是几十份上百份。手动操作完全不现实,必须脚本化、自动化。
第二个约束是格式保持。翻译完的文档不能变成一堆纯文本,段落结构、换行、标题层级最好能保留下来。这就意味着不能简单地把整篇文档当成一个大字符串丢给翻译接口,必须做“分段提取 → 逐段翻译 → 按位置回填”的处理。
第三个约束是成本可控。早期我评估过几家主流的翻译API,按字符计费,几十份长文档翻译下来费用不低。而谷歌翻译的网页接口虽然官方不推荐用于生产环境,但对个人项目和内部工具来说,速度快、不花钱、翻译质量也在线。作为一个内部自动化工具,这个性价比很有吸引力。
1.2 技术选型:Python + 正则 + 文档解析库
技术栈选型也很直接。语言用 Python,生态里处理文档格式的库最齐全。python-docx 处理 Word,PyPDF2 或 pdfplumber 处理 PDF,TXT 直接读写就行。网络请求用 requests,并发用 ThreadPoolExecutor 或者 asyncio。
为什么不用 Scrapy 这种重型爬虫框架?因为这里的“爬虫”本质是请求翻译接口,不是爬网页结构。Scrapy 的中间件、下载器、Item Pipeline 在这里都用不上,反而增加复杂度。轻量级 requests + 线程池就够用了。
这里还要说明一下“爬虫”这个词的准确含义。在这个项目里,爬虫不是去爬取网页内容,而是自动化地“抓取”翻译结果——把请求发出去,把响应抓回来,再填充到文档里。核心是接口调用和数据提取的逻辑。
1.3 整体架构:一条流水线
整套工具的架构可以用一条流水线来概括:
- 文档解析层:读取目标文件,按格式规则抽取文本内容,拆分成适合翻译的文本块。
- 请求控制层:管理对外部翻译接口的请求,处理参数构造、超时重试、并发控制。
- 翻译执行层:调度线程池,把文本块分发到多个工作线程,并发获取翻译结果。
- 文档重建层:把翻译结果按原顺序、原格式回填,生成新的输出文件。
每一层之间用简单的数据结构对接,文本块带着唯一的块 ID 在流水线里流转。这样设计的好处是,如果以后要换翻译引擎,只需要改请求控制层;如果要支持更多的文档格式,只需要扩展文档解析层。模块之间是解耦的。
2. 核心接口解析与请求参数
2.1 翻译接口的原理:就是一个普通的 HTTP 请求
这套工具依赖的翻译接口是一个公开的 HTTP 接口,早期调用方式很简单,构造一个 GET 请求,带上必要的参数,就能拿到 JSON 格式的翻译结果。虽然不同时期的接口路径和参数格式会有差异,但核心逻辑是一致的:客户端把待翻译文本和语言参数发给服务端,服务端返回翻译后的文本。
抽象来看,这个接口做了三件事:
- 接收源文本和源语言/目标语言代码;
- 返回一个包含翻译结果的 JSON 数组;
- 支持一次请求携带多段文本(批量翻译)。
第三点非常关键。文档翻译场景下,一次请求如果能携带多个文本块,请求次数就能大幅降低,翻译速度能提升一个数量级。这也是整套工具性能优化的理论基础。
2.2 请求参数构造细节
构造请求时有几个参数是必须的:
client:标识客户端类型,不同取值可能影响返回的数据格式;sl:源语言代码,例如en表示英语;tl:目标语言代码,例如zh-CN表示简体中文;dt:控制返回数据的详细程度,t表示只返回翻译结果文本,这是最精简的用法。
请求 URL 的路径和参数拼接方式,我是通过抓包观察网页端的行为得到的。先在浏览器里打开翻译页面,手动翻译一段文本,打开开发者工具看网络请求,就能看到实际发出的请求格式。这个过程基本就是爬虫开发的标准动作:先观察,再构造,最后验证。
值得提醒的是,接口的返回 JSON 是一个嵌套结构。新手经常在这里犯错,以为直接解析第一层就能拿到字符串,实际翻译结果埋在好几层数组里。我记得第一次写解析代码时,打印了原始响应才发现结构比想象中复杂得多。后来我写了一个专门的提取函数,递归扫描 JSON 结构,把所有字符串片段按顺序拼接起来,才稳定地拿到完整翻译文本。
2.3 为什么选择批量接口而不是单条翻译
文档翻译场景下,单条翻译的劣势是致命的。假设一份文档有 500 个文本块,单条翻译就是 500 次 HTTP 请求,每次请求还有网络往返延迟,算下来至少要几分钟甚至更久。而批量接口一次能携带几十个文本块,500 个文本块只需要 10 到 20 次请求,时间缩短到十几秒。差距是数量级的。
批量接口还有一个额外的好处:翻译结果在返回时保持了输入顺序。这意味着我可以放心地把文本块放进列表,批量请求,然后按索引取回结果,不需要额外的关联逻辑。这个特性对文档重建非常重要,因为文档的段落顺序一旦错乱,整个文档就毁了。
3. 文档解析与文本抽取:最容易被低估的一步
3.1 文档格式的差异远比想象中大
写这个工具之前,我以为“读文档”是件很简单的事。真正动手处理才发现,不同格式的文档,文本抽取方式完全不同,而且处处是坑。
Word 文档(docx)的结构是一个 zip 包,里面主体内容存在 XML 里。段落是w:p节点,文字是w:r节点。用 python-docx 解析很顺畅,遍历document.paragraphs就能拿到所有段落文本。但要注意表格里的文本不在paragraphs里,需要额外遍历document.tables。
PDF 文档就麻烦多了。用 PyPDF2 抽取文本,经常遇到排版错乱、多余换行、合并行的问题,因为 PDF 本身不是为文本提取设计的。而且要区分可复制文本的 PDF 和扫描版 PDF,后者必须走 OCR,我自己的经验是优先处理前者,扫描版单独走另外的流程,避免把工具复杂化。
TXT 相对简单,按行读取即可。但编码问题很常见,有些是 UTF-8,有些是 GBK。我用chardet自动检测编码,避免打开就乱码。
3.2 文本块拆分策略:太小浪费,太大报错
拆块是文档翻译工具里非常关键的环节。拆得太细,比如每个自然段一个块,请求次数会增多,而且很多短段落单独翻译没有上下文,译出来可能不准确。拆得太粗,比如把整个文档一股脑塞进去,翻译接口对单次请求的文本长度有限制,超长直接报错。
我在实践中摸索出的策略是“固定大小 + 边界对齐”:设定每个文本块的目标字符数,比如 1500 个字符,然后用换行符作为切分边界。往块里累加段落,加到接近上限时,在当前段落边界处切断,另起一个块。这样既不会出现超长请求,又能尽量保持语义完整性。
文本块拆分的代码逻辑大概是这样的:遍历所有段落,按顺序累积到一个列表里,实时统计累计字符数,超过阈值就把当前积累的段落作为一个块,重新开始累积。注意不要把段落拆成半个,中文翻译场景下,段落内部的句子被拆开会导致翻译质量明显下降。
3.3 保留原始边界信息
文本块拆完不是直接丢给翻译接口就行,每个块必须携带“从哪里来”的信息。我在数据结构里记录每个块的起始段落索引和块内段落数,翻译完成后,就能精确地把翻译结果映射回原段落位置。
这个设计在工程上叫“引用保留”,是文档翻译工具能保持格式的关键。如果没有这一步,翻译完的文本就是一个接一个的字符串,回填时完全无法对位。代码实现上,我用一个简单的TextBlock对象,包含一个 uuid 字符串、文本列表和位置信息,然后在流水线各层之间传递。虽然增加了代码量,但稳定性和可维护性大幅提升。
4. 请求控制与翻译执行:从串行到并发
4.1 用户代理与请求头伪装
写爬虫类工具,请求头是绕不开的一环。很多服务端会校验User-Agent,如果被识别成自动化脚本,轻则返回异常数据,重则直接屏蔽。我一开始没注意这个细节,跑的第一次就发现请求老是返回错误页面,排查了半天发现是 UA 的问题。
后来我在请求头里设置了完整的浏览器标识,包括User-Agent、Accept、Accept-Language、Referer等字段,请求立马恢复正常。这个经验后来也延伸到其他接口调用场景里:对外部服务发起请求时,把请求头伪装成正常浏览器的行为,能规避掉很多不必要的风险。
当然,这里说“伪装”不是鼓励恶意行为,而是正常工程实践。任何面向外部的 HTTP 请求,都应该以客户端的方式去模拟,这是对服务方的尊重,也是提高稳定性的手段。
4.2 请求频率控制:不要贪婪
并发请求能大幅提升翻译速度,但必须控制频率。翻译接口不是为高并发场景设计的,请求太密集,会触发服务端限流。我的经验是:线程数设置在 5 到 10 之间,每个线程请求完成后随机休眠 0.5 到 1.5 秒,把请求频率控制在合理区间。
这个频率控制策略来自一个现实教训。第一次写并发版本时,我开了 30 个线程,满负荷怼请求,结果运行了还不到两分钟,所有请求都开始返回错误,整个工具直接瘫痪。后来改成 8 个线程 + 随机延时,连续跑几个小时都没出问题。稳定压倒一切,尤其在批量任务里,宁可慢一点,也不能中途挂掉。
4.3 超时与重试机制
外部接口的稳定性是不可控的。网络抖动、服务端临时故障、请求排队积压,都可能导致请求超时。超时处理如果不做好,一个异常请求就可能让整个脚本崩溃。
我的做法是三重防护。第一重是 requests 库的timeout参数,每个请求都设置连接超时和读取超时,避免无限等待。第二重是重试机制,翻译接口偶发错误时,等工作线程随机休眠后重新请求,重试 3 次还失败,就把这个文本块标记为“翻译失败”,单独记录到日志文件里。第三重是整体的失败容忍度:脚本不因个别文本块失败而终止,而是继续处理剩余部分,最后汇总失败清单,方便人工二次处理。
5. 文档重建:把翻译结果放回该在的位置
5.1 Word 文档回填的关键操作
翻译完成后的文本块序列,要按原来的段落索引回填到 Word 文档中。这里涉及 python-docx 的一个重要操作:修改段落文本时,要清空原有 run,然后写入新的文本。如果直接在原 run 上追加,会导致新文本和旧文本混在一起。
具体操作是:遍历文档所有段落,如果当前段落属于某个翻译文本块,先把这个段落的所有 run 清除,再新增一个 run,设置翻译后的文本。样式方面,因为直接创建 run 默认继承段落样式,所以字体、大小、加粗这些信息基本能保留。如果原文里有多个 run 以不同格式混排,用这个方法会丢失 run 级样式差异,但对于技术文档这种以段落为单位的文本来说,影响很小。
5.2 PDF 与 TXT 的处理差异
PDF 的回填最麻烦。用 PyPDF2 只能抽取文本,无法原位写回。我采取的策略是“完全重建”:把翻译后的文本写入一个新的 PDF,或者更实用一点,直接生成一个 Markdown 或 Word 文件作为翻译结果。PDF 是不可编辑的“最终格式”,不适合作为翻译工具的输出目标。
TXT 就简单多了。按行读入,按块翻译,按行写出。因为 TXT 没有格式概念,回填就是纯粹的顺序重写。我额外注意了换行符的处理,不同的 TXT 可能用\n、\r\n或\r,读取时统一转换成\n,写出时再按目标平台要求转回去。
5.3 格式校验:翻译后必做的检查
回填完成后,一定要做格式校验。我写了一个简单的校验函数,统计翻译前后文本块的数量和段落总数,如果数量对不上,直接报错提示。这个函数看似简单,但曾经帮我拦住了一次严重的数据错位问题——当时有一段代码在处理列表时沿用了错误索引,导致整篇文档的段落顺序打乱,如果没有数量校验,这份错乱的文档就直接交付了。
数量校验之外,我还会抽查几段翻译结果。人工确认译文准确率没问题,才算正式完成。自动化工具能做的是处理和拼装,质量把关还是要人来兜底。
6. 完整代码实现:一个最小可跑通的版本
光说理论不行,下面给出一个简化但可运行的核心代码片段。为了便于阅读,我删除了错误处理的细节,保留了主流程逻辑。这个版本能处理 TXT 文件,完成“读取 → 拆块 → 批量翻译 → 回填 → 写文件”整个流程,Word 和 PDF 的解析层只需要按前文思路替换即可。
import requests import random import time from concurrent.futures import ThreadPoolExecutor # 翻译接口请求函数 def translate_batch(texts, source_lang="en", target_lang="zh-CN"): url = "https://translate.googleapis.com/translate_a/single" params = { "client": "gtx", "sl": source_lang, "tl": target_lang, "dt": "t", "q": texts, } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://translate.google.com/", } try: resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() # 从嵌套 JSON 中提取翻译结果 if isinstance(data, list) and data: return data[0] except Exception as e: print(f"翻译请求出错: {e}") return None # 文本拆块:按字符数阈值拆分 def split_blocks(paragraphs, max_chars=1500): blocks = [] current_block = [] current_len = 0 for p in paragraphs: p_len = len(p) if current_len + p_len > max_chars and current_block: blocks.append(current_block) current_block = [] current_len = 0 current_block.append(p) current_len += p_len + 1 # 额外加一个换行符的长度 if current_block: blocks.append(current_block) return blocks # 主流程:解析、翻译、重建 def translate_document(input_path, output_path, source_lang="en", target_lang="zh-CN"): with open(input_path, "r", encoding="utf-8") as f: content = f.read() paragraphs = content.split("\n") blocks = split_blocks(paragraphs) translated_blocks = [] for block in blocks: text = "\n".join(block) result = translate_batch([text], source_lang, target_lang) if result: translated_text = "".join(result) translated_blocks.append(translated_text.split("\n")) else: translated_blocks.append(block) # 失败时保留原文 time.sleep(random.uniform(0.5, 1.5)) # 按原段落顺序回填 output_lines = [] pos = 0 for block, trans_block in zip(blocks, translated_blocks): # 如果翻译结果段落数一致,直接替换 if len(trans_block) == len(block): output_lines.extend(trans_block) else: # 段落数不一致时,用整块替换 output_lines.append("\n".join(trans_block)) pos += len(block) with open(output_path, "w", encoding="utf-8") as f: f.write("\n".join(output_lines))这段代码最简单直接,但已经能跑通全流程。实际项目中我会在此基础上加线程池并发、失败重试、断点续传和日志系统,代码量大概增加两三倍,但稳定性完全不同。
7. 并发优化与断点续传:工程化的关键一步
7.1 线程池改造的正确姿势
基础版本是串行翻译,每一块文本都要等网络往返,几十份文档跑下来要几个小时。改成线程池并发后,速度提升非常明显。但线程池改造有一个坑:文本块是有顺序的,并发执行时结果返回的顺序是乱的。
解决方案是“分发时记录,完成时归位”。我用一个字典,key 是任务索引,value 是文本块内容。每个工作线程完成翻译后,把结果写入对应索引的位置。所有任务都完成后再按索引顺序汇总,这样即便返回顺序混乱,最终文档的顺序也是正确的。
def translate_concurrent(blocks, max_workers=8): results = {} def worker(index, block): text = "\n".join(block) result = None for attempt in range(3): result = translate_batch([text]) if result is not None: break time.sleep(random.uniform(1, 3)) results[index] = result time.sleep(random.uniform(0.3, 1.0)) with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = [ executor.submit(worker, i, block) for i, block in enumerate(blocks) ] for future in futures: future.result() # 确保所有任务执行完毕 # 按索引顺序重组结果 ordered_results = [results[i] for i in range(len(blocks))] return ordered_results这个版本运行时,翻译速度大约是串行版本的 5 到 6 倍。8 个线程加随机延时,既吃满了带宽,又不会触发服务端限流。实测下来稳得很。
7.2 断点续传:批量任务的保命符
批量处理几十份文档时,有一个无法回避的问题:任务跑到一半,网络断了,或者脚本崩了,怎么办?重新跑一遍前面已完成的翻译,既浪费时间又浪费请求额度。
我的方案是“双阶段提交”。翻译结果不直接写回文档,而是先落盘到一份 JSON 缓存文件里,格式大概是{文档名: {文本块索引: 翻译结果}}。翻译完一个文本块,就更新一次缓存。脚本启动时先检查缓存,如果存在就跳过已翻译的文本块,只处理剩余部分。这样即使中断多次,也能从中断点继续。
这个机制工程上简单,但价值非常高。我有一次跑了 23 份文档,到第 19 份时电脑断电,重启后脚本自动跳过前 18 份的所有已完成文本块,只重新翻译了第 19 份,几分钟就全部完成。
7.3 请求失败的精细分类
重试机制不是无脑重试。我后来发现,翻译接口的失败可以分成几类,处理方式完全不同。
第一类是网络层错误,比如连接超时、DNS 解析失败,这类错误属于临时故障,重试有效。第二类是服务端返回的异常响应,比如请求被拒,这类错误通常是请求频率太高,需要降低并发并加重试。第三类是响应解析失败,服务端返回了数据但格式对不上,这类错误重试意义不大,更可能是我代码里的参数构造有问题。
我在代码里给每类错误定了不同的处理策略:网络层错误立即重试,限流错误等待较长间隔后重试,解析错误直接记录日志并跳过。这个分类思路让工具从“能跑”变成了“耐跑”。
8. 常见问题与排查技巧实录
8.1 翻译结果被截断或乱码
这个问题的典型症状是翻译出来的句子突然变短,或者在中途断开。第一次遇到时我以为是接口限制,后来排查发现是自己代码的问题:文本块拆得太长,超过接口对单次请求的长度上限,多余部分被静默丢弃。
解决方式就是前文提到的文本块字符阈值控制。我把阈值从 1500 字符调整为 1200 字符后,这个现象就消失了。顺带说一下,如果你处理的是中英混合内容,字符数计算方式和纯英文也不一样,最好统一按 Unicode 字符长度计算。
8.2 出现类似乱码的符号
有段时间翻译结果里频繁出现莫名的符号,排查了很久才发现是原文档里有特殊字符,比如 Unicode 控制符、变体选择符,这些字符在解析和回填时如果不加处理,会被原样写入翻译结果,看起来就像乱码。
解决办法是解析阶段加一个清洗函数,把不需要的控制字符和零宽字符过滤掉。注意别把正常标点符号也过滤了,这个清洗函数只针对不可见字符和白名单外的控制字符。
8.3 Word 文档样式丢失
回填到 Word 时,如果直接用paragraph.text = new_text,python-docx 不支持这种赋值方式。正确的做法是清空段落的所有 run,然后新增 run。但这样会导致一个副作用:原段落的 run 级样式丢失。
我试过几种方案,最终的折中是保存段落样式的引用,新建 run 时应用段落的 style,这样字体、大小、颜色这些段落级样式能保留下来。如果你的文档有大量的局部加粗、高亮、斜体,这个方案无法完整还原,需要走更复杂的样式映射逻辑。普通技术文档不需要那么精细,这个折中方案足够实用。
8.4 请求频率过高被限制
这个问题我前面提到过,这里再展开说一下具体的表现和处理过程。被限制后不一定是直接报错,有可能返回一段无关的文本,或者返回结构正常但内容明显不对。
排查方法很简单:在请求头里打印响应状态码和响应体前 200 个字符,就能快速判断是请求被拒还是解析出错。处理方式是降低并发线程数、拉长随机延时区间、把服务端返回的异常响应状态当成需要退避的信号。我的最终参数是 6 个线程、随机延时 1 到 2.5 秒,这个参数在稳定性和速度之间取得了一个不错的平衡。
8.5 PDF 解析出的文本错乱
PDF 文本抽取是个老大难问题。用 PyPDF2 抽取时,常见的现象是抽取结果中有些段落被合并、有些字符顺序颠倒、标题和正文混在一起。这不是代码 bug,而是 PDF 内容流的排版方式决定的。
我的处理建议是:尽可能寻找源文档的 Word 或 Markdown 版本作为输入;实在只有 PDF 时,用 pdfplumber 替代 PyPDF2,按页面边界切割文本,减少跨页误拼接;对抽取出的文本做好清洗和规则分割。如果 PDF 是扫描版,就不要用文本抽取工具硬刚了,老老实实走 OCR 流程,或者让人工处理。
9. 工具执行效果与经验思考
这套工具上线后处理了一批 42 份英文文档的翻译任务,原始文档大约 3000 多页,总处理时间是 4 小时左右,平均一份文档不到 6 分钟。人工翻译同样规模的文档,按三天算,工具提速了十倍左右。虽然译文质量还达不到出版级标准,但用于内部理解和技术评审完全够用。
整个项目做下来,我有一个很深的体会:这类“爬虫 + 翻译 + 文档处理”的工具,真正决定成败的往往不是爬虫部分,而是文档解析和回填环节。爬虫接口调用本身是有一套成熟套路的,但每种文档格式都有自己的脾气,处理不好就是一堆乱码和错乱结构,前期做得再顺都白搭。
最后一个实用技巧:写这类工具时,优先用 txt 文件做邏輯验证,确认核心流程没问题后,再接入 word 和 pdf。调试过程中不要怕麻烦,把每个中间步骤的产物落盘到本地,方便定位是在哪一层出了问题。我见过很多项目失败,不是因为不会调接口,而是因为中间数据不可见,出了问题无从下手。好的调试习惯,比任何框架都值钱。