当手头有几十份英文 PDF 需要翻译时,逐个打开网页上传、等待、下载显然不现实。翻译服务的开放接口把这类需求变成了几十行代码的问题。本文以 PDFTranslator 的开放接口为例,用 Python + Requests 封装"上传-轮询-下载"的异步翻译客户端,加上失败重试与断点续传,再挂上 APScheduler 定时任务和 watchdog 目录监听,把一次性脚本升级为无人值守的调度服务,代码可直接复制运行。
前言
翻译 PDF 与翻译纯文本的差异在于:文件需要先上传到服务端,由服务端解析版面、调用大模型翻译,再把结果按原排版回填生成新 PDF,整个过程是异步的。客户端要做的只有三件事------提交任务、轮询状态、下载结果。把这三步封装成类,后面的批量、调度、监控全部复用这一层,就是本文的核心思路。相比直接调同步接口,异步模型更稳健:大文件不会被单个长连接卡死,中途崩溃也能从任务状态恢复。
环境准备
本文代码需要 Python 3.9 及以上版本,安装三个依赖:
bash
pip install requests apscheduler watchdog
| 依赖 | 用途 |
|---|---|
| requests | 与翻译 API 交互:上传、轮询、下载 |
| apscheduler | 定时触发批量翻译任务 |
| watchdog | 监听输入目录,新文件出现即触发翻译 |
API 约定
正式写代码前先明确 PDFTranslator API 的调用约定,这也是绝大多数翻译服务的通用模式:
POST /v1/translate:以 multipart 方式上传 PDF,服务端返回{"task_id": "..."};GET /v1/tasks/{task_id}:查询任务状态,completed时返回download_url;GET download_url:下载翻译完成的 PDF。
整体是异步交互,客户端需要轮询。接口每月提供免费翻译额度,超出后按提示等待额度重置即可,不影响代码结构。注意上传字段名和返回结构在文档中以 JSON 为准,如果接口更新,只需要改 TranslatorClient 一个类,业务层完全不受影响。
实现步骤
Step 1 封装异步翻译客户端
submit() 负责上传文件并拿到 task_id;wait() 每 2 秒轮询一次任务状态,只处理 completed / failed 两种终态,超过 10 分钟视为超时;download() 把结果落盘。等待期间不占用 CPU,适合跑在服务器或 CI 任务里。会话复用也很重要:这里统一用 requests.Session(),连接池会被后续每个文件的请求复用,批量场景下能明显减少 TCP 建连开销,避免大量文件上传时触发服务端限流。
Step 2 断点续传与批量处理
translate_one() 先检查结果目录是否已有同名文件,有就直接跳过------脚本中断后重新运行,已翻译完的文件不会重复消耗额度。命名规则用"原文件名 + 目标语言后缀",保证同一文件翻译成不同语言时互不覆盖。batch_run() 扫描 inbox 目录下全部 PDF 逐个处理,返回成功数与总数,供调度器记录;文件列表先排序再处理,保证日志和重跑的顺序可预期。
Step 3 失败重试
网络请求失败是常态。retry() 使用指数退避:第一次失败等 2 秒,第二次 4 秒,最多重试 3 次,并加随机抖动避免多个任务同时重试造成二次限流。注意这里重试的是"提交"和"查询"两个幂等操作,而非"下载"------因为下载失败通常代表文件还没生成好,重试查询任务状态更合理。最终仍失败的写入日志,由下一次定时任务补跑。
Step 4 定时调度与目录监听
APScheduler 的 CronTrigger 把 batch_run 挂到每天 02:00,选择凌晨是为了避开业务高峰和翻译服务的繁忙时段;watchdog 在 inbox 上做增量监听,新文件一落地就立刻触发翻译,适合持续接收外部投递文件的场景。两套机制互补:定时任务兜底全量,监听负责增量。实际部署时也可以把两者拆成两个独立进程,定时任务做每日清理与补跑,监听进程专注实时性,互不阻塞。
完整代码
python
"""PDFTranslator 批量翻译:脚本 + 定时 + 目录监控"""
import logging
import random
import time
from pathlib import Path
import requests
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
API_BASE = "https://api.pdftranslator.org/v1" # PDFTranslator API 基地址
API_KEY = "your_api_key" # 申请的 Key
TARGET_LANG = "zh" # 目标语言
INBOX, DONE = Path("inbox"), Path("done") # 输入 / 输出目录
MAX_POLL, MAX_RETRY = 600, 3 # 轮询超时(秒) / 重试次数
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("pdf_batch")
class TranslatorClient:
"""PDFTranslator 异步翻译客户端:上传 -> 轮询 -> 下载"""
def __init__(self, api_key: str):
self.api_key = api_key
self.session = requests.Session()
def submit(self, pdf: str) -> str:
"""上传 PDF 文件,返回 task_id"""
with open(pdf, "rb") as f:
r = self.session.post(
f"{API_BASE}/translate",
files={"file": f},
data={"target_lang": TARGET_LANG, "api_key": self.api_key},
timeout=60,
)
r.raise_for_status()
return r.json()["task_id"]
def wait(self, task_id: str) -> dict:
"""轮询任务状态直到 completed/failed,超时抛异常"""
deadline = time.time() + MAX_POLL
while time.time() < deadline:
data = self.session.get(
f"{API_BASE}/tasks/{task_id}", timeout=30
).json()
if data["status"] == "completed":
return data
if data["status"] == "failed":
raise RuntimeError(data.get("error"))
time.sleep(2)
raise TimeoutError(task_id)
def download(self, url: str, out: Path):
"""下载翻译结果文件到本地"""
r = self.session.get(url, timeout=120)
out.write_bytes(r.content)
def retry(fn, attempts=MAX_RETRY, delay=2.0):
"""指数退避重试:失败后延时翻倍并加抖动,最多重试 attempts 次"""
for i in range(attempts):
try:
return fn()
except Exception as e:
if i == attempts - 1:
raise
time.sleep(delay * (2 ** i) + random.uniform(0, 1))
log.warning("重试 %d/%d: %s", i + 1, attempts, e)
def translate_one(client: TranslatorClient, pdf: Path):
"""翻译单个 PDF;结果已存在则跳过,最终失败返回 None"""
out = DONE / f"{pdf.stem}_{TARGET_LANG}.pdf"
if out.exists():
log.info("跳过 %s(已翻译)", pdf.name)
return out
try:
task_id = retry(lambda: client.submit(str(pdf)))
result = retry(lambda: client.wait(task_id))
client.download(result["download_url"], out)
return out
except Exception as e:
log.error("翻译失败 %s: %s", pdf.name, e)
return None
def batch_run():
"""扫描 inbox 目录全部 PDF 逐个翻译,并输出统计结果"""
DONE.mkdir(exist_ok=True)
client = TranslatorClient(API_KEY)
pdfs = sorted(INBOX.glob("*.pdf"))
ok = sum(1 for p in pdfs if translate_one(client, p) is not None)
log.info("完成 %d/%d,输出目录: %s", ok, len(pdfs), DONE)
class InboxHandler(FileSystemEventHandler):
"""watchdog 事件处理器:新 PDF 落入 inbox 即触发翻译"""
def on_created(self, event):
if event.src_path.endswith(".pdf"):
batch_run()
if __name__ == "__main__":
INBOX.mkdir(exist_ok=True)
DONE.mkdir(exist_ok=True)
sched = BlockingScheduler(timezone="Asia/Shanghai")
sched.add_job(batch_run, CronTrigger(hour=2, minute=0), id="daily") # 每天 02:00
observer = Observer()
observer.schedule(InboxHandler(), str(INBOX), recursive=False)
observer.start()
log.info("调度启动:每天 02:00 全量扫描 + inbox 目录实时监听")
try:
sched.start()
except (KeyboardInterrupt, SystemExit):
observer.stop()
observer.join()
运行效果
把三份 PDF 丢进 inbox 后,日志输出如下:
text
2026-08-12 10:20:31 跳过 paper_a.pdf(已翻译)
2026-08-12 10:20:33 重试 1/3: 连接超时
2026-08-12 10:20:38 已保存 done/report_b_zh.pdf
2026-08-12 10:20:40 翻译失败 manual_c.pdf: 文件损坏
2026-08-12 10:20:40 完成 2/3,输出目录: done
paper_a 之前已翻译过,直接跳过不消耗额度;report_b 第一次请求超时,重试后成功;manual_c 文件损坏,重试 3 次后放弃并写日志。此时再向 inbox 丢一份新文件,watchdog 会立即触发 batch_run,无需任何人工干预。如果脚本在批量过程中被强杀,重启后会从 done 目录判断哪些已完成,自动从断点继续,这是断点续传带来的直接收益。
优化方向
文件多、单文件大时可以继续优化:用 ThreadPoolExecutor 并发提交多个任务缩短总耗时,但要注意把并发数控制在 API 允许的范围内,避免同时提交过多任务被限流;把任务列表持久化到 SQLite 实现真正的断点续传;下载后校验文件 MD5 或页数,防止拿到不完整结果;超过 20MB 的文件先压缩再上传,降低传输和解析成本。这些都属于同一套架构上的增量改进,翻译 API 的交互层无需改动。
总结
从单文件脚本到批量任务,核心只做了一件事:把 API 的异步交互封装成可复用客户端,再往外挂业务逻辑。断点续传、失败重试、定时调度、目录监听四个能力都是通用工程手段,与具体翻译服务解耦------换一家 PDF 翻译 API,只需改接口地址和字段名。这套代码部署到服务器后,配合 PDFTranslator 每月免费额度,每天定时翻译报告与手册,可以沉淀为团队共享的文档处理基础设施,也能接入 CI 流水线做发布物料的自动多语言生成。代码量不大,价值却很直接,值得放进工具库长期维护。
标签:PDF翻译、Python、AI翻译、效率工具