ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python文档翻译爬虫实战:批量解析PDF/Word并自动翻译

Python文档翻译爬虫实战:批量解析PDF/Word并自动翻译

前阵子接手一个活儿,要把一批英文技术报告整理成中文存档。手动打开网页翻译一篇还行,手头一共三十多篇,每篇几十页,翻到第二篇我就开始怀疑人生了。后来决定干脆写一个文档翻译爬虫,把“打开文档—复制文本—送去翻译—贴回结果”这件事全部自动化。整套流程跑下来,核心就是三件事:解析文档、调用谷歌翻译、管理好请求节奏。

这篇博文把这套工具的实现思路完整记录一下。不只给代码,也把每一步为什么这么设计的理由讲清楚。如果你经常需要处理外文文档、PDF、Word转写文本,或者单纯想搞一个批量翻译管道,这篇文章应该对你有用。基础大概需要Python和爬虫常识,小白也能跟着文章一点点搭出来。

1. 批量为啥这么难搞:从一次真实的文档翻译需求说起

1.1 一份报表到底能浪费多少人工

先说说当时手头这批文档的实际体感。一份标准的英文技术报告,A4纸满页大概是六七百个单词,网页翻译工具单次处理倒是够快,但问题是“搬运”成本太高:

  • 打开PDF,找到对应段落,手动复制
  • 切到网页翻译,粘贴,等结果
  • 复制译文,回到归档文件,手动对齐格式
  • 一篇几十页的文档,这个循环要重复几十上百次

我粗略估了一下,一页内容如果包含正文和图表标题,光“复制粘贴—翻译—贴回”就要三到五分钟;一份二十页的报告,光搬运就要一个多小时。三十多篇下来,完全靠人工就是三四十个小时的机械劳动,而且注意力稍不集中就会漏段、贴错位置、翻译重复。

当时还考虑过商业翻译软件和在线文档翻译平台。商业软件一年订阅费用不低,而且它们对文档格式的支持也不是万能的,遇到里面套着表格、多级列表、页眉页脚混乱的旧文档,照样有识别问题。在线平台倒是省事,但单日上传次数有限额,批量管理几十个文件,还涉及到文件在人家服务器上转一道,有些存档昨晚内容不适合这样传。

真正想明白之后,我觉得这件事的本质是:文档解析 + 批量请求翻译接口 + 结果写回。这三件事里没有一件是必须靠现成软件才能做的,既然自己会爬虫,那就自己写一个顺手的工具,按自己的需求定制。

1.2 各种翻译路线的对比:为什么最后选了爬虫

把当时对比的几个方案整理成表格,方便你结合自己的场景参考。

方案适合场景痛点成本
手动复制到网页翻译偶尔翻译三五页批量时搬运量大,容易出低级错误免费但要命
商业翻译软件需要MT记忆库、术语库的正式项目价格高,格式兼容性有上限按年订阅
在线文档翻译平台不介意文档上传的普通用户数量受限,隐私敏感文档不合适免费或按页计
自建文档翻译爬虫固定工作流、批量文档、隐私敏感内容需要自己维护,接口限制要自己处理免费,耗开发时间

最后选爬虫方案还有个重要原因:可重复使用。同一个工具,下一批文档来了还能用,代码里换一下目录路径就能继续跑。而且我可以按自己的需求定输出格式——是双语对照还是纯译文、是按段落还原还是按句对齐,全部自己说了算。

很多人觉得“文档翻译爬虫”重点在翻译接口怎么调,但实际做起来你会发现,翻译接口的调用只是其中一环,甚至是最简单的一环。最花时间反而不是调接口,而是把各种格式的文档“拆干净”,再把拆出来的文本块“管理好”。下面一章先讲拆文档。

2. 第一步不是翻译,是把文档拆成干净的文本块

2.1 纯文本与Markdown:最简单但也最容易忽略编码问题

从最简单的场景开始。纯文本文件和Markdown其实是最友好的解析对象,因为不需要任何格式解析库,直接读文件就行。但这里有个非常容易踩的坑:编码。

很多从旧系统导出或者从Windows记事本里存出来的TXT,可能是GBK或带有BOM头的UTF-8。Python 里open()默认编码取决于系统环境,如果你直接读,运气好全对,运气不好第一行就报UnicodeDecodeError。

我当时的做法是写一个健壮一点的读取函数,先尝试 UTF-8,失败后回退到 GBK,再不行就按拉丁编码读,尽量不因为编码问题让整个流程中断。

def read_text_file(path): encodings = ["utf-8-sig", "utf-8", "gbk", "gb2312", "latin-1"] for enc in encodings: try: with open(path, "r", encoding=enc) as f: return f.read() except (UnicodeDecodeError, TypeError): continue return ""

utf-8-sig这个编码名就是专门用来处理BOM头文件的,它会自动把开头那个看不见的字符去掉。

文本读进来之后,还得做一层清理。Markdown 文件里通常会有代码块、链接、图片引用,这些内容对你翻译正文没帮助,反而会干扰翻译结果。我当时用正则做了基础清理:

import re def clean_plain_text(content): # 去掉代码块 content = re.sub(r"```.*?```", "", content, flags=re.DOTALL) # 去掉行内代码 content = re.sub(r"`[^`]*`", "", content) # 去掉图片和链接语法,只保留显示文字 content = re.sub(r"!\[.*?\]\(.*?\)", "", content) content = re.sub(r"\[([^\]]*)\]\(.*?\)", r"\1", content) # 通用空白压缩 content = re.sub(r"[ \t]+", " ", content) return content.strip()

这一步做不做差别很大。不清理的话,翻译接口会把#标题符号、URL、代码片段一起翻成中文,出来的结果非常滑稽,而且白白浪费请求次数。

2.2 Word与PDF:两个最常见的解析场景

处理Word文档我推荐python-docx,它能把段落和表格分开遍历,非常适合翻译场景。关键点在于:不要只抓document.paragraphs,很多文档的核心数据在表格里,如果你的文档是带表格的,漏掉表格就等于漏掉半篇内容。

from docx import Document def extract_docx_blocks(path): doc = Document(path) blocks = [] for para in doc.paragraphs: text = para.text.strip() if text: blocks.append({"type": "paragraph", "text": text}) for table in doc.tables: for row in table.rows: cells = [cell.text.strip() for cell in row.cells if cell.text.strip()] if cells: blocks.append({"type": "table_row", "text": " | ".join(cells)}) return blocks

这里把表格每一行用|连接,翻译后再拆分回去会方便对照。Word文档还有个隐藏坑:段落里可能存在分节符、分页符或者域代码,python-docx有的能拿到有的拿不到,最好的办法是拿到文本后做一次非打印字符清洗。

PDF稍微麻烦一点。文本型PDF用pdfplumber提取效果不错,它的按页面提取方法比很多老库都稳定,而且对复杂排版的兼容性比PyPDF2好。代码很简单:

import pdfplumber def extract_pdf_blocks(path): blocks = [] with pdfplumber.open(path) as pdf: for page in pdf.pages: content = page.extract_text() if not content: continue for line in content.splitlines(): line = line.strip() if line: blocks.append({"type": "page_line", "text": line}) return blocks

但这里有个现实问题:扫描版PDF根本提不出文本。你打开PDF看到的是一张图片,extract_text()返回空。这种情况需要先做OCR识别,常见方案是用PaddleOCR或Tesseract,把每一页转成图像再识别出文本。我之前在某个项目里试过,准确率大概90%以上,但速度慢不少,而且会把排版的栏次顺序搞乱,需要额外按坐标排序。这不是这篇文章的重点,先按下不表,但你要有这个概念:PDF解析要先判断是文本型还是扫描型,两类文件的处理路径完全不同。

2.3 文本块的拆分规则:太长会被截断,太碎又浪费请求

不管从哪种文档拆出来的文本,都不能直接一股脑丢给翻译接口。这里有一个分块问题,分块的好坏直接决定翻译质量和请求效率。

先说过长的问题。网页版翻译对单次请求的字符数有上限,超过了就会直接报错,或者在中间某个位置被截断。实测中文场景下,安全阈值大致是1500到2000字符左右,拉丁字符可能要更谨慎。如果一整个段落有三千字,就必须拆成小份。

但也不能拆得太碎。每一句单独发一次请求,虽然不会超限,但有两个坏处:一是请求数量成倍增加,更容易触发频率限制;二是失去了上下文,很多口语化说法和指代词会被翻得很生硬。比如英文的“it”单独翻成“它”没什么问题,但整段翻的时候机器能结合前文翻成“该方案”,语感完全不一样。

我的拆法是这样的:按句子边界先切成单元,再把连续单元拼到接近上限为止。句子边界的正则要同时覆盖中英文标点:

def split_blocks(text, limit=1200): sentences = re.split(r"(?<=[。!?;.!?;])\s*", text) current = "" for sent in sentences: if not sent.strip(): continue if len(current) + len(sent) > limit and current: yield current current = sent else: current += sent if current.strip(): yield current

那个正则里的(?<=...)是零宽断言,意思是只在标点后面切,但标点本身保留。这样拆出来的块,翻译结果不会把句号丢掉。

最后还有一个重要的取舍:每个文本块最好控制在一到三句话以内。太长的块机器翻译容易“迷路”,特别是英文里带着好几个嵌套从句的长句,翻译结果经常出现语序混乱。宁可稍微多拆一点,也不要赌超长句子的翻译质量。

3. 翻译接口的调用策略:把一次请求的性价比拉到最高

3.1 从网页翻译链路理解接口参数

说到这,很多人担心“直接调谷歌翻译的接口是不是不太正统”。实际上,网页翻译在前端就是通过HTTP请求来完成的,爬虫只是把这个请求原样复现一遍。关键在于理解这条链路的参数含义。

直接打开网页翻译页面,输入一段文字,浏览器后台会向一个翻译接口发出POST请求。核心参数大概包括:

参数常见取值作用
clientgtx标识网页前端客户端,不同的客户端对应的返回结构不同
slauto或具体语言码源语言识别方式,auto表示自动检测
tlzh-CN目标语言
dtt指定返回结构,t表示返回翻译文本段
q待翻译文本请求体内容

响应回来的JSON结构第一项是一个数组,每个元素又是一个小数组,第一个元素就是翻好的句子片段。把所有片段的第一个元素拼起来,就是完整译文。这个结构我第一眼看的时候觉得有点绕,但其实就是“分片列表”的意思。

调用方式用requests就能实现,注意用POST,因为文本过长时GET请求的URL会超出浏览器和服务器能接受的长度:

import requests def translate_text(text, source="auto", target="zh-CN"): url = "https://translate.googleapis.com/translate_a/single" params = { "client": "gtx", "sl": source, "tl": target, "dt": "t", "q": text, } resp = requests.post(url, params=params, timeout=15) resp.raise_for_status() data = resp.json() if not data or not data[0]: return "" return "".join(seg[0] for seg in data[0] if seg and seg[0])

这里有几个细节值得注意。第一,timeout必须设置,因为外部接口不是百分百可靠,不设置超时会让程序卡死在等待里。第二,raise_for_status()要养成习惯,一旦对方返回4xx或5xx,直接发异常比返回一个空结果更容易暴露问题。第三,不要每次调用都重新发起TCP连接,用一个requests.Session()复用连接,长任务能省不少握手开销。

需要说明的是,这种调用方式依赖页面接口的稳定性。它的设计初衷是给网页前端使用的,不是公开API,所以接口行为随时可能调整。工程化一点的做法,是把translate_text这个函数做成唯一对接层,将来如果接口变了,或者你想换用其他翻译引擎,只需要改这一个小函数,整个流水线其余部分完全不用动。

如果你的运行环境无法正常访问这个翻译服务,思路也是同一个:换用其他你能访问的翻译引擎,把_do_request里面的调用逻辑换掉就好,上游的文档解析和下游的格式写回全都复用。

3.2 限流、重试与断点续传:爬虫能不能稳定跑完,就看这三件事

能跑通一次请求不代表能跑完整个项目。批量翻译和单次翻译最大的区别,就在于你有没有一套稳定的“节奏控制”。我早期犯过的错误就是拼命发请求,结果几百次请求之后突然开始大量返回错误——显然是触发限流了。所以现在我这个工具里,限流是第一优先级。

最简单的限流方式是“固定间隔法”:每次请求之间sleep一个固定秒数。比如每1秒发一次请求,一小时最多3600次,对个人用途来说已经完全够用。如果你追求更高效率,可以做一个滑动窗口限流器,只限制每秒的峰值,不限制总量:

import threading import time class RateLimiter: def __init__(self, max_per_second=5): self.max_per_second = max_per_second self.timestamps = [] self.lock = threading.Lock() def wait(self): with self.lock: now = time.time() self.timestamps = [t for t in self.timestamps if now - t < 1.0] if len(self.timestamps) >= self.max_per_second: sleep_time = 1.0 - (now - self.timestamps[0]) if sleep_time > 0: time.sleep(sleep_time) self.timestamps = [t for t in self.timestamps if now - t < 1.0] self.timestamps.append(time.time())

这个限流器维护一个“最近1秒内的请求时间戳列表”。如果达到峰值上限,就睡到最早的请求滑出窗口为止。实测下来,1秒5次这个频率对文档翻译足够用了,再高就有点冒险。

第二件大事是重试。网络请求不可能永远顺利,可能是对方服务临时抖动,也可能是你自己网络波动。重试不能盲目重试,要用指数退避,也就是第一次失败等1秒,第二次等2秒,第三次等4秒,逐步拉开间隔,给服务恢复时间。

import random def request_with_retry(func, *args, retries=5, **kwargs): for attempt in range(retries): try: return func(*args, **kwargs) except requests.RequestException as e: if attempt == retries - 1: raise sleep_time = 2 ** attempt + random.uniform(0, 0.5) time.sleep(sleep_time) return None

第三件大事是断点续传。批量文档几十篇,跑到一半突然断网或者被限流卡死,如果从头再来就太亏了。我的做法是维护一个本地JSON缓存,以原文文本的哈希作为key,把译文存进去。每次翻译前先查缓存,命中了就直接用,不命中才发请求。这样即使中断了,重新运行也只是补漏,不需要重跑全部内容。

import hashlib import json from pathlib import Path class TranslationCache: def __init__(self, cache_path="translation_cache.json"): self.cache_path = Path(cache_path) self.data = self._load() def _load(self): if self.cache_path.exists(): return json.loads(self.cache_path.read_text(encoding="utf-8")) return {} def get(self, text): key = hashlib.md5(text.encode("utf-8")).hexdigest() return self.data.get(key) def set(self, text, translation): key = hashlib.md5(text.encode("utf-8")).hexdigest() self.data[key] = translation self.cache_path.write_text( json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8" )

注意这里的ensure_ascii=False,否则JSON文件里所有中文都会被转成\uXXXX的转义序列,人没法直接审阅。

3.3 怎么判断翻译结果是否可靠

接口返回了,不代表翻译成功了。实际调用中会出现几种“假成功”的情况,我一个个说。

第一种最诡异:返回结果是原文,等于没翻译。这通常发生在源语言自动检测失效的时候。比如一段中文文本被自动判断成日文,翻出来的自然是中文原文的“机器伪译文”。解决办法是如果源语言已知,不要图省事用sl=auto,直接传具体的语言码。

第二种是返回结果为空字符串。接口成功返回200,但解析出来什么都没有。常见原因是文本块本身有问题,比如全是一些特殊符号、空格、表情字符。对这种块,可以提前过滤掉,不浪费时间请求。

第三种是返回结果明显截断。这种情况极少出现,一旦出现就说明请求体接近了接口长度的极限,需要再把文本块拆得更小。

我的做法是在翻译后备一层“空结果保护”:

def safe_translate(text, source, target, cache): cached = cache.get(text) if cached is not None: return cached result = translate_text(text, source, target) if not result or result.strip() == "": raise ValueError(f"empty translation for text block: {text[:50]}...") cache.set(text, result) return result

空结果直接抛异常,不要继续往下走。因为一个空译文写进输出文件里,比一个缺失段落还要难查,它会让整篇文档看起来像被吞了一段。

4. 一套可直接复用的批量翻译流水线

4.1 核心代码结构:把翻译引擎隔离在唯一入口

前面几章把零件讲完了,这一章组装成完整流水线。我的设计原则很明确:文档解析、文本块管理、翻译调用、结果输出,四层解耦。每一层只依赖上一层,不互相纠缠。

先看翻译客户端封装。把限流、缓存、重试全部收敛在一个类里:

import requests import time import random import hashlib import json from pathlib import Path class TranslatorClient: def __init__(self, cache_path="translation_cache.json", min_interval=0.2): self.session = requests.Session() self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }) self.min_interval = min_interval self.last_request_time = 0.0 self.cache_path = Path(cache_path) self.cache = self._load_cache() def _load_cache(self): if self.cache_path.exists(): return json.loads(self.cache_path.read_text(encoding="utf-8")) return {} def _save_cache(self): self.cache_path.write_text( json.dumps(self.cache, ensure_ascii=False, indent=2), encoding="utf-8" ) def _throttle(self): delta = time.time() - self.last_request_time if delta < self.min_interval: time.sleep(self.min_interval - delta) self.last_request_time = time.time() def translate(self, text, source="auto", target="zh-CN"): key = hashlib.md5(text.encode("utf-8")).hexdigest() if key in self.cache: return self.cache[key] self._throttle() translation = self._do_request(text, source, target) self.cache[key] = translation self._save_cache() return translation def _do_request(self, text, source, target): url = "https://translate.googleapis.com/translate_a/single" params = { "client": "gtx", "sl": source, "tl": target, "dt": "t", "q": text, } try: resp = self.session.post(url, params=params, timeout=15) resp.raise_for_status() data = resp.json() except requests.RequestException: # 网络层波动,指数退避后重试 for attempt in range(3): time.sleep(2 ** attempt + random.uniform(0, 0.5)) resp = self.session.post(url, params=params, timeout=15) resp.raise_for_status() data = resp.json() if not data or not data[0]: raise ValueError(f"empty response for text: {text[:50]}...") return "".join(seg[0] for seg in data[0] if seg and seg[0])

这个类把两件最重要的事情做了:缓存和限流。_do_request是唯一直接接触网络的函数,将来更换翻译引擎,只需要改这个方法内部实现。测试新引擎时,跑一遍TranslatorClient的用法完全不受影响,这是整个工具最值得保留的架构决定。

4.2 主流程串联:多文件、多格式、多语言

有了解析工具和翻译Client,主流程就很直白了:遍历目录下所有目标文件,识别扩展名,选择对应的解析器,把解析出的文本块逐个翻译,最后统一写出结果。

import argparse from pathlib import Path SUPPORTED_EXTENSIONS = {".txt", ".md", ".docx", ".pdf"} def process_document(input_path, translator, source, target): suffix = input_path.suffix.lower() if suffix in {".txt", ".md"}: blocks = extract_markdown_and_text(input_path) elif suffix == ".docx": blocks = extract_docx_blocks(input_path) elif suffix == ".pdf": blocks = extract_pdf_blocks(input_path) else: return [], f"unsupported extension: {suffix}" translated = [] for block in blocks: text = block["text"] for chunk in split_blocks(text): translated_chunk = translator.translate(chunk, source, target) translated.append((chunk, translated_chunk)) return translated, ""

extract_markdown_and_text就是把上一章的read_text_file和clean_plain_text串起来用的函数,代码不重复贴了。重点是process_document返回一个二元组:翻译结果列表和错误信息。这样主程序可以对错误做集中处理,而不会因为某一个文件坏了整个任务。

主程序入口做成命令行工具,方便重复调用:

def main(): parser = argparse.ArgumentParser(description="文档批量翻译爬虫") parser.add_argument("--input", required=True, type=Path, help="输入文件或目录") parser.add_argument("--output", required=True, type=Path, help="输出目录") parser.add_argument("--source", default="auto", help="源语言代码") parser.add_argument("--target", default="zh-CN", help="目标语言代码") args = parser.parse_args() translator = TranslatorClient() input_path = args.input output_path = args.output output_path.mkdir(parents=True, exist_ok=True) if input_path.is_dir(): files = [p for p in input_path.rglob("*") if p.suffix.lower() in SUPPORTED_EXTENSIONS] else: files = [input_path] for file_path in files: print(f"[*] processing: {file_path.name}") pairs, error = process_document(file_path, translator, args.source, args.target) if error: print(f"[!] skip {file_path.name}: {error}") continue save_bilingual(output_path / f"{file_path.stem}_双语.md", file_path.stem, pairs) print(f"[+] done: {file_path.name} -> {len(pairs)} segments")

有点工程经验的朋友可能已经注意到,这里没有做多线程并发。这是我故意的,翻译服务对频率非常敏感,并发拉高容易触发更严格的限流,得不偿失。个人批量翻译场景下,单线程加合理的间隔已经足够。

4.3 输出怎么设计才方便校对

输出格式是很多人不重视但实际非常重要的环节。翻译完成不算完,你总得校对吧。如果我直接生成纯译文,你根本没法一段段对照检查,所以我的默认输出是双语Markdown。

def save_bilingual(output_file, title, pairs): with open(output_file, "w", encoding="utf-8") as f: f.write(f"# {title}\n\n") for original, translated in pairs: f.write(f"> {original}\n\n") f.write(f"{translated}\n\n")

这样原文放在引用块里,译文紧随其后,校对的时候一眼能对上。而且Markdown文件可以直接用Typora这类工具阅读,格式友好,不用依赖某个特定软件。

如果后续需要做进一步加工,比如导入翻译记忆库或者做术语统计,可以在流水线后加一个--format json选项,把pairs直接存成JSON或CSV。但就日常使用来说,双语Markdown已经是性价比最高的方案了,一会儿我可以直接用diff工具扫一遍,特别方便。

还会有一个小细节:文件名带上_双语后缀,源文件保持原样不被改动。因为你翻译后一定还想保留原文档,万一某段翻译结果不满意,回到原文档查上下文才是正常流程。

5. 实测踩过的坑:频率限制、空文本与格式丢失

5.1 常见的HTTP错误与应对方式

跑完整批文档的过程中,我收集了一些实际问题和处理办法,整理成表方便查。

现象可能原因处理方案
429 Too Many Requests请求太密集触发了限流调大请求间隔,加上指数退避重试
503 Service Unavailable服务临时过载停止队列,等待2-5分钟后重试
读取超时文本块太大或网络波动拆小文本块,调大timeout
返回空文本特殊字符占满文本块过滤无意义字符,空结果抛异常定位
返回JSON结构异常接口变更或反爬策略调整检查返回内容,必要时更新解析逻辑

最严重的一次,是连续跑了两千多个请求之后突然开始大量返回429。当时我的限流间隔是0.1秒,太激进了。改成0.2秒之后,整个项目安安稳稳跑完了。我的体会是:文档翻译这种任务真不差那几秒钟,稳定压倒一切,给自己留点余量比极限压榨效率可靠得多。

还有一次是文本块解析问题。PDF里有一页是纯图片,extract_text()返回None,代码在if content:判断时直接跳过,这其实没问题。问题出在有些PDF页面里有“隐形文本层”——你视觉上看到的是图片,但PDF文件内部还藏着OCR识别过的透明文本。这种情况extract_text()能提出内容,但顺序可能乱。解决办法是根据每个文本对象的坐标排序,这里不展开,遇到复杂PDF布局时你需要一个“按坐标重排”的额外步骤。

5.2 翻译质量层面的几个意外

翻译接口稳定只是第一步,译文的“可用度”是另一个大问题。机器翻译对专业术语的翻译质量不稳定,这是老话题。我处理报告时其实遇到几种情况:

第一,专有名词。公司名、产品名、项目代号,机器经常翻得五花八门。比如某个内部系统叫“Gatekeeper”,机器翻成“守门人”,但公司内部文档习惯叫“网关系统”。如果自己对术语有要求,就得在翻译前先把原文做一次术语替换,把专有名词占位成不含特殊含义的变量名,翻译后再替换回来。

TERM_MAP = { "Gatekeeper": "__TERM_GATEKEEPER__", "PipelineX": "__TERM_PIPELINE_X__", } def protect_terms(text): for term, placeholder in TERM_MAP.items(): text = text.replace(term, placeholder) return text def restore_terms(text): for term, placeholder in TERM_MAP.items(): text = text.replace(placeholder, term) return text

这个方案不是万能的,但做系统名、产品名保护非常好用。注意占位符不能含有特殊符号,防止被翻译引擎当成异常文本处理。

第二,表格内的译文与列宽。我从Word表格里提取的每一行文本,用|连接后再翻译。如果表格格子特别多,这一行文本会非常长,很容易超过安全长度。我后来对表格行的拆分策略是:如果单行超过800字符,就按|重新切回单格,逐个翻译再拼回去,不再对整行做长文本翻译。

第三,页眉页脚混入正文。PDF解析最让人头疼的就是这个。页眉上通常有期刊名或章节名,页脚是页码,它们和正文一起被extract_text()提取出来后,翻译时会出现大量重复内容,翻出来的文档看起来像复读机。我的处理办法是,对每个页面文本先过滤掉连续多页重复的行,再进入文本块拆分。这个需要一些页面间比对逻辑,但判断并不复杂。

5.3 几个让整个流程更省心的小技巧

分享几个实际跑批里摸索出来的小技巧,不限代码层面。

第一,任务日志一定要打。我前面主程序里的print只是基础,后面我加了一个简单的日志系统,把每次翻译请求的原文哈希、耗时、结果状态记到CSV里。中途出问题的时候,翻日志比翻缓存文件直观得多。

import csv import datetime class SimpleLogger: def __init__(self, path="translation_log.csv"): self.file = open(path, "a", newline="", encoding="utf-8") self.writer = csv.writer(self.file) def log(self, action, text_hash, status, detail=""): now = datetime.datetime.now().isoformat() self.writer.writerow([now, action, text_hash, status, detail]) self.file.flush()

第二,每次翻译前先做一个“资源检查”。打印一下输入目录里每个文件的行数、预估文本块数量、总字符数,这样能大致估算请求总量和耗时。我的经验是,一万个字符的文档,按每秒5次请求算,大约需要多少时间心里有数,就不会因为中途太慢而焦虑。

第三,输出目录和缓存目录分开。缓存文件是运行过程数据,输出文件是最终成果,别放在同一个地方。否则你清理缓存时不小心会把双语文件一起删掉,白白重跑一遍。我的目录结构通常是:

project/ ├── input_docs/ # 原始文档 ├── output_docs/ # 双语MD输出 ├── cache/ # translation_cache.json, logs └── translate_files.py # 主脚本

最后想补一句,这套工具的价值不在于某一段代码有多高级,而在于它把“批量的、重复的、机械的翻译搬运工作”彻底变成了一个可控的、可审计的流程。我后来在别的环境里复用这套结构,把翻译引擎从网页接口换成了其他内部翻译服务,替换的方案非常简单,流水线本身一个字没改。这个“隔离层先行”的设计习惯,才是你在自己项目里最值得带走的收获。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进