- 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: 使用说明与下载原理分析
14 KiB
ebook-skill
把开源项目 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
快速开始
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)
- 用
search.url拼搜索地址:普通%s字符串格式化,或@js:动态生成(签名、加密参数等); - GET/POST(
data是 JSON,其中%s按顺序填参数),带随机 UA + Referer,可选 Cookie; result选出结果条目,逐条提取书名/作者/最新章节等,链接取@href的绝对地址;- 若有
nextPage,一次取全部翻页链接并发补齐(去重合并),再按search-limit截断; - 若结果条目为空但页面本身是详情页,则退化为「把搜索页当详情页」处理(部分源会直接跳转);
- 聚合搜索(
AggregatedSearchAction)对所有可搜索书源并发跑一遍,最后SearchResultsHandler用字符串相似度(StrUtil.similar)判断关键字更像书名还是作者,过滤低分(<0.25)、按相似度排序。 - 遇 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/ Pythonjson.loads都会还原成正确文本。 - 配置即参数:以
-Dconfig.file指向生成的tools/runtime/config.ini,书源规则走绝对路径, 与当前工作目录无关;日志落tools/runtime/logs/。 - 首次使用自动构建:
bin/novel.ps1发现app.jar或 class 缺失会先调tools/build.ps1。
三、命令参考
# 搜索(-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 输出契约(节选):
{
"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
}
{ "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)。
五、维护
# 更新上游代码(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), 都会产出残书/反序书,直接换源。 - 别把并发调太高:这些站点普遍限流,
concurrency40–60、间隔 200–400ms 是上游默认值,够用。
免责声明
本项目仅用于个人学习与技术研究,请遵守目标站点服务条款与著作权法规,不要用于商业分发。