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: 使用说明与下载原理分析
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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 是上游默认值,够用。
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅用于个人学习与技术研究,请遵守目标站点服务条款与著作权法规,不要用于商业分发。
|
||||
Reference in New Issue
Block a user