用Python+Requests批量翻译PDF:从脚本到调度

当手头有几十份英文 PDF 需要翻译时,逐个打开网页上传、等待、下载显然不现实。翻译服务的开放接口把这类需求变成了几十行代码的问题。本文以 PDFTranslator 的开放接口为例,用 Python + Requests 封装"上传-轮询-下载"的异步翻译客户端,加上失败重试与断点续传,再挂上 APScheduler 定时任务和 watchdog 目录监听,把一次性脚本升级为无人值守的调度服务,代码可直接复制运行。

前言

翻译 PDF 与翻译纯文本的差异在于:文件需要先上传到服务端,由服务端解析版面、调用大模型翻译,再把结果按原排版回填生成新 PDF,整个过程是异步的。客户端要做的只有三件事------提交任务、轮询状态、下载结果。把这三步封装成类,后面的批量、调度、监控全部复用这一层,就是本文的核心思路。相比直接调同步接口,异步模型更稳健:大文件不会被单个长连接卡死,中途崩溃也能从任务状态恢复。

环境准备

本文代码需要 Python 3.9 及以上版本,安装三个依赖:

bash 复制代码
pip install requests apscheduler watchdog
依赖 用途
requests 与翻译 API 交互:上传、轮询、下载
apscheduler 定时触发批量翻译任务
watchdog 监听输入目录,新文件出现即触发翻译

API 约定

正式写代码前先明确 PDFTranslator API 的调用约定,这也是绝大多数翻译服务的通用模式:

  1. POST /v1/translate:以 multipart 方式上传 PDF,服务端返回 {"task_id": "..."}
  2. GET /v1/tasks/{task_id}:查询任务状态,completed 时返回 download_url
  3. 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 的 CronTriggerbatch_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翻译、效率工具

相关推荐
草莓熊Lotso1 小时前
【LangChain】核心技术:嵌入模型与向量存储完全指南
服务器·网络·python·langchain
fthux7 小时前
装闭 RenoPit 源码解析(07):装修闭坑知识库与AI Prompt构建
人工智能·ai·开源·github·open source·renopit
zyplayer-doc8 小时前
VuePress类静态文档站和动态知识库怎么选:两种技术路线的适用场景
javascript·人工智能·后端·安全·智能手机
leisoo80979 小时前
100GBA股股票数据怎么存ClickHouseRedisMySQLJSON完整对比
大数据·linux·服务器·开发语言·python
WangYan20229 小时前
基于XGBoost与AI的生态—地学多源数据建模:植被与土地利用识别、土壤碳氮空间预测、生物多样性驱动机制、土壤微生物功能预测、生态退化与风险识别
python·机器学习·xgboost
fthux10 小时前
装闭 RenoPit 源码解析(06):SSE如何实时推送AI装修分析进度
人工智能·ai·开源·github·open source·renopit
用户9385156350710 小时前
从前后端分离到前端接口工程:React + MockJS + Vite 解析
前端·后端·全栈
宝桥南山11 小时前
Azure - 检查一下是否需要在Microsoft Extra ID中配置Passkeys认证方法
microsoft·微软·sass·azure