Skip to content
dsh.fish
Bundle

dsh-tool-epub

Read, build and take apart EPUB 3 books from DeepSeek Harness: inspect metadata and reading order, assemble a book from chapters, and split one back into per-chapter files. No external dependency.

Source
yuehancn
License
MIT
Updated
Updated yesterday

Readme

# dsh-tool-epub

> **在 dsh 里读、造、拆 EPUB 电子书** —— 先把一本书拆开看清楚它的元数据、
> 阅读顺序和目录到底对不对,再从章节拼出一本合规的书,或者把一本已有的书
> 拆回逐章的 HTML。

给 **DeepSeek Harness** 用的自建工具插件:**ZIP 读写器和 XML 扫描器都是自己写的**,
不装 `epubjs`、不装 `jszip`,在一台干净的机器上就能用。

> **Compatibility**: built and tested against dsh `0.2.0-rc.2` (preview).
> The `apply(ctx)` plugin spec is stable; verify against your own dsh version if newer.

---

## 一行安装

```bash
dsh plugin --profile desktop add github:yuehancn/dsh-tool-epub
```

**支持的 profile**:`desktop`(桌面版)/ `web`(Web 版)。装完重启 dsh 即可用。

---

## 为什么需要它

EPUB 不是「一个 Word 文档换个后缀」,它是一个**有强制骨架的 ZIP**:
`mimetype` 必须是第一个条目且**不压缩**存储,`META-INF/container.xml` 指向
一个 OPF 包文档,包文档里再声明 spine(阅读顺序)和 nav(目录)。
缺一环,阅读器就直接拒收。

真正容易出错的是**判断**而不是管道:

- 一本书的章节被**声明了两次** —— spine 一次、nav 一次。它们比你想的更常不一致,
  而「书坏了」绝大多数时候就是这两处对不上。
- 把一个大 HTML 拆成章节要靠标题层级**猜边界**。猜错了是**静默**的:
  书还是能打开,只是内容错位了。
- `mimetype` 要是被压缩了(绝大多数 ZIP 库的默认行为),严格的阅读器直接拒绝整本书。

这个插件把「看清楚」和「动手」分开:

```
epub_inspect(path="book.epub")   →  元数据 / 阅读顺序 / 目录 / 逐章字数 / 结构问题
epub_build(title="…", html="…")  →  合规的 EPUB 3,并告诉你用了哪条拆分规则
```

**关键设计**:`epub_inspect` 是**独立的工具**,不是 `epub_build` 的前置步骤。
因为「这本书现在长什么样」和「我要造一本什么样的书」是两件事 ——
先拿到体检报告,再决定动不动手。

---

## 四个工具

### `epub_status`
报这里有哪些能力:读、造、拆**都内置**,`externalDependency` 一栏明确写着
`none`。另外报出章节数上限和单次调用预算。
**长任务前先问一句**,避免做到一半才发现超限。

### `epub_inspect(path)`
**只读不写**。不解包就能拿到:

| 返回项 | 说明 |
|---|---|
| `title` / `creator` / `language` / `identifier` | OPF 里的元数据 |
| `epubVersion` | 从 `package` 元素的 `version` 属性读,通常是 `3.0` |
| `entryCount` | ZIP 里的条目数 |
| `chapters[]` | 按 **spine 顺序**(不是文件名顺序)排好的逐章记录,含 `words` 和 `bytes` |
| `toc[]` | nav 文档里的目录树 |
| `totalWords` | 全书字数 |
| `images[]` | 书里引用的图片资源 |
| `warnings[]` | **结构问题清单** |

`warnings` 是重点,它会把下面这些**明确点出来**而不是含糊带过:

- `mimetype` 被压缩了,或者不是第一个条目
- spine 里声明了某个文件,但 ZIP 里**没有这个文件**
- 目录(nav)里的 href **指向一个不存在的路径**
- 某个 spine 文档**从目录里根本走不到**(读者会看得见但翻不到)
- `linear="no"` 的文档(不算正文顺序)
- manifest 里没有 `properties="nav"` 的条目 → 这本书没有目录

### `epub_build(outputName, …)`
造一本书。**两条路径,二选一**:

| 方式 | 参数 | 用途 |
|---|---|---|
| 显式章节 | `chapters: [{title, html}, …]` | 边界已经知道了 |
| 自动推断 | `html: "<一个大 HTML>"` + `splitLevel` | 边界要从标题层级推 |

| 参数 | 说明 |
|---|---|
| `outputName` | **必填**,产出文件名,如 `my-book.epub` |
| `title` / `language` / `author` / `identifier` | 元数据;`identifier` 不给就按内容生成一个稳定的 URN |
| `splitLevel` | `auto`(默认,取最浅的标题层级)或 `h1`–`h6` |
| `coverImagePath` | 封面图路径 |

返回里带 `splitRule` 字段 —— **明确告诉你边界是怎么切出来的**
(例如 `split on 3 h2 headings`)。推断出来的东西必须可解释,
否则错了没人知道。第 0 章以外每一章的字数都从**生成的 XHTML** 里数
(包含注入的 `<h1>`),所以 build 和 inspect 数出来的字数是**对得上的**。

### `epub_split(path, …)`
把一本书拆回逐章文件,再写一份清单。

| 参数 | 说明 |
|---|---|
| `path` | **必填**,`.epub` 路径 |
| `outputDir` | 输出目录,默认走配置 |
| `format` | `html`(保真)或 `txt`(纯文本),默认 `html` |
| `prefix` | 文件名前缀,默认用书名/文件名生成的 slug(**支持中文**) |
| `includeToc` | 是否额外写一份 `index.html` 目录页 |

`txt` 模式和字数统计走的是**同一个标签剥离器**,所以两者对「正文是什么」
永远不会打架。章节序号按 **spine 顺序**补零到三位(`-001`、`-002`),
保证文件名的字典序和阅读顺序一致。

---

## 配置

```yaml
# ~/.dsh/profiles/<profile>/cordis.patch.yml
- id: tool-epub
  config:
    outputDir: C:/Users/you/Documents/epub-output
    defaultTitle: Untitled
    defaultLanguage: zh-CN
    defaultAuthor: 你的名字
    maxChapters: 500
    timeoutMs: 60000
```

| 字段 | 默认 | 说明 |
|---|---|---|
| `outputDir` | `epub-output` | 产物目录 |
| `defaultTitle` | `Untitled` | 调用方不给标题时的兜底 |
| `defaultLanguage` | `en` | BCP 47 语言标签,中文书改成 `zh-CN` |
| `defaultAuthor` | `""` | 写进生成书的作者 |
| `maxChapters` | `500` | 超过就拒绝构建(**防止一次请求把内存吃光**) |
| `timeoutMs` | `60000` | 单次调用预算 |
| `status`/`inspect`/`build`/`split` | `true` | 按需关掉某个工具 |

---

## 安全说明

- **Zip-slip 防护**:书里的 href 一律过 `resolvePath` 归一化,
  `../../etc/passwd` 这类路径会被**夹在压缩包根目录内**,逃不出去。
- **产物文件名同样净化**:`outputName` 里的 `../` 会被剥掉再做替换,
  不会往产物目录外面写文件。
- **不联网、不执行书里的任何东西** —— 只解析文本和 XML。
- **确定性**:固定 DOS 时间戳 + 按内容推导的 `identifier` +
  固定的 `dcterms:modified`。同一份输入**永远产出字节一致的书**,
  所以两次构建可以直接 `diff`。

---

## 实现说明:两个必须自己写的地方

EPUB 是 **ZIP + OCF + OPF + XHTML**,全是开放标准,但细节全是坑。
这里没有引第三方库,所以下面这几件事必须做对:

1. **`mimetype` 必须是第一个条目,且 STORED(不压缩)**。
   大多数 ZIP 库默认压缩所有条目 —— 那是错的,严格的阅读器会直接拒收整本书。
   所以 `writeZip` 里 `store: true` 走的是方法 0 的独立分支。

2. **读 ZIP 时必须用「本地头」的长度,不能用目录里的**。
   中央目录和本地头各自记录了自己的 name/extra 长度,**两者可以不一样**。
   用目录里的长度去算数据偏移,遇到带额外字段的条目就会读出乱码。

3. **EOCD 签名要**从后往前**扫**,且窗口要留出 65535 字节的注释空间。
   因为 ZIP 注释里**可以**包含 `0x06054b50` 这四个字节,
   从前往后扫会命中假签名。

4. **目录里指向的 href 是相对路径**,要相对**nav 文档自己的位置**解析,
   不是相对包根、也不是相对当前工作目录。

验证方式:`_test/verify-epub.py` 用**两条独立路径**校验 ——
Python 标准库 `zipfile` 检查容器合法性(`mimetype` 首位、STORED、CRC 全对,
`testzip()` 能跑通),再用 `ebooklib` 做一次**真正的 EPUB 解析**
(元数据、spine、目录)。

这个双路校验当场抓到了一个我自己的 bug:我的校验脚本没做 HTML 实体解码,
把书里的 `&amp;` 和 `&` 当成不相等,误报标题错误 —— **书是对的,校验器是错的**。
自己写的校验器犯的错,只有另一个实现才能发现。

当前结果:**4/4 本书通过校验**(含中文书、实体转义书、单章书、多章书)。

---

## 跑测试

```bash
mkdir -p node_modules/@deepseek-ai
cp -r "$HOME/.dsh/profiles/desktop/node_modules/@deepseek-ai/." node_modules/@deepseek-ai/
node _test/run-all.mjs
```

三个套件,**356 条断言全绿**(对着真实 `@deepseek-ai/dsh-tools` 跑,不 mock):

| 套件 | 断言 | 内容 |
|---|---|---|
| `test-logic.mjs` | 168 | `Config` 默认值与覆盖、注册开关、schema 归一化、XML 转义、标签剥离(含多行 `<script>`)、路径解析(**含 zip-slip 夹取**)、标题推断、字数统计(**CJK 逐字计**)、字节嗅探、CRC-32 已知向量(`"123456789"` = `0xcbf43926`)、ZIP 写出布局与**确定性**、ZIP 读入健壮性、OPF/nav 解析 |
| `test-integration.mjs` | 125 | status、显式章节构建、自动推断构建、8 条构建错误路径、章节上限、封面嵌入、真书 inspect、4 条 inspect 错误路径、**5 个结构问题 fixture**(mimetype 被压缩 / spine 文件缺失 / 目录悬空 / 孤儿章节 / `linear="no"`)、split 的 html 与 txt 两种模式、自定义输出目录 + 中文 slug、split 错误路径、确定性、注册开关 |
| `test-e2e.mjs` | 63 | 构建真书 → 断言结构自洽(`assertCoherent`)→ XHTML 良构性 → **中文往返** → 实体转义 → 产物文件名净化(zip-slip)→ 封面 → split 能还原正文 → build 与 split 对字数的口径一致 |

再跑一次独立校验(需要 Python + `ebooklib`):

```bash
python _test/verify-epub.py _test/samples
```

`test-e2e.mjs` 会把样本写到 `_test/samples/`,正是给这个脚本消费的 ——
所以 e2e 是「自己验自己」,而这个脚本是「**别人**验自己」。

---

## 许可

MIT

Install

dsh plugin --profile web add github:yuehancn/dsh-tool-epub

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source