Files
cheney 221b1f8add feat: 引入 so-novel submodule 并提供非交互下载工具
- vendor/so-novel: 上游 v1.12.0 (规则驱动的书源抓取/下载引擎)
- tools/src: ToolMain 封装 search/download/toc/sources,stdout 只输出 JSON
- tools/build.ps1: 自动定位/下载 JDK21,按平台显式激活 javet 原生库 profile
- bin/novel.ps1|.cmd: 命令行入口(人读输出或 -Json)
- SKILL.md/README.md: 使用说明与下载原理分析
2026-11-04 15:12:10 +08:00

254 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ebook-skill
把开源项目 [so-novel](https://github.com/freeok/so-novel)(通用网页小说抓取器)以 **git submodule** 形式引入,
并在它外面包一层**非交互 CLI**,让 agent / 脚本可以直接「搜索 → 选源 → 下载 → 拿到文件」,
不部署 Web 服务、不跑 TUI 交互菜单。
```
ebook-skill/
├─ vendor/so-novel/ # git submodule,上游引擎(Java 21 + Maven)
├─ tools/
│ ├─ src/skills/novel/ToolMain.java # 封装层:search / download / toc / sources
│ ├─ build.ps1 # 构建引擎 jar + 编译封装层 + 生成运行时配置
│ ├─ lib.ps1 # 公共函数(定位 JDK21、路径、jar 工具)
│ ├─ config.ini.template # 运行时配置模板
│ └─ runtime/ # 生成物(gitignore):app.jar、rules/、config.ini、logs/
├─ bin/
│ ├─ novel.ps1 # 人读表格 + JSON 两种输出
│ └─ novel.cmd # cmd.exe 入口(自动选 pwsh / powershell)
├─ books/ # 下载落盘目录(gitignore)
├─ SKILL.md # 给 agent 的使用说明
└─ README.md
```
---
## 快速开始
```powershell
git submodule update --init --recursive # 首次
pwsh tools\build.ps1 # 首次构建(可自动下载 JDK 21)
pwsh bin\novel.ps1 sources # 看书源
pwsh bin\novel.ps1 search 诡秘之主 # 聚合搜索
pwsh bin\novel.ps1 toc <详情页链接> # 只解析目录,验证完整性
pwsh bin\novel.ps1 download <详情页链接> -Ext txt
```
依赖:JDK 21(没有则构建脚本从清华镜像下载便携版)、Maven(仅构建时需要)、Git。
下载成品在 `books/书名(作者).txt|epub|html|pdf`。
---
## 一、so-novel 是怎么下载小说的
上游不是「针对某个站点写死的爬虫」,而是**规则驱动 + 通用抓取引擎**:
每个书源是一段 JSON(`vendor/so-novel/bundle/rules/main.json` 等),引擎按规则抓页面。
### 1. 书源规则(Rule)
一条规则对应一个站点,字段分五段:
| 段 | 作用 | 关键字段 |
| --- | --- | --- |
| `url` / `name` / `disabled` / `needProxy` | 站点标识 | `url` 同时是「按详情页 URL 前缀路由到书源」的依据 |
| `search` | 搜索接口 | `url`(支持 `%s` 或 `@js:`)、`method`/`data`(GET/POST 表单)、`result`(结果条目选择器)、`bookName`/`author`/`latestChapter`…、`nextPage` |
| `book` | 详情页 | `bookName`/`author`/`intro`/`coverUrl`/`latestChapter`/`lastUpdateTime`/`status` |
| `toc` | 目录页 | `url`(目录可能不在详情页)、`list`(先取容器 HTML)、`item`(章节条目)、`desc`(倒序)、`nextPage`(分页) |
| `chapter` | 正文页 | `content`、`nextPage`(正文分页)、`nextPageInJs`、`nextChapterLink`(末页判定正则)、`filterTxt`/`filterTag`(广告清洗) |
| `crawl` | 站点级抓取参数 | `concurrency`、`min/maxInterval`、`maxAttempts` 等覆盖全局配置 |
选择器同时支持 **CSS** 与 **XPath**(以 `/`、`//`、`(` 开头判定为 XPath),
`元素选择器@href` / `@src` / `@content` 表示取属性;`meta[property="og:novel:book_name"]` 这类缺省值让「标准 meta」站点无需逐字段配置。
选择器后面还能挂 **DSL**:`选择器@js:代码` / `@java:代码` / 多段串联(`a@js:...;@js:...`)。
`HtmlExtractor` 先用选择器取原始文本,再交给 `StepEngine` 依次执行 JS(Javet V8 引擎池)或 Java 代码做二次加工
——这也是 `javet` 依赖和启动时预热 V8 的原因。
### 2. 搜索(SearchParser)
1. 用 `search.url` 拼搜索地址:普通 `%s` 字符串格式化,或 `@js:` 动态生成(签名、加密参数等);
2. GET/POST(`data` 是 JSON,其中 `%s` 按顺序填参数),带随机 UA + Referer,可选 Cookie;
3. `result` 选出结果条目,逐条提取书名/作者/最新章节等,链接取 `@href` 的绝对地址;
4. 若有 `nextPage`,一次取全部翻页链接并发补齐(去重合并),再按 `search-limit` 截断;
5. 若结果条目为空但页面本身是详情页,则退化为「把搜索页当详情页」处理(部分源会直接跳转);
6. 聚合搜索(`AggregatedSearchAction`)对**所有可搜索书源**并发跑一遍,最后 `SearchResultsHandler`
用字符串相似度(`StrUtil.similar`)判断关键字更像书名还是作者,过滤低分(<0.25)、按相似度排序。
7. 遇 Cloudflare 挑战页(标题白名单判定)时可走外部 `cf-bypass` 服务取真实 HTML。
### 3. 详情 + 目录(BookParser / TocParser)
- 详情页解析书名、作者、简介、封面、最新章节、更新时间;规则没写的字段回退到 `og:novel:*` meta。
- 目录页并非总在详情页:`toc.url` 可用 `%s` 填详情页里的书籍 id(`book.url` 正则提取)。
- 目录分页有两种形态:`nextPage` 命中 `<option>` 下拉(一次性拿到所有分页 URL),或「下一页」按钮递归跟随。
- 多目录页用 5 个虚拟线程并发抓取,再按页序拼成章节列表;`desc` 表示站点是倒序,需要反转。
### 4. 正文抓取(ChapterParser / Crawler)
- **章节级并发**:Java 21 虚拟线程 + `VirtualThreadLimiter`,默认并发 `min(50, 章节数)`(配置 `concurrency` 可改,上限 100)。
- 每章请求前 `sleep` 一个随机间隔(默认 200–400ms,站点规则可覆盖),随机 UA + Referer。
- 正文页有分页时按 `nextPage` 循环拼接,用 `nextChapterLink` 正则 / 通用「_数字.html + 下一章文案」判定末页。
- 失败重试 `max-retries`(默认 3)次,重试间隔在 `retry-min/max-interval` 内随机并逐次递增;
重试用尽则记录日志跳过该章(`enable-retry=0` 时直接中断)。
- 每章先落成独立文件(`0001_章节名.html` 等),避免长任务中途失败全丢。
### 5. 清洗与合成
`ChapterFilter`:去不可见字符/私用区字符、去 HTML 实体、按 `filterTxt` 正则和 `filterTag` 选择器删广告、
去正文开头重复的章节名、`1. 标题` 归一成 `第1章 标题`。
`ChapterFormatter`:把 `<br>` 等转成 `<p>` 段落。
之后按格式合成:
| 格式 | 处理器 | 产物 |
| --- | --- | --- |
| `txt` | `TxtMergeHandler` | 单文件,开头书名/作者/简介,正文全角缩进;可用 `txt-encoding` 指定 GBK 等 |
| `epub` | `EpubMergeHandler` | 带封面、metadata、分章的 epub |
| `html` | `HtmlTocHandler` | 带目录的单页 HTML |
| `pdf` | `PdfMergeHandler` | openhtmltopdf 生成 PDF |
中间章节目录默认在合成后删除(`preserve-chapter-cache=1` 可保留)。
输出命名:中间目录 `书名 (作者) TXT`,成品 `书名(作者).txt`,均在 `download-path` 下。
### 6. 反爬与网络层
随机 UA(`RandomUA`)、Referer、随机间隔、限流重试、可选 HTTP 代理(`[proxy]`)、
起点 Cookie(`[cookie] qidian`,用于拿最新/正版章节)、Cloudflare 时借助 `cf-bypass` 服务。
`Main` 在启动时创建并销毁一个 V8 runtime,绕开 Windows 上 Javet 首次创建 SIGSEGV 的问题。
---
## 二、这层封装做了什么(以及为什么需要)
上游三种形态都不适合当工具:
| 形态 | 问题 |
| --- | --- |
| TUI(默认) | 交互菜单,无法脚本化 |
| Web(`-Dmode=web`,端口 7765) | 需要常驻服务,与「不部署服务」冲突 |
| CLI(`-u <详情页>`) | **只能下已知 URL 的单本**,没有搜索;输出是彩色文本,无机器可读结果 |
所以 `tools/src/skills/novel/ToolMain.java` 直接复用上游库类,补上缺的部分:
- `search`:并发调用各书源 `SearchParser`,复用上游的相似度过滤/排序,输出 JSON;附带每个源的失败统计。
- `download`:与原 CLI 同一条路径(`SourceUtils.getRule(url)` 路由书源 → `BookParser` → `Crawler`),
但支持 `--out` 指定目录、返回成品文件路径与大小。
- `toc`:只解析目录并给首尾章节,用来**先验证书源质量**(章节数、是否缺章)再决定下载。
- `sources`:列出激活书源(id、主页、是否支持搜索)。
工程细节:
- **stdout 只有一份 JSON**:库的彩色日志在进程内被重定向截留,`--log` 时以 `logTail` 返回最后 40 行,
便于排查「源站 403 / Cloudflare / 目录为空」。
- **JSON 中非 ASCII 全部转成 `\uXXXX`**:Windows 控制台代码页(936)不会把中文搞成乱码,
`ConvertFrom-Json` / Python `json.loads` 都会还原成正确文本。
- **配置即参数**:以 `-Dconfig.file` 指向生成的 `tools/runtime/config.ini`,书源规则走绝对路径,
与当前工作目录无关;日志落 `tools/runtime/logs/`。
- **首次使用自动构建**:`bin/novel.ps1` 发现 `app.jar` 或 class 缺失会先调 `tools/build.ps1`。
---
## 三、命令参考
```powershell
# 搜索(-Limit 每个源最多取多少条,默认 30;-Source 只搜某个源;-Timeout 秒)
pwsh bin\novel.ps1 search 关键词 [-Limit 30] [-Source 8] [-Timeout 180] [-Log] [-Json]
# 下载(-Ext txt|epub|html|pdf,默认 txt;-Out 指定目录;URL 也可用 -Url)
pwsh bin\novel.ps1 download <详情页链接> [-Ext txt] [-Out D:\books] [-Log] [-Json]
# 只解析目录
pwsh bin\novel.ps1 toc <详情页链接> [-Json]
# 书源列表
pwsh bin\novel.ps1 sources [-Json]
```
`-Json` 输出契约(节选):
```json
{
"ok": true,
"command": "search",
"keyword": "诡秘之主",
"count": 2,
"results": [
{ "index": 1, "sourceId": 10, "sourceName": "笔趣阁52", "bookName": "诡秘之主",
"author": "爱潜水的乌贼", "latestChapter": "新书已发", "lastUpdateTime": "09-26",
"url": "https://www.52bqg.com/book_2279/" }
],
"sources": { "total": 13, "returned": 20, "empty": 3, "failed": [] },
"tookMs": 12000
}
```
```json
{ "ok": true, "command": "download", "bookName": "诡秘之主", "author": "爱潜水的乌贼",
"sourceId": 10, "sourceName": "笔趣阁52", "ext": "txt",
"file": "D:\\workbench\\ebook-skill\\books\\诡秘之主(爱潜水的乌贼).txt",
"bytes": 1234567, "seconds": 42.1 }
```
失败时 `ok=false` 并带 `error`(附 `--log` 可看上游日志尾部)。
---
## 四、运行时配置(`tools/runtime/config.ini`)
构建时由 `tools/config.ini.template` 生成,仓库位置变化后删除该文件再跑一次 `tools\build.ps1` 即可重建。
| 配置 | 说明 |
| --- | --- |
| `[download] download-path` | 默认 `books/`,可用 `-Out` 单次覆盖 |
| `[download] extname` | 默认格式,命令行 `-Ext` 优先 |
| `[download] txt-encoding` | txt 编码,老设备可设 `GBK` |
| `[download] preserve-chapter-cache` | 1 = 保留中间章节文件 |
| `[source] active-rules` | 激活的规则文件(默认 `runtime/rules/main.json`,13 个源) |
| `[source] search-limit` | 每个源搜索结果上限 |
| `[source] search-filter` | 1 = 按相似度过滤低质量结果 |
| `[crawl] concurrency` | 章节并发,空 = 自动(≤50,上限 100) |
| `[crawl] min/max-interval` | 章节请求随机间隔(毫秒) |
| `[crawl] max-retries` | 单章重试次数 |
| `[proxy]` | 需要代理才能访问的书源:`enabled=1` + `host/port` |
| `[cookie] qidian` | 起点 `w_tsfp`,用于部分源的最新章节 |
| `[global] cf-bypass` | Cloudflare 绕过服务地址(如 `https://github.com/sarperavci/CloudflareBypassForScraping` 部署的实例) |
`runtime/rules/` 下的其他规则包(`cloudflare.json`、`proxy-required.json`、`rate-limit.json`、`no-search.json`)
默认**未激活**:需要时把 `active-rules` 指向对应文件(或把这些源合并进一个 json)。
---
## 五、维护
```powershell
# 更新上游代码(GitHub 直连不通时,本仓库已在 .git/config 配了 ghfast.top 镜像 insteadOf)
git -C vendor/so-novel fetch --tags
git -C vendor/so-novel checkout <tag>
git add vendor/so-novel && git commit -m "bump so-novel"
# 重建(引擎变更时必须;规则包更新可只刷规则)
pwsh tools\build.ps1 -Force -RefreshRules
```
已知坑:
- **Javet 原生库**:so-novel 依赖 JS 规则(`@js:`),需要平台对应的 `javet-v8-*` 原生库。
它的 Maven profile 用 `x86_64` 匹配架构,而 Windows JDK 报 `amd64`,自动激活会失效,
症状是「Javet library /libjavet-v8-windows-x86_64...dll is not found」、依赖 JS 规则的书源全部空结果。
`tools/build.ps1` 已按平台显式传 `-P windows-x64|linux-x64|…`,重建后 `app.jar` 约 36 MB(含原生库)。
- **GitHub 被重置**:仓库本地配置了 `url."https://ghfast.top/https://github.com/".insteadOf`,
删除用 `git config --unset url."https://ghfast.top/https://github.com/".insteadOf`。
- **JDK 21 必需**(虚拟线程 + 上游 class 版本 65);`Find-Jdk21` 会依次找
`EBOOK_NOVEL_JAVA_HOME` → `JAVA_HOME` → `tools/.jdk` → 常见安装路径(含 IntelliJ 自带 JBR)。
- **Maven** 只在构建时需要;Maven Central 走内网 nexus / 阿里云镜像均可。
- **规则失效**是最常见的失败原因(站点改版/关站):`search` 出来为空、`toc` 章节数异常、
下载 txt 只有简介——此时换一个书源,或更新子模块/规则。
- **目录质量比源数量重要**:`toc` 里首章不是第 1 章(分页缺失)或顺序反了(站点倒序但规则没标 `desc`),
都会产出残书/反序书,直接换源。
- **别把并发调太高**:这些站点普遍限流,`concurrency` 40–60、间隔 200–400ms 是上游默认值,够用。
---
## 免责声明
本项目仅用于个人学习与技术研究,请遵守目标站点服务条款与著作权法规,不要用于商业分发。