自托管的全网小说搜索与阅读服务。导入「阅读」(Legado) 书源,一个搜索框搜遍所有站点;自带阅读器,字体、背景、排版全部可调。 零第三方依赖,Docker 一条命令部署,也可以打包成 Windows 桌面软件。
| 你的需求 | 实现情况 |
|---|---|
| 按作者名 / 小说名搜索 | 并发请求所有已启用书源,支持「书名 / 作者 / 综合」三种模式、模糊匹配、跨源同名书聚合去重、相关度排序;搜索过程用 SSE 逐源流式返回,哪个源慢、哪个源失败都看得见 |
| 导入阅读 APP 的书源 | 完整支持 阅读(Legado) 3.0 书源语法。支持 粘贴 JSON / 网络订阅地址 / 本地 .json 文件 三种导入方式,以及 append / merge / replace 三种冲突策略;可批量启用禁用、分组、导出回「阅读」APP |
| 管理一大堆失效书源 | 书源页支持全选/跨页多选、批量体检(SSE 实时进度,逐个标记可用/失效)、一键清理失效源;体检结果写入数据库并可按「可用 / 失效 / 未测试」筛选。从未测试过的书源永远不会被自动删除 |
| 字体与背景设置 | 字体族(系统/宋体/黑体/楷体/仿宋…+自定义)、字号、字重、行高、字间距、段间距、首行缩进、对齐方式;背景色、文字色、背景图+不透明度、亮度遮罩、8 套主题预设 + 完全自定义 |
| 按页面大小自适应排版 | 列宽按视口自动收缩(min(100%, 上限宽度))、字号可随视口缩放、ResizeObserver 实时重排、横竖屏自适应、320px 手机到 2560px 显示器都不溢出;移动端自动切换底部抽屉、桌面端右侧抽屉 |
| 常规阅读软件功能 | 书架分组、阅读进度断点续读、目录(分页目录自动跟进)、上一章/下一章、换源、全文缓存离线读、书签、批注笔记、章节搜索、五种翻页模式、点击/手势/键盘快捷键翻页、自动阅读、阅读计时、亮度调节、简繁转换、榜单分类发现 |
# 1. 准备数据目录(首次需要,否则 Docker 会以 root 创建)
mkdir -p data
# 2. 启动
docker compose up -d --build
# 3. 打开浏览器
# http://<你的主机IP>:8080首次进入后:
- 打开左侧 书源管理 → 导入书源
- 粘贴一份 legado 书源 JSON(或填订阅地址)
- 回到 搜索,输入书名或作者名试试
换端口:编辑
.env(从.env.example复制)里的SHUHAI_PUBLISH_PORT,或直接改 compose 里的端口映射。 数据在哪:全部在./data目录(一个 SQLite 文件),备份/迁移直接拷这个目录。
docker build -t shuhai:1.0.0 .
docker run -d --name shuhai \
--restart unless-stopped \
-p 8080:8080 \
-v "$(pwd)/data:/data" \
-e PUID=1000 -e PGID=1000 \
-e TZ=Asia/Shanghai \
shuhai:1.0.0npm start # 等价于 node src/server.mjs
# 打开 http://localhost:8080不需要 npm install —— 项目零第三方依赖,只用 Node 标准库。
1. 单个书源对象 { "bookSourceName": "...", "bookSourceUrl": "...", ... }
2. 书源数组 [ {...}, {...} ]
3. 订阅包裹格式 { "bookSources": [ {...}, {...} ] }
4. 夹带说明文字 "分享几本书源:[ {...} ] 快导入吧" ← 会自动截取 JSON 片段
5. 网络订阅地址 https://example.com/sources.json
6. 本地 .json 文件 在导入面板里选择文件
| 策略 | 行为 |
|---|---|
append(默认) |
以「名称 + 地址」为唯一键,已存在则跳过 |
merge |
已存在则更新为新的规则 |
replace |
清空现有书源后导入 |
完整实现阅读 3.0 的规则语法:
| 语法 | 说明 |
|---|---|
class.x id.x tag.x |
类名 / ID / 标签选择器 |
css:.a > .b |
原生 CSS 选择器(支持 > + ~ 组合器与 :eq() :first-child :not() :contains() 等伪类) |
@ 链式步骤 |
class.book-list@tag.li@tag.a@href |
text textNodes ownText html all |
取值步骤 |
href src data-src content … |
取属性,自动补全为绝对地址 |
##正则##替换 |
正则替换;只写 ##正则 表示删除匹配内容 |
[0] [-1] [0:5] [!0] |
下标 / 负数下标 / 切片 / 排除 |
.0 .-1 !0 td.-1:-2 tr!0:-1 |
同上的省略方括号写法(真实书源里大量使用) |
! - 前缀 |
排除 / 反转结果 |
|| && %% |
或 / 与 / 交叉合并 |
{{ JS 表达式 }} |
内嵌 JS(含 java.ajax / java.base64Decode / java.md5Encode / java.timeFormat 等兼容实现) |
@js: <js>…</js> |
该步骤由 JS 计算;<js> 块作为独立步骤,可用来预处理 HTML 后再选择 |
@get:{k} @put:{k:"规则"} |
规则变量:@put 暂存(本步骤返回值不变),@get 取回 |
$ $.data.list[*] |
JSONPath(现代 JSON 接口书源),支持通配、递归下降 ..、过滤器 [?(@.x=="y")] |
| URL 选项 | /search,{"method":"POST","body":"q={{key}}","charset":"gbk"}(JSON 选项可跨行) |
| URL 动态计算 | searchUrl / header 可以是 @js: 或 <js>,运行时算出地址与请求头 |
org.jsoup |
JS 沙箱内可用 org.jsoup.Jsoup.parse(html).select(...).attr(...) |
cookie |
JS 里可 cookie.getCookie/setCookie/removeCookie(url),也能当字符串用({{cookie}}) |
source.* |
source.key / source.name / source.getKey() 等属性与方法均可 |
自动处理:GBK / GB2312 / GB18030 / Big5 编码嗅探(含无 charset 声明的站点)、搜索关键字按站点编码发送(GBK 站用 UTF-8 发关键字会一条都搜不到)、Cookie 会话保持、相对地址补全、URL 路径中的 {{key}} 模板、分页目录(nextTocUrl)、分页正文(nextContentUrl)、正文广告清洗、按站点限速。
- 书源管理页每条都有 测试 按钮:显示耗时、命中条数、3 条样例、错误原因
GET /api/logs有最近 500 条运行日志docker logs shuhai看服务端错误
- 只显示书名/作者:搜索结果默认「聚合去重」——同一本书只出现一条,
卡片上只有封面、书名、作者和「N 个源」徽标;各书源的写法差异(
作者:天蚕土豆/天蚕土豆 著/《斗破苍穹》/斗破 苍穹)都会被归一化后合并 - 换源由你决定:多来源的书点「换源(N)」自己挑一个,不会被自动替换
- 书源范围筛选:搜索栏旁边的「书源范围」可以勾选这次搜索用哪些书源 (按分组展示,支持 全部启用 / 全选 / 清空 / 反选 / 仅体检可用 / 排除体检失效)。 书源越多搜得越慢,只勾常用的几个能快很多
- 精确 / 模糊:「匹配方式」选精确时只保留书名或作者与关键词完全一致的条目 (搜「斗破苍穹」不会混进「斗破苍穹之秋雨」);模糊是默认,按相关度排序
- 书源页在窄屏下改为纵向堆叠:勾选框 + 书名 + 操作按钮分行,不会有横向滚动
- 勾选框、开关、徽标按钮、滑块、输入框在移动端统一放大到适合手指点按的尺寸
- 底部导航、抽屉、弹窗在移动端自动切换为底部弹出式
- 书籍的目录地址若是无效值(早期书源规则 bug 可能写入
["('/b/1.html', '', '')"]这类脏数据), 打开时会自动回源刷新一次,修好后再读 - 目录拉取失败时会自动刷新书籍信息重试一次;仍失败则给出「刷新目录 / 重新搜索这本书 / 换源」的出口,
而不是只丢一个
HTTP 404 Not Found
书源导入多了以后,最大的痛点是「哪些还能用」。书源管理页为此提供了一整套体检能力:
1. 批量选择
- 工具栏 全选 勾选当前已加载的一页;批量栏里的 选中全部 N 个 会把当前筛选条件下的书源全都选上(跨页,含未加载的页)
- 筛选条件(关键字 / 分组 / 启用状态 / 体检结果)与「全量体检」「清理失效」的范围完全一致,所见即所测
2. 批量测试(体检)
- 批量栏 批量测试:对选中的书源逐个真实搜一次,弹窗里用进度条 + 逐行结果实时显示(SSE 推送,
GET /api/sources/test-batch/stream) - 工具栏 全量体检:对整个筛选范围做同样的事
- 判定口径:用关键字(默认「斗破苍穹」)搜索,命中 ≥1 条即「可用」,否则「失效」,并记录耗时、命中条数、失败原因
- 结果写回数据库,列表上每条书源会显示
可用 N 条 / 失效 / 未测试徽标,点徽标可看详情并当场重测 - 顶部实时统计:
共 N 个书源 · 可用 X · 失效 Y · 未测试 Z
3. 一键删除失效书源
- 体检完成后,弹窗里直接给 删除失效的 N 个 按钮;确认后只删本次体检失败的
- 工具栏 清理失效 是全局入口,两种模式可选:
- 直接删除已知失效的 N 个:按上一次体检结果立刻清理,速度快
- 先重新体检一遍,再删除失效的:能发现「上次还活着、现在已经挂了」的源,耗时更长
⚠️ 安全边界:只删除「体检过 且 失败」的书源(last_test_at > 0 && last_test_ok = 0)。 从未体检过的书源永远不会被删,刚导入还没来得及测的源不会被误伤;筛选命中 0 个时一个都不删
等价命令行用法(适合挂定时任务):
# 全量体检(同步返回结果)
curl -X POST http://localhost:8080/api/sources/test-batch -H 'Content-Type: application/json' \
-d '{"keyword":"斗破苍穹","concurrency":6}'
# 只体检「已启用」的书源
curl -X POST http://localhost:8080/api/sources/test-batch -H 'Content-Type: application/json' \
-d '{"enabled":"1"}'
# 一键清理:先重新体检再删失效
curl -X POST http://localhost:8080/api/sources/delete-invalid -H 'Content-Type: application/json' \
-d '{"retest":true,"enabled":"1"}'定时任务示例(每周一凌晨 4 点体检并清理已启用的失效源):
0 4 * * 1 curl -s -X POST http://127.0.0.1:8080/api/sources/delete-invalid \
-H 'Content-Type: application/json' -d '{"retest":true,"enabled":"1"}' >> /var/log/shuhai-health.log 2>&1- 主题:默认白 / 羊皮纸 / 护眼绿 / 夜间灰 / 纯黑 OLED / 深蓝夜读 / 牛皮纸 / 淡蓝
- 自定义:背景色、文字色、背景图 URL + 不透明度、亮度滑块
- 字体:字体族(含自定义输入)、字号 12–40、字重 300–700、行高 1.2–3.0、字间距 0–5px
layoutMode: auto—— 正文列宽min(100%, pageWidth)自动居中收缩fontScaleWithWidth—— 视口变小时字号按比例跟随- 首行缩进、段间距、两端对齐/左对齐
- 窗口缩放、屏幕旋转、折叠屏展开都会实时重排并保持阅读位置
| 模式 | 说明 |
|---|---|
scroll |
上下滚动(连续阅读) |
slide |
左右滑动翻页 |
cover |
仿真覆盖翻页 |
none |
无动画瞬切 |
vertical |
上下翻页 |
- 点击翻页:左 1/3 上一页 / 右 1/3 下一页 / 中间呼出菜单(可切换为「上下半屏」或关闭)
- 手势:左右滑动、上下滑动翻页,与点击自动区分
- 快捷键:
←→翻页 ·↑↓滚动/翻章 ·空格下一页 ·T目录 ·S设置 ·B书签 ·[]调字号 ·F全屏 ·Esc关闭面板 ·?帮助
书架分组 · 阅读进度自动保存(翻章立即存 + 每 5 秒存 + 关页面 sendBeacon 兜底)· 目录(几千章分块渲染 + 标题搜索 + 正倒序)· 章节预加载 · 换源(自动匹配同名书、保留进度百分比)· 整本离线缓存 · 书签 · 选中文字高亮/写笔记 · 简繁转换 · 自动阅读 · 阅读时长统计 · 榜单分类发现
全部通过环境变量配置(compose 里已带注释):
| 变量 | 默认值 | 说明 |
|---|---|---|
SHUHAI_PORT |
8080 |
监听端口 |
SHUHAI_HOST |
0.0.0.0 |
监听地址 |
SHUHAI_DB |
/data/shuhai.db |
SQLite 数据库路径 |
PUID / PGID |
1000 / 1000 |
数据目录属主,NAS 部署必调(id -u 查看自己的 uid) |
SHUHAI_SEARCH_CONCURRENCY |
16 |
并发搜索的书源数,调大更快但更容易触发风控 |
SHUHAI_TIMEOUT |
15000 |
单书源请求超时(毫秒) |
SHUHAI_HOST_INTERVAL_MS |
120 |
同一站点最小请求间隔(毫秒),降低被封风险 |
SHUHAI_MAX_TOC_PAGES |
30 |
目录最大翻页数 |
SHUHAI_MAX_CONTENT_PAGES |
6 |
单章正文最大翻页数 |
SHUHAI_JS_TIMEOUT |
3000 |
书源内嵌 JS 执行超时(毫秒) |
SHUHAI_JS_MAX_AJAX |
8 |
单次 JS 求值最多预取几个地址 |
SHUHAI_LOG_SIZE |
500 |
内存日志条数 |
完整契约见 docs/API.md。所有接口以 /api 为前缀,统一返回 {ok:true,data} 或 {ok:false,error:{code,message}}。
# 健康检查
curl http://localhost:8080/api/health
# 导入书源
curl -X POST http://localhost:8080/api/sources/import \
-H 'Content-Type: application/json' \
-d '{"text":"[{\"bookSourceName\":\"示例\",\"bookSourceUrl\":\"https://example.com\",\"searchUrl\":\"/search?q={{key}}\"}]","mode":"append"}'
# 按书名搜索
curl 'http://localhost:8080/api/search?q=斗破苍穹&type=name'
# 按作者搜索
curl 'http://localhost:8080/api/search?q=天蚕土豆&type=author'
# 流式搜索(SSE)
curl -N 'http://localhost:8080/api/search/stream?q=斗破'主要接口一览:
| 分组 | 接口 |
|---|---|
| 系统 | /api/health /api/stats /api/logs |
| 书源 | /api/sources(增删改查) /import /import-url /export /test /preview /batch /groups |
| 搜索 | /api/search /api/search/stream /api/search/history /api/search/hot /api/sources/:id/search |
| 书籍 | /api/books/resolve /api/books/:id /chapters /content /cache /search /alternatives /change-source |
| 书架 | /api/shelf(增删改查) |
| 进度 | GET/PUT/POST /api/progress/:bookId(POST 是给 sendBeacon 用的别名) |
| 书签 | /api/bookmarks /api/notes |
| 设置 | /api/settings /api/settings/presets |
src/
├── server.mjs HTTP 服务、静态资源、SPA 回退、优雅退出
├── api.mjs REST 路由(46 个接口)
├── db.mjs SQLite(Node 内置 node:sqlite),WAL 模式
├── store.mjs 书籍/目录/正文缓存/书架/进度/书签 数据层
├── settings.mjs 阅读设置默认值与主题预设
├── source.mjs 书源归一化、导入导出、校验
├── engine.mjs 搜索 / 详情 / 目录 / 正文 / 换源 / 榜单(网络编排)
├── rule.mjs ★ legado 规则引擎(@ ## {{}} %% && ||)
├── jsonpath.mjs ★ JSONPath 子集
├── jsbox.mjs ★ 书源 JS 沙箱 + java.* 兼容层
├── content.mjs 正文清洗(块级还原 + 广告过滤)
├── log.mjs 内存环形日志
├── html/
│ ├── parser.mjs ★ 容错 HTML 解析器(零依赖)
│ └── selector.mjs ★ CSS 选择器引擎(零依赖)
└── net/http.mjs HTTP 客户端(编码嗅探 / Cookie 罐 / 限速 / 重试)
web/ 纯静态前端(原生 ES Module,无构建步骤)
├── index.html
├── css/style.css
└── js/ api · store · ui · main
page-search · page-sources · page-shelf · page-book · page-settings
reader · reader-settings · reader-pager
docs/API.md 冻结的接口契约
test/ 4 套测试 + 模拟站点 + 第三方书源夹具
1. 零第三方依赖。 HTML 解析器、CSS 选择器引擎、JSONPath、SQLite 访问全部用 Node 标准库实现。带来的好处:镜像小、构建几秒、可完全离线构建、没有供应链风险、没有版本冲突。
2. 同步 java.ajax 的「两遍求值」。 阅读书源里的 JS 大量使用同步的 java.ajax(url),而 Node 的网络请求是异步的。这里的做法是:第一遍用一个只记录 URL、返回空串的假 ajax 跑一遍表达式,收集所有要请求的地址;并发取回后放进缓存;第二遍用同步读缓存的真 ajax 再跑一遍。对绝大多数真实书源完全等价。
3. Jsoup 兼容的选择器语义。 规则的每一步在选择后代时会包含元素自身(与阅读 APP 行为一致),这保证了第三方书源的兼容性。
4. 正文清洗的保守原则。 广告行规则只匹配「明显是站点广告」的短行(长度 > 80 字符一律放行),宁可漏删也不误删正文。
npm test # 全部 4 套,共 142 项断言
npm run test:unit # 解析器/选择器 + 规则引擎(67 项)
npm run test:e2e # 端到端,自带模拟站点(48 项)
npm run test:fixtures # 第三方书源夹具联调(27 项)
npm run mock # 单独启动模拟小说站(127.0.0.1:18080)
bash test/demo.sh # 真机演示:起服务+模拟站点,导入书源并跑完整阅读链路| 套件 | 覆盖内容 |
|---|---|
test/unit-parser.mjs |
HTML 解析容错、隐式闭合、实体解码、CSS 选择器、10k 节点性能 |
test/unit-rule.mjs |
规则语法全量:步骤、下标、正则、JS、JSONPath、模板渲染 |
test/e2e.mjs |
导入 → 搜索 → 榜单 → 落地 → 目录 → 正文 → 书架/进度/书签 → 缓存 → 换源 → 导出 → GBK → 错误处理 |
test/fixtures-e2e.mjs |
用独立编写的第三方书源 JSON 驱动真实 HTTP 链路,交叉验证规则引擎 |
诚实说明,避免踩坑:
- XPath 不支持。 遇到
@xpath:规则会降级为 CSS 选择器尝试,并在日志里告警。绝大多数 3.0 书源不使用 XPath。 - 需要 WebView 的书源不支持。 规则里
webView:true或依赖loginCheckJs图形验证的源无法工作(服务端没有浏览器)。 - JS 沙箱不是安全边界。 见下方安全须知。
- 榜单覆盖有限。
exploreUrl形态差异极大,只支持 JSON 数组与「标题::地址」两种格式;源不支持就返回空。 - 简繁转换是字符级映射,不是词组级(「头发」不会被误转,但个别词组可能不理想)。
- 单用户设计。 没有账号体系,API 无鉴权,不要直接暴露到公网。
- 阅读进度不做多端实时同步,但有服务端存储,多设备打开同一本书会读到同一进度。
⚠️ 请只在可信内网部署,不要暴露到公网。
原因:书源里的 {{ }} 与 @js: 表达式会在服务端执行 JavaScript。这是兼容阅读书源的必要代价(阅读 APP 同样如此)。本项目用 node:vm 新建上下文执行,沙箱内不存在 process / require / global,并带 3 秒执行超时;但 vm 不是真正的安全边界。
实践建议:
- 只导入来源可信的书源(自己导出、知名社区分享),不要导入来路不明的 JSON
- 用 Docker 隔离运行(默认已经这么做了)
- 不要给它挂载敏感目录、不要给它过大的权限
- 如需公网访问,请放在反向代理 + 身份认证之后
Q:搜索很慢 / 有书源一直失败?
每个源都有独立超时(默认 15 秒),慢的源不会阻塞其它源 —— 搜索页是流式返回的,先出来的先显示。可以在配置里调低 SHUHAI_TIMEOUT,并去「书源管理」把长期失败的源禁用掉。
Q:提示「疑似被站点风控拦截」?
说明对方站点在挡你的服务器 IP。可以调大 SHUHAI_HOST_INTERVAL_MS、调小 SHUHAI_SEARCH_CONCURRENCY;如果是 Cloudflare 盾,通常无解,换源即可。
Q:容器起来了但数据库写入失败?
数据目录属主不对。在宿主机执行 chown -R 1000:1000 ./data,或在 compose 里设置 PUID/PGID 为 id -u/id -g 的值。容器入口脚本会尝试自动修正,但某些 NAS 文件系统不允许。
Q:怎么备份?
拷贝 ./data 目录即可(含数据库、书源、书架、进度、书签)。也可以用 GET /api/sources/export 单独导出书源。
Q:能导入到手机上的「阅读」APP 吗? 可以。书源管理页勾选后点「导出」,得到的就是标准 legado 格式 JSON。
Q:怎么更新?
git pull && docker compose up -d --build数据在 ./data,不会丢。
仓库里的 desktop/ 附带了打包工具,能把整个服务做成绿色免安装的 Windows 软件:拷到任何一台 Windows 10/11 电脑,双击 书海.exe 就能用,目标机器不需要安装 Node。
cd desktop
powershell -ExecutionPolicy Bypass -File build.ps1 # 产物在 ..\dist\书海打包出来的程序以独立窗口运行(用系统自带的 Edge/Chrome 应用模式,没有标签页也没有地址栏),右下角带托盘图标,支持开机自启、桌面快捷方式、单实例;关掉窗口即整体退出。数据跟着程序目录走,书源和阅读进度都在 data\ 里。
- 默认只监听
127.0.0.1,不触发 Windows 防火墙弹窗;要让手机访问,把config.ini里的host改成0.0.0.0 - 没装 Edge 也没装 Chrome 的机器会自动退回默认浏览器开标签页,功能完全一样
- 打包细节和实现说明见
desktop/README.md
MIT