跳到主内容
快讯直播
AI智模界
教程

用 AI 翻译整本电子书:EPUB 保留排版实战

翻译一本电子书,散装做法是复制粘贴到对话框里,翻完再手动拼回去——排版全丢,术语前后打架,几百页下来人先崩。这篇教程给一条可复用、可断点续跑的流水线:把 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);
  • .ncxnav.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-CNdc: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 按作者、系列、学科分类存好,同一作者的第二本书直接沿用,译名一致性会明显变好。

流程本身不复杂,值钱的是那几个不起眼的细节:占位符校验、缓存、长度异常检测。把这三样做扎实,翻十本和翻一本的工作量差别不大。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。