EPUB转换工具页

文档编号 10

10 - 轻小说 EPUB 转换页面

背景

为方便直接从 wenku8(安全起见不展示完整域名)下载链接生成 EPUB 文件,新增一个独立转换工具页面。用户粘贴下载链接后,后端实时以 SSE 推送进度(下载→转码→查询元信息→封面→生成),完成后提供 EPUB 下载。


架构

epub-convert.php    (Typecho 模板,前端 SSE 消费者)
       │  EventSource
       ▼
epub-convert-api.php   (根目录,SSE API + 文件下载 API)
       │  下载 TXT → iconv → mb_check_encoding → EpubBuilder::build()
       │  PDO SQLite (元信息查询)
       │  复用 inc/cover-cache.php(封面下载 + usr/covers/ 缓存)
       ▼
usr/themes/classic-22/inc/epub-builder.php   (EPUB 生成类)
usr/themes/classic-22/inc/cover-cache.php    (封面缓存共享模块,与 novel-cover.php 复用)
       │  ZipArchive 组装 EPUB 3 (兼容 EPUB 2 NCX)
       ▼
usr/epub-temp/{uuid}.epub   (临时输出,1 小时后自动清理)

新增 / 修改文件

文件类型说明
epub-convert.php(主题目录)新增Typecho 自定义页面模板,含 URL 输入框、进度条、下载按钮、内联 JS/CSS
epub-convert-api.php(根目录)新增SSE API(action=convert)+ 文件下载(action=download
usr/themes/classic-22/inc/epub-builder.php新增纯 PHP EPUB 生成类,依赖 ZipArchive(PHP 默认开启)
usr/themes/classic-22/inc/cover-cache.php新增封面下载+缓存共享模块,与 novel-cover.php 共用,避免重复实现
usr/epub-temp/.gitkeep新增占位文件,保证临时目录存在于 Git 仓库
.gitignore修改追加 usr/epub-temp/*!/usr/epub-temp/.gitkeep

关键实现说明

支持的下载链接格式

格式示例参数说明
Format 1(全本)id=2428&type=utf8type ∈ {txt, utf8, big5, gbk}
Format 2/3(分卷)aid=4273&vid=175618&charset=gbkcharset ∈ {txt, utf8, big5, gbk}

后端 API(epub-convert-api.php)

  • SSE 流程action=convert):

    1. 清理 usr/epub-temp/ 中超过 1 小时的临时文件
    2. URL 校验(parseWenku8Url()):验证 HTTPS scheme、wenku8 host、路径、参数白名单
    3. TXT 分块下载(8192 字节/块),解析 Content-Length header 推送 10%–60% 进度;上限 30 MB
    4. 编码转换:iconv() GBK/Big5 → UTF-8,并经 mb_check_encoding() 校验结果,防止乱码写入 EPUB
    5. SQLite 查询元信息(novels.db → title/author/press)
    6. 封面获取:调用共享模块 inc/cover-cache.phpfetchLocalLargeOrSmallCover(),优先读 usr/covers/{aid}_l.jpg(详情页校准时写入的 bangumi 大图),未命中降级到 usr/covers/{aid}.jpg(wenku8 小图缓存或即时下载);EPUB 流程绝不主动请求 Bangumi
    7. 调用 EpubBuilder::build(),写 {uuid}.epub + {uuid}.json
    8. 发送 done 事件,携带 uuid 和文件名
  • 文件下载action=download):UUID 严格过滤,读 .json 获取 filename;readfile() 前将 .epub 重命名为 .epub.downloading 规避 cleanupTempFiles() 竞态删除,输出后清理临时文件

EPUB 生成类(EpubBuilder)

  • 只有静态方法,无外部依赖,仅需 ZipArchive(PHP 默认)
  • 章节检测策略(四级降级):

    1. 已知标题策略(新增):调用方传入 $knownTitles(来自 wenku8 目录页)时,按紧凑形式(去除全/半角空格)逐行哈希匹配;命中率 ≥ 60% 才采用,否则返回 null 走下面的正则策略
    2. 正则主策略\n{3,} 分割段落块,第一非空行为标题(适配 wenku8 格式化全本)
    3. 正则次策略:正则扫描标题行,参考 kaf-cli DefaultMatchTips

      • ^第[0-9零一二三四五六七八九十〇百千两\s ]+[章回节集幕卷部](含数字变体 //全角空格)
      • 特殊标题:^引子$^楔子$^序章^番外...^完本感言...
      • 标题长度上限:35 个 Unicode 字符(防长句误判)
      • 排除词:部门/部队/部属/部分/部件/部落(防 "第三部分" 等被误识别)
    4. 兜底降级:整本作为单章
  • EPUB 结构:mimetype(无压缩置首)→ META-INF/container.xmlOEBPS/ 含封面、各章 XHTML、nav.xhtml(EPUB 3)、toc.ncx(EPUB 2 兼容)、content.opf

EPUB 封面升级到 bangumi large(2026-05-07)

  • 输入:fetchLocalLargeOrSmallCover($aid) → 仅消费本地缓存,按 {aid}_l.jpg{aid}.jpg(命中/下载)顺序
  • 刻意不联网 Bangumi:大图的写入由 novel-search.php 详情页校准流程(novel-rating-override-api.php)负责;EPUB 侧只读,避免 SSE 并发下触发 Bangumi 限流或拖慢生成
  • 降级行为:未写过大图的旧书走 wenku8 200px 小图,生成的 EPUB 视觉与升级前一致
  • 升级方式:在详情页点击状态徽章完成 Bangumi ID 校准即可生成 {aid}_l.jpg,随后的 EPUB 生成即可直接使用大图

章节目录增强(2026-05-07)

背景

部分轻小说章节名并非"第 X 章"模式(例如 aid=351《狼与香辛料》卷名为 X弹),正则主/次策略都无法识别,导致整本降级为单章影响阅读体验。新增基于 wenku8 官方目录页的"已知标题"策略作为一级优先。

数据源

  • URLhttps://www.wenku8.net/modules/article/reader.php?aid={aid}(权威目录路径,无需猜 /novel/{p1}/{aid}/ 分段,后者对部分 aid 返回 404)
  • 结构

    <table class="css">
      <tr><td class="vcss" colspan="4">第 X 卷</td></tr>
      <tr><td class="ccss"><a href="...">章节名</a></td>... (每行 4 章)</tr>
    </table>
  • 编码:GBK(需 iconv('GBK', 'UTF-8//TRANSLIT//IGNORE', ...)
  • DOMDocument 坑点:对含中文的 HTML,<?xml encoding="UTF-8"> 前缀不生效(XPath 解析出 0 个节点)。正确做法:mb_encode_numericentity($utf8, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8') 将非 ASCII 转 HTML 实体后再 loadHTML
  • 无需登录

进度条阶段

阶段百分比文案变更
start5%解析链接成功...不变
downloading10%→60%下载中 N%不变
metadata65%获取元信息中 65%不变
metadata70%获取封面中 70%原 75%
indexing75%解析章节目录中 75%新增
converting80%→95%转换中 N%不变
done100%转换完成不变

匹配逻辑

  • "紧凑化"函数:str_replace(["\xE3\x80\x80", ' ', "\t"], '', trim($s)),对标题和 TXT 每行做同等处理后哈希查找
  • 接入层预筛(关键):wenku8 TXT 内章节行普遍是"卷名 + 空格 + 章节名"(如 第一卷 2弹 神崎.H.亚莉亚),但目录页 ccss 只含纯章节名。解决方案:

    1. 为每章生成两种候选形态:$plain(仅章节名)与 $prefixed(卷名+章节名)
    2. 扫 TXT 一次,构建紧凑行集合,用该集合对两套候选做命中统计
    3. 命中多的形态胜出,只把命中子集传给 EpubBuilder::build($knownTitles)
    4. EpubBuilder 端因此拿到的是"必定能命中"的标题列表,分母与分子对齐,阈值稳定
  • EpubBuilder 内阈值(防御性):命中数 / 去重标题数 ≥ 60% 且章节数 ≥ 2,否则返回 null 降级到正则

实测(aid=351 绯弹的亚莉亚)

  • 目录页 46 卷 361 章
  • prefixed 形态命中 352/361(97.5%),plain 仅 2/361
  • 选 prefixed → EpubBuilder 切出 352 章,对比正则策略此前降级到单章,质量显著提升

降级规则(任一失败即进入下一级)

  1. HTTP 非 2xx / timeout / 连接失败
  2. 响应前 2048 字节出现 Just a moment / cf-browser-verification / challenge-platform → 视为 CF 拦截
  3. iconv 失败或返回空
  4. DOMDocument::loadHTML 失败或 XPath 无匹配节点
  5. fetchWenku8Index() 返回空数组 → SSE 推送 "章节目录解析失败,使用兜底策略",走原正则链

Format 2/3 单卷处理

  • URL 里的 vid 是 wenku8 服务端的 volume id(如 175618),目录页 HTML 不暴露该字段(只有章节的 cid),因此无法精准切片到单卷
  • 统一用全书标题做预筛;单卷 TXT 仅会命中该卷章节,预筛后集合就是该卷章节列表,自然对齐

CF 轻量防御

  • 请求头:Chrome 124 UA + Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 + Referer: https://www.wenku8.net/
  • 超时:8 秒(目录页轻量,无需更长)
  • 无重试:失败即降级,避免阻塞整体转换流程
  • 无 Cookie 持久化:目录页无需登录态,简化实现

涉及文件

文件改动
epub-convert-api.php新增 fetchWenku8Index(int $aid): array;SSE 主流程插入 indexing 阶段;封面阶段 75%→70%
usr/themes/classic-22/inc/epub-builder.phpbuild() 新增 array $knownTitles = [] 参数;新增私有方法 splitByKnownTitles()

卷/章节两级 TOC(2026-05-07)

此前扁平 $knownTitles 生成的 TOC 只有单层,且按"卷+章节"胜出策略会把 第一卷 2弹 神崎.H.亚莉亚 整串塞进一个 <navPoint>,丢失卷层级。新增结构化目录支持:

  • 数据形态fetchWenku8Index() 返回 [['title'=>卷名,'chapters'=>[章名,...]], ...]epub-convert-api.php 原样透传为 $knownStructure,预筛命中率 ≥ 60% 才启用。
  • 路由EpubBuilder::build() 新增 array $knownStructure = [](位于 $knownTitles 之后)。

    • $knownStructure 非空 → splitByKnownStructure()(两级)
    • 失败回退 → splitByKnownTitles()(单层)
    • 再失败 → detectChapters() 正则兜底
  • splitByKnownStructure:展平候选为 compact_prefixed / compact_plain 两套紧凑键,与 TXT lineSet 对撞;命中更多者胜出。title 始终用纯章节名,volume 字段承载卷名;命中率 < 0.6 或章节数 < 2 返回 null。
  • buildNavXhtml 分组分支:检测到任一章节带非空 volume 即走分组;按 volume 连续分段输出嵌套 <ol>,外层 <li><a href="卷首章.xhtml">卷名</a>volume === '' 的章节直接挂到顶层,不套空卷壳。
  • buildTocNcx 分组分支:卷节点 <navPoint id="navpoint-v{n}"> 借首章 playOrder,子 navPoint 保持线性递增 playOrder;分组生效时 <meta name="dtb:depth" content="2"/>,否则保持 1
  • 空卷降级volume === '' 的章节与无 volume 字段的章节一致处理,顶层直挂;保证无目录页/全兜底场景输出与历史版本字节级等价。
  • spine 不变$chapters 仍是顺序数组,manifest/spine 照旧按 chap_NNN 线性铺开,阅读顺序不受 TOC 层级影响。

前端(epub-convert.php)

  • 使用原生 EventSource 消费 SSE 流,无框架依赖
  • 客户端 URL 校验与后端对称,早期过滤无效链接
  • 文件名含非 ASCII 字符时使用 filename*=UTF-8''<encoded> 编码(RFC 5987)
  • pico.css <progress> 元素实现进度条,通过 value 属性驱动

部署注意事项

1. epub-temp 目录权限

服务器上 {TYPECHO_ROOT}/usr/epub-temp/ 目录必须对 Web 服务器进程(如 www-data)可写:

# 查看当前权限
ls -la {TYPECHO_ROOT}/usr/epub-temp/

# 修复写权限(选择适合服务器环境的方式)
chmod 755 {TYPECHO_ROOT}/usr/epub-temp/
chown www-data:www-data {TYPECHO_ROOT}/usr/epub-temp/
参考:usr/covers/ 目录曾因同样问题导致封面写入失败,排查方法见 09-novel-search.md

2. PHP 扩展要求

扩展用途默认状态
ZipArchiveEPUB 打包PHP 默认开启
mbstring字符串长度校验通常开启,wenku8 编码转换也依赖
PDO + pdo_sqlitenovels.db 元信息查询通常开启
iconvGBK/Big5 → UTF-8 转码PHP 默认开启

3. epub-temp 临时文件清理

后端在每次 action=convert 请求时自动清理超过 1 小时的 .epub.json 文件,无需额外 cron。若需手动清理:

find {TYPECHO_ROOT}/usr/epub-temp/ -name "*.epub" -mmin +60 -delete
find {TYPECHO_ROOT}/usr/epub-temp/ -name "*.json" -mmin +60 -delete

4. novels.db 路径

所有根目录 PHP 端点(novel-api.phpnovel-cover.phpnovel-rating-api.phpnovel-rating-override-api.phpepub-convert-api.php)统一使用 config.inc.php 中定义的 NOVELS_DB_PATH 常量。若数据库位置变更,仅需修改 config.inc.php 一处即可。


使用方式

在 Typecho 后台新建独立页面:

  • 模板:选"轻小说epub转换"
  • Slug:建议 epub-convert
  • 父页面:更多(使其出现在导航下拉菜单中)

注意事项

  1. 仅支持 wenku8 下载链接,其他来源不受理(后端有严格 host 校验)
  2. TXT 单文件上限 30 MB;超限时 SSE 返回错误事件,前端显示提示
  3. usr/covers/ 封面缓存被 EPUB 转换器复用,无需额外配置
  4. usr/epub-temp/ 已加入 .gitignore,临时生成文件不会进入版本库