翻译一本电子书,散装做法是复制粘贴到对话框里,翻完再手动拼回去——排版全丢,术语前后打架,几百页下来人先崩。这篇教程给一条可复用、可断点续跑的流水线:把 EPUB 拆开、按段落抽取文本、批量调用大模型翻译、把译文写回原文件、重新打包成一本结构完整的 EPUB。做完之后,你得到的是一本带目录、带章节层级、格式基本没坏的译文书,而不是一堆 txt。
这篇教程能做出什么
跑完整条流水线,你会得到:
- 一本新的
.epub文件,章节切分、标题层级、加粗斜体、超链接、脚注锚点都还在; - 一份可复用的术语表(glossary),同一个专有名词全书译法一致;
- 一份中间产物
segments.jsonl,记录每个段落的原文、译文、所在文件与元素路径,方便你抽查、改稿、只重翻某几段; - 一个缓存机制,中途断网、限流、程序崩了,重跑不会重复烧钱。
流水线分成五步:解包 → 抽取 → 翻译 → 回写 → 打包。如果你手上只有纯文本或 Markdown,直接跳到第 3 步,省略解包与打包。
前置条件清单
- Python 3.9 以上(具体以官方文档当前版本为准),能建虚拟环境;
- 一个可用的大模型 API Key,并且该接口支持 OpenAI 兼容的
chat.completions调用方式。模型名以你所使用服务商的官方文档为准,本教程不写死型号; - 能跑 Java 的环境(可选,用于最后的 EPUB 校验工具 epubcheck,安装方式以官方文档为准);
- 一本你有权处理的 EPUB。公版书、你自己写的书、已获授权的稿件都可以;商业出版物的整本翻译属于改编行为,先确认授权,别拿别人的版权书做实验;
- 硬盘上留出几百 MB 空间放中间文件。
建环境:
```bash
python -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
pip install beautifulsoup4 lxml openai tqdm
```
把要翻译的书放到项目根目录,重命名为 book.epub,目录结构建议是:
```text
project/
├── book.epub
├── glossary.csv
├── work/
│ ├── unpacked/
│ ├── segments.jsonl
│ └── translated.jsonl
└── scripts/
```
步骤 1:解包 EPUB
EPUB 本质是一个 ZIP 包,先解开看看内部长什么样。
```python
scripts/01_unpack.py
import pathlib, zipfile
SRC = pathlib.Path("book.epub")
OUT = pathlib.Path("work/unpacked")
OUT.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(SRC) as z:
z.extractall(OUT)
for p in sorted(OUT.rglob("*")):
if p.is_file():
print(p.relative_to(OUT))
```
跑完之后你会看到大致几类东西:
mimetype:固定内容application/epub+zip,必须存在;META-INF/container.xml:指向真正的清单文件.opf;- 若干
.xhtml或.html:正文,一章一个文件很常见; .opf:元数据、书脊顺序(spine)、清单(manifest);.ncx或nav.xhtml:目录;- CSS、字体、图片。
先打开 META-INF/container.xml,找到 full-path 指向的 opf 文件路径,记下来,下一步要用。
步骤 2:按段落抽取文本,保留行内标签
关键判断:块级元素(p、h1、li……)才是一条翻译单元,行内标签(a、em、strong……)不能丢,因为回写时要还原。
做法是把行内标签替换成数字占位符 <0>...</0>,让模型把它们当成不可翻译的括号保留下来。
```python
scripts/02_extract.py
import json, pathlib, re
from bs4 import BeautifulSoup, NavigableString
UNPACKED = pathlib.Path("work/unpacked")
OUT_JSONL = pathlib.Path("work/segments.jsonl")
INLINE = {"a", "em", "strong", "i", "b", "code", "span",
"sub", "sup", "small", "cite", "q", "br", "abbr"}
BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li",
"blockquote", "td", "th", "figcaption", "dd", "dt"}
def is_translatable(text: str) -> bool:
t = text.strip()
if not t:
return False
纯数字/符号的段落跳过,比如页码、分隔符
if re.fullmatch(r"[\d\s.,;:%()\[\]/\-–—+*=<>|&'\"#@$~^`\\]*", t):
return False
return True
def to_template(el):
"""把元素内部转成带数字占位符的文本,同时返回占位符对应的原始标签。"""
store = []
def walk(node):
out = []
for child in node.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif child.name in INLINE:
idx = len(store)
store.append(child)
out.append(f"<{idx}>" + "".join(walk(child)) + f"</{idx}>")
else:
out.append("".join(walk(child)))
return out
return "".join(walk(el)), store
def main():
rows = []
for path in sorted(UNPACKED.rglob("*.xhtml")) + sorted(UNPACKED.rglob("*.html")):
rel = str(path.relative_to(UNPACKED))
if "/nav" in rel or rel.endswith("nav.xhtml"):
目录文件单独处理也行,这里一并翻,但标记出来
pass
soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml")
n = 0
for el in soup.find_all(sorted(BLOCK)):
if el.find_parent(sorted(BLOCK)): # 嵌套块交给最外层
continue
if el.find_parent(["pre", "code", "svg", "math"]):
continue
template, store = to_template(el)
if not is_translatable(template):
continue
rows.append({
"id": f"{rel}#{n}",
"file": rel,
"index": n,
"tag": el.name,
"text": template,
"placeholders": len(store),
})
n += 1
with OUT_JSONL.open("w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"共抽取 {len(rows)} 条待翻译片段")
if __name__ == "__main__":
main()
```
抽完之后,随机翻几条看看。正常的样子应该像 The <0>Great Gatsby</0> was published in 1925. 这样——标签变成了 <0>,其余是纯文本。如果某条里塞了几千字,说明块级判断有问题,回头检查 BLOCK 集合。
步骤 3:批量调用大模型翻译
这一步是整个流程里唯一花钱的环节,所以要做三件事:分批、缓存、校验。
准备一个术语表 glossary.csv,两列:
```csv
source,target
Great Gatsby,了不起的盖茨比
Long Island,长岛
```
翻译脚本:
```python
scripts/03_translate.py
import json, os, pathlib, time
from openai import OpenAI
SEG = pathlib.Path("work/segments.jsonl")
OUT = pathlib.Path("work/translated.jsonl")
CACHE = pathlib.Path("work/cache.jsonl")
BATCH_CHARS = 3000 # 单次请求的原文总字数上限,按你的模型上下文调整
SLEEP = 0.5 # 请求间隔,遇到限流就调大
kwargs = {"api_key": os.environ["AI_API_KEY"]}
if os.environ.get("AI_BASE_URL"):
kwargs["base_url"] = os.environ["AI_BASE_URL"]
client = OpenAI(**kwargs)
MODEL = os.environ["AI_MODEL"] # 填你账号里可用的模型名,以服务商官方文档为准
SYSTEM = """你是资深图书译者,把用户给出的 JSON 数组中每一项的 text 翻译成简体中文。
硬性规则:
1. 数字占位符 <0>...</0> 必须原样保留,数量、顺序、配对关系与原文完全一致。
不要翻译、删除、合并、改写它们,也不要改变它们的嵌套关系。
2. 占位符包裹的内容照常翻译,占位符本身只是标签的替身。
3. 专有名词优先使用术语表给定的译法;术语表没有的,人名地名首次出现时写成「中文(原文)」。
4. 保持原文语气、人称、时态感;不要添加译者注、解释、总结或任何原文没有的内容。
5. 标题行保持短促,不要扩写成句子。
6. 只输出 JSON,格式为 {"items":[{"id":"...","text":"..."}]},不要输出其他任何文字。"""
def load_glossary(path="glossary.csv"):
g = {}
p = pathlib.Path(path)
if not p.exists():
return g
for line in p.read_text(encoding="utf-8").splitlines()[1:]:
if "," in line:
s, t = line.split(",", 1)
g[s.strip()] = t.strip()
return g
def load_cache():
done = {}
if CACHE.exists():
for line in CACHE.read_text(encoding="utf-8").splitlines():
if line.strip():
r = json.loads(line)
done[r["id"]] = r["text"]
return done
def ph_signature(text):
return sorted(int(m) for m in __import__("re").findall(r"<(\d+)>", text))
def translate_batch(items, glossary, retries=3):
payload = json.dumps(
{"glossary": glossary, "items": [{"id": i["id"], "text": i["text"]} for i in items]},
ensure_ascii=False,
)
last_err = None
for attempt in range(retries):
try:
resp = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": payload},
],
response_format={"type": "json_object"},
temperature=0.3,
)
data = json.loads(resp.choices[0].message.content)
got = {x["id"]: x["text"] for x in data["items"]}
for it in items:
if it["id"] not in got:
raise ValueError(f"返回缺少 {it['id']}")
src_sig, dst_sig = ph_signature(it["text"]), ph_signature(got[it["id"]])
if src_sig != dst_sig:
raise ValueError(f"{it['id']} 占位符不匹配:{src_sig} -> {dst_sig}")
return got
except Exception as e:
last_err = e
time.sleep(2 ** attempt)
raise last_err
def main():
rows = [json.loads(l) for l in SEG.read_text(encoding="utf-8").splitlines() if l.strip()]
glossary = load_glossary()
cache = load_cache()
todo = [r for r in rows if r["id"] not in cache]
print(f"待翻译 {len(todo)} / 共 {len(rows)} 条")
out = OUT.open("w", encoding="utf-8")
cache_f = CACHE.open("a", encoding="utf-8")
for r in rows:
if r["id"] in cache:
out.write(json.dumps({"id": r["id"], "text": cache[r["id"]]}, ensure_ascii=False) + "\n")
batch, size = [], 0
def flush(batch):
if not batch:
return
got = translate_batch(batch, glossary)
for it in batch:
rec = {"id": it["id"], "text": got[it["id"]]}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
cache_f.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush(); cache_f.flush()
time.sleep(SLEEP)
for r in todo:
if size + len(r["text"]) > BATCH_CHARS and batch:
flush(batch); batch, size = [], 0
batch.append(r); size += len(r["text"])
flush(batch)
out.close(); cache_f.close()
print("翻译完成")
if __name__ == "__main__":
main()
```
运行:
```bash
export AI_API_KEY="你的密钥"
export AI_BASE_URL="https://你的服务商接口地址/v1" # 用官方直连则不用设
export AI_MODEL="你账号里可用的模型名"
python scripts/03_translate.py
```
几点说明。批大小 BATCH_CHARS 不是越大越好:一批里塞进太多内容,模型越容易漏占位符;太小则上下文断裂、指代混乱、费用也没省。可以先拿 20 条试跑,找到漏标率低的规模再放开。另外 response_format 并非所有服务商都支持,如果你的接口报错,把它删掉,改成在 system 里更严厉地强调"只输出 JSON",并在解析失败时重试。
步骤 4:把译文写回 XHTML
回写时要用占位符索引找回原始标签对象,重建 DOM。
```python
scripts/04_apply.py
import json, pathlib, re
from bs4 import BeautifulSoup, NavigableString
UNPACKED = pathlib.Path("work/unpacked")
TRANS = pathlib.Path("work/translated.jsonl")
def rebuild(node, store, soup):
out = []
for child in node.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif getattr(child, "name", None) == "span" and child.get("data-ph") is not None:
orig = store[int(child["data-ph"])]
new = soup.new_tag(orig.name, attrs=dict(orig.attrs))
for sub in rebuild(child, store, soup):
new.append(sub)
out.append(new)
else:
out.append(child)
return out
def main():
trans = {json.loads(l)["id"]: json.loads(l)["text"]
for l in TRANS.read_text(encoding="utf-8").splitlines() if l.strip()}
按文件分组,重新解析、抽取、替换、写回
from scripts.extract_helpers import BLOCK, to_template # 复用步骤 2 的函数
by_file = {}
for sid, text in trans.items():
f, _ = sid.rsplit("#", 1)
by_file.setdefault(f, {})[sid] = text
for rel, mapping in by_file.items():
path = UNPACKED / rel
soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml")
n = 0
for el in soup.find_all(sorted(BLOCK)):
if el.find_parent(sorted(BLOCK)):
continue
if el.find_parent(["pre", "code", "svg", "math"]):
continue
template, store = to_template(el)
sid = f"{rel}#{n}"
n += 1
if sid not in mapping:
continue
html = re.sub(r"<(\d+)>", r'<span data-ph="\1">', mapping[sid])
html = re.sub(r"</(\d+)>", "</span>", html)
frag = BeautifulSoup(html, "html.parser")
el.clear()
for node in rebuild(frag, store, soup):
el.append(node)
path.write_text(str(soup), encoding="utf-8")
print("回写完成")
if __name__ == "__main__":
main()
```
同时别忘了元数据:把 opf 里的 dc:language 改成 zh-CN,dc:title 换成中文书名,目录文件 nav.xhtml / .ncx 里的标题也在步骤 2、3 里一并翻了,如果没翻,手工改一下。
步骤 5:重新打包并校验
打包有两个硬要求:mimetype 必须是压缩包里的第一个条目,且不能被压缩;其余文件按原相对路径写入。
```python
scripts/05_pack.py
import pathlib, zipfile
UNPACKED = pathlib.Path("work/unpacked")
TARGET = pathlib.Path("book.zh.epub")
MIMETYPE = "application/epub+zip"
with zipfile.ZipFile(TARGET, "w") as z:
info = zipfile.ZipInfo("mimetype")
info.compress_type = zipfile.ZIP_STORED
z.writestr(info, MIMETYPE)
for p in sorted(UNPACKED.rglob("*")):
if p.is_file() and p.name != "mimetype":
z.write(p, p.relative_to(UNPACKED))
print("已生成", TARGET)
```
用 epubcheck 跑一遍校验(安装方式以官方文档为准):
```bash
epubcheck book.zh.epub
```
没有 ERROR 级别的问题,就可以丢进阅读器看了。推荐在桌面端和手机端各开一次,重点看目录能不能跳转、脚注能不能点开、标题层级有没有乱。
常见坑与排错
占位符丢失或错位。 最常见的失败模式。症状是某段译文里 <3> 有开无闭,回写后整段被包进错误的标签里。脚本里已经做了签名比对,遇到就重试该批;重试仍失败的,把这条单独拆出来翻,或者把该段按句子切短。批量过大是主因。
术语前后不一致。 章节之间是独立请求,模型记不住前面的译法。解法是术语表 + 翻译完成后跑一次全局替换脚本,把漏网的统一。术语表别贪多,二十到五十条核心词就够,太多反而稀释了提示词。
译文比原文短一大截。 通常是模型偷懒做了摘要。写个检查脚本,统计每条译文长度与原文长度之比,低于某个阈值(比如中文对英文低于 0.4)的挑出来人工看。这类问题靠肉眼翻几百页是发现不了的。
代码块、公式、诗行被翻译。 抽取阶段用 el.find_parent(["pre","code","svg","math"]) 跳过;如果是诗行切成多个 <p>,可以在 system 里加一句"诗歌行保持分行,不合并"。
中文标点没统一。 模型有时输出半角逗号、直引号。写个后处理脚本统一成弯引号与全角标点,注意别误伤代码和 URL。
EPUB 打不开或阅读器不认。 十有八九是 mimetype 被压缩了,或者写入顺序不对。检查方式是把新包当普通 ZIP 打开,看第一个条目是不是 mimetype。
目录和元数据没翻。 这是最容易被忽略的一处,尤其 .ncx 文件里的 navLabel。翻完记得回头检查。
费用与限流。 一本二十万字的书,按批量方式跑下来请求数是几百到上千次。务必开缓存,work/cache.jsonl 千万不要删;遇到 429 就把 SLEEP 调大,或者降低并发。
版权边界。 只处理公版书、自有作品或已获授权的内容。成品要不要署名原作者与译者、能不能分发,按你所在地区的规则和授权协议来。
下一步建议
流水线跑通之后,可以往上加几件事:
- 双语对照版:回写时把原文放进一个隐藏层或者
<details>折叠块,做成上下对照的阅读版本; - 自动质检:把译文再翻回源语言,与原句做相似度比较,分数低的段落标红人工复核;
- 朗读版:把译文交给 TTS 生成有声书,EPUB 3 支持 media overlays,做法以官方规范为准;
- 格式转换:需要 mobi / azw3 时用 Calibre 这类工具转,参数以官方文档为准;
- 术语表复用:把 glossary 按作者、系列、学科分类存好,同一作者的第二本书直接沿用,译名一致性会明显变好。
流程本身不复杂,值钱的是那几个不起眼的细节:占位符校验、缓存、长度异常检测。把这三样做扎实,翻十本和翻一本的工作量差别不大。
