EPUB转换工具页
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=utf8 | type ∈ {txt, utf8, big5, gbk} |
| Format 2/3(分卷) | aid=4273&vid=175618&charset=gbk | charset ∈ {txt, utf8, big5, gbk} |
后端 API(epub-convert-api.php)
SSE 流程(
action=convert):- 清理
usr/epub-temp/中超过 1 小时的临时文件 - URL 校验(
parseWenku8Url()):验证 HTTPS scheme、wenku8 host、路径、参数白名单 - TXT 分块下载(8192 字节/块),解析
Content-Lengthheader 推送 10%–60% 进度;上限 30 MB - 编码转换:
iconv()GBK/Big5 → UTF-8,并经mb_check_encoding()校验结果,防止乱码写入 EPUB - SQLite 查询元信息(
novels.db→ title/author/press) - 封面获取:调用共享模块
inc/cover-cache.php的fetchLocalLargeOrSmallCover(),优先读usr/covers/{aid}_l.jpg(详情页校准时写入的 bangumi 大图),未命中降级到usr/covers/{aid}.jpg(wenku8 小图缓存或即时下载);EPUB 流程绝不主动请求 Bangumi - 调用
EpubBuilder::build(),写{uuid}.epub+{uuid}.json - 发送
done事件,携带 uuid 和文件名
- 清理
- 文件下载(
action=download):UUID 严格过滤,读.json获取 filename;readfile()前将.epub重命名为.epub.downloading规避cleanupTempFiles()竞态删除,输出后清理临时文件
EPUB 生成类(EpubBuilder)
- 只有静态方法,无外部依赖,仅需 ZipArchive(PHP 默认)
章节检测策略(四级降级):
- 已知标题策略(新增):调用方传入
$knownTitles(来自 wenku8 目录页)时,按紧凑形式(去除全/半角空格)逐行哈希匹配;命中率 ≥ 60% 才采用,否则返回 null 走下面的正则策略 - 正则主策略:
\n{3,}分割段落块,第一非空行为标题(适配 wenku8 格式化全本) 正则次策略:正则扫描标题行,参考 kaf-cli
DefaultMatchTips:^第[0-9零一二三四五六七八九十〇百千两\s ]+[章回节集幕卷部](含数字变体两/〇/全角空格)- 特殊标题:
^引子$、^楔子$、^序章、^番外...、^完本感言... - 标题长度上限:35 个 Unicode 字符(防长句误判)
- 排除词:
部门/部队/部属/部分/部件/部落(防 "第三部分" 等被误识别)
- 兜底降级:整本作为单章
- 已知标题策略(新增):调用方传入
- EPUB 结构:
mimetype(无压缩置首)→META-INF/container.xml→OEBPS/含封面、各章 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 官方目录页的"已知标题"策略作为一级优先。
数据源
- URL:
https://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 - 无需登录
进度条阶段
| 阶段 | 百分比 | 文案 | 变更 |
|---|---|---|---|
| start | 5% | 解析链接成功... | 不变 |
| downloading | 10%→60% | 下载中 N% | 不变 |
| metadata | 65% | 获取元信息中 65% | 不变 |
| metadata | 70% | 获取封面中 70% | 原 75% |
| indexing | 75% | 解析章节目录中 75% | 新增 |
| converting | 80%→95% | 转换中 N% | 不变 |
| done | 100% | 转换完成 | 不变 |
匹配逻辑
- "紧凑化"函数:
str_replace(["\xE3\x80\x80", ' ', "\t"], '', trim($s)),对标题和 TXT 每行做同等处理后哈希查找 接入层预筛(关键):wenku8 TXT 内章节行普遍是"卷名 + 空格 + 章节名"(如
第一卷 2弹 神崎.H.亚莉亚),但目录页ccss只含纯章节名。解决方案:- 为每章生成两种候选形态:
$plain(仅章节名)与$prefixed(卷名+章节名) - 扫 TXT 一次,构建紧凑行集合,用该集合对两套候选做命中统计
- 命中多的形态胜出,只把命中子集传给
EpubBuilder::build($knownTitles) - EpubBuilder 端因此拿到的是"必定能命中"的标题列表,分母与分子对齐,阈值稳定
- 为每章生成两种候选形态:
- EpubBuilder 内阈值(防御性):命中数 / 去重标题数 ≥ 60% 且章节数 ≥ 2,否则返回 null 降级到正则
实测(aid=351 绯弹的亚莉亚)
- 目录页 46 卷 361 章
- prefixed 形态命中 352/361(97.5%),plain 仅 2/361
- 选 prefixed → EpubBuilder 切出 352 章,对比正则策略此前降级到单章,质量显著提升
降级规则(任一失败即进入下一级)
- HTTP 非 2xx / timeout / 连接失败
- 响应前 2048 字节出现
Just a moment/cf-browser-verification/challenge-platform→ 视为 CF 拦截 iconv失败或返回空DOMDocument::loadHTML失败或 XPath 无匹配节点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.php | build() 新增 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两套紧凑键,与 TXTlineSet对撞;命中更多者胜出。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.md2. PHP 扩展要求
| 扩展 | 用途 | 默认状态 |
|---|---|---|
ZipArchive | EPUB 打包 | PHP 默认开启 |
mbstring | 字符串长度校验 | 通常开启,wenku8 编码转换也依赖 |
PDO + pdo_sqlite | novels.db 元信息查询 | 通常开启 |
iconv | GBK/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 -delete4. novels.db 路径
所有根目录 PHP 端点(novel-api.php、novel-cover.php、novel-rating-api.php、novel-rating-override-api.php、epub-convert-api.php)统一使用 config.inc.php 中定义的 NOVELS_DB_PATH 常量。若数据库位置变更,仅需修改 config.inc.php 一处即可。
使用方式
在 Typecho 后台新建独立页面:
- 模板:选"轻小说epub转换"
- Slug:建议
epub-convert - 父页面:更多(使其出现在导航下拉菜单中)
注意事项
- 仅支持 wenku8 下载链接,其他来源不受理(后端有严格 host 校验)
- TXT 单文件上限 30 MB;超限时 SSE 返回错误事件,前端显示提示
usr/covers/封面缓存被 EPUB 转换器复用,无需额外配置usr/epub-temp/已加入.gitignore,临时生成文件不会进入版本库