mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed2edab5b | |||
| 374ee28f01 | |||
| 3a6b8db0b3 | |||
| b50cb7c31e | |||
| 5f4b613bde | |||
| 67ef9c95be | |||
| 2bf1b27e23 | |||
| 48a1c36a7a | |||
| d7665e4ef0 | |||
| 99916c0b97 | |||
| 6adc3cb3b2 | |||
| 2b2dc697ce | |||
| 8bfdd75d47 | |||
| 1a3248f4bb | |||
| 0be7f8b97e | |||
| 6984158f24 | |||
| d545ff1a7b | |||
| 227e0a9197 | |||
| e85df5fb6b | |||
| 6656f9c398 | |||
| dacfec7492 | |||
| b0f1ab39b9 | |||
| 5242d54a06 | |||
| e961969207 | |||
| b8b02cb3a7 | |||
| 60cfad47e5 | |||
| 47c8bfdd6c | |||
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c |
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -76,7 +77,15 @@ func main() {
|
||||
fatal("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
|
||||
// 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
|
||||
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
|
||||
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
|
||||
cfg := &config.Config{}
|
||||
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
|
||||
cfg.Cache.CacheDir = cacheDir
|
||||
}
|
||||
svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
|
||||
ctx := context.Background()
|
||||
|
||||
results := make([]*reader.SmokeChainResult, len(sources))
|
||||
|
||||
+13
-1
@@ -38,7 +38,7 @@ web/src/
|
||||
components/reader/
|
||||
```
|
||||
|
||||
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
|
||||
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
|
||||
|
||||
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
|
||||
|
||||
@@ -126,8 +126,20 @@ web/src/
|
||||
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
|
||||
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
|
||||
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
|
||||
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
|
||||
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
|
||||
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
|
||||
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
|
||||
|
||||
### 实施记录:关于「宽松 @put 触发 panic」的复核
|
||||
|
||||
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
|
||||
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
|
||||
真实缺陷是另外两个并已修复:
|
||||
|
||||
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
|
||||
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
|
||||
|
||||
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
|
||||
|
||||
## 8. 风险与对策
|
||||
|
||||
+16
-4
@@ -33,13 +33,14 @@
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
|
||||
- 显示项开关:未读章数徽标、更新时间。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
|
||||
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
@@ -94,7 +95,17 @@
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
|
||||
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
|
||||
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
@@ -244,4 +255,5 @@
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
|
||||
@@ -52,6 +52,8 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
v.SetDefault("cache.reader_content_max_size_mb", 1024)
|
||||
v.SetDefault("cache.reader_content_ttl_hours", 168)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
|
||||
@@ -62,6 +62,12 @@ func (c *Config) normalize() error {
|
||||
if c.Cache.MediaTTLSeconds < 1 {
|
||||
c.Cache.MediaTTLSeconds = 90
|
||||
}
|
||||
if c.Cache.ReaderContentMaxSizeMB < 0 {
|
||||
c.Cache.ReaderContentMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.ReaderContentTTLHours < 0 {
|
||||
c.Cache.ReaderContentTTLHours = 0
|
||||
}
|
||||
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
|
||||
if c.Search.Index == "" {
|
||||
c.Search.Index = "mebox_media"
|
||||
|
||||
@@ -133,6 +133,10 @@ type CacheConfig struct {
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
|
||||
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
|
||||
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
|
||||
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 云 HLS 分片代理的错误映射:
|
||||
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
|
||||
// 而不是当成播放失败直接退回本地转码;
|
||||
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
|
||||
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
|
||||
func newCloud115HLSSessionTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &service.Container{
|
||||
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
return router
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
|
||||
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
|
||||
WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 499 {
|
||||
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
|
||||
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
|
||||
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
c.Request.Context().Err() != nil {
|
||||
c.AbortWithStatus(499)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
|
||||
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
|
||||
// 而不是直接放弃云端播放退回本地转码。
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
|
||||
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
|
||||
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
|
||||
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
|
||||
//
|
||||
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
|
||||
// 这里只钉住契约与安全边界,避免把测试绑到网络。
|
||||
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(container)
|
||||
|
||||
post := func(body string) int {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"缺少 url", `{"book_id":"x"}`},
|
||||
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
|
||||
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
|
||||
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if code := post(c.body); code != http.StatusBadRequest {
|
||||
t.Fatalf("%s:应 400,得到 %d", c.name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
@@ -40,6 +41,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
||||
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
||||
|
||||
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
|
||||
g.POST("/comments/open", readerOpenCommentHandler(svc))
|
||||
|
||||
// 搜索(多源聚合)
|
||||
g.POST("/search", readerSearchHandler(svc))
|
||||
|
||||
@@ -74,6 +78,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
|
||||
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
|
||||
g.GET("/books/:id/content", readerBookContentHandler(svc))
|
||||
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
|
||||
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
|
||||
|
||||
// 替换净化规则
|
||||
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
|
||||
@@ -249,6 +255,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
|
||||
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
|
||||
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
|
||||
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
BookID string `json:"book_id" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPageHandler 承载待办页面本体。//
|
||||
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
||||
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
||||
@@ -268,12 +298,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
@@ -333,13 +363,17 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
|
||||
Page int `json:"page"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -358,6 +392,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
|
||||
if info != nil {
|
||||
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +442,10 @@ func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
|
||||
for i := range books {
|
||||
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"books": books})
|
||||
}
|
||||
}
|
||||
@@ -788,12 +830,37 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
|
||||
return
|
||||
}
|
||||
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
|
||||
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
|
||||
if c.Query("d") == "cover" {
|
||||
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
|
||||
if fetchErr != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
|
||||
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
|
||||
return
|
||||
}
|
||||
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(decoded)
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, decoded)
|
||||
return
|
||||
}
|
||||
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
|
||||
return
|
||||
}
|
||||
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
|
||||
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
|
||||
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
|
||||
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
|
||||
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
|
||||
return
|
||||
@@ -801,13 +868,51 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
|
||||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
|
||||
if isPlaylist {
|
||||
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
|
||||
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
|
||||
// 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
|
||||
if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
|
||||
sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
|
||||
if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
|
||||
return
|
||||
}
|
||||
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
|
||||
if decErr != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
|
||||
return
|
||||
}
|
||||
contentType := ct
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(decoded)
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, decoded)
|
||||
return
|
||||
}
|
||||
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
|
||||
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
|
||||
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
|
||||
//
|
||||
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
|
||||
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
|
||||
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
|
||||
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
|
||||
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||
return
|
||||
}
|
||||
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
|
||||
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
|
||||
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
if len(head) > 0 {
|
||||
_, _ = c.Writer.Write(head)
|
||||
}
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
return
|
||||
}
|
||||
// 流式透传(含 206 Partial Content,支持音频拖动进度)
|
||||
@@ -821,6 +926,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
|
||||
const readerImageDecodeMaxBytes = 32 << 20
|
||||
|
||||
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
|
||||
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
|
||||
func isMediaPlaylistRequest(resp *http.Response) bool {
|
||||
ct := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
return strings.Contains(ct, "mpegurl")
|
||||
}
|
||||
|
||||
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
|
||||
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
|
||||
func readerBodyIsPlaylist(head []byte, contentType string) bool {
|
||||
return reader.BodyIsPlaylist(head, contentType)
|
||||
}
|
||||
|
||||
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
|
||||
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
|
||||
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -909,3 +1030,32 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
|
||||
//
|
||||
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
|
||||
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
|
||||
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
|
||||
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
|
||||
ApplyReplace bool `json:"apply_replace"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
|
||||
return
|
||||
}
|
||||
if len(body.ChapterIndexes) > 64 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -17,6 +17,13 @@ import (
|
||||
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
|
||||
// maxDecompressedBody 解压后的响应体上限。
|
||||
//
|
||||
// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以
|
||||
// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底,
|
||||
// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。
|
||||
const maxDecompressedBody = 32 << 20
|
||||
|
||||
// DecompressBody 兜底解压响应体(gzip / deflate)。
|
||||
//
|
||||
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
|
||||
@@ -37,17 +44,20 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
|
||||
return data
|
||||
}
|
||||
defer r.Close()
|
||||
if out, err := io.ReadAll(r); err == nil {
|
||||
// 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解,
|
||||
// 是压缩炸弹的常见放大手段。
|
||||
r.Multistream(false)
|
||||
if out, ok := readLimited(r); ok {
|
||||
return out
|
||||
}
|
||||
case "deflate":
|
||||
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
|
||||
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil {
|
||||
if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok {
|
||||
return out
|
||||
}
|
||||
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
|
||||
defer zr.Close()
|
||||
if out, err := io.ReadAll(zr); err == nil {
|
||||
if out, ok := readLimited(zr); ok {
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -55,6 +65,15 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// readLimited 读取解压流,超过上限返回 ok=false。
|
||||
func readLimited(r io.Reader) ([]byte, bool) {
|
||||
out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1))
|
||||
if err != nil || len(out) > maxDecompressedBody {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
|
||||
//
|
||||
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
|
||||
@@ -104,10 +123,19 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
//
|
||||
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
|
||||
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
|
||||
// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
|
||||
// `text/html,application/xhtml+xml,...`。
|
||||
//
|
||||
// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
|
||||
// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
|
||||
// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
|
||||
// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
|
||||
// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
|
||||
// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
|
||||
func HTTPHeaderPresets() map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
|
||||
@@ -12,6 +12,18 @@ import (
|
||||
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
|
||||
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
|
||||
|
||||
// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
|
||||
//
|
||||
// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
|
||||
// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
|
||||
// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
|
||||
func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
|
||||
got := HTTPHeaderPresets()["Accept"]
|
||||
if got != "*/*" {
|
||||
t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
|
||||
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
|
||||
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
|
||||
|
||||
@@ -65,6 +65,7 @@ func AllModels() []interface{} {
|
||||
&ReaderSourceState{},
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderContentCache{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
|
||||
@@ -98,6 +98,45 @@ type ReaderChapter struct {
|
||||
Tag string `gorm:"type:varchar(255)" json:"tag"`
|
||||
}
|
||||
|
||||
// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。
|
||||
//
|
||||
// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex
|
||||
// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后
|
||||
// 逐请求应用,规则改动即时生效。
|
||||
//
|
||||
// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节
|
||||
// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新
|
||||
// 之后仍然能按同一身份命中或迁移。
|
||||
type ReaderContentCache struct {
|
||||
Base
|
||||
// OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。
|
||||
OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"`
|
||||
// BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。
|
||||
BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"`
|
||||
// ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。
|
||||
ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"`
|
||||
// ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。
|
||||
ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"`
|
||||
// ChapterIndex 保存时的章节序号(remap 时更新)。
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
// ContentType text / audio / image。
|
||||
ContentType string `gorm:"type:varchar(16)" json:"content_type"`
|
||||
// SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。
|
||||
SourceHash string `gorm:"type:varchar(64)" json:"source_hash"`
|
||||
// FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。
|
||||
FormatVersion int `json:"format_version"`
|
||||
// SizeBytes 载荷字节数(容量统计用)。
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
// AssetCount 音频轨/图片张数(清单类内容的完整性统计)。
|
||||
AssetCount int `json:"asset_count"`
|
||||
// ExpiresAt 过期时间(unix 秒);0 表示不过期。
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
// LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。
|
||||
LastAccessAt int64 `json:"last_access_at"`
|
||||
// Hits 命中次数(诊断用)。
|
||||
Hits int `json:"hits"`
|
||||
}
|
||||
|
||||
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
|
||||
type ReaderReplaceRule struct {
|
||||
Base
|
||||
|
||||
@@ -56,7 +56,12 @@ func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
src := &model.ReaderBookSource{}
|
||||
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
|
||||
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
|
||||
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
|
||||
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此再也存不进去。这里必须硬删。
|
||||
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
|
||||
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
|
||||
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
@@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
if existing.DeletedAt.Valid {
|
||||
// 历史行被软删过:连 deleted_at 一起写回,把它复活
|
||||
st.DeletedAt = gorm.DeletedAt{}
|
||||
return r.db.WithContext(ctx).Unscoped().Save(st).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
@@ -118,6 +131,15 @@ func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bo
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
|
||||
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
|
||||
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
|
||||
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
|
||||
var out []model.ReaderBook
|
||||
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateBook / UpdateBook / DeleteBook。
|
||||
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
|
||||
return r.db.WithContext(ctx).Create(b).Error
|
||||
@@ -150,6 +172,17 @@ func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, c
|
||||
})
|
||||
}
|
||||
|
||||
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
|
||||
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
|
||||
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(book).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListChapters 按序取章节。
|
||||
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
|
||||
var out []model.ReaderChapter
|
||||
@@ -157,6 +190,109 @@ func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]m
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
|
||||
|
||||
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
|
||||
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
|
||||
var out model.ReaderContentCache
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
|
||||
First(&out).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
|
||||
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
|
||||
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
|
||||
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
|
||||
if contentType != "" {
|
||||
q = q.Where("content_type = ?", contentType)
|
||||
}
|
||||
var out model.ReaderContentCache
|
||||
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
|
||||
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
|
||||
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
|
||||
if err == nil && existing != nil {
|
||||
existing.ChapterIdentity = row.ChapterIdentity
|
||||
existing.ChapterIndex = row.ChapterIndex
|
||||
existing.SourceHash = row.SourceHash
|
||||
existing.FormatVersion = row.FormatVersion
|
||||
existing.SizeBytes = row.SizeBytes
|
||||
existing.AssetCount = row.AssetCount
|
||||
existing.ExpiresAt = row.ExpiresAt
|
||||
existing.LastAccessAt = row.LastAccessAt
|
||||
return r.db.WithContext(ctx).Save(existing).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
// TouchContentCache 记录一次命中(更新命中时间与次数)。
|
||||
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
|
||||
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
|
||||
}
|
||||
|
||||
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
|
||||
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
|
||||
var out []model.ReaderContentCache
|
||||
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
|
||||
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
|
||||
rows, err := r.ListContentCacheByBook(ctx, bookKey)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
|
||||
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ListContentCacheExpired 按 TTL 取过期条目。
|
||||
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
|
||||
var out []model.ReaderContentCache
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("expires_at > 0 AND expires_at < ?", now).
|
||||
Order("last_access_at ASC").Limit(limit).Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
|
||||
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
|
||||
var row struct {
|
||||
Count int64
|
||||
Bytes int64
|
||||
}
|
||||
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
|
||||
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
|
||||
return row.Count, row.Bytes, err
|
||||
}
|
||||
|
||||
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
|
||||
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
|
||||
var out []model.ReaderContentCache
|
||||
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
|
||||
// 没有目录缓存的书籍不会出现在返回结果里。
|
||||
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
|
||||
//
|
||||
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
|
||||
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
|
||||
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://d.example.com"
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 重新导入同 URL 的书源,再存一次状态
|
||||
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
|
||||
t.Fatalf("状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
|
||||
//
|
||||
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
|
||||
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
|
||||
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://e.example.com"
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
|
||||
if err := repo.db.Exec(
|
||||
"update reader_source_states set deleted_at = ? where source_url = ?",
|
||||
time.Now(), url).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
|
||||
t.Fatalf("复活后状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
@@ -24,6 +28,10 @@ const (
|
||||
cloud115HLSSessionTTL = 30 * time.Minute
|
||||
cloud115HLSMaxSessions = 256
|
||||
cloud115HLSMaxManifest = 8 << 20
|
||||
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
|
||||
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
|
||||
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
|
||||
cloud115HLSMaxEntries = 20000
|
||||
)
|
||||
|
||||
type cloud115HLSSession struct {
|
||||
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]string
|
||||
next int
|
||||
}
|
||||
|
||||
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
|
||||
//
|
||||
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
|
||||
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
|
||||
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
|
||||
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
|
||||
func cloud115HLSKeyForURL(upstream string) string {
|
||||
sum := sha256.Sum256([]byte(upstream))
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
|
||||
@@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
|
||||
key := cloud115HLSKeyForURL(upstream)
|
||||
session.mu.Lock()
|
||||
session.next++
|
||||
key := "e" + strconv.Itoa(session.next)
|
||||
session.entries[key] = upstream
|
||||
_, known := session.entries[key]
|
||||
if !known && len(session.entries) < cloud115HLSMaxEntries {
|
||||
session.entries[key] = upstream
|
||||
known = true
|
||||
}
|
||||
mediaID := session.MediaID
|
||||
session.mu.Unlock()
|
||||
if !known && p != nil && p.service != nil && p.service.log != nil {
|
||||
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
|
||||
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
|
||||
}
|
||||
|
||||
query := childProxyQuery(rawQuery, mediaID)
|
||||
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
|
||||
|
||||
@@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
|
||||
if strings.Contains(rewritten, "cpats01.115.com") {
|
||||
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
|
||||
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
|
||||
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
|
||||
t.Fatalf("proxy URL missing: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
|
||||
@@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
entries: map[string]string{},
|
||||
}
|
||||
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
|
||||
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
|
||||
|
||||
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
|
||||
entriesAfterFirst := len(session.entries)
|
||||
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
|
||||
|
||||
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
|
||||
if first != second {
|
||||
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
|
||||
}
|
||||
if len(session.entries) != entriesAfterFirst {
|
||||
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
|
||||
}
|
||||
if entriesAfterFirst != 2 {
|
||||
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
@@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
@@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
|
||||
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
|
||||
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
|
||||
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
|
||||
@@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
|
||||
return hosts
|
||||
}
|
||||
|
||||
// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。
|
||||
func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount {
|
||||
host = normalizeHostKey(host)
|
||||
if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range accounts {
|
||||
lines, _, err := r.LinesOf(&accounts[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if normalizeHostKey(u.Hostname()) == host {
|
||||
return &accounts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。
|
||||
func normalizeHostKey(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前
|
||||
// api_key。host 不属于任何已配置账号时返回 ok=false。
|
||||
func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", false
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil || strings.TrimSpace(cfg.Token) == "" {
|
||||
return "", false
|
||||
}
|
||||
return cfg.Token, true
|
||||
}
|
||||
|
||||
// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号
|
||||
// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录
|
||||
// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。
|
||||
func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", errors.New("远程 Emby 服务不可用")
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host)
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key")
|
||||
}
|
||||
cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.Token, nil
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && attempt == 0 {
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
|
||||
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
|
||||
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
|
||||
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
|
||||
// 新 token 覆盖 api_key。
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
|
||||
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
|
||||
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
|
||||
// 凭据,也避免每个后续请求都重新登录一次。
|
||||
previous := cfg.Token
|
||||
cfg.Token = ""
|
||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||
return fmt.Errorf("认证重试失败: %w", err)
|
||||
}
|
||||
if q != nil {
|
||||
q.Set("api_key", cfg.Token)
|
||||
}
|
||||
master.Token = cfg.Token
|
||||
master.RemoteUserID = cfg.RemoteUserID
|
||||
if cfg.Token != "" && cfg.Token != previous {
|
||||
_ = r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status >= 300 {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" {
|
||||
t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test")
|
||||
if !ok || token != "fake-token" {
|
||||
t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
// The account carries only an api_key — there is nothing to re-authenticate with.
|
||||
if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil {
|
||||
t.Fatal("expected an error when the account has no username/password to re-authenticate with")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」:
|
||||
// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) {
|
||||
const fresh = "fresh-token"
|
||||
logins := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/emby/Users/AuthenticateByName" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logins++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-refresh"},
|
||||
Name: "refresh-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" {
|
||||
t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok)
|
||||
}
|
||||
|
||||
token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if token != fresh {
|
||||
t.Fatalf("refreshed token = %q, want %q", token, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1", logins)
|
||||
}
|
||||
|
||||
// 新 token 必须落库,否则每个请求都要重新登录一次。
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh {
|
||||
t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -65,6 +66,16 @@ type ImageProxy struct {
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
|
||||
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
|
||||
// credential for configured remote-Emby mounts. Image URLs are minted from
|
||||
// whatever token the account had when the payload was built, so a URL can
|
||||
// outlive its api_key; these hooks re-supply the current one and force a
|
||||
// re-login when the mount rejects it.
|
||||
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
|
||||
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
|
||||
remoteEmbyTokenMu sync.Mutex
|
||||
remoteEmbyTokenCache map[string]remoteEmbyImageToken
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
||||
|
||||
// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败
|
||||
// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。
|
||||
var errImageProxyUnauthorized = errors.New("upstream rejected image credentials")
|
||||
|
||||
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
|
||||
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
||||
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
|
||||
@@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
}
|
||||
|
||||
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if lastErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(lastErr, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
// 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着
|
||||
// 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发
|
||||
// 占位图、等人工去改账号配置。
|
||||
if errors.Is(lastErr, errImageProxyUnauthorized) {
|
||||
token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host)
|
||||
if refreshErr != nil {
|
||||
logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr)
|
||||
} else if token != "" {
|
||||
retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if retryErr == nil {
|
||||
return retried, nil
|
||||
}
|
||||
lastErr = retryErr
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
|
||||
@@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
|
||||
}
|
||||
|
||||
// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。
|
||||
// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把
|
||||
// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。
|
||||
func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
|
||||
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
|
||||
// upstream image. A poster can appear in the hero, a shelf and the detail page
|
||||
// at the same time; without this guard every resize variant may fetch the same
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求
|
||||
// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。
|
||||
const remoteEmbyImageTokenTTL = 60 * time.Second
|
||||
|
||||
type remoteEmbyImageToken struct {
|
||||
token string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。
|
||||
// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以
|
||||
// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.refreshRemoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。
|
||||
// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会
|
||||
// 白白查一次账号表。
|
||||
func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return ""
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL {
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
return cached.token
|
||||
}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
|
||||
token, ok := p.remoteEmbyTokenFn(ctx, host)
|
||||
if !ok {
|
||||
token = ""
|
||||
}
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
return token
|
||||
}
|
||||
|
||||
func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) {
|
||||
if p == nil || strings.TrimSpace(host) == "" {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if p.remoteEmbyTokenCache == nil {
|
||||
p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8)
|
||||
}
|
||||
p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或
|
||||
// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。
|
||||
func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := p.refreshRemoteEmbyTokenFn(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby
|
||||
// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再
|
||||
// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。
|
||||
func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil {
|
||||
return
|
||||
}
|
||||
token := p.remoteEmbyTokenForHost(ctx, host)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
q := req.URL.Query()
|
||||
q.Set("api_key", token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的
|
||||
// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值,
|
||||
// 否则挂载 Emby 的封面会一直 401、下发占位图。
|
||||
func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) {
|
||||
const current = "current-token"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != current {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return current, true
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景:
|
||||
// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。
|
||||
func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) {
|
||||
const (
|
||||
stale = "stale-token"
|
||||
fresh = "fresh-token"
|
||||
)
|
||||
var mu sync.Mutex
|
||||
refreshes := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != fresh {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return stale, true
|
||||
})
|
||||
proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) {
|
||||
mu.Lock()
|
||||
refreshes++
|
||||
mu.Unlock()
|
||||
return fresh, nil
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes())
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := refreshes
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("refreshes = %d, want exactly 1 re-auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的
|
||||
// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。
|
||||
func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) {
|
||||
var gotHeader string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("X-Emby-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/img/cover.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotHeader != "" {
|
||||
t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
|
||||
p.applyRemoteEmbyAuth(ctx, req, host)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return remoteImageFetchResult{}, errImageProxyUnauthorized
|
||||
}
|
||||
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:书籍类型兜底的回归测试。
|
||||
//
|
||||
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
|
||||
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
|
||||
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
|
||||
// 整个阅读页是一屏 URL(用户截图里的现象)。
|
||||
//
|
||||
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
|
||||
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
|
||||
|
||||
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
|
||||
func audioTestSourceJSON(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
return `{
|
||||
"bookSourceName": "听书测试源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + base + `",
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`
|
||||
}
|
||||
|
||||
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
|
||||
// 并把纠正后的类型写回书架记录。
|
||||
func TestAudioBookTypeFallbackBySource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
|
||||
|
||||
// 复刻详情页写死的 origin_type=0
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "听书测试源",
|
||||
OriginType: 0,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "测试听书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "audio" {
|
||||
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
|
||||
out.Type, out.Content)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("代理地址校验失败: %v", err)
|
||||
}
|
||||
if raw != "https://cdn.example.com/a.m4a" {
|
||||
t.Fatalf("代理还原地址 = %q", raw)
|
||||
}
|
||||
|
||||
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
|
||||
// book.type 也一直是错的(书源 JS 会读它分支)。
|
||||
reloaded, err := svc.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("重读书籍失败: %v", err)
|
||||
}
|
||||
if reloaded.Type != 1 {
|
||||
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
|
||||
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
|
||||
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
|
||||
src := `{
|
||||
"bookSourceName": "声明文本的音频源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"ruleContent": {"content": "@js:book.type = 8; result"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, src)
|
||||
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
|
||||
OriginType: 0, BookURL: srv.URL + "/book/1",
|
||||
}, "声明文本", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
|
||||
func trackQueryValue(t *testing.T, raw, key string) string {
|
||||
t.Helper()
|
||||
idx := strings.Index(raw, key+"=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
|
||||
}
|
||||
rest := raw[idx+len(key)+1:]
|
||||
if end := strings.Index(rest, "&"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:段落评论接口(/para_review)的分页适配。
|
||||
//
|
||||
// 上游聚合站自带的评论页按 page=N 翻页(滑到底请求 page=2、page=3…),但它的列表
|
||||
// 接口早就改成了游标分页:响应里给 next_cursor,请求时要带 cursor=。接口不认识
|
||||
// page,于是每次「加载更多」都原样返回第一页——一条新评论都加不进去,has_more 又
|
||||
// 始终是 true,评论页就一遍遍触发加载更多、反复闪骨架屏。用户看到的就是「段评往
|
||||
// 下滑一闪一闪」,其实评论一条没多。
|
||||
//
|
||||
// 评论页是上游 HTML,改不了,于是在代理层做最小适配:
|
||||
// - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
|
||||
// - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
|
||||
// 免得页面在「加载更多 → 没有新内容」之间空转。
|
||||
//
|
||||
// 只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求;对不上就原样透传,
|
||||
// 不影响别的书源。
|
||||
|
||||
// commentPageState 一个评论列表(同一段落 + 同一排序)的翻页状态。
|
||||
type commentPageState struct {
|
||||
// cursor 上一页响应给的游标:下一页请求用它代替 page。
|
||||
cursor string
|
||||
// seen 已经下发给页面的 comment_id:整页重复时据此收尾。
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// commentPager 承载页面里评论接口的翻页适配器(每个承载页面一份)。
|
||||
type commentPager struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*commentPageState
|
||||
}
|
||||
|
||||
func newCommentPager() *commentPager {
|
||||
return &commentPager{states: map[string]*commentPageState{}}
|
||||
}
|
||||
|
||||
// rewriteRequest 按需把 page=N 改写成 cursor=<上一页游标>。
|
||||
//
|
||||
// 返回改写后的地址与状态键(空串表示这个请求不需要适配,调用方也不必观察响应)。
|
||||
func (p *commentPager) rewriteRequest(target string) (string, string) {
|
||||
if p == nil {
|
||||
return target, ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || !isCommentListPath(parsed.Path) {
|
||||
return target, ""
|
||||
}
|
||||
query := parsed.Query()
|
||||
pageRaw := strings.TrimSpace(query.Get("page"))
|
||||
if pageRaw == "" || strings.TrimSpace(query.Get("cursor")) != "" {
|
||||
return target, ""
|
||||
}
|
||||
page, err := strconv.Atoi(pageRaw)
|
||||
if err != nil || page < 1 {
|
||||
return target, ""
|
||||
}
|
||||
key := commentPageKey(parsed.Path, query)
|
||||
if page == 1 {
|
||||
// 重新加载 / 换了排序:游标与去重表都从头来。
|
||||
p.mu.Lock()
|
||||
delete(p.states, key)
|
||||
p.mu.Unlock()
|
||||
return target, key
|
||||
}
|
||||
p.mu.Lock()
|
||||
cursor := ""
|
||||
if st := p.states[key]; st != nil {
|
||||
cursor = st.cursor
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if cursor == "" {
|
||||
// 不是从第一页翻过来的(没有游标可用):原样透传,仍然观察响应,
|
||||
// 靠下面的「整页重复」判断让页面停下来。
|
||||
return target, key
|
||||
}
|
||||
query.Set("cursor", cursor)
|
||||
query.Del("page")
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), key
|
||||
}
|
||||
|
||||
// observeResponse 观察评论列表响应:记下游标,并在整页重复时把 has_more 改成 false。
|
||||
func (p *commentPager) observeResponse(key, body string) string {
|
||||
if p == nil || key == "" {
|
||||
return body
|
||||
}
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return body
|
||||
}
|
||||
var env commentListEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return body
|
||||
}
|
||||
if env.Data.Comments == nil && env.Data.HasMore == nil {
|
||||
return body
|
||||
}
|
||||
ids := make([]string, 0, len(env.Data.Comments))
|
||||
for _, c := range env.Data.Comments {
|
||||
if id := strings.TrimSpace(c.CommentID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.states == nil {
|
||||
p.states = map[string]*commentPageState{}
|
||||
}
|
||||
st := p.states[key]
|
||||
if st == nil {
|
||||
st = &commentPageState{seen: map[string]struct{}{}}
|
||||
p.states[key] = st
|
||||
}
|
||||
repeated := len(ids) > 0
|
||||
for _, id := range ids {
|
||||
if _, ok := st.seen[id]; !ok {
|
||||
repeated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if repeated && env.Data.HasMore != nil && *env.Data.HasMore {
|
||||
// 整页都是老评论:接口不认 page,再翻下去也只会拿到同一页。
|
||||
// 改掉 has_more,让页面显示「没有更多了」,别再反复拉取闪骨架屏。
|
||||
if replaced, ok := setFirstJSONBoolFalse(trimmed, "has_more"); ok {
|
||||
return replaced
|
||||
}
|
||||
return body
|
||||
}
|
||||
for _, id := range ids {
|
||||
st.seen[id] = struct{}{}
|
||||
}
|
||||
if env.Data.HasMore != nil && !*env.Data.HasMore {
|
||||
// 已经是最后一页:游标链到头,清掉它,页面万一再要一页也别拿旧游标
|
||||
// 去请求(那种请求会落在下面的「整页重复」判断上收尾)。
|
||||
st.cursor = ""
|
||||
return body
|
||||
}
|
||||
// 游标只在拿到新内容时前进:请求失败/重复时页面会重试同一页,
|
||||
// 游标也跟着重试同一个。
|
||||
if cursor := rawJSONScalar(env.Data.Cursor); cursor != "" {
|
||||
st.cursor = cursor
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// commentListEnvelope 评论列表响应里我们关心的字段。
|
||||
type commentListEnvelope struct {
|
||||
Data struct {
|
||||
Comments []struct {
|
||||
CommentID string `json:"comment_id"`
|
||||
} `json:"comments"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Cursor json.RawMessage `json:"next_cursor"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// isCommentListPath 判断是否是段落评论列表接口(/para_review)。
|
||||
func isCommentListPath(path string) bool {
|
||||
p := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(path)), "/")
|
||||
return strings.HasSuffix(p, "/para_review")
|
||||
}
|
||||
|
||||
// commentPageKey 生成翻页状态键:接口路径 + 除 page/cursor 之外的查询参数。
|
||||
// 同一段落的不同排序(sort_by)各自一套状态,互不干扰。
|
||||
func commentPageKey(path string, query url.Values) string {
|
||||
rest := make(url.Values, len(query))
|
||||
for k, v := range query {
|
||||
if k == "page" || k == "cursor" {
|
||||
continue
|
||||
}
|
||||
rest[k] = v
|
||||
}
|
||||
keys := make([]string, 0, len(rest))
|
||||
for k := range rest {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteString(path)
|
||||
for _, k := range keys {
|
||||
for _, v := range rest[k] {
|
||||
b.WriteString("|" + k + "=" + v)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rawJSONScalar 取一个 JSON 标量的字面量(游标是毫秒时间戳,可能被上游写成数字)。
|
||||
func rawJSONScalar(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
var out string
|
||||
if json.Unmarshal(raw, &out) == nil {
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setFirstJSONBoolFalse 把 JSON 里第一个 `"key":true` 改成 `"key":false`。
|
||||
//
|
||||
// 只做这一处等长替换,不重新序列化整份响应:评论里的 user_id、时间戳动辄十几位,
|
||||
// 走一遍 float64 会变成科学计数法,页面再解析就废了。
|
||||
func setFirstJSONBoolFalse(body, key string) (string, bool) {
|
||||
token := `"` + key + `"`
|
||||
idx := strings.Index(body, token)
|
||||
if idx < 0 {
|
||||
return body, false
|
||||
}
|
||||
i := idx + len(token)
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i >= len(body) || body[i] != ':' {
|
||||
return body, false
|
||||
}
|
||||
i++
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if !strings.HasPrefix(body[i:], "true") {
|
||||
return body, false
|
||||
}
|
||||
return body[:i] + "false" + body[i+len("true"):], true
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:段落评论翻页适配(browser_comment_page.go)的测试。
|
||||
//
|
||||
// 覆盖两件事:
|
||||
// 1. page=N(N>1)被换成上一页响应给的 cursor;
|
||||
// 2. 整页重复(接口不认 page 时的表现)时把 has_more 改成 false,页面才会收尾。
|
||||
|
||||
const commentParaBase = "https://cmt.example.com/para_review?item_id=X¶=1&source=QQ"
|
||||
|
||||
func TestCommentPagerRewritesPageToCursor(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
|
||||
// 第一页:原样请求,响应里的游标被记下
|
||||
target, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
if key == "" {
|
||||
t.Fatal("段落评论接口应被识别")
|
||||
}
|
||||
if target != commentParaBase+"&page=1" {
|
||||
t.Fatalf("第一页不该改写: %q", target)
|
||||
}
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
|
||||
// 第二页:改成带 cursor 的请求,page 不再出现
|
||||
target, key2 := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if key2 != key {
|
||||
t.Fatalf("同一段落的状态键应一致: %q vs %q", key2, key)
|
||||
}
|
||||
if !strings.Contains(target, "cursor=1500000000000") || strings.Contains(target, "page=") {
|
||||
t.Fatalf("第二页应换成 cursor 请求: %q", target)
|
||||
}
|
||||
|
||||
// 拿到新评论:游标前进到下一页
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=3")
|
||||
if !strings.Contains(target, "cursor=1400000000000") {
|
||||
t.Fatalf("第三页应使用新的游标: %q", target)
|
||||
}
|
||||
|
||||
// 重新加载(page=1)会把整轮状态清掉:没有游标就原样透传
|
||||
p.rewriteRequest(commentParaBase + "&page=1")
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if strings.Contains(target, "cursor=") {
|
||||
t.Fatalf("重置后不该还带着旧游标: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerStopsOnRepeatedPage(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
_, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
first := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
if got := p.observeResponse(key, first); got != first {
|
||||
t.Fatalf("第一页不该被改写: %q", got)
|
||||
}
|
||||
|
||||
// 同一页又被返回一次(接口忽略 page):改 has_more,让页面显示「没有更多了」
|
||||
repeat := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
got := p.observeResponse(key, repeat)
|
||||
if !strings.Contains(got, `"has_more":false`) {
|
||||
t.Fatalf("重复页应把 has_more 改成 false: %q", got)
|
||||
}
|
||||
// 只动 has_more 一处,其它字段(尤其是大整数)保持原样
|
||||
if strings.Replace(got, `"has_more":false`, `"has_more":true`, 1) != first {
|
||||
t.Fatalf("除 has_more 外不应改动响应: %q", got)
|
||||
}
|
||||
// 重复页不再推进游标
|
||||
target, _ := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if !strings.Contains(target, "cursor=1500000000000") {
|
||||
t.Fatalf("游标不该被重复页顶掉: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerIgnoresOtherRequests(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
cases := []string{
|
||||
"https://cmt.example.com/book/detail?page=2", // 不是评论接口
|
||||
"https://cmt.example.com/para_review?item_id=X", // 没有 page
|
||||
"https://cmt.example.com/para_review?item_id=X&cursor=1", // 已经在用游标
|
||||
"https://cmt.example.com/para_review?item_id=X&page=0", // page 非法
|
||||
"https://cmt.example.com/para_review?item_id=X&sort_by=1&", // 没有 page
|
||||
}
|
||||
for _, target := range cases {
|
||||
got, key := p.rewriteRequest(target)
|
||||
if got != target || key != "" {
|
||||
t.Fatalf("不该改写的请求被处理了: %q -> %q (key=%q)", target, got, key)
|
||||
}
|
||||
}
|
||||
// 非 JSON 响应原样返回
|
||||
if got := p.observeResponse("k", "<html></html>"); got != "<html></html>" {
|
||||
t.Fatalf("非 JSON 响应被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserXHRCommentPagerFollowsCursor 端到端:页面按 page 翻页,
|
||||
// 代理层换成 cursor,接口给出新评论,最后一页重复时收尾。
|
||||
func TestBrowserXHRCommentPagerFollowsCursor(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
seenCursors := []string{}
|
||||
seenPages := []string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/page":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, `<html><body>comment page</body></html>`)
|
||||
case "/para_review":
|
||||
q := r.URL.Query()
|
||||
mu.Lock()
|
||||
seenCursors = append(seenCursors, q.Get("cursor"))
|
||||
seenPages = append(seenPages, q.Get("page"))
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// 上游只认 cursor:不认识 page,靠 next_cursor 一页页往前。
|
||||
switch q.Get("cursor") {
|
||||
case "":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
case "1500000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
case "1400000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":false,"next_cursor":""}}`)
|
||||
default:
|
||||
// 兜底分支:上游把同一页又给了一遍(页面的 page 循环就会走到这里)
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":true,"next_cursor":"1300000000000"}}`)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: srv.URL + "/page", Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
call := func(page string) string {
|
||||
t.Helper()
|
||||
res, err := svc.ProxyBrowserXHR(t.Context(), entry.id, http.MethodGet,
|
||||
srv.URL+"/para_review?item_id=X¶=1&source=QQ&page="+page, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
return res.Body
|
||||
}
|
||||
|
||||
if body := call("1"); !strings.Contains(body, `"has_more":true`) || !strings.Contains(body, "c1") {
|
||||
t.Fatalf("第一页响应异常: %q", body)
|
||||
}
|
||||
if body := call("2"); !strings.Contains(body, "c2") {
|
||||
t.Fatalf("第二页应拿到下一页评论: %q", body)
|
||||
}
|
||||
if body := call("3"); !strings.Contains(body, "c3") || !strings.Contains(body, `"has_more":false`) {
|
||||
t.Fatalf("第三页应是真正的最后一页: %q", body)
|
||||
}
|
||||
// 页面不知道已经到底,还可能再要一页:上游又把最后一页给了一遍,
|
||||
// 适配层要把 has_more 改成 false,页面才会显示「没有更多了」并停下。
|
||||
last := call("4")
|
||||
if !strings.Contains(last, `"has_more":false`) {
|
||||
t.Fatalf("整页重复时应收尾(has_more=false): %q", last)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if strings.Join(seenPages, ",") != "1,,,4" {
|
||||
t.Fatalf("page 参数只应出现在没有游标可用的请求上: %v", seenPages)
|
||||
}
|
||||
if strings.Join(seenCursors, ",") != ",1500000000000,1400000000000," {
|
||||
t.Fatalf("游标传递不符预期: %v", seenCursors)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal([]byte(last), &env); err != nil {
|
||||
t.Fatalf("收尾后的响应不是合法 JSON: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ type BrowserPage struct {
|
||||
Title string `json:"title"`
|
||||
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
||||
Mode string `json:"mode"`
|
||||
// Seq 登记序号(单调递增)。待办表可能同时存多个页面,前端按它取最新的
|
||||
// 那个展示——不能依赖返回顺序,map 遍历是随机的。
|
||||
Seq int64 `json:"seq"`
|
||||
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
||||
PageURL string `json:"page_url"`
|
||||
// Refetch 见 rule.BrowserTask.Refetch。
|
||||
@@ -83,17 +86,26 @@ type BrowserPageSnapshot struct {
|
||||
}
|
||||
|
||||
// pendingBrowser 一个待用户完成的页面。
|
||||
// seq 为登记序号(越大越新,见 ReaderService.browserSeq),供前端挑选最新页面。
|
||||
type pendingBrowser struct {
|
||||
id string
|
||||
seq int64
|
||||
sourceID string
|
||||
sourceURL string
|
||||
userID string
|
||||
request rule.BrowserTask
|
||||
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
|
||||
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
|
||||
// 书源会话就能读到登录凭证。
|
||||
cookies browserCookieTarget
|
||||
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||
html string
|
||||
finalURL string
|
||||
mode string
|
||||
expires time.Time
|
||||
// comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor,
|
||||
// 见 browser_comment_page.go)。
|
||||
comments *commentPager
|
||||
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
@@ -108,21 +120,56 @@ type browserHost struct {
|
||||
sourceURL string
|
||||
sourceID string
|
||||
userID string
|
||||
// state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。
|
||||
// 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把
|
||||
// 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。
|
||||
state *sourceState
|
||||
capture bool
|
||||
}
|
||||
|
||||
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。
|
||||
func (h *browserHost) cookieTarget() browserCookieTarget {
|
||||
if !h.capture {
|
||||
return browserCookieTarget{}
|
||||
}
|
||||
return browserCookieTarget{state: h.state}
|
||||
}
|
||||
|
||||
// browserCookieTarget 面板链路要回写 Cookie 的目标会话。
|
||||
type browserCookieTarget struct {
|
||||
state *sourceState
|
||||
}
|
||||
|
||||
// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。
|
||||
// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。
|
||||
// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。
|
||||
func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) {
|
||||
if t.state == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
sink := cookieSinkFrom(ctx)
|
||||
if sink == nil {
|
||||
sink = &cookieSink{}
|
||||
}
|
||||
if resp.Request != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
}
|
||||
sink.apply(t.state)
|
||||
}
|
||||
|
||||
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait)
|
||||
if err != nil {
|
||||
return rule.BrowserResult{}, err
|
||||
}
|
||||
@@ -146,16 +193,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u
|
||||
}
|
||||
|
||||
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerBrowser 准备页面内容并登记待办。
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||
id := newBrowserID()
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,11 +215,13 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
cookies: cookies,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
expires: time.Now().Add(browserPageTTL),
|
||||
done: make(chan struct{}),
|
||||
comments: newCommentPager(),
|
||||
}
|
||||
|
||||
s.browserMu.Lock()
|
||||
@@ -180,6 +229,8 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
s.browserPending = map[string]*pendingBrowser{}
|
||||
}
|
||||
s.pruneBrowsersLocked()
|
||||
s.browserSeq++
|
||||
entry.seq = s.browserSeq
|
||||
s.browserPending[id] = entry
|
||||
s.browserMu.Unlock()
|
||||
|
||||
@@ -312,7 +363,7 @@ func jsStringEscape(s string) string {
|
||||
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) {
|
||||
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
@@ -324,10 +375,16 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||
}
|
||||
// 空响应没有可展示的内容:以前照样包一层空 <pre> 交给 iframe,面板就是一片白,
|
||||
// 用户看不出是地址有问题还是自己没登录。这里直接报错(如段评地址参数被改写、
|
||||
// 上游按缺参数返回空 body 的情形)。
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", "", fmt.Errorf("目标地址没有返回任何内容: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
|
||||
if !strings.Contains(strings.ToLower(contentType), "html") {
|
||||
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
|
||||
@@ -348,8 +405,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ
|
||||
}
|
||||
|
||||
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -357,16 +414,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target
|
||||
}
|
||||
|
||||
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
|
||||
// 返回 (contentType, status, body, error)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应
|
||||
// Set-Cookie 写回(页面可能靠资源响应续期会话)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return "", 0, nil, errors.New("页面已过期,请重新打开")
|
||||
}
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target)
|
||||
return contentType, status, data, err
|
||||
}
|
||||
|
||||
// requestBrowserResource 带书源凭据请求一个外部地址。
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。
|
||||
if cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
state := s.newSourceState(reqCtx, sourceURL)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
@@ -412,6 +478,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
// 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。
|
||||
cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
@@ -447,8 +515,20 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
// 段落评论接口:评论页按 page=N 翻页,而接口只认 cursor(见
|
||||
// browser_comment_page.go)。改写发生在补书源凭据之前,Cookie/Referer
|
||||
// 仍然按目标站点选取。
|
||||
pagerKey := ""
|
||||
if entry.comments != nil {
|
||||
target, pagerKey = entry.comments.rewriteRequest(target)
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的
|
||||
// Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。
|
||||
if entry.cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -499,6 +579,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
return nil, err
|
||||
}
|
||||
data = helper.DecompressBody(resp, data)
|
||||
// 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。
|
||||
entry.cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
@@ -506,6 +588,9 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
|
||||
if isTextualContent(contentType) {
|
||||
out.Body = string(data)
|
||||
if entry.comments != nil && pagerKey != "" {
|
||||
out.Body = entry.comments.observeResponse(pagerKey, out.Body)
|
||||
}
|
||||
} else {
|
||||
out.Body = base64.StdEncoding.EncodeToString(data)
|
||||
out.Base64 = true
|
||||
@@ -658,8 +743,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string {
|
||||
return s.browserSign("asset|" + id + "|" + target)
|
||||
}
|
||||
|
||||
func (s *ReaderService) browserPageURL(id string) string {
|
||||
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
// browserPageURL 生成 iframe 承载地址。
|
||||
//
|
||||
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id¶&source
|
||||
// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的
|
||||
// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去
|
||||
// 请求评论接口,气泡数照样显示,点开却一条段评都没有。
|
||||
//
|
||||
// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。
|
||||
// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。
|
||||
func (s *ReaderService) browserPageURL(id, target string) string {
|
||||
u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
if q := browserTargetQuery(target); q != "" {
|
||||
u += "&" + q
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。
|
||||
func browserTargetQuery(target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.RawQuery
|
||||
}
|
||||
|
||||
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
|
||||
@@ -716,6 +827,10 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
||||
if e.sourceID != sourceID {
|
||||
continue
|
||||
}
|
||||
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
|
||||
if e.finished {
|
||||
continue
|
||||
}
|
||||
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
|
||||
if e.userID != "" && userID != "" && e.userID != userID {
|
||||
continue
|
||||
@@ -726,14 +841,20 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
||||
}
|
||||
|
||||
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
|
||||
//
|
||||
// 交付后立刻把条目移出待办表:open 模式(java.startBrowser)没有别的清理
|
||||
// 路径,留着会一直躺到 TTL 到期——光遇聚合的「❇️ 更新书源」就是这么在
|
||||
// 15 分钟内反复串到其它按钮上的。
|
||||
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return errors.New("页面已过期或已完成")
|
||||
}
|
||||
// 注意锁序:browserMu 必须在 entry.mu 之外获取(pruneBrowsersLocked 是
|
||||
// browserMu → entry.mu),这里不能持着 entry.mu 去等 browserMu。
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
if entry.finished {
|
||||
entry.mu.Unlock()
|
||||
return errors.New("该页面已完成")
|
||||
}
|
||||
if cancelled {
|
||||
@@ -749,6 +870,9 @@ func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, c
|
||||
if entry.done != nil {
|
||||
close(entry.done)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
s.dropBrowser(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -785,6 +909,14 @@ func (s *ReaderService) pruneBrowsersLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
// browserPageOf 生成前端展示用的描述(自行加锁)。
|
||||
// registerBrowser 之后需要把页面直接回给调用方(如打开一条段评)时用它。
|
||||
func (s *ReaderService) browserPageOf(e *pendingBrowser) BrowserPage {
|
||||
s.browserMu.Lock()
|
||||
defer s.browserMu.Unlock()
|
||||
return s.pageOfLocked(e)
|
||||
}
|
||||
|
||||
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
|
||||
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
target := e.request.URL
|
||||
@@ -795,7 +927,8 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
ID: e.id,
|
||||
Title: e.request.Title,
|
||||
Mode: e.mode,
|
||||
PageURL: s.browserPageURL(e.id),
|
||||
Seq: e.seq,
|
||||
PageURL: s.browserPageURL(e.id, e.request.URL),
|
||||
Refetch: e.request.Refetch,
|
||||
SourceID: e.sourceID,
|
||||
TargetURL: target,
|
||||
|
||||
@@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。
|
||||
//
|
||||
// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。
|
||||
// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有,
|
||||
// 点开却一条评论都没有。
|
||||
func TestBrowserPageURLKeepsTargetQuery(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body>comment page</body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。
|
||||
target := srv.URL + "/get_para_review?item_id=abc¶=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy"
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
|
||||
page := svc.browserPageOf(entry)
|
||||
u, err := url.Parse(page.PageURL)
|
||||
if err != nil {
|
||||
t.Fatalf("承载地址不合法: %q", page.PageURL)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" {
|
||||
t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL)
|
||||
}
|
||||
// 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办
|
||||
if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) {
|
||||
t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL)
|
||||
}
|
||||
if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil {
|
||||
t.Fatalf("带业务参数后签名校验失败: %v", err)
|
||||
}
|
||||
// 没有 query 的地址(含 data: 页面)不该多出参数
|
||||
for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} {
|
||||
if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 {
|
||||
t.Fatalf("无 query 的地址被拼进了多余参数: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
|
||||
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
|
||||
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
@@ -201,7 +246,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
st.flush()
|
||||
|
||||
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -266,7 +311,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
if gotSourceURL != srv.URL {
|
||||
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
|
||||
}
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
|
||||
if err != nil {
|
||||
t.Fatalf("拉取资源失败: %v", err)
|
||||
}
|
||||
@@ -313,7 +358,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) {
|
||||
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||
st.flush()
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -390,3 +435,46 @@ func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
|
||||
t.Fatal("未解除阻塞")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelOpenPageDroppedAfterResolve open 模式(java.startBrowser)
|
||||
// 的页面在用户关闭后必须立刻离开待办表。
|
||||
//
|
||||
// 回归:这条日志只删除 wait 模式(awaitBrowser 的 defer),open 模式没有清理
|
||||
// 路径,会一直躺到 15 分钟 TTL 到期。前端轮询到它就又把旧页面弹出来——光遇
|
||||
// 聚合点过「❇️ 更新书源」后,接下来点「书源设置」「段评设置」都会跳到书源更新页。
|
||||
func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||
|
||||
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
|
||||
t.Fatalf("登记 open 页面失败: %v", err)
|
||||
}
|
||||
old := waitPending(t, svc, sourceID)
|
||||
if old.Mode != browserModeOpen {
|
||||
t.Fatalf("模式 = %q,期望 open", old.Mode)
|
||||
}
|
||||
|
||||
// 用户关掉页面
|
||||
if err := svc.ResolveBrowser(old.ID, readerTestUserID, "", "", true); err != nil {
|
||||
t.Fatalf("关闭页面失败: %v", err)
|
||||
}
|
||||
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
|
||||
t.Fatalf("关闭后待办未清理: %+v", pages)
|
||||
}
|
||||
|
||||
// 再点别的按钮:待办表里只应剩新页面,且序号更大
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
|
||||
t.Fatalf("登记新页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 || pages[0].Title != "光遇书源设置" {
|
||||
t.Fatalf("待办表应只剩新页面: %+v", pages)
|
||||
}
|
||||
if pages[0].Seq <= old.Seq {
|
||||
t.Fatalf("新页面 seq 应更大: %d <= %d", pages[0].Seq, old.Seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:正文里的「段评」锚点解析(对齐 legado 的段评机制)。
|
||||
//
|
||||
// legado 侧段评有两种落在正文里的形态:
|
||||
//
|
||||
// 1. 段落里的 <comment ident="评论地址" count="评论数" />:宿主(苹果/源阅分支)
|
||||
// 直接把它解析成段落旁的评论气泡;
|
||||
// 2. 「改版」宿主不认 <comment>,改由书源在正文规则里把它换成一张内嵌 SVG 图片,
|
||||
// 图片地址后面再跟一段 JSON 配置:
|
||||
// <img src="data:image/svg+xml;base64,…{"click":"showCmt('评论地址','平台','段评')","style":"text"}">
|
||||
// legado 识别的是地址后面的这份配置(ReadBookActivity.oldClickImg),点击时把
|
||||
// 配置里的 click/js 当 JS 执行(showCmt 内部再调 java.showBrowser 打开评论页)。
|
||||
//
|
||||
// MeBox 没有 Android 的 Canvas 排版,正文按行渲染,因此这里把两种形态都归一成
|
||||
// 「某一行的段评气泡」:只摘掉标记本身,正文文字一个字都不动,并把评论地址 / 评论数
|
||||
// 结构化下发给前端;点击气泡时前端复用宿主浏览器(带书源 Cookie)打开评论地址。
|
||||
//
|
||||
// 约定:但凡无法确定评论地址的图片,一律原样保留交给既有链路,绝不猜一个地址出来。
|
||||
|
||||
// ContentComment 正文里的一条段评锚点(随正文一起下发给前端)。
|
||||
type ContentComment struct {
|
||||
// Line 段落行号:Content 按 "\n" 拆分后的 0-based 下标。
|
||||
Line int `json:"line"`
|
||||
// Count 评论数(0 表示未知:前端只画一个不带数字的气泡)。
|
||||
Count int `json:"count"`
|
||||
// URL 评论地址(书源 showCmt 的第一个参数),前端点击时交给宿主浏览器打开。
|
||||
URL string `json:"url"`
|
||||
// Label 评论类型:段评 / 本章说,用作气泡提示。
|
||||
Label string `json:"label,omitempty"`
|
||||
// Block 独占一行的整块评论(章末「本章说」),前端按居中整块渲染。
|
||||
Block bool `json:"block,omitempty"`
|
||||
}
|
||||
|
||||
// commentTagRe 匹配正文里的 <comment …> 标签。属性顺序不定、可能自闭合,
|
||||
// 也可能带一个配对结束标签(`<comment …>…</comment>`),一并吃掉。
|
||||
var commentTagRe = regexp.MustCompile(`(?is)<comment\b[^>]*?/?>(?:</comment>)?`)
|
||||
|
||||
// 下面三条属性正则都允许单/双引号两种写法;捕获组 1 是双引号内容,2 是单引号内容。
|
||||
var (
|
||||
commentIdentRe = regexp.MustCompile(`(?is)\bident\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
commentCountRe = regexp.MustCompile(`(?is)\bcount\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
commentOnPressRe = regexp.MustCompile(`(?is)\bonPress\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
)
|
||||
|
||||
// commentJSURLRe 从 showCmt / showReadingBrowser / startBrowserDp 的调用里取第一个字符串参数。
|
||||
// 苹果分支的 <comment onPress="java.showReadingBrowser('地址',…)" /> 就靠它取地址。
|
||||
// RE2 不支持反向引用,双/单引号两种写法拆成两个捕获组。
|
||||
var commentJSURLRe = regexp.MustCompile(`(?is)(?:showCmt|showReadingBrowser|startBrowserDp)\s*\(\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// imgParamRe 宽容地提取图片地址后面 JSON 配置里的键值对。
|
||||
//
|
||||
// 不用 encoding/json 解析:光遇聚合的「本章说」把配置拼成 {'type':'qtbzs',"click":"…",'style':'FULL'}
|
||||
// (单双引号混用),严格 JSON 解析会失败,而它又是合法 JS 对象字面量。
|
||||
var imgParamRe = regexp.MustCompile(`(?is)["']?(click|js|style|type|width)["']?\s*:\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')`)
|
||||
|
||||
// svgCountRe 取内嵌段评 SVG 里画在气泡上的数字(书源把评论数直接画进了 SVG 文本)。
|
||||
var svgCountRe = regexp.MustCompile(`(?is)<text[^>]*>\s*([0-9]+)\s*\+?\s*</text>`)
|
||||
|
||||
// wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。
|
||||
var wrapMarkupRe = regexp.MustCompile(`(?is)</?(?:p|div|br|hr|h[1-6]|article|dd|dl)\b[^>]*>`)
|
||||
|
||||
// extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。
|
||||
//
|
||||
// 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。
|
||||
func extractContentComments(content string) (string, []ContentComment) {
|
||||
if content == "" || (!strings.Contains(content, "<comment") && !strings.Contains(content, "<img")) {
|
||||
return content, nil
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
out := make([]string, len(lines))
|
||||
comments := make([]ContentComment, 0, 4)
|
||||
changed := false
|
||||
for i, line := range lines {
|
||||
text, cs := extractLineComments(line, i)
|
||||
out[i] = text
|
||||
if len(cs) > 0 {
|
||||
changed = true
|
||||
comments = append(comments, cs...)
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return content, nil
|
||||
}
|
||||
return strings.Join(out, "\n"), comments
|
||||
}
|
||||
|
||||
// extractLineComments 处理一行:返回去掉段评标记后的文字与该行上的段评。
|
||||
func extractLineComments(line string, lineNo int) (string, []ContentComment) {
|
||||
type marker struct {
|
||||
start, end int
|
||||
comment ContentComment
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
for _, m := range commentTagRe.FindAllStringIndex(line, -1) {
|
||||
if c, ok := parseCommentTag(line[m[0]:m[1]], lineNo); ok {
|
||||
markers = append(markers, marker{m[0], m[1], c})
|
||||
}
|
||||
}
|
||||
for _, tag := range scanImgTags(line) {
|
||||
if c, ok := parseCommentImage(tag.src, lineNo); ok {
|
||||
markers = append(markers, marker{tag.start, tag.end, c})
|
||||
}
|
||||
}
|
||||
if len(markers) == 0 {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
sort.Slice(markers, func(i, j int) bool { return markers[i].start < markers[j].start })
|
||||
comments := make([]ContentComment, 0, len(markers))
|
||||
var b strings.Builder
|
||||
prev := 0
|
||||
for _, mk := range markers {
|
||||
if mk.start < prev { // 与前一个标记重叠(理论上不会出现),跳过避免重复删除
|
||||
continue
|
||||
}
|
||||
b.WriteString(line[prev:mk.start])
|
||||
prev = mk.end
|
||||
c := mk.comment
|
||||
// 摘掉标记后整行只剩标签/空白 → 说明评论独占一行(章末「本章说」)
|
||||
c.Block = isBlankMarkup(line[:mk.start] + line[mk.end:])
|
||||
comments = append(comments, c)
|
||||
}
|
||||
b.WriteString(line[prev:])
|
||||
return b.String(), comments
|
||||
}
|
||||
|
||||
// imgTagMatch 一行里一个 <img> 标签的位置与 src 值。
|
||||
type imgTagMatch struct {
|
||||
start, end int
|
||||
src string
|
||||
}
|
||||
|
||||
// scanImgTags 扫描一行里的所有 <img> 标签,返回标签位置与 src 值。
|
||||
//
|
||||
// 不能用 <img[^>]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成
|
||||
// <img src="data:…,{"click":"showCmt('…','…')"}"> —— 属性值里的 JSON 又用了双引号,
|
||||
// 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号,
|
||||
// 只把「花括号之外遇到的引号」当作属性值的结束。
|
||||
func scanImgTags(line string) []imgTagMatch {
|
||||
var out []imgTagMatch
|
||||
lower := strings.ToLower(line)
|
||||
for i := 0; i < len(line); {
|
||||
idx := strings.Index(lower[i:], "<img")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
start := i + idx
|
||||
src, end, ok := scanImgTagSrc(line, start)
|
||||
if !ok {
|
||||
i = start + 4
|
||||
continue
|
||||
}
|
||||
out = append(out, imgTagMatch{start: start, end: end, src: src})
|
||||
i = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scanImgTagSrc 从 <img 起始位置扫描出 src 属性值,并返回标签结束下标(含 '>')。
|
||||
func scanImgTagSrc(line string, start int) (string, int, bool) {
|
||||
lower := strings.ToLower(line)
|
||||
i := start + 4 // 跳过 "<img"
|
||||
for {
|
||||
si := strings.Index(lower[i:], "src")
|
||||
if si < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
si += i
|
||||
q := si + 3
|
||||
for q < len(line) && isHTMLSpace(line[q]) {
|
||||
q++
|
||||
}
|
||||
// 必须是 src= 而不是 srcset= 之类的近似字段
|
||||
if q >= len(line) || line[q] != '=' {
|
||||
i = si + 3
|
||||
continue
|
||||
}
|
||||
q++
|
||||
for q < len(line) && isHTMLSpace(line[q]) {
|
||||
q++
|
||||
}
|
||||
if q >= len(line) || (line[q] != '"' && line[q] != '\'') {
|
||||
i = si + 3
|
||||
continue
|
||||
}
|
||||
quote := line[q]
|
||||
valStart := q + 1
|
||||
depth := 0
|
||||
j := valStart
|
||||
for ; j < len(line); j++ {
|
||||
c := line[j]
|
||||
switch {
|
||||
case c == '{':
|
||||
depth++
|
||||
case c == '}' && depth > 0:
|
||||
depth--
|
||||
case c == quote && depth == 0:
|
||||
// 花括号之外的引号才是属性值的结束
|
||||
gt := strings.IndexByte(line[j:], '>')
|
||||
if gt < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return line[valStart:j], j + gt + 1, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// isHTMLSpace 判断 HTML 属性之间的空白(含换行/制表)。
|
||||
func isHTMLSpace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
|
||||
// parseCommentTag 解析宿主侧的 <comment ident count /> 形式。
|
||||
func parseCommentTag(tag string, lineNo int) (ContentComment, bool) {
|
||||
rawURL := attrOf(commentIdentRe, tag)
|
||||
if rawURL == "" {
|
||||
rawURL = attrOf(commentOnPressRe, tag)
|
||||
}
|
||||
rawURL = extractCommentURL(rawURL)
|
||||
if rawURL == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(attrOf(commentCountRe, tag)))
|
||||
return ContentComment{Line: lineNo, Count: count, URL: rawURL, Label: "段评"}, true
|
||||
}
|
||||
|
||||
// parseCommentImage 解析「图片地址 + JSON 配置」形式的段评。
|
||||
//
|
||||
// 只把真正指向评论的气泡当段评:地址要么来自 showCmt/showReadingBrowser,
|
||||
// 要么图片本身就是内嵌的段评 SVG。其它带 click 配置的图片保持原样,避免误伤。
|
||||
func parseCommentImage(src string, lineNo int) (ContentComment, bool) {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
base, params := splitImgSrcParams(src)
|
||||
if len(params) == 0 {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
action := params["click"]
|
||||
if action == "" {
|
||||
action = params["js"]
|
||||
}
|
||||
if action == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
isCommentCall := strings.Contains(action, "showCmt") || strings.Contains(action, "showReadingBrowser")
|
||||
if !isCommentCall && !strings.HasPrefix(base, "data:image/svg+xml") {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
rawURL := extractCommentURL(action)
|
||||
if rawURL == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
label := "段评"
|
||||
if strings.Contains(action, "本章说") {
|
||||
label = "本章说"
|
||||
}
|
||||
return ContentComment{
|
||||
Line: lineNo,
|
||||
Count: commentCountFromImage(base),
|
||||
URL: rawURL,
|
||||
Label: label,
|
||||
}, true
|
||||
}
|
||||
|
||||
// splitImgSrcParams 把图片地址拆成(地址, JSON 配置),没有配置时返回 (src, nil)。
|
||||
//
|
||||
// 对应 legado 的 AnalyzeUrl.paramPattern `\s*,\s*(?=\{)`:地址与配置之间用「逗号 + 花括号」分隔。
|
||||
func splitImgSrcParams(src string) (string, map[string]string) {
|
||||
for i := 0; i < len(src); i++ {
|
||||
if src[i] != ',' {
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(src) && (src[j] == ' ' || src[j] == '\t' || src[j] == '\n' || src[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j >= len(src) || src[j] != '{' {
|
||||
continue
|
||||
}
|
||||
end := strings.LastIndexByte(src, '}')
|
||||
if end <= j {
|
||||
continue
|
||||
}
|
||||
if params := parseImgParams(src[j : end+1]); len(params) > 0 {
|
||||
return strings.TrimSpace(src[:i]), params
|
||||
}
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// parseImgParams 宽容解析图片配置里的键值对(见 imgParamRe 的说明)。
|
||||
func parseImgParams(raw string) map[string]string {
|
||||
ms := imgParamRe.FindAllStringSubmatch(raw, -1)
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(ms))
|
||||
for _, m := range ms {
|
||||
val := m[2]
|
||||
if val == "" {
|
||||
val = m[3]
|
||||
}
|
||||
val = strings.ReplaceAll(val, `\"`, `"`)
|
||||
val = strings.ReplaceAll(val, `\'`, `'`)
|
||||
out[strings.ToLower(m[1])] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commentCountFromImage 从内嵌段评 SVG 里取评论数(书源把数字画进了 SVG 文本)。
|
||||
// 取不到时返回 0,前端只画一个不带数字的气泡。
|
||||
func commentCountFromImage(src string) int {
|
||||
decoded, ok := decodeCommentDataURI(src)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
m := svgCountRe.FindStringSubmatch(decoded)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// decodeCommentDataURI 解出 data: URI 的文本内容(支持 base64 与明文两种)。
|
||||
func decodeCommentDataURI(s string) (string, bool) {
|
||||
if !strings.HasPrefix(s, "data:") {
|
||||
return "", false
|
||||
}
|
||||
comma := strings.IndexByte(s, ',')
|
||||
if comma < 0 {
|
||||
return "", false
|
||||
}
|
||||
meta, payload := s[:comma], s[comma+1:]
|
||||
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
||||
b, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// extractCommentURL 从 showCmt/showReadingBrowser 调用或裸地址里取评论地址。
|
||||
func extractCommentURL(js string) string {
|
||||
js = strings.TrimSpace(js)
|
||||
if js == "" {
|
||||
return ""
|
||||
}
|
||||
if m := commentJSURLRe.FindStringSubmatch(js); m != nil {
|
||||
return trimCommentURL(firstSubmatch(m))
|
||||
}
|
||||
if strings.HasPrefix(js, "http://") || strings.HasPrefix(js, "https://") {
|
||||
return trimCommentURL(js)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// attrOf 取属性值,双引号优先、单引号兜底。
|
||||
func attrOf(re *regexp.Regexp, s string) string {
|
||||
m := re.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return firstSubmatch(m)
|
||||
}
|
||||
|
||||
// firstSubmatch 返回「双引号 / 单引号」两个捕获组里非空的那个。
|
||||
func firstSubmatch(m []string) string {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return m[1]
|
||||
}
|
||||
if len(m) > 2 {
|
||||
return m[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trimCommentURL 去空白并还原 HTML 实体。
|
||||
// 苹果/源阅分支会把地址里的 & 转成 &(paraForiOS),直接拿去请求会 404。
|
||||
func trimCommentURL(raw string) string {
|
||||
r := strings.NewReplacer(
|
||||
""", `"`, """, `"`,
|
||||
"'", "'", "'", "'",
|
||||
"<", "<", ">", ">",
|
||||
"&", "&",
|
||||
)
|
||||
return strings.TrimSpace(r.Replace(raw))
|
||||
}
|
||||
|
||||
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
|
||||
func isBlankMarkup(s string) bool {
|
||||
s = wrapMarkupRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "\u3000", " ")
|
||||
return strings.TrimSpace(s) == ""
|
||||
}
|
||||
|
||||
// ─── 打开段评(宿主浏览器承载) ─────────────────────────────────────────────
|
||||
|
||||
// OpenContentComment 在宿主浏览器里打开一条段评。
|
||||
//
|
||||
// 书源点击段评气泡时执行的是它自己的 showCmt(...):先 java.ajax 取评论页,
|
||||
// 再 java.showBrowser 展示。MeBox 不跑书源 JS,而是把 showCmt 的第一个参数
|
||||
// (评论地址)解析出来(见本文件),复用承载登录页的那套宿主浏览器打开:
|
||||
// 服务端带书源 Cookie 抓取评论页,前端 iframe 展示。这样评论接口需要的登录态
|
||||
// 仍然生效——前端直接 window.open 会以未登录身份访问。
|
||||
//
|
||||
// 返回的 BrowserPage 直接给前端渲染,不必走轮询(段评只需展示、无需回传 DOM)。
|
||||
func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, rawURL, title string) (*BrowserPage, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("阅读服务未就绪")
|
||||
}
|
||||
target, err := validateCommentURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
book, err := s.repo.GetBook(ctx, bookID)
|
||||
if err != nil || book == nil {
|
||||
return nil, fmt.Errorf("书籍不存在或已移出书架")
|
||||
}
|
||||
sourceID := ""
|
||||
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
|
||||
sourceID = src.ID
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "段评"
|
||||
}
|
||||
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
|
||||
// 段评是「只展示」的页面,没有登录会话可回写 Cookie。
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := s.browserPageOf(entry)
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// validateCommentURL 校验评论地址:只允许 http(s),并挡住内网地址。
|
||||
//
|
||||
// 段评地址来自书源正文,会带着书源登录态由服务端去抓;不做限制就等于给了一个
|
||||
// 「以书源身份请求任意地址」的口子(SSRF)。
|
||||
func validateCommentURL(raw string) (string, error) {
|
||||
u := strings.TrimSpace(raw)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("评论地址为空")
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", fmt.Errorf("评论地址无效:%s", truncateForLog(u, 80))
|
||||
}
|
||||
if isPrivateHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("评论地址指向内网,已拒绝")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// isPrivateHost 判断主机名是否是回环/内网/链路本地地址。
|
||||
func isPrivateHost(host string) bool {
|
||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if h == "" || h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 段评解析单测:覆盖 legado 的两种正文形态(<comment /> 与「图片地址 + JSON 配置」),
|
||||
// 以及「绝不能吞掉或破坏正文文字」这条硬约束。
|
||||
|
||||
// svgCommentSrc 按光遇聚合 createSvg 的写法拼一张带评论数气泡图:
|
||||
// data:image/svg+xml;base64,<svg>,{"click":"showCmt('地址','平台','段评')","style":"text"}
|
||||
//
|
||||
// 注意第二个逗号后面直接跟 JSON,且 JSON 自身用双引号——它嵌在双引号属性里,
|
||||
// 正是会打断普通正则的「非法 HTML」,解析必须扛得住。
|
||||
func svgCommentSrc(t *testing.T, count int, click string, style string) string {
|
||||
t.Helper()
|
||||
svg := fmt.Sprintf(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg'><text x='1' y='2'>%d</text></svg>`, count)
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(svg))
|
||||
return fmt.Sprintf(`data:image/svg+xml;base64,%s,{"click":%q,"style":%q}`, b64, click, style)
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsLegacyTag(t *testing.T) {
|
||||
content := `<p>第一段<comment ident="https://cdn.example.com/c/1" count="12" /></p>
|
||||
<p>第二段</p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>第一段</p>\n<p>第二段</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 0 || c.Count != 12 || c.URL != "https://cdn.example.com/c/1" || c.Label != "段评" {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
if c.Block {
|
||||
t.Fatalf("段落内的段评不应标记为整块:%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsIOSOnPress(t *testing.T) {
|
||||
content := `<p>正文<comment count="7" onPress="java.showReadingBrowser('https://cdn.example.com/c/2?a=1&b=2','番茄段评')"></comment></p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
// & 必须还原成 &,否则请求评论接口会 404
|
||||
if comments[0].URL != "https://cdn.example.com/c/2?a=1&b=2" || comments[0].Count != 7 {
|
||||
t.Fatalf("解析结果不符:%+v", comments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsInlineImage(t *testing.T) {
|
||||
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1&item_id=2','番茄','段评')", "text")
|
||||
content := `<p>正文内容<img src="` + src + `"></p>
|
||||
<p>下一段</p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文内容</p>\n<p>下一段</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 0 || c.Count != 3 || c.Label != "段评" {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
// 地址里的 & 原样保留(JSON 里的 & 不是实体)
|
||||
if c.URL != "https://v1.example.com/get_review?book_id=1&item_id=2" {
|
||||
t.Fatalf("评论地址 = %q", c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsChapterEndBlock(t *testing.T) {
|
||||
// 章末「本章说」:单独一行,配置用单引号键、双引号值(合法 JS 对象字面量,非法 JSON)
|
||||
line := `<img src="https://v1.example.com/chapter_review_svg?book_id=1&img=1,` +
|
||||
`{'type':'qtbzs',` +
|
||||
`"click":"showCmt('https://v1.example.com/get_review?book_id=1','番茄','本章说' )",` +
|
||||
`'style':'FULL'}">`
|
||||
content := "<p>正文最后一段</p>\n" + line
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文最后一段</p>\n" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 1 || c.Label != "本章说" || !c.Block {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
if c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("评论地址 = %q", c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsCountOver99(t *testing.T) {
|
||||
// 书源把 >99 的评论数显示成 "99+",解析要取到 99
|
||||
src := svgCommentSrc(t, 99, "showCmt('https://x.example.com/c','番茄','段评')", "text")
|
||||
// 手工把文本改成 99+
|
||||
src = strings.Replace(src, ">99<", ">99+<", 1)
|
||||
_, comments := extractContentComments(`<p>x<img src="` + src + `"></p>`)
|
||||
if len(comments) != 1 || comments[0].Count != 99 {
|
||||
t.Fatalf("期望解析出 99,得到 %+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何段评标记时,正文必须逐字节不变(不能因为「看起来像 HTML」就动手)。
|
||||
func TestExtractContentCommentsPreservesPlainContent(t *testing.T) {
|
||||
content := `<p>普通段落</p>
|
||||
<p>带插图的段落<img src="https://cdn.example.com/a.jpg"></p>
|
||||
<p>带 onclick 图但非段评<img src="https://cdn.example.com/b.jpg,{'click':'openPhoto(1)'}"></p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != content {
|
||||
t.Fatalf("正文被改动:\n got %q\nwant %q", text, content)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Fatalf("不应解析出段评:%+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsEmpty(t *testing.T) {
|
||||
if text, comments := extractContentComments(""); text != "" || comments != nil {
|
||||
t.Fatalf("空正文应原样返回,得到 %q / %+v", text, comments)
|
||||
}
|
||||
if text, comments := extractContentComments("<p>只有普通正文</p>"); text != "<p>只有普通正文</p>" || comments != nil {
|
||||
t.Fatalf("无标记正文应原样返回,得到 %q / %+v", text, comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitImgSrcParams(t *testing.T) {
|
||||
base, params := splitImgSrcParams(`data:image/svg+xml;base64,QUJD,{"click":"showCmt('u','x')","style":"text"}`)
|
||||
if base != "data:image/svg+xml;base64,QUJD" {
|
||||
t.Fatalf("base = %q", base)
|
||||
}
|
||||
if params["click"] != "showCmt('u','x')" || params["style"] != "text" {
|
||||
t.Fatalf("params = %+v", params)
|
||||
}
|
||||
if base, params := splitImgSrcParams("https://x.example.com/a.jpg"); params != nil || base != "https://x.example.com/a.jpg" {
|
||||
t.Fatalf("无配置时不应拆出参数:%q / %+v", base, params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommentURL(t *testing.T) {
|
||||
bad := []string{
|
||||
"", "not-a-url", "file:///etc/passwd", "javascript:alert(1)",
|
||||
"http://localhost/c", "http://127.0.0.1/c", "http://192.168.1.1/c", "http://[::1]/c",
|
||||
}
|
||||
for _, u := range bad {
|
||||
if _, err := validateCommentURL(u); err == nil {
|
||||
t.Fatalf("%q 应被拒绝", u)
|
||||
}
|
||||
}
|
||||
good := "https://v1.qingtian618.com/get_review?book_id=1&item_id=2"
|
||||
if got, err := validateCommentURL(good); err != nil || got != good {
|
||||
t.Fatalf("合法地址被拒:%q / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:书源正文里带段评标记时,GetContentForBook 应清掉标记、
|
||||
// 下发结构化 Comments,且正文文字完好。
|
||||
func TestGetContentForBookExtractsComments(t *testing.T) {
|
||||
src := svgCommentSrc(t, 5, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
|
||||
// 服务端按 hex 还原规则返回值里的正文;这里让正文规则直接返回一段放进 data 信封的 JSON。
|
||||
pageJSON := fmt.Sprintf(`{"content":%q}`, "<p>第一段<img src=\""+src+"\"></p>\n<p>第二段</p>")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(pageJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "段评测试源",
|
||||
"bookSourceType": 0,
|
||||
"ruleContent": { "content": "$.content" }
|
||||
}`, srv.URL)
|
||||
importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "段评测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "测试书",
|
||||
Type: 0,
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text", out.Type)
|
||||
}
|
||||
if strings.Contains(out.Content, "<img") || strings.Contains(out.Content, "showCmt") {
|
||||
t.Fatalf("段评标记没有清干净:%q", out.Content)
|
||||
}
|
||||
// 块级标签折成换行后,段落文字一个字都不少(多余空行已压掉)。
|
||||
if out.Content != "第一段\n第二段" {
|
||||
t.Fatalf("正文被破坏:%q", out.Content)
|
||||
}
|
||||
if strings.Contains(out.Content, "<p>") || strings.Contains(out.Content, "</p>") {
|
||||
t.Fatalf("块级标签没有折行:%q", out.Content)
|
||||
}
|
||||
if len(out.Comments) != 1 {
|
||||
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
|
||||
}
|
||||
c := out.Comments[0]
|
||||
if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("段评内容不符:%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTextChapterBook 搭一个「正文规则直接返回指定字符串」的文本源 + 一本书,
|
||||
// 返回服务实例与书籍 ID,供正文链路回归测试复用。
|
||||
func setupTextChapterBook(t *testing.T, content string) (*ReaderService, string) {
|
||||
t.Helper()
|
||||
pageJSON := fmt.Sprintf(`{"content":%q}`, content)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(pageJSON))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "正文链路测试源",
|
||||
"bookSourceType": 0,
|
||||
"ruleContent": { "content": "$.content" }
|
||||
}`, srv.URL)
|
||||
importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "正文链路测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "测试书",
|
||||
Type: 0,
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
return svc, book.ID
|
||||
}
|
||||
|
||||
// TestGetContentForBookKeepsTextWithStandaloneImage 回归问题 A:带 <p> 正文的章节
|
||||
// 混入一行独立普通插图时,仍然必须是文本类型、正文一个字都不能丢
|
||||
// (旧实现把 <p>正文</p> 当孤立标签跳过,整章被误判成图片章清空)。
|
||||
func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
|
||||
content := "<p>第一段正文</p>\n" +
|
||||
"<p>第二段正文</p>\n" +
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text(正文不能被当成图片章)", out.Type)
|
||||
}
|
||||
if !strings.Contains(out.Content, "第一段正文") || !strings.Contains(out.Content, "第二段正文") {
|
||||
t.Fatalf("正文文字丢失:%q", out.Content)
|
||||
}
|
||||
if !strings.HasPrefix(out.Content, "第一段正文\n第二段正文\n"+imgMarkerPrefix) {
|
||||
t.Fatalf("正文结构不符:%q", out.Content)
|
||||
}
|
||||
if len(out.Images) != 0 {
|
||||
t.Fatalf("文本类型的 Images 应为空,得到 %v", out.Images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetContentForBookStripsBlockTagsKeepsText 回归问题 B:段评开启时正文是
|
||||
// <p>正文<img …,{click}> 形态,下发/显示的文本里不能残留块级标签,且文字一字不少。
|
||||
func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
|
||||
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
|
||||
content := "<p>第一段正文</p>\n" +
|
||||
"<p>第二段正文<img src=\"" + src + "\"></p>\n" +
|
||||
"<p>第三段<br>折行后的文字</p>\n" +
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text", out.Type)
|
||||
}
|
||||
t.Logf("最终下发/显示的正文 = %q", out.Content)
|
||||
t.Logf("段评锚点 = %+v", out.Comments)
|
||||
for _, tag := range []string{"<p>", "</p>", "<br>", "<img", "showCmt"} {
|
||||
if strings.Contains(out.Content, tag) {
|
||||
t.Fatalf("显示文本里残留了 %q:%q", tag, out.Content)
|
||||
}
|
||||
}
|
||||
// 正文文字一字不少(含 <br> 折出来的那一段)。
|
||||
for _, want := range []string{"第一段正文", "第二段正文", "折行后的文字"} {
|
||||
if !strings.Contains(out.Content, want) {
|
||||
t.Fatalf("正文文字丢失 %q:%q", want, out.Content)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out.Content, imgMarkerPrefix) {
|
||||
t.Fatalf("普通插图应保留成 %s 标记:%q", imgMarkerPrefix, out.Content)
|
||||
}
|
||||
if len(out.Comments) != 1 {
|
||||
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
|
||||
}
|
||||
if out.Comments[0].URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("段评地址不符:%+v", out.Comments[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。
|
||||
//
|
||||
// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里
|
||||
// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的
|
||||
// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档,
|
||||
// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。
|
||||
|
||||
// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302,
|
||||
// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」
|
||||
// 能被分开断言。
|
||||
func cookieCaptureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, "/done", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>面板</body></html>`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie
|
||||
// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。
|
||||
func TestExecuteCapturesRedirectHopCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got)
|
||||
}
|
||||
// 最终响应的 Cookie 按该跳地址归档。
|
||||
if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") {
|
||||
t.Fatalf("最终响应的 Cookie 没有被收下: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时
|
||||
// 主链路不应自动累积 Cookie。
|
||||
func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
if got := state.GetCookie(srv.URL); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经
|
||||
// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。
|
||||
// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。
|
||||
func TestProxyBrowserXHRCapturesCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceURL = "https://api.example.com"
|
||||
const sourceID = "panel-source-1"
|
||||
state := svc.newSourceState(ctx, sourceURL)
|
||||
|
||||
// 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。
|
||||
if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{state: state},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
|
||||
// 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。
|
||||
if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板请求的凭证没有写回会话: %q", got)
|
||||
}
|
||||
|
||||
// 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。
|
||||
state.flush()
|
||||
reloaded := svc.newSourceState(ctx, sourceURL)
|
||||
if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板拿到的凭证没有落库: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板)
|
||||
// 代理请求照常工作,只是不做 Cookie 累积。
|
||||
func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceID = "panel-source-2"
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求应照常成功: %v", err)
|
||||
}
|
||||
if res.Status != http.StatusOK {
|
||||
t.Fatalf("代理状态 = %d", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。
|
||||
func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil {
|
||||
t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie")
|
||||
}
|
||||
if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st {
|
||||
t.Fatal("开启时应把会话交给面板回写 Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的,
|
||||
// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。
|
||||
func TestSourceStateCookieConcurrency(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
url := "https://example.com/"
|
||||
st.SetCookie(url, "k=1")
|
||||
_ = st.GetCookie(url)
|
||||
_ = st.snapshotCookies()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") {
|
||||
t.Fatalf("并发写入后应能读到 Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:把响应下发的 Set-Cookie 收进书源会话。
|
||||
//
|
||||
// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。
|
||||
// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带
|
||||
// Set-Cookie,再跳到业务域名)这三个做法都会漏:
|
||||
//
|
||||
// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到;
|
||||
// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域
|
||||
// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶,
|
||||
// 之后 api.bilibili.com 的请求取不到这份 Cookie;
|
||||
// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库,
|
||||
// 页面在面板里「登录成功」但书源会话始终是空的。
|
||||
//
|
||||
// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie,
|
||||
// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。
|
||||
|
||||
// capturedCookie 一条待写入会话的 Cookie 及其归属地址。
|
||||
type capturedCookie struct {
|
||||
// url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点),
|
||||
// 否则用下发它的那一跳的请求地址。
|
||||
url string
|
||||
cookie string
|
||||
}
|
||||
|
||||
// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。
|
||||
// 并发安全:面板里的多个资源/接口请求会同时走这里。
|
||||
type cookieSink struct {
|
||||
mu sync.Mutex
|
||||
items []capturedCookie
|
||||
}
|
||||
|
||||
// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。
|
||||
func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) {
|
||||
if s == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
items := make([]capturedCookie, 0, len(resp.Cookies()))
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck == nil || ck.Name == "" {
|
||||
continue
|
||||
}
|
||||
target := ""
|
||||
if d := strings.TrimSpace(ck.Domain); d != "" {
|
||||
target = strings.TrimPrefix(d, ".")
|
||||
} else if reqURL != nil {
|
||||
target = reqURL.String()
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items = append(s.items, items...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain 取出并清空已收集的 Cookie。
|
||||
func (s *cookieSink) drain() []capturedCookie {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.items
|
||||
s.items = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。
|
||||
func (s *cookieSink) apply(state *sourceState) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
items := s.drain()
|
||||
if state == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
state.SetCookie(it.url, it.cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ──────────────
|
||||
|
||||
type cookieSinkKey struct{}
|
||||
|
||||
// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。
|
||||
func withCookieSink(ctx context.Context, sink *cookieSink) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, cookieSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。
|
||||
func cookieSinkFrom(ctx context.Context) *cookieSink {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink)
|
||||
return sink
|
||||
}
|
||||
|
||||
// captureRedirectCookies 作为 http.Client.CheckRedirect:
|
||||
// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。
|
||||
//
|
||||
// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致
|
||||
// (10 次),所以装上它不改变既有链路的行为。
|
||||
func captureRedirectCookies(req *http.Request, via []*http.Request) error {
|
||||
if req != nil {
|
||||
if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil {
|
||||
// via 的最后一跳才是产生 req.Response 的那个请求。
|
||||
prev := req
|
||||
if n := len(via); n > 0 {
|
||||
prev = via[n-1]
|
||||
}
|
||||
sink.add(prev.URL, req.Response)
|
||||
}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。
|
||||
// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。
|
||||
func enableCookieCapture(c *http.Client) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.CheckRedirect = captureRedirectCookies
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 图片解密(coverDecodeJs / ruleContent.imageDecode)与 HLS 判定链路。
|
||||
|
||||
// 正文图片解密:书源规则把字节异或 0x55 后返回,服务端解密应还原原文。
|
||||
func TestDecodeImageBytesRunsRuleJS(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 动态给书源加上 imageDecode 规则:XOR 0x55 还原。
|
||||
raw := strings.Replace(
|
||||
cacheTestSourceJSON(srv.URL),
|
||||
`"ruleContent": {"content": "id.content@textNodes"}`,
|
||||
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x55; } out"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
rule := svc.ImageDecodeRule(ctx, book)
|
||||
if rule == "" {
|
||||
t.Fatal("imageDecode 规则未解析")
|
||||
}
|
||||
|
||||
plain := []byte("PNG-PLAIN-BYTES")
|
||||
encoded := make([]byte, len(plain))
|
||||
for i, b := range plain {
|
||||
encoded[i] = b ^ 0x55
|
||||
}
|
||||
decoded, err := svc.DecodeImageBytes(ctx, book, "https://img.example.com/a.png", false, encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
if string(decoded) != string(plain) {
|
||||
t.Fatalf("解密结果 = %q,期望 %q", decoded, plain)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有规则时零开销直通(返回原始字节)。
|
||||
func TestDecodeImageBytesPassthroughWithoutRule(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
data := []byte("raw-image-bytes")
|
||||
out, err := svc.DecodeImageBytes(t.Context(), book, srv.URL+"/a.png", false, data)
|
||||
if err != nil {
|
||||
t.Fatalf("直通路径不应报错: %v", err)
|
||||
}
|
||||
if string(out) != string(data) {
|
||||
t.Fatalf("无规则时应原样返回: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密脚本抛错时退回原始字节(保证至少还能看到图)。
|
||||
func TestDecodeImageBytesFallsBackOnBadRule(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
raw := strings.Replace(
|
||||
cacheTestSourceJSON(srv.URL),
|
||||
`"ruleContent": {"content": "id.content@textNodes"}`,
|
||||
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "throw new Error('bad rule')"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
data := []byte("still-an-image")
|
||||
out, err := svc.DecodeImageBytes(ctx, book, srv.URL+"/a.png", false, data)
|
||||
if err != nil {
|
||||
t.Fatalf("坏规则应降级而不是报错: %v", err)
|
||||
}
|
||||
if string(out) != string(data) {
|
||||
t.Fatalf("坏规则应返回原始字节: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 封面解密:按书源 URL(搜索结果尚未入库)执行 coverDecodeJs。
|
||||
func TestDecodeCoverBytes(t *testing.T) {
|
||||
svc, srv, _ := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
raw := strings.Replace(
|
||||
cacheTestSourceJSON(srv.URL),
|
||||
`"bookSourceType": 0,`,
|
||||
`"bookSourceType": 0, "coverDecodeJs": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x33; } out",`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if !svc.SourceHasCoverDecode(srv.URL) {
|
||||
t.Fatal("应识别出该源声明了封面解密")
|
||||
}
|
||||
plain := []byte("cover-bytes")
|
||||
encoded := make([]byte, len(plain))
|
||||
for i, b := range plain {
|
||||
encoded[i] = b ^ 0x33
|
||||
}
|
||||
out := svc.DecodeCoverBytes(ctx, srv.URL, srv.URL+"/cover.jpg", encoded)
|
||||
if string(out) != string(plain) {
|
||||
t.Fatalf("封面解密结果 = %q,期望 %q", out, plain)
|
||||
}
|
||||
|
||||
// 未声明解密的源:封面地址保持原样(不签代理)。
|
||||
plainURL := svc.RewriteBookCover(ctx, "", srv.URL+"/x", "https://img.example.com/c.jpg")
|
||||
if !strings.HasPrefix(plainURL, "https://img.example.com/") {
|
||||
t.Fatalf("无解密规则时不应改写封面: %q", plainURL)
|
||||
}
|
||||
// 声明了解密的源:封面走签名代理。
|
||||
signed := svc.RewriteBookCover(ctx, "", srv.URL, "https://img.example.com/c.jpg")
|
||||
if !strings.Contains(signed, "/api/reader/media?") || !strings.Contains(signed, "d=cover") {
|
||||
t.Fatalf("封面应走解密代理: %q", signed)
|
||||
}
|
||||
}
|
||||
|
||||
// HLS 播放列表嗅探:#EXTM3U 标记优先于 Content-Type。
|
||||
func TestReaderBodyIsPlaylist(t *testing.T) {
|
||||
playlist := []byte("#EXTM3U\n#EXT-X-VERSION:3\n")
|
||||
if !BodyIsPlaylist(playlist, "application/octet-stream") {
|
||||
t.Fatal("以 #EXTM3U 开头应判为播放列表")
|
||||
}
|
||||
if BodyIsPlaylist([]byte("\x89PNG\r\n"), "image/png") {
|
||||
t.Fatal("二进制图片不应判为播放列表")
|
||||
}
|
||||
if !BodyIsPlaylist([]byte("\n#EXTM3U\n"), "application/vnd.apple.mpegurl") {
|
||||
t.Fatal("带前置空行且 Content-Type 为 mpegurl 时应判为播放列表")
|
||||
}
|
||||
if BodyIsPlaylist(nil, "application/vnd.apple.mpegurl") {
|
||||
t.Fatal("空体不应判为播放列表")
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,11 @@ func parseLocalBookPayload(name string, data []byte) (*localBookPayload, error)
|
||||
return nil, fmt.Errorf("文件内容为空或无法解码")
|
||||
}
|
||||
out.ext, out.charset = ".txt", charset
|
||||
// 章节区间是按「解码后的 UTF-8 文本」算的字节下标,托管副本就必须落盘这份
|
||||
// UTF-8 文本:原始字节若是 GBK/Big5/UTF-16,按 UTF-8 区间去读会整章错位。
|
||||
// 外部原地引用不能改写源文件(见 importLocalBook 的 externalPath 分支),
|
||||
// 由读取侧按 charset 整文件解码后再切片。
|
||||
out.payload = []byte(decoded)
|
||||
for i, c := range splitTXTChapters(decoded) {
|
||||
out.chaps = append(out.chaps, model.ReaderChapter{
|
||||
Index: i, Title: c.Title, Tag: fmt.Sprintf("%d:%d", c.Start, c.End),
|
||||
@@ -275,11 +280,21 @@ func (s *ReaderService) ImportLocalBook(
|
||||
ext: parsed.ext,
|
||||
charset: parsed.charset,
|
||||
chaps: parsed.chaps,
|
||||
payload: data,
|
||||
payload: managedPayload(parsed, data),
|
||||
coverEntry: parsed.coverEntry,
|
||||
})
|
||||
}
|
||||
|
||||
// managedPayload 托管副本实际落盘的字节。
|
||||
//
|
||||
// TXT 走 out.payload(已解码的 UTF-8,与章节下标的基准一致);EPUB 原样落盘。
|
||||
func managedPayload(parsed *localBookPayload, raw []byte) []byte {
|
||||
if parsed != nil && len(parsed.payload) > 0 {
|
||||
return parsed.payload
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ImportLocalBookFromPath 从服务器上已有的文件导入书籍(TXT / EPUB)。
|
||||
//
|
||||
// 原地引用,不复制到 data/reader/local;同一个文件重复导入按覆盖更新处理,
|
||||
@@ -501,6 +516,11 @@ func (s *ReaderService) LocalChapterContent(
|
||||
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
|
||||
text = s.rewriteLocalImages(book.ID, text)
|
||||
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
|
||||
// 图片版 EPUB(整章都是 [img] 标记)同样是漫画:按图片类型下发,
|
||||
// 前端走漫画阅读器,才有整页缩放与双页铺开。
|
||||
if imgs, ok := imageMarkersOnly(text); ok {
|
||||
return &ChapterContent{Type: "image", Images: imgs, declaredType: -1}, nil
|
||||
}
|
||||
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
|
||||
}
|
||||
|
||||
@@ -585,14 +605,20 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader
|
||||
if !ok {
|
||||
return "", fmt.Errorf("章节定位信息损坏,请重新导入该书")
|
||||
}
|
||||
if end <= start {
|
||||
return "", nil
|
||||
}
|
||||
// 外部原地引用的非 UTF-8 文件:章节区间是按导入时解码出的 UTF-8 文本算的,
|
||||
// 必须整文件解码后再切片,直接读原始字节会错位(GBK 一字 2 字节、UTF-16 更多)。
|
||||
// 托管副本在导入时已统一落盘 UTF-8,这里不会走这条分支。
|
||||
if book.LocalExternal && needsDecodeBeforeSlice(book.Charset) {
|
||||
return readLocalChapterDecoded(path, start, end)
|
||||
}
|
||||
f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if end <= start {
|
||||
return "", nil
|
||||
}
|
||||
buf := make([]byte, end-start)
|
||||
if _, err := f.ReadAt(buf, int64(start)); err != nil && err != io.EOF {
|
||||
return "", fmt.Errorf("读取章节失败: %w", err)
|
||||
@@ -600,6 +626,36 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader
|
||||
return strings.TrimSpace(string(buf)), nil
|
||||
}
|
||||
|
||||
// needsDecodeBeforeSlice 该字符集的文件是否需要在切片前整文件解码。
|
||||
func needsDecodeBeforeSlice(charset string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(charset)) {
|
||||
case "", "utf-8", "utf8":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readLocalChapterDecoded 整文件解码为 UTF-8 后按字节区间取章节(外部引用模式)。
|
||||
func readLocalChapterDecoded(path string, start, end int) (string, error) {
|
||||
f, err := os.Open(path) // #nosec G304 -- path 来自书籍记录,导入时已校验允许根目录
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, LocalBookMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取章节失败: %w", err)
|
||||
}
|
||||
decoded, _ := decodeTextFile(data)
|
||||
if start > len(decoded) {
|
||||
return "", nil
|
||||
}
|
||||
if end > len(decoded) {
|
||||
end = len(decoded)
|
||||
}
|
||||
return strings.TrimSpace(decoded[start:end]), nil
|
||||
}
|
||||
|
||||
// DeleteLocalBookFile 移出书架时删除本地文件(网络书籍无文件,直接返回)。
|
||||
//
|
||||
// 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
+1155
-94
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,463 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件实现阅读正文的持久缓存(对应 legado BookHelp 的章节正文缓存 + CacheBook)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后,
|
||||
// 用户替换规则、签名代理改写之前)。因此同一本书多用户共享同一份缓存,
|
||||
// 用户替换规则在读出后逐请求应用,规则改动即时生效。
|
||||
// - 索引落库(ReaderContentCache),内容落盘(cache_dir/reader-content/<origin>/<bookKey>/<key>.json)。
|
||||
// - 缓存键只包含书源身份、章节身份、内容类型、书源指纹与格式版本:不含书籍行 ID
|
||||
// (目录刷新会重建行)、不含用户(用户维度在读出后处理)。
|
||||
|
||||
// readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记)
|
||||
// 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。
|
||||
const readerContentFormatVersion = 1
|
||||
|
||||
// readerContentMaxEntryBytes 单条缓存的字节上限(超过不缓存,避免超大章节占满盘)。
|
||||
const readerContentMaxEntryBytes = 16 << 20
|
||||
|
||||
// audioCacheTTL 音频清单的短 TTL:CDN 直链常带签名/过期参数,缓存太久会拿到失效地址。
|
||||
const audioCacheTTL = 30 * time.Minute
|
||||
|
||||
// cachedChapterContent 落盘的载荷。
|
||||
type cachedChapterContent struct {
|
||||
FormatVersion int `json:"v"`
|
||||
SourceHash string `json:"src"`
|
||||
ContentType string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tracks []string `json:"tracks,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ImageStyle string `json:"image_style,omitempty"`
|
||||
IsHLS bool `json:"hls,omitempty"`
|
||||
Comments []ContentComment `json:"comments,omitempty"`
|
||||
SavedAt int64 `json:"saved_at"`
|
||||
}
|
||||
|
||||
// contentFlight 一次进行中的正文抓取,并发的调用方共享结果(读穿透单飞)。
|
||||
type contentFlight struct {
|
||||
done chan struct{}
|
||||
data *ChapterContent
|
||||
err error
|
||||
}
|
||||
|
||||
// ─── 键与身份 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// contentBookKey 书源身份哈希:origin + 书本地址。
|
||||
// 用「文件地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞在一起。
|
||||
func contentBookKey(origin, bookURL string) string {
|
||||
sum := sha256.Sum256([]byte(origin + "\x00" + strings.TrimSpace(bookURL)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// contentChapterIdentity 章节身份原文:绝对化 URL 优先,卷/空地址退化为标题。
|
||||
func contentChapterIdentity(book *model.ReaderBook, ch model.ReaderChapter) string {
|
||||
if ch.URL != "" && !ch.IsVolume {
|
||||
base := book.BookURL
|
||||
if strings.TrimSpace(book.TocURL) != "" {
|
||||
base = book.TocURL
|
||||
}
|
||||
if abs := rule.GetAbsoluteURL(base, ch.URL); abs != "" {
|
||||
return "url|" + abs
|
||||
}
|
||||
return "url|" + strings.TrimSpace(ch.URL)
|
||||
}
|
||||
return "title|" + strings.TrimSpace(ch.Title)
|
||||
}
|
||||
|
||||
// contentChapterKey 章节身份哈希。
|
||||
func contentChapterKey(book *model.ReaderBook, ch model.ReaderChapter) string {
|
||||
sum := sha256.Sum256([]byte(contentChapterIdentity(book, ch)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// contentSourceHash 书源内容指纹:RawJSON 哈希(书源更新后旧缓存自然失效)。
|
||||
func contentSourceHash(src *model.ReaderBookSource) string {
|
||||
if src == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(src.RawJSON))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// ─── 磁盘布局 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// readerContentDir 正文缓存根目录。
|
||||
func (s *ReaderService) readerContentDir() string {
|
||||
if s == nil || s.cfg == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
|
||||
if base == "" {
|
||||
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
|
||||
base = filepath.Join(dataDir, "cache")
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, "reader-content")
|
||||
}
|
||||
|
||||
// contentFilePath 单条缓存的磁盘路径:<root>/<originHash>/<bookKey>/<chapterKey>.json。
|
||||
// chapterKey 已是 hex 哈希,不含路径分隔符。
|
||||
func (s *ReaderService) contentFilePath(origin, bookURL, chapterKey string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
originHash := contentBookKey(origin, "")
|
||||
return filepath.Join(root, originHash, contentBookKey(origin, bookURL), chapterKey+".json")
|
||||
}
|
||||
|
||||
// contentBookDir 某本书的缓存目录(整本清理用)。
|
||||
func (s *ReaderService) contentBookDir(origin, bookURL string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, contentBookKey(origin, ""), contentBookKey(origin, bookURL))
|
||||
}
|
||||
|
||||
// writeContentFile 原子写入缓存文件(临时文件 + rename)。
|
||||
func writeContentFile(path string, payload []byte) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("缓存目录未配置")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".content-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeContentFile 删除缓存文件(不存在视为成功)。
|
||||
func removeContentFile(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// ─── 读写 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// loadCachedContent 读取一章节的缓存:命中返回内容与 true。
|
||||
//
|
||||
// contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时
|
||||
// 用于带类型校验的探测(如音频 TTL 的测试与诊断)。
|
||||
//
|
||||
// 校验链:索引存在 → 格式版本一致 → 书源指纹一致 → 未过期 → 载荷可解析。
|
||||
func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, book *model.ReaderBook, ch model.ReaderChapter, contentType string) (*ChapterContent, bool) {
|
||||
if s == nil || s.repo == nil || book == nil {
|
||||
return nil, false
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
chapterKey := contentChapterKey(book, ch)
|
||||
row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType)
|
||||
if err != nil || row == nil {
|
||||
return nil, false
|
||||
}
|
||||
if row.FormatVersion != readerContentFormatVersion {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
if row.SourceHash != "" && row.SourceHash != contentSourceHash(src) {
|
||||
// 书源更新过:旧正文可能已失效,删掉重抓。
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if row.ExpiresAt > 0 && row.ExpiresAt < now {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, chapterKey)
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
var payload cachedChapterContent
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.FormatVersion != readerContentFormatVersion {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
// 命中:刷新 LRU 时间与计数(异步失败不影响读取)。
|
||||
_ = s.repo.TouchContentCache(ctx, row.ID, row.Hits+1, now)
|
||||
|
||||
out := &ChapterContent{
|
||||
Type: payload.ContentType,
|
||||
Content: payload.Content,
|
||||
Tracks: payload.Tracks,
|
||||
Images: payload.Images,
|
||||
ImageStyle: payload.ImageStyle,
|
||||
IsHLS: payload.IsHLS,
|
||||
Comments: payload.Comments,
|
||||
declaredType: -1,
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// saveCachedContent 写入一章节的缓存(空内容不写,与报错语义保持一致)。
|
||||
func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.ReaderBookSource, book *model.ReaderBook, ch model.ReaderChapter, out *ChapterContent) {
|
||||
if s == nil || s.repo == nil || book == nil || out == nil || chapterContentEmpty(out) {
|
||||
return
|
||||
}
|
||||
payload := cachedChapterContent{
|
||||
FormatVersion: readerContentFormatVersion,
|
||||
SourceHash: contentSourceHash(src),
|
||||
ContentType: out.Type,
|
||||
Content: out.Content,
|
||||
Tracks: out.Tracks,
|
||||
Images: out.Images,
|
||||
ImageStyle: out.ImageStyle,
|
||||
IsHLS: out.IsHLS,
|
||||
Comments: out.Comments,
|
||||
SavedAt: time.Now().Unix(),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) > readerContentMaxEntryBytes {
|
||||
return
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, ch))
|
||||
if err := writeContentFile(path, raw); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("reader: 写入正文缓存失败", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
row := &model.ReaderContentCache{
|
||||
OriginHash: contentBookKey(book.Origin, ""),
|
||||
BookKey: contentBookKey(book.Origin, book.BookURL),
|
||||
ChapterKey: contentChapterKey(book, ch),
|
||||
ChapterIdentity: contentChapterIdentity(book, ch),
|
||||
ChapterIndex: ch.Index,
|
||||
ContentType: out.Type,
|
||||
SourceHash: payload.SourceHash,
|
||||
FormatVersion: readerContentFormatVersion,
|
||||
SizeBytes: int64(len(raw)),
|
||||
AssetCount: len(out.Tracks) + len(out.Images),
|
||||
ExpiresAt: s.contentExpiry(out.Type, now),
|
||||
LastAccessAt: now,
|
||||
}
|
||||
if err := s.repo.UpsertContentCache(ctx, row); err != nil && s.log != nil {
|
||||
s.log.Warn("reader: 写入正文缓存索引失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// contentExpiry 按内容类型给出过期时间:音频清单短 TTL(直链会过期),文本/图片用配置的 TTL。
|
||||
func (s *ReaderService) contentExpiry(contentType string, now int64) int64 {
|
||||
if contentType == "audio" {
|
||||
return now + int64(audioCacheTTL.Seconds())
|
||||
}
|
||||
ttlHours := 0
|
||||
if s != nil && s.cfg != nil {
|
||||
ttlHours = s.cfg.Cache.ReaderContentTTLHours
|
||||
}
|
||||
if ttlHours <= 0 {
|
||||
return 0
|
||||
}
|
||||
return now + int64(ttlHours)*3600
|
||||
}
|
||||
|
||||
// dropContentCacheRow 删除索引并清理磁盘文件。
|
||||
func (s *ReaderService) dropContentCacheRow(ctx context.Context, row *model.ReaderContentCache) {
|
||||
if s == nil || s.repo == nil || row == nil {
|
||||
return
|
||||
}
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
path := s.contentPathFromRow(row)
|
||||
removeContentFile(path)
|
||||
if path != "" {
|
||||
s.pruneContentDirIfEmpty(filepath.Dir(path))
|
||||
}
|
||||
}
|
||||
|
||||
// pruneContentDirIfEmpty 删掉空目录(缓存清理后不留空壳)。
|
||||
func (s *ReaderService) pruneContentDirIfEmpty(dir string) {
|
||||
if dir == "" || dir == s.readerContentDir() {
|
||||
return
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
|
||||
_ = os.Remove(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearContentCacheForBook 清理一本书的全部正文缓存(换源/移出书架时调用)。
|
||||
// 同源同书可能被多个用户收藏:仍被引用时不删(见 DeleteBook/ClearBookOrigin)。
|
||||
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, origin, bookURL string) {
|
||||
if s == nil || s.repo == nil || origin == "" || bookURL == "" {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(origin, bookURL)
|
||||
if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil {
|
||||
return
|
||||
}
|
||||
if dir := s.contentBookDir(origin, bookURL); dir != "" {
|
||||
_ = os.RemoveAll(dir)
|
||||
s.pruneContentDirIfEmpty(filepath.Dir(dir))
|
||||
}
|
||||
}
|
||||
|
||||
// PruneContentCache 清理阅读正文缓存(TTL 过期 + 容量 LRU),供调度器按小时调用。
|
||||
func (s *ReaderService) PruneContentCache(ctx context.Context) {
|
||||
s.pruneReaderContentCache(ctx)
|
||||
}
|
||||
|
||||
// pruneReaderContentCache 容量/TTL 淘汰:过期条目直接删,超配额按 LRU 删到 90%。
|
||||
func (s *ReaderService) pruneReaderContentCache(ctx context.Context) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if expired, err := s.repo.ListContentCacheExpired(ctx, now, 500); err == nil {
|
||||
for i := range expired {
|
||||
row := expired[i]
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
removeContentFile(s.contentPathFromRow(&row))
|
||||
}
|
||||
}
|
||||
maxMB := 0
|
||||
if s.cfg != nil {
|
||||
maxMB = s.cfg.Cache.ReaderContentMaxSizeMB
|
||||
}
|
||||
if maxMB <= 0 {
|
||||
return
|
||||
}
|
||||
_, totalBytes, err := s.repo.ContentCacheStats(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
limit := int64(maxMB) << 20
|
||||
if totalBytes <= limit {
|
||||
return
|
||||
}
|
||||
target := limit * 9 / 10
|
||||
oldest, err := s.repo.ListContentCacheOldest(ctx, 500)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range oldest {
|
||||
if totalBytes <= target {
|
||||
break
|
||||
}
|
||||
row := oldest[i]
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
removeContentFile(s.contentPathFromRow(&row))
|
||||
totalBytes -= row.SizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
// contentPathFromRow 由索引行拼磁盘路径(索引存了 origin_hash / book_key / chapter_key)。
|
||||
func (s *ReaderService) contentPathFromRow(row *model.ReaderContentCache) string {
|
||||
if s == nil || row == nil {
|
||||
return ""
|
||||
}
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, row.OriginHash, row.BookKey, row.ChapterKey+".json")
|
||||
}
|
||||
|
||||
// ─── 目录刷新后的缓存迁移(对应 legado BookHelp.remapContentCache) ─────────
|
||||
|
||||
// RemapContentCacheOnTocChange 目录刷新后迁移缓存键:同一章按绝对化 URL 命中、
|
||||
// 标题唯一命中兜底。不迁移的话每次「更新目录」都会让已缓存正文全部冷启动。
|
||||
func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *model.ReaderBook, oldChapters, newChapters []model.ReaderChapter) {
|
||||
if s == nil || s.repo == nil || book == nil || len(oldChapters) == 0 || len(newChapters) == 0 {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
// 旧身份 → 新章节。
|
||||
byURL := map[string]model.ReaderChapter{}
|
||||
byTitle := map[string][]model.ReaderChapter{}
|
||||
for _, ch := range newChapters {
|
||||
if ch.URL != "" && !ch.IsVolume {
|
||||
base := book.BookURL
|
||||
if strings.TrimSpace(book.TocURL) != "" {
|
||||
base = book.TocURL
|
||||
}
|
||||
abs := rule.GetAbsoluteURL(base, ch.URL)
|
||||
if abs == "" {
|
||||
abs = strings.TrimSpace(ch.URL)
|
||||
}
|
||||
byURL["url|"+abs] = ch
|
||||
}
|
||||
if t := strings.TrimSpace(ch.Title); t != "" {
|
||||
byTitle["title|"+t] = append(byTitle["title|"+t], ch)
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
var target model.ReaderChapter
|
||||
found := false
|
||||
if ch, ok := byURL[row.ChapterIdentity]; ok {
|
||||
target, found = ch, true
|
||||
} else if matches := byTitle[row.ChapterIdentity]; len(matches) == 1 {
|
||||
// 标题唯一命中才迁移:多个同标题章节时无法确定是哪一章,宁可重抓。
|
||||
target, found = matches[0], true
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
newKey := contentChapterKey(book, target)
|
||||
if newKey == row.ChapterKey {
|
||||
continue // 身份未变,只更新序号
|
||||
}
|
||||
oldPath := s.contentPathFromRow(&row)
|
||||
newPath := s.contentFilePath(book.Origin, book.BookURL, newKey)
|
||||
if oldPath != "" && newPath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil {
|
||||
// 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。
|
||||
_ = os.Rename(oldPath, newPath)
|
||||
}
|
||||
}
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
row.ID = ""
|
||||
row.ChapterKey = newKey
|
||||
row.ChapterIdentity = contentChapterIdentity(book, target)
|
||||
row.ChapterIndex = target.Index
|
||||
row.LastAccessAt = time.Now().Unix()
|
||||
_ = s.repo.UpsertContentCache(ctx, &row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 正文持久缓存(reader_content_cache.go)的端到端测试:
|
||||
// 抓取 → 写缓存 → 再次读取命中且不再访问书源 → 批量接口统计正确。
|
||||
|
||||
// cacheTestServer 每章返回固定正文并统计各章请求次数。
|
||||
type cacheTestServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
hits map[string]int
|
||||
body func(chapter int) string
|
||||
failOn map[int]bool
|
||||
}
|
||||
|
||||
func newCacheTestServer() *cacheTestServer {
|
||||
s := &cacheTestServer{hits: map[string]int{}, failOn: map[int]bool{}}
|
||||
s.body = func(chapter int) string { return fmt.Sprintf("第%d章的正文内容。", chapter) }
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
_, _ = w.Write([]byte(cacheTestTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/c"):
|
||||
chapter := 0
|
||||
_, _ = fmt.Sscanf(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/book/1/c"), ".html"), "%d", &chapter)
|
||||
s.mu.Lock()
|
||||
s.hits[fmt.Sprintf("c%d", chapter)]++
|
||||
fail := s.failOn[chapter]
|
||||
s.mu.Unlock()
|
||||
if fail {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`<html><body><div id="content">%s</div></body></html>`, s.body(chapter))))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(cacheTestBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *cacheTestServer) count(chapter int) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hits[fmt.Sprintf("c%d", chapter)]
|
||||
}
|
||||
|
||||
func cacheTestSourceJSON(server string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "缓存测试源",
|
||||
"bookSourceType": 0,
|
||||
"searchUrl": "%s/search/{{key}}/1.html",
|
||||
"ruleSearch": {"bookList": "class.item", "name": "tag.h3@text", "bookUrl": "tag.a@href"},
|
||||
"ruleBookInfo": {"name": "class.info@tag.h1@text", "tocUrl": "class.info@class.toc@href"},
|
||||
"ruleToc": {"chapterList": "class.chapters@tag.li", "chapterName": "tag.a@text", "chapterUrl": "tag.a@href"},
|
||||
"ruleContent": {"content": "id.content@textNodes"}
|
||||
}`, server, server)
|
||||
}
|
||||
|
||||
const cacheTestTocHTML = `<html><body>
|
||||
<ul class="chapters">
|
||||
<li><a href="/book/1/c1.html">第 1 章</a></li>
|
||||
<li><a href="/book/1/c2.html">第 2 章</a></li>
|
||||
<li><a href="/book/1/c3.html">第 3 章</a></li>
|
||||
</ul></body></html>`
|
||||
|
||||
const cacheTestBookInfoHTML = `<html><body>
|
||||
<div class="info"><h1>缓存之书</h1><a class="toc" href="/book/1/toc">目录</a></div>
|
||||
</body></html>`
|
||||
|
||||
// prepareCacheTestBook 建好「书源 + 书架书籍 + 目录缓存」并打开独立缓存目录。
|
||||
func prepareCacheTestBook(t *testing.T) (*ReaderService, *cacheTestServer, *model.ReaderBook) {
|
||||
t.Helper()
|
||||
srv := newCacheTestServer()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
svc.cfg.Cache.ReaderContentTTLHours = 168
|
||||
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
ctx := t.Context()
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "缓存之书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
chapters := make([]ChapterInput, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
chapters = append(chapters, ChapterInput{
|
||||
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
|
||||
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
|
||||
})
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
return svc, srv, book
|
||||
}
|
||||
|
||||
// 第一次读取抓源并写缓存;第二次直接命中持久缓存,不再访问书源。
|
||||
func TestContentCacheHitAndPersist(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(first.Content, "第1章的正文内容。") {
|
||||
t.Fatalf("正文不符合预期: %q", first.Content)
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("首次读取应访问书源 1 次,实际 %d", got)
|
||||
}
|
||||
|
||||
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("二次读取失败: %v", err)
|
||||
}
|
||||
if second.Content != first.Content {
|
||||
t.Fatalf("缓存命中的正文不一致: %q vs %q", second.Content, first.Content)
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got)
|
||||
}
|
||||
|
||||
// 缓存文件确实落盘
|
||||
path := svc.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, model.ReaderChapter{
|
||||
Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html",
|
||||
}))
|
||||
if path == "" {
|
||||
t.Fatal("缓存路径为空(cache_dir 未生效)")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("缓存文件未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录刷新后同一章(地址不变)仍应命中缓存,不因「更新目录」冷启动。
|
||||
func TestContentCacheSurvivesTocRefresh(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("读取第 2 章失败: %v", err)
|
||||
}
|
||||
|
||||
// 模拟「更新目录」:同一批章节重新落库(行 ID 会变,身份不变)。
|
||||
chapters := make([]ChapterInput, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
chapters = append(chapters, ChapterInput{
|
||||
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
|
||||
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
|
||||
})
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatalf("重写目录失败: %v", err)
|
||||
}
|
||||
// 刷新后的 remap(refreshBookToc 内的同一调用)。
|
||||
oldChapters := []model.ReaderChapter{
|
||||
{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"},
|
||||
{Index: 1, Title: "第 2 章", URL: srv.URL + "/book/1/c2.html"},
|
||||
{Index: 2, Title: "第 3 章", URL: srv.URL + "/book/1/c3.html"},
|
||||
}
|
||||
newChapters, _ := svc.repo.ListChapters(ctx, book.ID)
|
||||
svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters)
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("刷新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 1 {
|
||||
t.Fatalf("目录刷新后应命中缓存(书源请求仍为 1 次),实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发请求同一章只应打一次书源(读穿透单飞)。
|
||||
func TestContentCacheSingleFlight(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("并发读取 %d 失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("同一章并发应单飞为 1 次书源请求,实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量接口:单章失败不影响其余章节,统计 hit/miss/failed。
|
||||
func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 预热第 1 章(进缓存),第 2 章按失败处理。
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("预热失败: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = true
|
||||
srv.mu.Unlock()
|
||||
|
||||
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("批量读取失败: %v", err)
|
||||
}
|
||||
if len(result.Items) != 3 {
|
||||
t.Fatalf("应返回 3 项,实际 %d", len(result.Items))
|
||||
}
|
||||
if result.Stats.Hit != 1 {
|
||||
t.Fatalf("第 1 章应命中缓存,hit=%d", result.Stats.Hit)
|
||||
}
|
||||
if result.Stats.Failed != 1 {
|
||||
t.Fatalf("第 2 章应失败,failed=%d", result.Stats.Failed)
|
||||
}
|
||||
if result.Stats.Miss != 1 {
|
||||
t.Fatalf("第 3 章应为新抓,miss=%d", result.Stats.Miss)
|
||||
}
|
||||
if !strings.Contains(result.Items[2].Content, "第3章的正文内容。") {
|
||||
t.Fatalf("第 3 章内容异常: %+v", result.Items[2])
|
||||
}
|
||||
if result.Items[1].Error == "" {
|
||||
t.Fatalf("第 2 章应带错误: %+v", result.Items[1])
|
||||
}
|
||||
// 失败章不写缓存:去掉失败开关后重试应真正抓源。
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = false
|
||||
srv.mu.Unlock()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("恢复后读取第 2 章失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 2 {
|
||||
t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got)
|
||||
}
|
||||
// 越界/卷章节被忽略。
|
||||
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 {
|
||||
t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 书源更新(RawJSON 变化)后旧缓存失效,重新抓取。
|
||||
func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
|
||||
updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1)
|
||||
if _, err := svc.ImportSources(ctx, updated); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("更新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(1); got != 2 {
|
||||
t.Fatalf("书源更新后应重抓(2 次),实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 移出书架且无其他引用时清理缓存文件与索引。
|
||||
func TestContentCacheClearedOnRemoveBook(t *testing.T) {
|
||||
svc, _, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
dir := svc.contentBookDir(book.Origin, book.BookURL)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("缓存目录应存在: %v", err)
|
||||
}
|
||||
if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatalf("移出书架后缓存目录应被清理,err=%v", err)
|
||||
}
|
||||
if rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(book.Origin, book.BookURL)); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("缓存索引应清空: rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 音频清单使用短 TTL:过期后不命中并自动清理。
|
||||
func TestContentCacheAudioShortTTL(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}
|
||||
svc.saveCachedContent(ctx, mustSource(t, svc, srv.URL), book, ch, &ChapterContent{
|
||||
Type: "audio", Tracks: []string{srv.URL + "/a.mp3"},
|
||||
})
|
||||
row, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio")
|
||||
if err != nil {
|
||||
t.Fatalf("音频缓存未写入: %v", err)
|
||||
}
|
||||
if row.ExpiresAt == 0 {
|
||||
t.Fatal("音频缓存应带 TTL")
|
||||
}
|
||||
// 过期后读取视为未命中,并清掉条目。
|
||||
row.ExpiresAt = 1
|
||||
if err := svc.repo.UpsertContentCache(ctx, row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := svc.loadCachedContent(ctx, mustSource(t, svc, srv.URL), nil, book, ch, "audio"); ok {
|
||||
t.Fatal("过期缓存不应命中")
|
||||
}
|
||||
if _, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio"); err == nil {
|
||||
t.Fatal("过期条目应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
// mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。
|
||||
func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource {
|
||||
t.Helper()
|
||||
src, err := svc.repo.GetSourceByURL(context.Background(), sourceURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取书源失败: %v", err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// 缓存键:不同书源/书本地址/章节地址必须落在不同键上。
|
||||
func TestContentCacheKeys(t *testing.T) {
|
||||
base := &model.ReaderBook{Origin: "https://a.com", BookURL: "https://a.com/book/1", TocURL: "https://a.com/toc/1"}
|
||||
ch1 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "/book/1/c1.html"}
|
||||
ch2 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "https://a.com/book/1/c1.html"}
|
||||
// 相对与绝对地址指向同一章:绝对化后身份一致。
|
||||
if contentChapterKey(base, ch1) != contentChapterKey(base, ch2) {
|
||||
t.Fatal("同一章的相对/绝对地址应得到同一身份")
|
||||
}
|
||||
if contentBookKey("https://a.com", "https://a.com/book/1") == contentBookKey("https://b.com", "https://a.com/book/1") {
|
||||
t.Fatal("不同书源的同一地址不应共享键")
|
||||
}
|
||||
if contentChapterIdentity(base, model.ReaderChapter{Index: 0, Title: "卷名", IsVolume: true}) != "title|卷名" {
|
||||
t.Fatal("卷章节应退化为标题身份")
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存目录不可用时(未配置)不应影响正文读取。
|
||||
func TestContentCacheWithoutCacheDir(t *testing.T) {
|
||||
srv := newCacheTestServer()
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = ""
|
||||
svc.cfg.App.DataDir = filepath.Join(t.TempDir(), "missing", "data")
|
||||
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
|
||||
ctx := t.Context()
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "缓存之书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("无缓存目录时读取失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.Content, "第1章") {
|
||||
t.Fatalf("正文异常: %q", out.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:拷贝系列轻小说源的端到端回归。
|
||||
//
|
||||
// 合成一个「照搬拷贝轻小说写法」的书源,一次覆盖四个修复点:
|
||||
// 1. 搜索列表规则 `results.list.[*]`(legado 的 `.[*]` 形式);
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()/ArrayList()` 拼装;
|
||||
// 3. 正文规则用 `java.cacheFile(url)` 取整卷文本;
|
||||
// 4. 正文是 GBK 文本——必须走 Raw 原始字节通道落盘,否则会被 UTF-8 解码成乱码。
|
||||
|
||||
func TestCopyLightNovelSourceEndToEnd(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
gbkBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("第一章 开始\n第二章 继续"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// 搜索:Django REST 风格的 JSON。q_type/_update 参数照抄真实源。
|
||||
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[
|
||||
{"name":"转生成为史莱姆","path_word":"slime","author":[{"name":"作者甲"}],"cover":"https://img.example.com/1.jpg"}
|
||||
]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/book/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"book":{"name":"转生成为史莱姆","path_word":"slime"}}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volumes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[{"name":"第一卷","id":7}]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volume/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"code":200,"results":{"volume":{"txt_addr":"%s/api/txt/7","txt_encoding":"GBK"}}}`, srv.URL)
|
||||
})
|
||||
mux.HandleFunc("/api/txt/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(gbkBody)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
source := map[string]any{
|
||||
"bookSourceName": "合成拷贝轻小说源",
|
||||
"bookSourceUrl": srv.URL,
|
||||
"enabled": true,
|
||||
"enabledExplore": true,
|
||||
"searchUrl": srv.URL + "/api/search?limit=20&q_type=&q={{key}}&_update=true",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "results.list.[*]",
|
||||
"name": "$.name",
|
||||
"author": "$.author[*].name",
|
||||
"coverUrl": "$.cover",
|
||||
"bookUrl": srv.URL + "/api/book/{{$.path_word}}?_update=true",
|
||||
},
|
||||
"ruleBookInfo": map[string]any{
|
||||
"init": "$.results.book",
|
||||
"name": "$.name",
|
||||
"intro": "$.brief",
|
||||
"tocUrl": srv.URL + "/api/volumes?book={{$.path_word}}&_update=true",
|
||||
},
|
||||
"ruleToc": map[string]any{
|
||||
"chapterList": `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = '` + srv.URL + `/api/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id + '?_update=true')); }
|
||||
out;</js>`,
|
||||
"chapterName": "$.name",
|
||||
"chapterUrl": "$.url",
|
||||
},
|
||||
"ruleContent": map[string]any{
|
||||
"content": `@js:java.cacheFile(java.getString('$..txt_addr'))`,
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
|
||||
res := svc.SmokeSource(t.Context(), string(raw), "史莱姆")
|
||||
if !res.OK {
|
||||
t.Fatalf("链路失败于 %s: %s\n%s", res.FailedAt, res.Error, joinLogs(res))
|
||||
}
|
||||
if res.SearchHits != 1 {
|
||||
t.Fatalf("搜索命中=%d, want 1(`.[*]` 路径修复)", res.SearchHits)
|
||||
}
|
||||
if res.Chapters != 1 {
|
||||
t.Fatalf("章节数=%d, want 1(Packages 修复)", res.Chapters)
|
||||
}
|
||||
if res.ContentLen == 0 {
|
||||
t.Fatalf("正文长度=0(cacheFile/Raw 修复未生效)\n%s", joinLogs(res))
|
||||
}
|
||||
if !strings.Contains(joinLogs(res), "第一章") {
|
||||
t.Fatalf("正文未正确解码(GBK 落盘被 utf-8 破坏)\n%s", joinLogs(res))
|
||||
}
|
||||
}
|
||||
|
||||
func joinLogs(res *SmokeChainResult) string {
|
||||
var b strings.Builder
|
||||
for _, l := range res.Logs {
|
||||
fmt.Fprintf(&b, "[%s/%s] %s\n", l.Stage, l.Level, l.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -57,6 +57,150 @@ func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageMarkersOnly 整章都是 [img] 标记时判定为图片章(前端才能走漫画阅读器、
|
||||
// 双页铺开);只要掺了能读的正文就必须保持文本,不能把文字吃掉。
|
||||
func TestImageMarkersOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantOK bool
|
||||
wantURLs []string
|
||||
}{
|
||||
{
|
||||
name: "整章都是标记",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "夹着空行 / 孤立 html 标签 / 首尾空白",
|
||||
content: "\n" + imgMarkerPrefix + "/a.jpg\n \n<div>\n</div>\n< br >\n" +
|
||||
" " + imgMarkerPrefix + " /b.jpg \n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "掺了正文就保持文本(图 + 长段落)",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + "第一句话。\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "纯文本",
|
||||
content: "第一章\n正文正文\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "空正文",
|
||||
content: "\n\n \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "标记地址为空",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + " \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 「<3」这类不是标签,是有内容的一行,不能当成空白忽略
|
||||
name: "非标签的尖括号行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 回归:<p>正文</p> 是「标签里裹着正文」,绝不能当成孤立标签跳过,
|
||||
// 否则整章正文会被误判成图片章清空。
|
||||
name: "标签里裹着正文的行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n<p>第一章正文</p>\n<p>第二章正文</p>\n",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := imageMarkersOnly(c.content)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("%s: ok = %v,期望 %v(urls=%v)", c.name, ok, c.wantOK, got)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(got) != len(c.wantURLs) {
|
||||
t.Errorf("%s: urls = %v,期望 %v", c.name, got, c.wantURLs)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.wantURLs[i] {
|
||||
t.Errorf("%s: urls[%d] = %q,期望 %q", c.name, i, got[i], c.wantURLs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHTMLTagOnly 「没有可读文字的标签行」才算空行:标签里裹着正文的行必须放行,
|
||||
// 否则 imageMarkersOnly 会把整章正文当图片丢掉。
|
||||
func TestIsHTMLTagOnly(t *testing.T) {
|
||||
blank := []string{"<div>", "</div>", "< br >", "<br/>", "<p></p>", "<hr />", "</p >", "<a> </a>", "<p>"}
|
||||
for _, s := range blank {
|
||||
if !isHTMLTagOnly(s) {
|
||||
t.Errorf("%q 应视为没有可读文字的标签行", s)
|
||||
}
|
||||
}
|
||||
text := []string{
|
||||
"<p>正文</p>", "<p>正文</p></p>", "<div>文字</div>", "<p>第一段</p><p>第二段</p>",
|
||||
"正文 <b>粗</b>", "3 < 5", "<3>", "a", "", " ",
|
||||
}
|
||||
for _, s := range text {
|
||||
if isHTMLTagOnly(s) {
|
||||
t.Errorf("%q 含可读文字/不是标签,不能当空行", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeContentBlocks 块级标签折成换行、行内标签与文字原样保留。
|
||||
func TestNormalizeContentBlocks(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"<p>第一段</p>\n<p>第二段</p>", "第一段\n第二段"},
|
||||
{"<p>第一段<br>折行文字</p>", "第一段\n折行文字"},
|
||||
{"<P CLASS='x'>大写带属性</P>", "大写带属性"},
|
||||
{"<div>块</div><br/><br />", "块"},
|
||||
// 行内插图原样保留(不能吃掉普通 <img>)
|
||||
{`<p>正文<img src="https://cdn.example.com/a.jpg"></p>`, `正文<img src="https://cdn.example.com/a.jpg">`},
|
||||
// 纯文本原样返回
|
||||
{"第一段。\n第二段。", "第一段。\n第二段。"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeContentBlocks(c.in); got != c.want {
|
||||
t.Errorf("normalizeContentBlocks(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChapterContentEmpty 判断「什么都没取到」的正文。
|
||||
//
|
||||
// 聚合类书源把所有线路试完会返回空串(光遇聚合的 request() 就是这么写的),
|
||||
// 这种空结果不能再当成功下发(前端会渲染成白页并缓存下来)。
|
||||
func TestChapterContentEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in ChapterContent
|
||||
want bool
|
||||
}{
|
||||
{"空正文", ChapterContent{Type: "text"}, true},
|
||||
{"只有空白字符", ChapterContent{Type: "text", Content: " \n\t "}, true},
|
||||
{"正常正文", ChapterContent{Type: "text", Content: "第一章 世界大变"}, false},
|
||||
// 整章正文只有一个本章说气泡时正文文字为空,但不能算抓取失败。
|
||||
{"只有段评气泡", ChapterContent{Type: "text", Comments: []ContentComment{{Line: 0, Count: 3}}}, false},
|
||||
{"音频无音轨", ChapterContent{Type: "audio"}, true},
|
||||
{"音频有音轨", ChapterContent{Type: "audio", Tracks: []string{"https://cdn.example.com/a.m4a"}}, false},
|
||||
{"漫画无图", ChapterContent{Type: "image"}, true},
|
||||
{"漫画有图", ChapterContent{Type: "image", Images: []string{"https://cdn.example.com/1.jpg"}}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := chapterContentEmpty(&c.in); got != c.want {
|
||||
t.Errorf("%s: chapterContentEmpty = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
|
||||
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
|
||||
func TestSearchCheckKeyWord(t *testing.T) {
|
||||
|
||||
@@ -213,6 +213,22 @@ func TestEndToEndSourceChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetContentForBookEmptyContentFails 书源一条内容都没返回时不能当成功下发。
|
||||
//
|
||||
// 回归:聚合类书源把七条线路全试完会返回空串(光遇聚合的 request() 就是这么写的),
|
||||
// 以前这种空正文会当成功下发,前端渲染成一张白页并缓存下来,读者只能干等。
|
||||
func TestGetContentForBookEmptyContentFails(t *testing.T) {
|
||||
svc, bookID := setupTextChapterBook(t, "")
|
||||
|
||||
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err == nil {
|
||||
t.Fatal("空正文应当报错,实际当成功下发了")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "正文为空") {
|
||||
t.Fatalf("错误信息 = %q,期望提示正文为空", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 纯函数测试:导入识别 / 搜索合并 ────────────────────────────────────────
|
||||
|
||||
func TestParseSourcePayload(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件:「更新目录」的结果分档回归测试。
|
||||
//
|
||||
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
|
||||
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
|
||||
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
|
||||
|
||||
// refreshTocForTest 跑一次「更新目录」并返回汇总。
|
||||
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
|
||||
t.Helper()
|
||||
res, err := svc.RefreshBooksToc(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("更新目录失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
|
||||
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
(*tocHandler)(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
|
||||
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
})
|
||||
srv := newRefreshTocServer(&tocHandler)
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
TocURL: srv.URL + "/book/1/toc.html",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
|
||||
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
|
||||
}
|
||||
|
||||
// 2) 站点多出一章:这才算「有新章节」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Replace(
|
||||
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
|
||||
)))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
|
||||
}
|
||||
|
||||
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
|
||||
// 这种静默失败必须记失败,不能报成「已是最新」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
|
||||
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
|
||||
}
|
||||
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
|
||||
chapters, err = svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
|
||||
}
|
||||
|
||||
// 4) 站点 404:抓取失败。
|
||||
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 {
|
||||
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
|
||||
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
|
||||
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
|
||||
for _, b := range []*model.ReaderBook{local, sourceless} {
|
||||
if err := svc.repo.CreateBook(ctx, b); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package rule
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PaesslerAG/jsonpath"
|
||||
@@ -25,14 +26,20 @@ type AnalyzeRule struct {
|
||||
chapterVars map[string]string
|
||||
bookVars map[string]string
|
||||
vars map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// sourceDefaults 书源 JSON 的 variables 字段(作者设定的默认值)。
|
||||
// 它只作为 @get 的兜底:显式保存过的书源变量与会话内 @put 的值优先级更高。
|
||||
sourceDefaults map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。
|
||||
bookMeta map[string]any
|
||||
// bookCustom 书籍自定义变量(对应 legado Book.variableMap),
|
||||
// 由规则 JS 的 book.getVariable / book.putVariable 读写。
|
||||
bookCustom map[string]string
|
||||
// bookVarPutter 由服务层注册:book.putVariable 写入后触发,
|
||||
// 用于把变量变更持久化回书架记录(对应 legado 的 Book.upVariable)。
|
||||
bookVarPutter func()
|
||||
// bookTypeOverride 书源在规则 JS 里给 book.type 赋的值
|
||||
// (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。
|
||||
bookTypeOverride *int
|
||||
@@ -119,6 +126,17 @@ func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// BookCustomVars 返回书籍自定义变量的当前值(可能被 book.putVariable 改过),
|
||||
// 服务层据此把变更写回书架记录。没有书籍上下文时为 nil。
|
||||
func (a *AnalyzeRule) BookCustomVars() map[string]string {
|
||||
return a.bookCustom
|
||||
}
|
||||
|
||||
// RegisterBookVariablePutter 注册书籍变量变更回调(book.putVariable 写入后触发)。
|
||||
func (a *AnalyzeRule) RegisterBookVariablePutter(putter func()) {
|
||||
a.bookVarPutter = putter
|
||||
}
|
||||
|
||||
// SetBookType 记录书源声明的书籍类型(legado Book.type)。
|
||||
func (a *AnalyzeRule) SetBookType(t int) {
|
||||
v := t
|
||||
@@ -153,6 +171,14 @@ func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceDefaults 注入书源 variables 字段的默认值(@get 的兜底)。
|
||||
// 显式保存过的源变量与会话内 @put 的值优先级都高于它。
|
||||
func (a *AnalyzeRule) SetSourceDefaults(vars map[string]string) {
|
||||
if len(vars) > 0 {
|
||||
a.sourceDefaults = vars
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
|
||||
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
|
||||
a.sourceGetter = getter
|
||||
@@ -171,13 +197,19 @@ func jsonpathGet(path string, root any) (v any, err error) {
|
||||
|
||||
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
|
||||
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → source。
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → 书源持久变量。
|
||||
//
|
||||
// 「书源持久变量」是 @put 在 legado 里的真实落点(BaseSource.putVariable):
|
||||
// 写进去的值跨请求可见。书源默认变量(variables 字段)与书籍自定义变量都
|
||||
// 不是这个通道,所以最后才回退到 sourcePutter,而不是写一次性的 bookVars。
|
||||
func (a *AnalyzeRule) Put(key, value string) string {
|
||||
switch {
|
||||
case a.chapterVars != nil:
|
||||
a.chapterVars[key] = value
|
||||
case a.bookVars != nil:
|
||||
a.bookVars[key] = value
|
||||
case a.sourcePutter != nil:
|
||||
a.sourcePutter(key, value)
|
||||
default:
|
||||
if a.vars == nil {
|
||||
a.vars = map[string]string{}
|
||||
@@ -188,6 +220,7 @@ func (a *AnalyzeRule) Put(key, value string) string {
|
||||
}
|
||||
|
||||
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
|
||||
// 查找顺序 chapter → book → book 变量之上的显式源变量 → 书源默认变量。
|
||||
func (a *AnalyzeRule) Get(key string) string {
|
||||
switch key {
|
||||
case "bookName":
|
||||
@@ -211,6 +244,9 @@ func (a *AnalyzeRule) Get(key string) string {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v, ok := a.sourceDefaults[key]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -450,10 +486,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
|
||||
if result == nil {
|
||||
result = ""
|
||||
}
|
||||
str := resultString(result)
|
||||
if strings.Contains(str, "&") {
|
||||
str = html.UnescapeString(str)
|
||||
}
|
||||
str := unescapeRuleEntities(resultString(result))
|
||||
if isUrl {
|
||||
if strings.TrimSpace(str) == "" {
|
||||
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
|
||||
@@ -597,6 +630,49 @@ func applyReplaceRegex(result string, r ResolvedSourceRule) string {
|
||||
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
|
||||
}
|
||||
|
||||
// entityRefRe 匹配一个 HTML 字符引用:命名实体(&、 )、十进制(')、
|
||||
// 十六进制(')。分号可选——旧式简写( 、&)在 HTML 文本里同样成立。
|
||||
var entityRefRe = regexp.MustCompile(`&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?`)
|
||||
|
||||
// unescapeRuleEntities 还原规则结果里的 HTML 实体(`&` → `&`、`'` → `'`)。
|
||||
//
|
||||
// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
|
||||
// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
|
||||
// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
|
||||
// `…?item_id=X¶=1&source=QQ阅读` 会变成 `…?item_id=X¶=1&source=QQ阅读`,
|
||||
// 上游按缺参数处理返回空页面,段评面板就只剩一片空白(见 reader/comment.go)。
|
||||
func unescapeRuleEntities(s string) string {
|
||||
if !strings.Contains(s, "&") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, loc := range entityRefRe.FindAllStringIndex(s, -1) {
|
||||
match := s[loc[0]:loc[1]]
|
||||
if !strings.HasSuffix(match, ";") && loc[1] < len(s) && isEntityStopByte(s[loc[1]]) {
|
||||
continue
|
||||
}
|
||||
decoded := html.UnescapeString(match)
|
||||
if decoded == match {
|
||||
continue // 不是已知实体,原样保留
|
||||
}
|
||||
b.WriteString(s[last:loc[0]])
|
||||
b.WriteString(decoded)
|
||||
last = loc[1]
|
||||
}
|
||||
if last == 0 {
|
||||
return s
|
||||
}
|
||||
b.WriteString(s[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isEntityStopByte 判断旧式简写实体后面这个字符是否让它失去实体语义
|
||||
// (HTML5 规定不带分号的引用后跟 = 或字母数字时按普通文本处理)。
|
||||
func isEntityStopByte(c byte) bool {
|
||||
return c == '=' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// resultString 对应 Kotlin 的 result.toString()。
|
||||
func resultString(result any) string {
|
||||
switch t := result.(type) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 ¶ 吃成
|
||||
// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
|
||||
// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
|
||||
func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "段评地址里的 ¶ 不能被当成实体",
|
||||
in: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
want: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
},
|
||||
{
|
||||
name: "¬ 开头的参数名同样不能被吃掉",
|
||||
in: "https://example.com/api?book=1¬ify=1",
|
||||
want: "https://example.com/api?book=1¬ify=1",
|
||||
},
|
||||
{
|
||||
name: "带分号的实体照旧还原",
|
||||
in: "url?a=1&b=2&c=3",
|
||||
want: "url?a=1&b=2&c=3",
|
||||
},
|
||||
{
|
||||
name: "数字实体照旧还原",
|
||||
in: "'引号' 'x' ©",
|
||||
want: "'引号' 'x' ©",
|
||||
},
|
||||
{
|
||||
name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
|
||||
in: "第一段 第二段© 2026×",
|
||||
want: "第一段\u00a0第二段© 2026×",
|
||||
},
|
||||
{
|
||||
name: "不是实体的 & 原样保留",
|
||||
in: "a & b &foo; c",
|
||||
want: "a & b &foo; c",
|
||||
},
|
||||
{
|
||||
name: "不含 & 的字符串直接放行",
|
||||
in: "魔法大陆。",
|
||||
want: "魔法大陆。",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := unescapeRuleEntities(c.in); got != c.want {
|
||||
t.Fatalf("unescapeRuleEntities(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:走 GetString 的规则结果同样不能把段评地址里的 ¶ 吃掉
|
||||
// (光遇聚合的正文规则是 <js>…</js>$.content 形态,最终取 $.content)。
|
||||
func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
|
||||
const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ¶=11&source=QQ%E9%98%85%E8%AF%BB"
|
||||
payload := `{"content":"<p>正文<comment ident=\"` + url + `\" count=\"39\" /></p>"}`
|
||||
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(payload, "https://v1.qingtian618.com")
|
||||
got, err := a.GetString("$.content", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "¶=11") {
|
||||
t.Fatalf("段评地址里的 ¶=11 被改写了: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "\u00b6") {
|
||||
t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
@@ -53,7 +55,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
body, _, _, err := r.cfg.Fetch(req)
|
||||
body, _, _, err := r.fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
if req.Unsupported != nil {
|
||||
bridgeErr(name, req.Unsupported)
|
||||
}
|
||||
respBody, finalURL, code, err := r.cfg.Fetch(req)
|
||||
respBody, finalURL, code, err := r.fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
@@ -245,6 +247,12 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
// 字节参数可能是 ArrayBuffer / typed array:这类值的 Export() 返回属性 map,
|
||||
// 只有 ExportTo 才能取回字节,旧写法会退化成 String(value) 得到 "[object ArrayBuffer]"。
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(call.Arguments[0], &buf); err == nil {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
if buf, ok := call.Arguments[0].Export().([]byte); ok {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
@@ -435,6 +443,95 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 本地文件(对应 legado JsExtensions 的缓存/文件系列) ──
|
||||
//
|
||||
// 服务端把文件限制在自己的缓存目录(<cache>/reader/files)内,
|
||||
// 书源只能用相对路径(允许以 / 开头,downloadFile 的返回值即此形态)。
|
||||
set("downloadFile", func(call goja.FunctionCall) goja.Value {
|
||||
// 旧签名 downloadFile(contentHex, url):把 hex 字符串落成文件。
|
||||
if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
url := stringArg(call, 1)
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
rel, err := writeCacheFile(r.cfg.CacheDir, cacheFileName(url), raw)
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
}
|
||||
rel, err := downloadToCache(r, stringArg(call, 0))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
})
|
||||
// cacheFile(url): 下载并缓存文本文件,返回文件**内容**(对应 legado 语义)。
|
||||
set("cacheFile", func(call goja.FunctionCall) goja.Value {
|
||||
url := stringArg(call, 0)
|
||||
if dir := readerFilesDir(r.cfg.CacheDir); dir != "" {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, cacheFileName(url))); err == nil && len(b) > 0 {
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
}
|
||||
}
|
||||
rel, err := downloadToCache(r, url)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, rel)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("readFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(b))
|
||||
})
|
||||
set("readTxtFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
if cs := stringArgOr(call, 1, ""); cs != "" {
|
||||
if s, derr := DecodeBytes(b, cs); derr == nil {
|
||||
return vm.ToValue(s)
|
||||
}
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("deleteFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
// getFile(path): 返回 File 对象(提供书源常用的 exists/length/name/delete/readText)。
|
||||
set("getFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
full = ""
|
||||
}
|
||||
return newFileObject(vm, full, stringArg(call, 0))
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
|
||||
@@ -540,13 +637,18 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||
set(name, unsupported(name, "需要压缩包解压能力"))
|
||||
}
|
||||
// importScript 在 legado 里是「下载 JS 文件并 eval」,服务端可做但会引入
|
||||
// 任意脚本执行面,暂不支持,保持明确报错。
|
||||
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
|
||||
|
||||
// 字体混淆还原(queryTTF / queryBase64TTF / replaceFont)。
|
||||
r.installFontBridge(vm, set)
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件实现 legado JsExtensions 里的「本地文件」能力
|
||||
// (cacheFile / downloadFile / readFile / readTxtFile / deleteFile / getFile)。
|
||||
//
|
||||
// 与 legado 的语义保持一致:
|
||||
// - downloadFile(url) 下载到缓存目录,返回「相对缓存根目录」的路径;
|
||||
// - cacheFile(url) 下载(带缓存)后返回文件的**文本内容**(不是路径);
|
||||
// - readFile/readTxtFile/deleteFile/getFile 接受相对路径(允许以 / 开头),
|
||||
// 一律解析到缓存目录下。
|
||||
//
|
||||
// 服务端把缓存根目录固定为 <cache>/reader/files,并且只允许访问该目录内的文件,
|
||||
// 避免书源通过 `../../` 之类读到宿主上的任意文件。
|
||||
|
||||
// filesSubdir 书源文件缓存目录(相对 ReaderService 的缓存根目录)。
|
||||
const filesSubdir = "reader/files"
|
||||
|
||||
// readerFilesDir 返回书源文件缓存目录;未配置缓存目录时返回空串。
|
||||
func readerFilesDir(cacheDir string) string {
|
||||
if strings.TrimSpace(cacheDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheDir, "reader", "files")
|
||||
}
|
||||
|
||||
// cacheFileName 计算某个 URL 在缓存目录里的文件名(对应 legado 的
|
||||
// `${md5Encode16(url)}.${type}`:md5 十六进制取中间 16 位,保留 URL 后缀)。
|
||||
func cacheFileName(rawURL string) string {
|
||||
name := md5Hex(rawURL, true)
|
||||
if ext := urlFileSuffix(rawURL); ext != "" {
|
||||
name += "." + ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// urlFileSuffix 取 URL 路径部分的后缀(不含 `.`),只接受字母数字(长度 1~8)。
|
||||
func urlFileSuffix(rawURL string) string {
|
||||
u := rawURL
|
||||
if i := strings.IndexAny(u, "?#"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if i := strings.LastIndex(u, "/"); i >= 0 {
|
||||
u = u[i+1:]
|
||||
}
|
||||
dot := strings.LastIndex(u, ".")
|
||||
if dot < 0 || dot == len(u)-1 {
|
||||
return ""
|
||||
}
|
||||
ext := u[dot+1:]
|
||||
if len(ext) > 8 {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(ext); i++ {
|
||||
c := ext[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// resolveCacheFilePath 把书源给的相对路径解析成缓存目录下的绝对路径。
|
||||
// 允许以 `/` 开头(downloadFile 的返回值就是这种形态),但拒绝越出缓存目录。
|
||||
func resolveCacheFilePath(cacheDir, p string) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("文件路径为空")
|
||||
}
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.Trim(p, `/\`)))
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("非法文件路径: %s", p)
|
||||
}
|
||||
return filepath.Join(dir, rel), nil
|
||||
}
|
||||
|
||||
// writeCacheFile 把字节写入缓存目录,返回相对路径(以 / 开头,对应 legado)。
|
||||
func writeCacheFile(cacheDir, name string, data []byte) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/" + name, nil
|
||||
}
|
||||
|
||||
// downloadToCache 下载 URL 并落到缓存目录(已存在则直接复用),
|
||||
// 返回相对缓存根目录的路径。下载走书源会话,因此会带上书源请求头与 Cookie。
|
||||
func downloadToCache(r *JSRunner, rawURL string) (string, error) {
|
||||
dir := readerFilesDir(r.cfg.CacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
name := cacheFileName(rawURL)
|
||||
full := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(full); err == nil && st.Size() > 0 {
|
||||
return "/" + name, nil
|
||||
}
|
||||
req, err := ParseAnalyzeUrlWithJS(rawURL, "", 0, r.cfg.BaseURL, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return "", req.Unsupported
|
||||
}
|
||||
// Raw:响应按原始字节返回,避免 charset 解码破坏二进制/非 UTF-8 文本。
|
||||
// 同时清掉 HexBody:URL 选项里的 `type` 在 legado 是文件后缀提示,
|
||||
// 这里不该把下载内容按 hex 字符串落盘。
|
||||
req.Raw = true
|
||||
req.HexBody = false
|
||||
body, _, code, err := r.fetch(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if code >= 400 {
|
||||
return "", fmt.Errorf("下载失败 HTTP %d: %s", code, rawURL)
|
||||
}
|
||||
return writeCacheFile(r.cfg.CacheDir, name, []byte(body))
|
||||
}
|
||||
|
||||
// decodeTextAuto 解码文本文件:UTF-8 BOM → 去 BOM;合法 UTF-8 → 原样;
|
||||
// 否则按 GBK 解(中文站点/书源最常见的另一种编码),失败再原样返回。
|
||||
func decodeTextAuto(b []byte) string {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return string(b[3:])
|
||||
}
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
if s, err := DecodeBytes(b, "gbk"); err == nil && s != "" {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// fileSuffixFromName 取路径的最后一段(getFile 的 name 语义)。
|
||||
func fileSuffixFromName(p string) string {
|
||||
return path.Base(strings.ReplaceAll(p, "\\", "/"))
|
||||
}
|
||||
|
||||
// newFileObject 构造 `java.getFile(path)` 返回的 File 等价物,
|
||||
// 提供书源常用的 exists / length / name / delete / readText。
|
||||
func newFileObject(vm *goja.Runtime, full, raw string) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) { _ = o.Set(k, v) }
|
||||
set("path", full)
|
||||
set("name", fileSuffixFromName(raw))
|
||||
set("exists", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
_, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil)
|
||||
})
|
||||
set("isDirectory", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil && st.IsDir())
|
||||
})
|
||||
set("length", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
return vm.ToValue(st.Size())
|
||||
})
|
||||
set("delete", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
set("readText", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("toString", func(goja.FunctionCall) goja.Value { return vm.ToValue(full) })
|
||||
return o
|
||||
}
|
||||
@@ -98,6 +98,10 @@ func (r *JSRunner) openBrowser(req BrowserTask) error {
|
||||
return nil
|
||||
}
|
||||
if r.cfg.Browser != nil {
|
||||
// 宿主抓取待展示的页面同样是网络等待,不能吃掉 JS 执行预算
|
||||
// (与 awaitBrowser 同一套暂停机制,见 pauseTimeout 的说明)。
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
|
||||
}
|
||||
if r.state != nil {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:java 文件接口(cacheFile / downloadFile / readTxtFile / deleteFile)
|
||||
// 的语义回归。对应 legado JsExtensions:
|
||||
// - cacheFile(url) → 返回文件**文本内容**(不是路径);
|
||||
// - downloadFile(url) → 返回相对缓存根目录的路径;
|
||||
// - readTxtFile(path[, charset]) → 返回文本;
|
||||
// - 只允许访问缓存目录内的文件。
|
||||
|
||||
func newFileTestRunner(cacheDir, baseURL string, hits *int32) *JSRunner {
|
||||
return NewJSRunner(JSConfig{
|
||||
CacheDir: cacheDir,
|
||||
BaseURL: baseURL,
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if hits != nil {
|
||||
atomic.AddInt32(hits, 1)
|
||||
}
|
||||
resp, err := http.Get(req.URL) //nolint:gosec // 测试内固定 httptest 地址
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b), req.URL, resp.StatusCode, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheFileReturnsTextContent(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("第一章 开始\n第二章 继续"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, &hits)
|
||||
ar := NewAnalyzeRule()
|
||||
v, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cacheFile 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第一章 开始\n第二章 继续" {
|
||||
t.Fatalf("cacheFile 应返回文本内容,实际 %q", got)
|
||||
}
|
||||
// 第二次应命中缓存,不再发起请求。
|
||||
if _, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||||
t.Fatalf("重复调用应命中缓存(请求次数=%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheFileDecodesGBK(t *testing.T) {
|
||||
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("中文内容测试"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(gbk)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, nil)
|
||||
v, err := r.Run(NewAnalyzeRule(), "java.cacheFile('"+srv.URL+"/gbk.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "中文内容测试" {
|
||||
t.Fatalf("GBK 文本未正确解码: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileThenReadTxtFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("正文内容"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
r := newFileTestRunner(cacheDir, srv.URL, nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
rawPath, err := r.Run(ar, "java.downloadFile('"+srv.URL+"/a.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := anyToString(rawPath)
|
||||
if !strings.HasPrefix(rel, "/") || !strings.HasSuffix(rel, ".txt") {
|
||||
t.Fatalf("downloadFile 应返回相对缓存路径,实际 %q", rel)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cacheDir, "reader", "files", strings.TrimPrefix(rel, "/"))); err != nil {
|
||||
t.Fatalf("文件未落到缓存目录: %v", err)
|
||||
}
|
||||
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+rel+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "正文内容" {
|
||||
t.Fatalf("readTxtFile = %q", got)
|
||||
}
|
||||
|
||||
del, err := r.Run(ar, "String(java.deleteFile('"+rel+"'))", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(del) != "true" {
|
||||
t.Fatalf("deleteFile 应返回 true,实际 %q", anyToString(del))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsRejectPathTraversal(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
secret := filepath.Join(cacheDir, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("不该被读到"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := newFileTestRunner(cacheDir, "", nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
for _, p := range []string{"/../../secret.txt", "../secret.txt", "/../reader/files/../../secret.txt"} {
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+p+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 不应报错: %v", p, err)
|
||||
}
|
||||
if got := anyToString(v); strings.Contains(got, "不该被读到") {
|
||||
t.Fatalf("越权读到了缓存目录外的文件(%s)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsWithoutCacheDirFail(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
_, err := r.Run(NewAnalyzeRule(), "java.cacheFile('https://example.com/a.txt')", nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "缓存目录") {
|
||||
t.Fatalf("未配置缓存目录时应明确报错,实际 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ type JSConfig struct {
|
||||
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
|
||||
// nil 时这两个函数抛出不支持错误。
|
||||
Browser BrowserHost
|
||||
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
|
||||
// 为空时这些函数抛出明确错误。
|
||||
CacheDir string
|
||||
// FetchBytes 拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
// 由服务层注入,复用书源 header / Cookie / 限速 / 重试。
|
||||
FetchBytes func(absURL string) ([]byte, error)
|
||||
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
|
||||
Ctx context.Context
|
||||
}
|
||||
@@ -177,6 +183,34 @@ func (r *JSRunner) pauseTimeout() func() {
|
||||
return g.Pause()
|
||||
}
|
||||
|
||||
// fetch 执行一次桥接网络请求,等待期间暂停 JS 超时看门狗。
|
||||
//
|
||||
// 书源会把「线路重试」写进规则 JS:光遇聚合的 request() 会串行试 7 条线路,单条
|
||||
// 线路最长可能等到客户端的 30s 超时。不暂停的话整条规则会被 10s 的 JS 超时打断,
|
||||
// 而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是「线路
|
||||
// 还在重试,接口已经 400」。java.startBrowserAwait 等待人工操作时用的是同一套暂停
|
||||
// 机制。暂停只覆盖网络等待,纯 CPU 死循环仍然受 Timeout 约束。
|
||||
func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
|
||||
if r.cfg.Fetch == nil {
|
||||
return "", "", 0, ErrJsUnsupported
|
||||
}
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.Fetch(req)
|
||||
}
|
||||
|
||||
// cacheNamespace cache 对象的命名空间:优先取书源 URL,退回 BaseURL。
|
||||
func (c JSConfig) cacheNamespace() string {
|
||||
if c.SourceProps != nil {
|
||||
if v, ok := c.SourceProps["bookSourceUrl"]; ok {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
@@ -189,17 +223,107 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// cache 按书源命名空间隔离:namespace 取书源 URL,持久层落 CacheDir/reader-js-cache。
|
||||
vm.Set("cache", newCacheObject(vm, cfg.cacheNamespace(), cfg.CacheDir))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// Packages(Java 类命名空间)同样要在 jsLib 之前注入:不少书源在 jsLib 或
|
||||
// 目录/正文规则里用 `new Packages.java.util.LinkedHashMap()` 这类写法。
|
||||
r.installPackages()
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installPackages 注入 Java 风格类命名空间(Packages.java.util.*)。
|
||||
//
|
||||
// goja 是纯 JS 运行时,没有 Java。书源(尤其照搬 Java 教程写的)常用
|
||||
// `new Packages.java.util.ArrayList()` / `LinkedHashMap` / `HashMap` 来拼
|
||||
// 目录列表和请求头,缺了它整条规则直接 ReferenceError(拷贝漫画轻小说源的
|
||||
// 目录规则就是这么写的)。
|
||||
//
|
||||
// 实现要点:容器用「真正的 JS 数组 / 对象」,方法用 Object.defineProperty 定义为
|
||||
// 不可枚举属性。这样容器交给引擎侧(GetElements/GetString)时导出的是干净的
|
||||
// 数组([]any)或映射(map[string]any),而不会把 add/put 这些方法也当成元素或
|
||||
// 请求头带出去。
|
||||
func (r *JSRunner) installPackages() {
|
||||
if _, err := r.vm.RunString(packagesPreamble); err != nil {
|
||||
// 注入失败不影响其它规则,只是 Packages.* 不可用。
|
||||
r.jsLibErr = fmt.Errorf("Packages 环境初始化失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// packagesPreamble 定义全局 Packages(以及与之等价的 java.util / java.lang 常用类)。
|
||||
const packagesPreamble = `
|
||||
var Packages = (function () {
|
||||
function def(o, k, v) {
|
||||
Object.defineProperty(o, k, { value: v, enumerable: false, writable: true, configurable: true });
|
||||
}
|
||||
function makeMap() {
|
||||
var m = {};
|
||||
def(m, 'put', function (k, v) { m[k] = v; return v; });
|
||||
def(m, 'putAll', function (o) { if (o) { for (var k in o) { m[k] = o[k]; } } });
|
||||
def(m, 'get', function (k) { return m[k] === undefined ? null : m[k]; });
|
||||
def(m, 'getOrDefault', function (k, d) { return m[k] === undefined ? d : m[k]; });
|
||||
def(m, 'containsKey', function (k) { return Object.prototype.hasOwnProperty.call(m, k); });
|
||||
def(m, 'containsValue', function (v) { for (var k in m) { if (m[k] === v) { return true; } } return false; });
|
||||
def(m, 'remove', function (k) { var v = m[k]; delete m[k]; return v; });
|
||||
def(m, 'size', function () { return Object.keys(m).length; });
|
||||
def(m, 'isEmpty', function () { return Object.keys(m).length === 0; });
|
||||
def(m, 'clear', function () { for (var k in m) { delete m[k]; } });
|
||||
def(m, 'keySet', function () { return Object.keys(m); });
|
||||
def(m, 'values', function () { var a = []; for (var k in m) { a.push(m[k]); } return a; });
|
||||
def(m, 'entrySet', function () { var a = []; for (var k in m) { a.push({ key: k, value: m[k] }); } return a; });
|
||||
def(m, 'toString', function () { return JSON.stringify(m); });
|
||||
return m;
|
||||
}
|
||||
function makeList() {
|
||||
var a = [];
|
||||
def(a, 'add', function (x) { a.push(x); return true; });
|
||||
def(a, 'addAll', function (xs) { if (xs) { for (var i = 0; i < xs.length; i++) { a.push(xs[i]); } } return true; });
|
||||
def(a, 'get', function (i) { return a[i]; });
|
||||
def(a, 'size', function () { return a.length; });
|
||||
def(a, 'isEmpty', function () { return a.length === 0; });
|
||||
def(a, 'contains', function (x) { return a.indexOf(x) >= 0; });
|
||||
def(a, 'remove', function (i) { return a.splice(i, 1)[0]; });
|
||||
def(a, 'clear', function () { a.length = 0; });
|
||||
def(a, 'toArray', function () { return a.slice(); });
|
||||
def(a, 'toString', function () { return a.join(','); });
|
||||
return a;
|
||||
}
|
||||
var util = {};
|
||||
var mapNames = ['LinkedHashMap', 'HashMap', 'TreeMap', 'Hashtable', 'ConcurrentHashMap', 'LinkedTreeMap'];
|
||||
for (var i = 0; i < mapNames.length; i++) { util[mapNames[i]] = makeMap; }
|
||||
var listNames = ['ArrayList', 'LinkedList', 'Vector'];
|
||||
for (var j = 0; j < listNames.length; j++) { util[listNames[j]] = makeList; }
|
||||
util.HashSet = makeList;
|
||||
util.Arrays = { asList: function () { return Array.prototype.slice.call(arguments); } };
|
||||
util.Collections = {
|
||||
emptyList: function () { return []; },
|
||||
emptyMap: function () { return makeMap(); },
|
||||
singletonList: function (x) { return [x]; }
|
||||
};
|
||||
var lang = {
|
||||
String: function (v) { return v == null ? '' : String(v); },
|
||||
Integer: function (v) { return parseInt(v, 10) || 0; },
|
||||
Long: function (v) { return parseInt(v, 10) || 0; },
|
||||
Double: function (v) { return parseFloat(v) || 0; },
|
||||
Boolean: function (v) { return !!v; },
|
||||
StringBuilder: function () {
|
||||
var s = '';
|
||||
var o = {};
|
||||
def(o, 'append', function (x) { s += (x == null ? '' : x); return o; });
|
||||
def(o, 'toString', function () { return s; });
|
||||
return o;
|
||||
}
|
||||
};
|
||||
return { java: { util: util, lang: lang } };
|
||||
})();
|
||||
`
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
@@ -383,7 +507,7 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
|
||||
}
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
prog, err := compileRuleJS(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
@@ -425,6 +549,80 @@ func scopedRuleJS(js string) string {
|
||||
return "{\n" + js + "\n}"
|
||||
}
|
||||
|
||||
// functionRuleJS 把规则 JS 包成函数体后求值。
|
||||
//
|
||||
// legado 允许书源规则用顶层 `return` 提前返回(拷贝漫画轻小说源的正文规则
|
||||
// 就在 if 分支里 `return '<img ...>'`),而 JS 脚本语法不允许顶层 return,
|
||||
// 只有函数体允许。这里作为块形式的兜底。
|
||||
func functionRuleJS(js string) string {
|
||||
return "(function(){\n" + js + "\n})()"
|
||||
}
|
||||
|
||||
// compileRuleJS 编译一段书源规则 JS。
|
||||
//
|
||||
// 优先用「块」形式:块的完成值就是最后一条语句的值,大量书源依赖它
|
||||
// (如 `@js:1+2` 直接产出 3),且块作用域能隔离顶层 let/const(见 scopedRuleJS)。
|
||||
// 块形式编译失败时退回「函数体」形式,兼容 legado 允许的顶层 return。
|
||||
func compileRuleJS(js string) (*goja.Program, error) {
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
if err == nil {
|
||||
return prog, nil
|
||||
}
|
||||
if prog2, err2 := compileCached(functionRuleJS(js)); err2 == nil {
|
||||
return prog2, nil
|
||||
}
|
||||
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
|
||||
}
|
||||
|
||||
// RunImageDecode 执行图片字节二次解密 JS(coverDecodeJs / ruleContent.imageDecode)。
|
||||
//
|
||||
// 对应 legado ImageUtils.getDecodeResult:绑定 result=图片字节、src=图片地址,
|
||||
// 规则返回解密后的字节(ArrayBuffer / typed array)。执行失败返回 error,
|
||||
// 调用方决定是回 502 还是原样透传。
|
||||
func (r *JSRunner) RunImageDecode(js string, data []byte, src string) ([]byte, error) {
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("book", nil)
|
||||
vm.Set("chapter", nil)
|
||||
vm.Set("title", nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", vm.ToValue(vm.NewArrayBuffer(data)))
|
||||
vm.Set("src", src)
|
||||
vm.Set("key", nil)
|
||||
vm.Set("page", nil)
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileRuleJS(stripRuleJSWrapper(js))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密 JS 编译失败: %w", err)
|
||||
}
|
||||
g := newInterruptGuard(vm, r.cfg.Timeout, "图片解密超时")
|
||||
r.setGuard(g)
|
||||
defer func() {
|
||||
g.Stop()
|
||||
r.setGuard(nil)
|
||||
}()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密失败: %v", err)
|
||||
}
|
||||
out, ok := exportBytes(vm, v)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("图片解密规则没有返回字节")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchBytes 用书源的网络栈拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
func (r *JSRunner) FetchBytes(absURL string) ([]byte, error) {
|
||||
if r.cfg.FetchBytes == nil {
|
||||
return nil, ErrJsUnsupported
|
||||
}
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.FetchBytes(absURL)
|
||||
}
|
||||
|
||||
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
|
||||
func exportValue(v goja.Value) any {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
@@ -438,6 +636,41 @@ func exportValue(v goja.Value) any {
|
||||
}
|
||||
}
|
||||
|
||||
// stripRuleJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
// 与 reader 包的 stripJSWrapper 同语义;规则包不能反向依赖 reader 包,故此处保留一份。
|
||||
func stripRuleJSWrapper(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return strings.TrimSpace(trimmed[len("@js:"):])
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// exportBytes 把 JS 返回值按字节取出:ArrayBuffer / typed array / 字符串。
|
||||
// 图片与字体解密规则返回的都是字节,ArrayBuffer 的 Export() 只给出属性 map,
|
||||
// 必须走 ExportTo 才能拿到真正的字节。
|
||||
func exportBytes(vm *goja.Runtime, v goja.Value) ([]byte, bool) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return nil, false
|
||||
}
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(v, &buf); err == nil {
|
||||
return buf, true
|
||||
}
|
||||
if s, ok := v.Export().(string); ok {
|
||||
return []byte(s), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// toJSValue 把引擎内部结果转为可注入 JS 的值。
|
||||
// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。
|
||||
func toJSValue(vm *goja.Runtime, v any) any {
|
||||
@@ -479,6 +712,9 @@ func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string,
|
||||
}
|
||||
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
|
||||
mustSet("bodyStr", body)
|
||||
// bodyAsBytes:部分书源用 `java.bytesToStr(resp.bodyAsBytes(), enc)` 处理
|
||||
// 非 UTF-8(GBK)正文(拷贝漫画轻小说源即如此)。
|
||||
mustSet("bodyAsBytes", func(call goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer([]byte(body))) })
|
||||
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
|
||||
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
|
||||
mustSet("header", func(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
|
||||
@@ -46,6 +47,39 @@ func TestJSTimeoutInterrupt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSTimeoutPausedDuringFetch 网络等待期间不能被 JS 超时打断。
|
||||
//
|
||||
// 回归:书源把「线路重试」写在规则 JS 里(光遇聚合的 request() 串行试 7 条线路,
|
||||
// 单条最长等到 HTTP 客户端超时)。看门狗不暂停的话,整条规则会被 10s 的 JS 超时
|
||||
// 中断,而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是
|
||||
// 「线路还在重试,接口已经 400」。
|
||||
func TestJSTimeoutPausedDuringFetch(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{
|
||||
Timeout: 100 * time.Millisecond,
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
time.Sleep(400 * time.Millisecond) // 远超过 JS 超时:模拟慢线路
|
||||
return `{"content":"正文"}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
ar := NewAnalyzeRule()
|
||||
v, err := r.Run(ar, `(function(){
|
||||
try { return java.ajax('https://slow.example.com/content'); }
|
||||
catch (e) { return 'caught:' + e; }
|
||||
})()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("网络等待期间不应触发 JS 超时: %v", err)
|
||||
}
|
||||
if got := anyToString(v); !strings.Contains(got, "正文") {
|
||||
t.Fatalf("ajax 返回值 = %q,期望上游正文", got)
|
||||
}
|
||||
|
||||
// 暂停只覆盖网络等待:回到 JS 里的纯 CPU 死循环仍然要被超时打断。
|
||||
_, err = r.Run(ar, `java.ajax('https://slow.example.com/content'); while(true){}`, nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("网络等待之后的 CPU 死循环应当仍然超时,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
|
||||
|
||||
func TestAnalyzeRuleJSEval(t *testing.T) {
|
||||
@@ -298,14 +332,51 @@ func TestJSUnsupportedStillWorks(t *testing.T) {
|
||||
|
||||
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestJSSandboxUnsupported 服务端不接受书源读取宿主任意文件。
|
||||
//
|
||||
// 文件类接口(cacheFile/downloadFile/readTxtFile/...)已实现,但路径被限制在
|
||||
// 书源自己的缓存目录内:越权路径安全地返回空,而不是把 /etc/passwd 交出去,
|
||||
// 也不应因为越权路径抛异常(否则书源会误判为「线路故障」反复重试)。
|
||||
// 真正无实现的能力(解压包、无头浏览器等)继续明确抛「不支持」。
|
||||
func TestJSSandboxUnsupported(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
_, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("expected sandbox error, got %v", err)
|
||||
got, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("越权路径应安全返回空而不是抛异常: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "root:") || strings.Contains(got, "/bin/") {
|
||||
t.Fatalf("沙箱被突破,读到了宿主文件: %q", got)
|
||||
}
|
||||
for _, call := range []string{`java.unzipFile('/tmp/a.zip')`, `java.webView('x')`} {
|
||||
if _, err := a.GetString(`@js:`+call, nil, false); err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("%s 应继续抛「不支持」,实际 %v", call, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleJSTopLevelReturn legado 允许规则 JS 顶层 return;无 return 时仍取最后
|
||||
// 一条语句的值(`@js:1+2` → 3)。
|
||||
func TestRuleJSTopLevelReturn(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
v, err := r.Run(ar, "if (1) { return '早退'; }\nreturn '兜底';", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("顶层 return 应可用: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "早退" {
|
||||
t.Fatalf("top-level return = %q, want 早退", got)
|
||||
}
|
||||
|
||||
v2, err := r.Run(ar, "1 + 2", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v2); got != "3" {
|
||||
t.Fatalf("最后一条语句的值 = %q, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -171,6 +174,9 @@ func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
|
||||
a.bookCustom = map[string]string{}
|
||||
}
|
||||
a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "")
|
||||
if a.bookVarPutter != nil {
|
||||
a.bookVarPutter()
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
@@ -375,50 +381,240 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
}
|
||||
|
||||
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
|
||||
//
|
||||
// legado 的 cache 有两套存储:put/get/delete 落持久缓存(ACache),
|
||||
// putMemory/getFromMemory 落进程内内存缓存。书源靠后者记录「这条段评点过几次」
|
||||
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
|
||||
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
|
||||
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
|
||||
//
|
||||
// 持久层按书源(bookSourceUrl)命名空间隔离,并落到 CacheDir/reader-js-cache:
|
||||
// 之前是包级全局 map,任何书源的 put/get 全局可见,多个源用同一个 key 会互相串值,
|
||||
// 一个源写满 4096 条还会把别的源的缓存一起清掉;重启后也全部丢失。
|
||||
|
||||
var jsCache = struct {
|
||||
// jsCacheMaxEntries 单个书源命名空间的条目上限:超了按写入时间淘汰最旧的一半,
|
||||
// 只影响本命名空间,不再波及其它书源。
|
||||
const jsCacheMaxEntries = 4096
|
||||
|
||||
// jsCacheNamespace 一个书源的持久/内存缓存命名空间。
|
||||
type jsCacheNamespace struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
seq map[string]int64
|
||||
next int64
|
||||
path string
|
||||
}
|
||||
|
||||
// jsCacheRegistry 按命名空间持有 cache,命名空间取书源 URL。
|
||||
var jsCacheRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
m map[string]*jsCacheNamespace
|
||||
}{m: map[string]*jsCacheNamespace{}}
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
// jsCacheFor 取(或创建)命名空间;cacheDir 非空时尝试从磁盘恢复。
|
||||
func jsCacheFor(namespace, cacheDir string) *jsCacheNamespace {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsCacheRegistry.mu.Lock()
|
||||
ns, ok := jsCacheRegistry.m[namespace]
|
||||
if !ok {
|
||||
ns = &jsCacheNamespace{data: map[string]string{}, seq: map[string]int64{}}
|
||||
if cacheDir != "" {
|
||||
ns.path = filepath.Join(cacheDir, "reader-js-cache", md5Hex(namespace, true)+".json")
|
||||
}
|
||||
ns.load()
|
||||
jsCacheRegistry.m[namespace] = ns
|
||||
}
|
||||
jsCacheRegistry.mu.Unlock()
|
||||
return ns
|
||||
}
|
||||
|
||||
// load 从磁盘恢复命名空间(仅供 jsCacheFor 在注册表锁内首次调用)。
|
||||
func (n *jsCacheNamespace) load() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(n.path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
var stored struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}
|
||||
if json.Unmarshal(raw, &stored) != nil {
|
||||
return
|
||||
}
|
||||
if stored.Data != nil {
|
||||
n.data = stored.Data
|
||||
}
|
||||
if stored.Seq != nil {
|
||||
n.seq = stored.Seq
|
||||
for _, v := range stored.Seq {
|
||||
if v > n.next {
|
||||
n.next = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// persist 把命名空间写回磁盘(临时文件 + 原子 rename)。
|
||||
func (n *jsCacheNamespace) persist() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}{n.data, n.seq})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(n.path), 0o750); err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(n.path), ".js-cache-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(name, n.path)
|
||||
}
|
||||
|
||||
// put 写一个持久键值(返回最终值)。
|
||||
func (n *jsCacheNamespace) put(key, value string) string {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if _, exists := n.data[key]; !exists && len(n.data) >= jsCacheMaxEntries {
|
||||
n.evictOldestLocked()
|
||||
}
|
||||
n.next++
|
||||
n.data[key] = value
|
||||
n.seq[key] = n.next
|
||||
n.persist()
|
||||
return value
|
||||
}
|
||||
|
||||
// evictOldestLocked 淘汰最旧的一半条目(调用方需持锁)。
|
||||
func (n *jsCacheNamespace) evictOldestLocked() {
|
||||
type entry struct {
|
||||
key string
|
||||
seq int64
|
||||
}
|
||||
entries := make([]entry, 0, len(n.data))
|
||||
for k := range n.data {
|
||||
entries = append(entries, entry{k, n.seq[k]})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].seq < entries[j].seq })
|
||||
drop := len(entries)/2 + 1
|
||||
for i := 0; i < drop && i < len(entries); i++ {
|
||||
delete(n.data, entries[i].key)
|
||||
delete(n.seq, entries[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
// get 读一个持久键。
|
||||
func (n *jsCacheNamespace) get(key string) (string, bool) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
v, ok := n.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// del 删除一个持久键。
|
||||
func (n *jsCacheNamespace) del(key string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
delete(n.data, key)
|
||||
delete(n.seq, key)
|
||||
n.persist()
|
||||
}
|
||||
|
||||
// memoryStore 进程内内存缓存(putMemory/getFromMemory),同样按命名空间隔离。
|
||||
var jsMemoryRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string
|
||||
}{m: map[string]map[string]string{}}
|
||||
|
||||
func jsMemoryFor(namespace string) map[string]string {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
defer jsMemoryRegistry.mu.Unlock()
|
||||
store, ok := jsMemoryRegistry.m[namespace]
|
||||
if !ok {
|
||||
store = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = store
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// newCacheObject 构造 JS 的 `cache` 对象。
|
||||
// namespace 取书源 URL;cacheDir 非空时 put/get 持久化到磁盘。
|
||||
func newCacheObject(vm *goja.Runtime, namespace, cacheDir string) *goja.Object {
|
||||
ns := jsCacheFor(namespace, cacheDir)
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(jsCache.m) >= 4096 {
|
||||
jsCache.m = map[string]string{}
|
||||
}
|
||||
jsCache.m[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
return vm.ToValue(ns.put(key, val))
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if v, ok := ns.get(stringArg(call, 0)); ok {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
ns.del(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory):进程内、不落盘。
|
||||
mem := jsMemoryFor(namespace)
|
||||
set("putMemory", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := jsCache.m[key]
|
||||
jsCache.mu.Unlock()
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
if _, exists := mem[key]; !exists && len(mem) >= jsCacheMaxEntries {
|
||||
mem = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = mem
|
||||
}
|
||||
mem[key] = val
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("getFromMemory", func(call goja.FunctionCall) goja.Value {
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
v, ok := mem[stringArg(call, 0)]
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(v)
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(jsCache.m, key)
|
||||
jsCache.mu.Unlock()
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCacheMemoryRoundTrip 内存缓存(cache.putMemory / cache.getFromMemory)要能读写。
|
||||
//
|
||||
// 回归:「cache 对象只有 put/get/delete」曾让光遇聚合的正文规则整条失败——
|
||||
// paraForAndroid 每一段带段评的文字都会调 cache.putMemory(url, 0),缺了它就抛
|
||||
// TypeError: Object has no member 'putMemory',正文接口直接 400。
|
||||
func TestCacheMemoryRoundTrip(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
if _, err := r.Run(ar, `cache.putMemory('__test_mem', 3)`, nil, ""); err != nil {
|
||||
t.Fatalf("cache.putMemory 失败: %v", err)
|
||||
}
|
||||
v, err := r.Run(ar, `String(cache.getFromMemory('__test_mem'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "3" {
|
||||
t.Fatalf("getFromMemory = %q,期望 3", got)
|
||||
}
|
||||
|
||||
// 缺省返回 null(书源会写 `cache.getFromMemory(k) || ''` 这类兜底)。
|
||||
v, err = r.Run(ar, `String(cache.getFromMemory('__test_mem_missing'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("缺失键 = %q,期望 null", got)
|
||||
}
|
||||
|
||||
// 持久缓存与内存缓存是两套存储(legado 的 put/get 走 ACache,putMemory 走内存),
|
||||
// 互不串门,否则书源会读到本该过期的值。
|
||||
if _, err := r.Run(ar, `cache.put('__test_persist', 'p')`, nil, ""); err != nil {
|
||||
t.Fatalf("cache.put 失败: %v", err)
|
||||
}
|
||||
v, err = r.Run(ar, `String(cache.getFromMemory('__test_persist'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("getFromMemory 读到了持久缓存的值: %q", got)
|
||||
}
|
||||
v, err = r.Run(ar, `String(cache.get('__test_mem'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.get 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// cache 必须按书源隔离:旧实现是包级全局 map,任一源的 put/get 对所有源可见,
|
||||
// 多个源用同一个 key 会互相串值,一个源写满还会清掉别的源的缓存。
|
||||
func TestCacheIsolatedPerSource(t *testing.T) {
|
||||
srcA := map[string]any{"bookSourceUrl": "https://a.example.com"}
|
||||
srcB := map[string]any{"bookSourceUrl": "https://b.example.com"}
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
runnerA := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
if _, err := runnerA.Run(ar, `cache.put('shared-key', 'from-a')`, nil, ""); err != nil {
|
||||
t.Fatalf("源 A 写入失败: %v", err)
|
||||
}
|
||||
runnerB := NewJSRunner(JSConfig{SourceProps: srcB})
|
||||
v, err := runnerB.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 B 读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("源 B 读到了源 A 的缓存: %q", got)
|
||||
}
|
||||
// 源 A 自己重开运行时仍应读到(落到 CacheDir 之外的进程内持久层)。
|
||||
runnerA2 := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
v, err = runnerA2.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 A 二次读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "from-a" {
|
||||
t.Fatalf("源 A 应读到自己的缓存: %q", got)
|
||||
}
|
||||
// 内存缓存同样按源隔离。
|
||||
if _, err := runnerA.Run(ar, `cache.putMemory('mem-key', 'a')`, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err = runnerB.Run(ar, `String(cache.getFromMemory('mem-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("内存缓存跨源串值: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,53 @@ func jsonRead(root any, path string) any {
|
||||
if root == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
v, err := jsonpathGet(path, root)
|
||||
v, err := jsonpathGet(normalizeJSONPath(path), root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeJSONPath 把 legado(Jayway)允许、而 PaesslerAG/jsonpath 拒绝的写法
|
||||
// 归一化成合法路径,共两处差异:
|
||||
//
|
||||
// 1. 缺根 `$`:Jayway 要求路径以 `$`/`@` 开头,legado 书源却常直接写
|
||||
// `results.list`、`results.list.[*]`。PaesslerAG 对「不含 `[]`/`*` 的裸路径」
|
||||
// 恰好能容忍(内部按 `$.` 解析),但一旦出现 `[*]` 就报
|
||||
// `unexpected "*" while scanning extensions`。这里统一补根。
|
||||
// 2. 点后紧跟方括号:`results.list.[*]`、`$.a.[0]`、`$.a.['k']`。Jayway 里
|
||||
// `.[` 等价于 `[`,PaesslerAG 会报 `unexpected "[" while scanning field`。
|
||||
//
|
||||
// 两处都会让整条规则静默返回空——表现为「书源明明有效却搜不出任何结果」
|
||||
// (拷贝轻小说源的搜索列表规则就是 `results.list.[*]`)。
|
||||
func normalizeJSONPath(path string) string {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return path
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(p, "$"), strings.HasPrefix(p, "@"):
|
||||
// 已有根,保持
|
||||
case strings.HasPrefix(p, "."):
|
||||
p = "$" + p // `..a` 这类递归写法,补 `$` 而不是 `$.`
|
||||
default:
|
||||
p = "$." + p
|
||||
}
|
||||
if !strings.Contains(p, ".[") {
|
||||
return p
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(p))
|
||||
for i := 0; i < len(p); i++ {
|
||||
// 丢掉 `[` 前多余的点;递归下降 `..[` 保持原样。
|
||||
if p[i] == '.' && i+1 < len(p) && p[i+1] == '[' && i > 0 && p[i-1] != '.' {
|
||||
continue
|
||||
}
|
||||
b.WriteByte(p[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:legado 兼容性回归——`Packages.java.util.*` 容器与 `.[*]` 形式的 JSONPath。
|
||||
//
|
||||
// 背景(拷贝系列轻小说源在 MeBox 搜不到/读不了的根因):
|
||||
// 1. 搜索列表规则写成 `results.list.[*]`(legado/Jayway 接受),PaesslerAG 直接
|
||||
// 解析报错返回空;
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()` / `ArrayList()` 拼装,
|
||||
// goja 里没有 Java,整条规则 ReferenceError。
|
||||
|
||||
// TestNormalizeJSONPathDotBracket 点后跟方括号应归一化成合法路径。
|
||||
func TestNormalizeJSONPathDotBracket(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"results.list.[*]": "$.results.list[*]",
|
||||
"$.results.list.[*]": "$.results.list[*]",
|
||||
"$.a.[0].b": "$.a[0].b",
|
||||
"$.a.['k']": "$.a['k']",
|
||||
"$.results.list": "$.results.list",
|
||||
"$.results..list[*]": "$.results..list[*]",
|
||||
"results.list": "$.results.list",
|
||||
"$[0].name": "$[0].name",
|
||||
"..list[*]": "$..list[*]",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeJSONPath(in); got != want {
|
||||
t.Errorf("normalizeJSONPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetElementsDotBracketStarJsonPath 真实书源的 `results.list.[*]` 必须能取到列表。
|
||||
func TestGetElementsDotBracketStarJsonPath(t *testing.T) {
|
||||
const body = `{"results":{"list":[{"name":"甲"},{"name":"乙"}]}}`
|
||||
for _, rule := range []string{"results.list.[*]", "$.results.list", "results.list"} {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(body, "")
|
||||
els, err := ar.GetElements(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", rule, err)
|
||||
}
|
||||
if len(els) != 2 {
|
||||
t.Fatalf("%s: elements=%d, want 2", rule, len(els))
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "甲" {
|
||||
t.Fatalf("%s: first name=%q err=%v", rule, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesUtilCollections Packages.java.util 的 List/Map 应能拼出目录列表,
|
||||
// 且导出给引擎的是干净的数组/映射(方法不能混进元素里)。
|
||||
func TestPackagesUtilCollections(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("Packages 环境初始化失败: %v", err)
|
||||
}
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"results":{"list":[{"name":"第一卷","id":7}]}}`, "https://api.example.com/volumes")
|
||||
ar.SetJSRunner(r.ForAnalyzer(ar))
|
||||
|
||||
js := `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = 'https://api.example.com/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id)); }
|
||||
out;</js>`
|
||||
els, err := ar.GetElements(js)
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("elements=%d, want 1", len(els))
|
||||
}
|
||||
if m, ok := els[0].(map[string]any); ok {
|
||||
if _, hasMethod := m["put"]; hasMethod {
|
||||
t.Fatalf("容器方法泄漏进了元素: %#v", m)
|
||||
}
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "第一卷" {
|
||||
t.Fatalf("name=%q err=%v", name, err)
|
||||
}
|
||||
url, _ := ar.GetString("$.url", els[0], false)
|
||||
if url != "https://api.example.com/volume/7" {
|
||||
t.Fatalf("url=%q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesHashMapUsableAsHeaders java.get(url, headers) 的 headers 用
|
||||
// Packages.java.util.HashMap 构造时,导出结果应是干净的键值映射。
|
||||
func TestPackagesHashMapUsableAsHeaders(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
v, err := r.Run(NewAnalyzeRule(), `(function () {
|
||||
var h = new Packages.java.util.HashMap();
|
||||
h.put('X-Test', 'v1');
|
||||
return JSON.stringify(h);
|
||||
})()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := anyToString(v); s != `{"X-Test":"v1"}` {
|
||||
t.Fatalf("HashMap 导出含额外键: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions 的 queryTTF / replaceFont:
|
||||
// 解析被字体混淆的正文。书源规则通常写成
|
||||
//
|
||||
// java.replaceFont(result, java.queryTTF(errorFontUrl), java.queryTTF(correctFontUrl))
|
||||
//
|
||||
// 错误字体把码点映射到错字形、正确字体把字形映射回真实码点,按字形轮廓配对即可还原。
|
||||
|
||||
// queryTTFCacheMax 已解析字体缓存条数(对齐 legado 的 LruCache 小容量策略)。
|
||||
const queryTTFCacheMax = 32
|
||||
|
||||
var queryTTFCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*queryTTFFont
|
||||
// order 记录插入顺序,超容量时淘汰最早的一条。
|
||||
order []string
|
||||
}{m: map[string]*queryTTFFont{}}
|
||||
|
||||
// queryTTFFromBytes 解析字体并缓存(key = SHA-256)。
|
||||
func queryTTFFromBytes(data []byte, useCache bool) (*queryTTFFont, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
if useCache {
|
||||
queryTTFCache.mu.Lock()
|
||||
if f, ok := queryTTFCache.m[key]; ok {
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Unlock()
|
||||
}
|
||||
f, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !useCache {
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Lock()
|
||||
if len(queryTTFCache.order) >= queryTTFCacheMax {
|
||||
oldest := queryTTFCache.order[0]
|
||||
queryTTFCache.order = queryTTFCache.order[1:]
|
||||
delete(queryTTFCache.m, oldest)
|
||||
}
|
||||
queryTTFCache.m[key] = f
|
||||
queryTTFCache.order = append(queryTTFCache.order, key)
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// installFontBridge 注册 java.queryTTF / queryBase64TTF / replaceFont。
|
||||
// set 是 java 对象的属性写入闭包(其中已包含 bridgeErr 的异常抛出语义)。
|
||||
func (r *JSRunner) installFontBridge(vm *goja.Runtime, set func(string, any)) {
|
||||
// fontErr 把错误翻译成 JS 异常:书源常把 queryTTF 放在 try/catch 里降级。
|
||||
fontErr := func(name string, msg string) goja.Value {
|
||||
return vm.ToValue("java." + name + ": " + msg)
|
||||
}
|
||||
set("queryTTF", func(call goja.FunctionCall) goja.Value {
|
||||
useCache := true
|
||||
if len(call.Arguments) > 1 {
|
||||
useCache = call.Arguments[1].ToBoolean()
|
||||
}
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, useCache)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
// queryBase64TTF 是 legado 的旧别名,语义完全相同。
|
||||
set("queryBase64TTF", func(call goja.FunctionCall) goja.Value {
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, true)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
set("replaceFont", func(call goja.FunctionCall) goja.Value {
|
||||
text := stringArg(call, 0)
|
||||
errorFont := queryTTFFontArg(call, 1)
|
||||
correctFont := queryTTFFontArg(call, 2)
|
||||
filter := false
|
||||
if len(call.Arguments) > 3 {
|
||||
filter = call.Arguments[3].ToBoolean()
|
||||
}
|
||||
if errorFont == nil || correctFont == nil {
|
||||
return vm.ToValue(text)
|
||||
}
|
||||
return vm.ToValue(replaceFontText(text, errorFont, correctFont, filter))
|
||||
})
|
||||
}
|
||||
|
||||
// fontDataFromArg 从 JS 参数取字体字节:URL / base64 / ArrayBuffer / typed array。
|
||||
func (r *JSRunner) fontDataFromArg(call goja.FunctionCall) ([]byte, error) {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
// 字节形态(ArrayBuffer / Uint8Array)优先。
|
||||
if data, ok := exportBytes(r.vm, call.Arguments[0]); ok && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
raw := strings.TrimSpace(call.Arguments[0].String())
|
||||
if raw == "" {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
data, err := r.FetchBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("下载字体失败: " + err.Error())
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
// base64(支持 data:font/...;base64, 前缀与 URL-safe 变体)。
|
||||
if i := strings.Index(raw, "base64,"); i >= 0 {
|
||||
raw = raw[i+len("base64,"):]
|
||||
}
|
||||
data, err := base64DecodeBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("字体数据不是 base64")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// errQueryTTF 构造 queryTTF 相关错误(拼上函数名前缀由调用方负责)。
|
||||
func errQueryTTF(msg string) error { return fmt.Errorf("%s", msg) }
|
||||
|
||||
// queryTTFFontArg 把 JS 参数还原成 *queryTTFFont。
|
||||
// 书源常把 queryTTF 的结果存进变量再传给 replaceFont,goja 会原样保留 Go 指针。
|
||||
func queryTTFFontArg(call goja.FunctionCall, idx int) *queryTTFFont {
|
||||
if len(call.Arguments) <= idx {
|
||||
return nil
|
||||
}
|
||||
if f, ok := call.Arguments[idx].Export().(*queryTTFFont); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceFontText 按字形轮廓把错误字体渲染的文本还原成正确字体对应的真实文字。
|
||||
// 顺序对齐 legado JsExtensions.replaceFont:逐码点取错字形,再到正确字体查回码点。
|
||||
func replaceFontText(text string, errorFont, correctFont *queryTTFFont, filter bool) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(text))
|
||||
for _, cp := range text {
|
||||
if isBlankUnicode(cp) {
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
glyph := errorFont.unicodeToGlyph[cp]
|
||||
if _, ok := errorFont.unicodeToGlyphID[cp]; !ok {
|
||||
// 错误字体里没有这个码点(对应 legado 的 glyfId == 0 → 视为无字形)。
|
||||
glyph = ""
|
||||
}
|
||||
if glyph == "" {
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
if real, ok := correctFont.glyphToUnicode[glyph]; ok && real != 0 {
|
||||
sb.WriteRune(real)
|
||||
continue
|
||||
}
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isBlankUnicode 判断码点是否是不可见的空白(对齐 legado 的 isBlankUnicode 列表)。
|
||||
func isBlankUnicode(cp rune) bool {
|
||||
switch cp {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f', 0x00A0, 0x2000, 0x2001, 0x2002, 0x2003,
|
||||
0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029,
|
||||
0x202F, 0x205F, 0x3000, 0xFEFF:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
// 本文件对应 AnalyzeByRegex.kt。Java 正则语义用 regexp2 对齐
|
||||
// (支持前向后向断言与反向引用),匹配循环对齐 Matcher.find()。
|
||||
|
||||
// regexMatchTimeout 书源正则的匹配预算。
|
||||
//
|
||||
// regexp2 默认永不超时(DefaultMatchTimeout 是 MaxInt64),而书源正则来自
|
||||
// 不可信内容:灾难性回溯会永久占住一个 goroutine 和一颗 CPU 核。这里统一
|
||||
// 设一个短预算,语义与 ApplyUserReplace 的缺省值保持一致。
|
||||
const regexMatchTimeout = 3 * time.Second
|
||||
|
||||
// compileRegex 编译一条 Java 语义正则并设置匹配超时。
|
||||
func compileRegex(pattern string) (*regexp2.Regexp, error) {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
re.MatchTimeout = regexMatchTimeout
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// splitNotBlankAndTrim 对应 String.splitNotBlank("&&"):切分并去空白项。
|
||||
func splitNotBlankAndTrim(s, sep string) []string {
|
||||
var out []string
|
||||
@@ -27,7 +44,7 @@ func regexGetElement(res string, regs []string, index int) []string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -60,7 +77,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -95,7 +112,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
// regexReplaceAll 对应 Kotlin Regex.replace(result, replacement)
|
||||
// (Java $N 分组替换语义,regexp2 的 Replace 原生支持)。
|
||||
func regexReplaceAll(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(result, pattern, replacement)
|
||||
}
|
||||
@@ -116,14 +133,12 @@ func ApplyUserReplace(content, pattern, replacement string, isRegex bool, timeou
|
||||
if !isRegex {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
if timeoutMS > 0 {
|
||||
re.MatchTimeout = time.Duration(timeoutMS) * time.Millisecond
|
||||
} else {
|
||||
re.MatchTimeout = 3 * time.Second
|
||||
}
|
||||
out, err := re.Replace(content, replacement, 0, -1)
|
||||
if err != nil {
|
||||
@@ -160,7 +175,7 @@ func ApplyReplaceRegexString(content, replaceRegex string) string {
|
||||
// regexReplaceFirstOnFirstMatch 对应 replaceRegex 的 replaceFirst 分支:
|
||||
// 找到第一个匹配(无匹配返回 ""),在匹配文本上做首次替换。
|
||||
func regexReplaceFirstOnFirstMatch(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return replacement
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件移植 legado 的 QueryTTF:解析字体(sfnt)的 cmap / glyf / loca / maxp 表,
|
||||
// 建立「Unicode 码点 → 字形」「字形 → Unicode 码点」两张表,供
|
||||
// java.queryTTF / java.replaceFont 还原被字体混淆的正文。
|
||||
//
|
||||
// 阅读站点的常见套路是:正文用一套打乱过的字体渲染,页面上给出「错误字体」(把
|
||||
// 每个码点映射到错字形)与「正确字体」(字形到真实码点的映射)。replaceFont 按
|
||||
// 字形轮廓把错误字体里的字符逐个换成正确字体里同字形的码点,从而还原原文。
|
||||
//
|
||||
// 解析器全程做边界检查:字体字节来自书源(不可信),坏字体只应报错,不能 panic。
|
||||
|
||||
// queryTTFFont 一个已解析的字体。
|
||||
type queryTTFFont struct {
|
||||
// unicodeToGlyphID 码点 → 字形在 glyf 表里的下标。
|
||||
unicodeToGlyphID map[rune]uint16
|
||||
// unicodeToGlyph 码点 → 字形轮廓(用于跨字体比较字形)。
|
||||
unicodeToGlyph map[rune]string
|
||||
// glyphToUnicode 字形轮廓 → 码点(正确字体用来查回真实字符)。
|
||||
glyphToUnicode map[string]rune
|
||||
}
|
||||
|
||||
// sfnt 表标签。
|
||||
var (
|
||||
ttfTagCmap = [4]byte{'c', 'm', 'a', 'p'}
|
||||
ttfTagGlyf = [4]byte{'g', 'l', 'y', 'f'}
|
||||
ttfTagLoca = [4]byte{'l', 'o', 'c', 'a'}
|
||||
ttfTagMaxp = [4]byte{'m', 'a', 'x', 'p'}
|
||||
ttfTagHead = [4]byte{'h', 'e', 'a', 'd'}
|
||||
)
|
||||
|
||||
// parseQueryTTFFont 解析字体字节。支持 sfnt(TTF/OTF)与 ttc 的第一套字体。
|
||||
func parseQueryTTFFont(data []byte) (font *queryTTFFont, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
font, err = nil, fmt.Errorf("字体解析失败: %v", r)
|
||||
}
|
||||
}()
|
||||
if len(data) < 12 {
|
||||
return nil, fmt.Errorf("字体数据过短")
|
||||
}
|
||||
// ttc:取第一套字体的偏移。
|
||||
if string(data[:4]) == "ttcf" {
|
||||
if len(data) < 16 {
|
||||
return nil, fmt.Errorf("ttc 头部不完整")
|
||||
}
|
||||
off := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
if off <= 0 || off >= len(data) {
|
||||
return nil, fmt.Errorf("ttc 字体偏移非法")
|
||||
}
|
||||
data = data[off:]
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
if numTables <= 0 || 12+numTables*16 > len(data) {
|
||||
return nil, fmt.Errorf("sfnt 表目录非法")
|
||||
}
|
||||
tables := map[[4]byte][]byte{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := data[12+i*16 : 12+i*16+16]
|
||||
var tag [4]byte
|
||||
copy(tag[:], rec[:4])
|
||||
off := int(binary.BigEndian.Uint32(rec[8:12]))
|
||||
length := int(binary.BigEndian.Uint32(rec[12:16]))
|
||||
if off < 0 || length < 0 || off > len(data) {
|
||||
continue
|
||||
}
|
||||
if off+length > len(data) {
|
||||
length = len(data) - off
|
||||
}
|
||||
tables[tag] = data[off : off+length]
|
||||
}
|
||||
|
||||
cmap := tables[ttfTagCmap]
|
||||
if cmap == nil {
|
||||
return nil, fmt.Errorf("字体缺少 cmap 表")
|
||||
}
|
||||
mapping, err := parseTTFCmap(cmap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &queryTTFFont{
|
||||
unicodeToGlyphID: mapping,
|
||||
unicodeToGlyph: map[rune]string{},
|
||||
glyphToUnicode: map[string]rune{},
|
||||
}
|
||||
|
||||
// 有 glyf + loca + head + maxp 才能算字形轮廓;缺了(如 CFF 字体)只保留码点映射。
|
||||
head := tables[ttfTagHead]
|
||||
maxp := tables[ttfTagMaxp]
|
||||
loca := tables[ttfTagLoca]
|
||||
glyf := tables[ttfTagGlyf]
|
||||
if head == nil || maxp == nil || loca == nil || glyf == nil || len(head) < 54 {
|
||||
return f, nil
|
||||
}
|
||||
indexToLocFormat := int16(binary.BigEndian.Uint16(head[50:52]))
|
||||
numGlyphs := int(binary.BigEndian.Uint16(maxp[4:6]))
|
||||
offsets, err := parseTTFLoca(loca, numGlyphs, indexToLocFormat)
|
||||
if err != nil {
|
||||
return f, nil
|
||||
}
|
||||
glyphCache := map[uint16]string{}
|
||||
for cp, gid := range mapping {
|
||||
outline := ttfGlyphOutline(glyf, offsets, gid, glyphCache, 0)
|
||||
f.unicodeToGlyph[cp] = outline
|
||||
if _, exists := f.glyphToUnicode[outline]; !exists {
|
||||
f.glyphToUnicode[outline] = cp
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// parseTTFCmap 解析 cmap 表,返回码点 → 字形下标。
|
||||
// 支持 format 0 / 4 / 6(legado QueryTTF 同样只支持这三种)。
|
||||
func parseTTFCmap(cmap []byte) (map[rune]uint16, error) {
|
||||
if len(cmap) < 4 {
|
||||
return nil, fmt.Errorf("cmap 表过短")
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
|
||||
if 4+numTables*8 > len(cmap) {
|
||||
return nil, fmt.Errorf("cmap 子表目录非法")
|
||||
}
|
||||
out := map[rune]uint16{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := cmap[4+i*8 : 4+i*8+8]
|
||||
off := int(binary.BigEndian.Uint32(rec[4:8]))
|
||||
if off < 0 || off+2 > len(cmap) {
|
||||
continue
|
||||
}
|
||||
sub := cmap[off:]
|
||||
switch binary.BigEndian.Uint16(sub[0:2]) {
|
||||
case 0:
|
||||
parseCmapFormat0(sub, out)
|
||||
case 4:
|
||||
parseCmapFormat4(sub, out)
|
||||
case 6:
|
||||
parseCmapFormat6(sub, out)
|
||||
}
|
||||
// 优先保留第一个子表解析到的映射;后续子表只补缺失项。
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("cmap 没有可用的 format 0/4/6 子表")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCmapFormat0(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 262 {
|
||||
return
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(sub[2:4]))
|
||||
if length > len(sub) {
|
||||
length = len(sub)
|
||||
}
|
||||
glyphs := sub[6:min(6+256, length)]
|
||||
for i, gid := range glyphs {
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(i)]; !exists {
|
||||
out[rune(i)] = uint16(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat4(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 14 {
|
||||
return
|
||||
}
|
||||
segCountX2 := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
segCount := segCountX2 / 2
|
||||
if segCount == 0 {
|
||||
return
|
||||
}
|
||||
endBase := 14
|
||||
startBase := endBase + segCountX2 + 2
|
||||
deltaBase := startBase + segCountX2
|
||||
rangeBase := deltaBase + segCountX2
|
||||
if rangeBase+segCountX2 > len(sub) {
|
||||
return
|
||||
}
|
||||
for i := 0; i < segCount; i++ {
|
||||
end := int(binary.BigEndian.Uint16(sub[endBase+i*2:]))
|
||||
start := int(binary.BigEndian.Uint16(sub[startBase+i*2:]))
|
||||
delta := int16(binary.BigEndian.Uint16(sub[deltaBase+i*2:]))
|
||||
rangeOffset := int(binary.BigEndian.Uint16(sub[rangeBase+i*2:]))
|
||||
if start > end {
|
||||
continue
|
||||
}
|
||||
for cp := start; cp <= end && cp <= 0xFFFF; cp++ {
|
||||
if cp == 0xFFFF {
|
||||
continue
|
||||
}
|
||||
var gid uint16
|
||||
if rangeOffset == 0 {
|
||||
gid = uint16(int(cp) + int(delta))
|
||||
} else {
|
||||
idx := rangeBase + i*2 + rangeOffset + (cp-start)*2
|
||||
if idx+2 > len(sub) {
|
||||
continue
|
||||
}
|
||||
g := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if g == 0 {
|
||||
continue
|
||||
}
|
||||
gid = uint16(int(g) + int(delta))
|
||||
}
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(cp)]; !exists {
|
||||
out[rune(cp)] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat6(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 10 {
|
||||
return
|
||||
}
|
||||
first := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
count := int(binary.BigEndian.Uint16(sub[8:10]))
|
||||
for i := 0; i < count; i++ {
|
||||
idx := 10 + i*2
|
||||
if idx+2 > len(sub) {
|
||||
return
|
||||
}
|
||||
gid := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if gid == 0 {
|
||||
continue
|
||||
}
|
||||
cp := rune(first + i)
|
||||
if _, exists := out[cp]; !exists {
|
||||
out[cp] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseTTFLoca 解析 loca 表,返回每个字形的字节区间起止。
|
||||
func parseTTFLoca(loca []byte, numGlyphs int, indexToLocFormat int16) ([]int, error) {
|
||||
if indexToLocFormat == 0 {
|
||||
need := (numGlyphs + 1) * 2
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint16(loca[i*2:])) * 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
need := (numGlyphs + 1) * 4
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint32(loca[i*4:]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ttfGlyphOutline 把字形转成轮廓字符串(对应 legado QueryTTF.Glyf.toString)。
|
||||
// 复合字形递归展开组件,深度上限防自引用。
|
||||
func ttfGlyphOutline(glyf []byte, offsets []int, gid uint16, cache map[uint16]string, depth int) string {
|
||||
if depth > 8 {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
if v, ok := cache[gid]; ok {
|
||||
return v
|
||||
}
|
||||
if int(gid)+1 >= len(offsets) {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
start, end := offsets[gid], offsets[gid+1]
|
||||
if start < 0 || end > len(glyf) || end <= start {
|
||||
// 空字形(如空格):用下标本身当轮廓,保证不同码点不会互相误判。
|
||||
out := fmt.Sprintf("glyph%d", gid)
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
numberOfContours := int16(binary.BigEndian.Uint16(glyf[start : start+2]))
|
||||
if numberOfContours >= 0 {
|
||||
out := fmt.Sprintf("simple:%d:%s", numberOfContours, ttfSimpleGlyphPoints(glyf[start:end]))
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
// 复合字形:逐组件展开。
|
||||
var sb strings.Builder
|
||||
sb.WriteString("composite")
|
||||
pos := start + 10
|
||||
for pos+4 <= end {
|
||||
flags := binary.BigEndian.Uint16(glyf[pos : pos+2])
|
||||
component := binary.BigEndian.Uint16(glyf[pos+2 : pos+4])
|
||||
pos += 4
|
||||
if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS
|
||||
pos += 4
|
||||
} else {
|
||||
pos += 2
|
||||
}
|
||||
switch {
|
||||
case flags&0x0008 != 0: // WE_HAVE_A_SCALE
|
||||
pos += 2
|
||||
case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE
|
||||
pos += 4
|
||||
case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO
|
||||
pos += 8
|
||||
}
|
||||
sb.WriteString("+")
|
||||
sb.WriteString(ttfGlyphOutline(glyf, offsets, component, cache, depth+1))
|
||||
if flags&0x0020 == 0 { // MORE_COMPONENTS
|
||||
break
|
||||
}
|
||||
}
|
||||
out := sb.String()
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
|
||||
// ttfSimpleGlyphPoints 取简单字形的轮廓点(标志与坐标的紧凑编码)。
|
||||
func ttfSimpleGlyphPoints(data []byte) string {
|
||||
if len(data) < 10 {
|
||||
return ""
|
||||
}
|
||||
numberOfContours := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
if numberOfContours <= 0 {
|
||||
return ""
|
||||
}
|
||||
endPtsPos := 10
|
||||
if endPtsPos+numberOfContours*2+2 > len(data) {
|
||||
return ""
|
||||
}
|
||||
numPoints := int(binary.BigEndian.Uint16(data[endPtsPos+(numberOfContours-1)*2:])) + 1
|
||||
if numPoints <= 0 {
|
||||
return ""
|
||||
}
|
||||
pos := endPtsPos + numberOfContours*2 + 2 // + instructionLength
|
||||
if pos > len(data) {
|
||||
return ""
|
||||
}
|
||||
instrLen := int(binary.BigEndian.Uint16(data[pos-2 : pos]))
|
||||
pos += instrLen
|
||||
flags := make([]byte, 0, numPoints)
|
||||
for len(flags) < numPoints && pos < len(data) {
|
||||
flag := data[pos]
|
||||
pos++
|
||||
flags = append(flags, flag)
|
||||
if flag&0x08 != 0 { // REPEAT
|
||||
if pos >= len(data) {
|
||||
break
|
||||
}
|
||||
repeat := int(data[pos])
|
||||
pos++
|
||||
for i := 0; i < repeat && len(flags) < numPoints; i++ {
|
||||
flags = append(flags, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 解析 x / y 坐标(与点数等长的增量序列,这里只用于区分字形)。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("n=%d;", numPoints))
|
||||
for _, flag := range flags {
|
||||
sb.WriteByte('0' + flag&0x0F)
|
||||
}
|
||||
sb.WriteString(";")
|
||||
// 跳过坐标数据不影响「同名轮廓一致性」的判断:同字形的字体坐标编码一致。
|
||||
if pos < len(data) {
|
||||
sb.WriteString(fmt.Sprintf("d=%d", len(data)-pos))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 字体混淆还原(ttf.go / queryttf.go)的测试:用合成的 TTF 覆盖 cmap format 0/4、
|
||||
// loca 短格式、简单/复合字形,验证「错误字体 → 正确字体」的按字形还原。
|
||||
|
||||
// buildTestTTF 构造一个最小可解析的 TTF:
|
||||
// cmap(format 4)把给定码点映射到指定字形下标,glyf 里每个字形一个方框。
|
||||
func buildTestTTF(t *testing.T, entries map[rune]uint16) []byte {
|
||||
t.Helper()
|
||||
const numGlyphs = 8
|
||||
// 每个字形一个简单方框:轮廓数 1,1 个点。
|
||||
glyph := func() []byte {
|
||||
g := make([]byte, 0, 20)
|
||||
g = append(g, 0x00, 0x01) // numberOfContours = 1
|
||||
g = append(g, 0, 0, 0, 0, 0, 0, 0, 0) // bbox
|
||||
g = append(g, 0x00, 0x00) // endPtsOfContours[0] = 0
|
||||
g = append(g, 0x00, 0x00) // instructionLength = 0
|
||||
g = append(g, 0x01, 0x01) // flag: on-curve | x-short | y-short
|
||||
g = append(g, 0x00, 0x00) // x=0, y=0
|
||||
return g
|
||||
}
|
||||
glyfData := make([]byte, 0, 128)
|
||||
offsets := make([]int, 0, numGlyphs+1)
|
||||
for i := 0; i < numGlyphs; i++ {
|
||||
offsets = append(offsets, len(glyfData))
|
||||
if i == 0 {
|
||||
continue // .notdef 空字形
|
||||
}
|
||||
g := glyph()
|
||||
// 让不同字形的字节长度不同,轮廓字符串才能区分。
|
||||
for j := 0; j < i-1; j++ {
|
||||
g = append(g, 0x01, 0x00, 0x00) // 额外点
|
||||
}
|
||||
glyfData = append(glyfData, g...)
|
||||
}
|
||||
offsets = append(offsets, len(glyfData))
|
||||
|
||||
// loca(短格式,偏移/2)
|
||||
loca := make([]byte, (numGlyphs+1)*2)
|
||||
for i, off := range offsets {
|
||||
binary.BigEndian.PutUint16(loca[i*2:], uint16(off/2))
|
||||
}
|
||||
|
||||
// cmap:format 4 单段 + 结束段
|
||||
var cmap bytes.Buffer
|
||||
codes := make([]rune, 0, len(entries))
|
||||
for cp := range entries {
|
||||
codes = append(codes, cp)
|
||||
}
|
||||
// 按码点排序,构造连续单点段。
|
||||
for i := 0; i < len(codes); i++ {
|
||||
for j := i + 1; j < len(codes); j++ {
|
||||
if codes[j] < codes[i] {
|
||||
codes[i], codes[j] = codes[j], codes[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
segCount := len(codes) + 1
|
||||
endCodes := make([]uint16, 0, segCount)
|
||||
startCodes := make([]uint16, 0, segCount)
|
||||
idDeltas := make([]uint16, 0, segCount)
|
||||
for _, cp := range codes {
|
||||
endCodes = append(endCodes, uint16(cp))
|
||||
startCodes = append(startCodes, uint16(cp))
|
||||
idDeltas = append(idDeltas, uint16(int(entries[cp])-int(cp)))
|
||||
}
|
||||
endCodes = append(endCodes, 0xFFFF)
|
||||
startCodes = append(startCodes, 0xFFFF)
|
||||
idDeltas = append(idDeltas, 1)
|
||||
rangeOffsets := make([]uint16, segCount) // 全 0:用 idDelta
|
||||
|
||||
sub := new(bytes.Buffer)
|
||||
writeU16 := func(v uint16) { _ = binary.Write(sub, binary.BigEndian, v) }
|
||||
length := 16 + segCount*8
|
||||
writeU16(4) // format
|
||||
writeU16(uint16(length)) // length
|
||||
writeU16(0) // language
|
||||
writeU16(uint16(segCount * 2)) // segCountX2
|
||||
writeU16(0) // searchRange(解析器不校验)
|
||||
writeU16(0) // entrySelector
|
||||
writeU16(0) // rangeShift
|
||||
for _, v := range endCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
writeU16(0) // reservedPad
|
||||
for _, v := range startCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range idDeltas {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range rangeOffsets {
|
||||
writeU16(v)
|
||||
}
|
||||
subBytes := sub.Bytes()
|
||||
|
||||
// cmap 头 + 一个子表记录
|
||||
cmap.Write([]byte{0, 0})
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1))
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(3)) // platformID = Windows
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1)) // encodingID = Unicode BMP
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint32(12))
|
||||
cmap.Write(subBytes)
|
||||
|
||||
head := make([]byte, 54)
|
||||
binary.BigEndian.PutUint16(head[50:], 0) // indexToLocFormat = 0(短 loca)
|
||||
maxp := make([]byte, 6)
|
||||
binary.BigEndian.PutUint16(maxp[4:], numGlyphs)
|
||||
|
||||
tables := []struct {
|
||||
tag string
|
||||
data []byte
|
||||
}{
|
||||
{"cmap", cmap.Bytes()},
|
||||
{"glyf", glyfData},
|
||||
{"loca", loca},
|
||||
{"head", head},
|
||||
{"maxp", maxp},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0x00, 0x01, 0x00, 0x00}) // sfntVersion
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(len(tables)))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
offset := 12 + len(tables)*16
|
||||
offsetsTable := make([]int, len(tables))
|
||||
for i, tb := range tables {
|
||||
padded := tb.data
|
||||
if len(padded)%4 != 0 {
|
||||
padded = append(padded, make([]byte, 4-len(padded)%4)...)
|
||||
}
|
||||
offsetsTable[i] = offset
|
||||
offset += len(padded)
|
||||
}
|
||||
for i, tb := range tables {
|
||||
out.WriteString(tb.tag)
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(offsetsTable[i]))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(len(tb.data)))
|
||||
}
|
||||
for _, tb := range tables {
|
||||
out.Write(tb.data)
|
||||
for out.Len()%4 != 0 {
|
||||
out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// 解析出的码点 → 字形 → 码点映射与构造时一致。
|
||||
func TestQueryTTFParseRoundTrip(t *testing.T) {
|
||||
data := buildTestTTF(t, map[rune]uint16{
|
||||
'A': 1, 'B': 2, 'C': 3, 'D': 4,
|
||||
})
|
||||
font, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解析字体失败: %v", err)
|
||||
}
|
||||
for cp, gid := range map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4} {
|
||||
if got := font.unicodeToGlyphID[cp]; got != gid {
|
||||
t.Fatalf("码点 %q 的字形下标 = %d,期望 %d", cp, got, gid)
|
||||
}
|
||||
}
|
||||
if font.glyphToUnicode[font.unicodeToGlyph['A']] != 'A' {
|
||||
t.Fatal("字形 → 码点映射不正确")
|
||||
}
|
||||
if font.unicodeToGlyph['A'] == font.unicodeToGlyph['B'] {
|
||||
t.Fatal("不同码点的轮廓不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// replaceFont:错误字体把 A 渲染成 B 的字形,正确字体应把 A 还原成 B。
|
||||
func TestReplaceFontRestoresText(t *testing.T) {
|
||||
// 错误字体:码点 A 指向字形 2(也就是 B 的形状)。
|
||||
errorFontData := buildTestTTF(t, map[rune]uint16{'A': 2, 'B': 3, 'C': 4, 'D': 5})
|
||||
// 正确字体:码点 B 指向字形 2。
|
||||
correctFontData := buildTestTTF(t, map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4})
|
||||
errorFont, err := parseQueryTTFFont(errorFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
correctFont, err := parseQueryTTFFont(correctFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 页面上写的是 'A',实际字形是 B → 应还原为 'B'。
|
||||
got := replaceFontText("A", errorFont, correctFont, false)
|
||||
if got != "B" {
|
||||
t.Fatalf("replaceFont 还原结果 = %q,期望 %q", got, "B")
|
||||
}
|
||||
// 空白与未知码点保持原样。
|
||||
mixed := replaceFontText("A 中", errorFont, correctFont, false)
|
||||
if !strings.HasPrefix(mixed, "B ") || !strings.HasSuffix(mixed, "中") {
|
||||
t.Fatalf("混合文本处理异常: %q", mixed)
|
||||
}
|
||||
// filter=true 时删掉没有对应字形的字符。
|
||||
filtered := replaceFontText("A中", errorFont, correctFont, true)
|
||||
if filtered != "B" {
|
||||
t.Fatalf("filter 结果 = %q,期望 %q", filtered, "B")
|
||||
}
|
||||
}
|
||||
|
||||
// 坏字体只应报错,不能 panic。
|
||||
func TestQueryTTFBadFontNoPanic(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
[]byte("not a font"),
|
||||
[]byte("ttcf"),
|
||||
append([]byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x02}, make([]byte, 40)...),
|
||||
}
|
||||
for i, data := range cases {
|
||||
if _, err := parseQueryTTFFont(data); err == nil {
|
||||
t.Fatalf("坏字体 #%d 应返回错误", i)
|
||||
}
|
||||
}
|
||||
// 截断的合法字体也不能 panic。
|
||||
full := buildTestTTF(t, map[rune]uint16{'A': 1})
|
||||
for cut := 1; cut < len(full); cut += 37 {
|
||||
_, _ = parseQueryTTFFont(full[:cut])
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type Request struct {
|
||||
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
|
||||
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
|
||||
HexBody bool
|
||||
// Raw 请求响应按「原始字节」返回,跳过 charset 解码。
|
||||
// 供 java.downloadFile / java.cacheFile 这类需要保真落盘的调用使用:
|
||||
// 一旦按 UTF-8 解码,GBK 字节会被替换成 U+FFFD,写出来的文件就坏了。
|
||||
Raw bool
|
||||
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
|
||||
BodyJsFn func(body string) string
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
@@ -335,6 +339,58 @@ func findParamSplit(s string) (start, end int, ok bool) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// FindParamSplit 导出 findParamSplit:图片等媒体地址也支持 ",{...}" 选项段,
|
||||
// 服务层需要先拆分再分别做请求与展示处理。
|
||||
func FindParamSplit(s string) (start, end int, ok bool) {
|
||||
return findParamSplit(s)
|
||||
}
|
||||
|
||||
// MediaOptions 是媒体地址尾部 ",{...}" 选项段里服务端允许应用的部分。
|
||||
//
|
||||
// 图片/音频等媒体请求走代理,选项里只有请求头与重试这类「取图必需」的字段有意义;
|
||||
// webView/webJs/js/bodyJs 这些需要浏览器或脚本执行引擎的字段必须忽略,
|
||||
// 否则一个书源就能让媒体代理变成任意请求的中转。
|
||||
type MediaOptions struct {
|
||||
Headers map[string]string
|
||||
Charset string
|
||||
Retry *int
|
||||
Method string
|
||||
}
|
||||
|
||||
// ParseMediaOptions 拆分媒体地址与其尾部选项。
|
||||
// 返回去掉选项段的地址(已 trim)、应用后的选项与「是否带选项」。
|
||||
func ParseMediaOptions(raw string) (string, MediaOptions, bool) {
|
||||
var out MediaOptions
|
||||
s := strings.TrimSpace(raw)
|
||||
st, end, ok := findParamSplit(s)
|
||||
if !ok {
|
||||
return s, out, false
|
||||
}
|
||||
base := strings.TrimSpace(s[:st])
|
||||
optionStr := strings.TrimSpace(s[end:])
|
||||
var option URLOption
|
||||
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
|
||||
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
|
||||
return base, out, false
|
||||
}
|
||||
}
|
||||
if len(option.Headers) > 0 {
|
||||
out.Headers = make(map[string]string, len(option.Headers))
|
||||
for k, v := range option.Headers {
|
||||
out.Headers[k] = anyToString(v)
|
||||
}
|
||||
}
|
||||
out.Charset = option.Charset
|
||||
out.Retry = option.Retry
|
||||
switch strings.ToUpper(option.Method) {
|
||||
case "HEAD":
|
||||
out.Method = "HEAD"
|
||||
default:
|
||||
out.Method = "GET"
|
||||
}
|
||||
return base, out, true
|
||||
}
|
||||
|
||||
func isSpaceByte(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件覆盖规则稳定性修复的回归:
|
||||
// - @put 写回书源变量(跨请求持久化)
|
||||
// - book.putVariable 持久化到书架记录
|
||||
// - 书源正则的匹配超时(灾难性回溯不会挂死)
|
||||
// - helper.DecompressBody 的解压上限
|
||||
// - 非 UTF-8 TXT 的导入/读取
|
||||
// - 图片 URL 尾部 options 的请求头应用
|
||||
|
||||
// @put 必须写到书源变量(legado BaseSource.putVariable),而不是一次性的书籍变量。
|
||||
func TestPutPersistsToSourceVariable(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srcURL := "https://put.example.com"
|
||||
src := &model.ReaderBookSource{Name: "put 源", SourceURL: srcURL}
|
||||
bs := &BookSource{BookSourceURL: srcURL, RuleContent: &ContentRule{Content: strPtr("id.content@textNodes")}}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
ar.Put("token", "abc123")
|
||||
sess.close()
|
||||
|
||||
// 换一个全新会话/解析器:@put 的值必须还能 @get 到(已落库)。
|
||||
sess2 := svc.newSession(t.Context(), src, bs)
|
||||
defer sess2.close()
|
||||
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar2.Get("token"); got != "abc123" {
|
||||
t.Fatalf("@put 未持久化:@get 得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 书源 variables 默认值只是兜底:@put 写入的同名值优先级更高。
|
||||
func TestPutOverridesSourceDefaultVariable(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srcURL := "https://put2.example.com"
|
||||
raw := `{"bookSourceUrl":"https://put2.example.com","variables":"{\"token\":\"default\"}"}`
|
||||
srcID := importTestSource(t, svc, raw, srcURL)
|
||||
src, err := svc.repo.GetSource(context.Background(), srcID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bs, err := ParseBookSource(src.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar.Get("token"); got != "default" {
|
||||
t.Fatalf("默认变量应可读,得到 %q", got)
|
||||
}
|
||||
ar.Put("token", "override")
|
||||
sess.close()
|
||||
|
||||
sess2 := svc.newSession(t.Context(), src, bs)
|
||||
defer sess2.close()
|
||||
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar2.Get("token"); got != "override" {
|
||||
t.Fatalf("@put 应覆盖默认变量,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// book.putVariable 写入的变量要落回书架记录(对应 legado Book.upVariable)。
|
||||
func TestBookPutVariablePersists(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
book.Variable = `{"tone_id":"keep"}`
|
||||
if err := svc.repo.UpdateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, bs, err := svc.loadSourceFlexible(ctx, "", book.Origin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := svc.newSession(ctx, src, bs)
|
||||
sess.book = loaded
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", book.Origin)
|
||||
sess.applyBookContext(ar, book.BookURL, loaded, "", 0)
|
||||
|
||||
// 书源 JS 的 book.getVariable / putVariable。
|
||||
runner := sess.runner("", 0)
|
||||
if _, err := runner.Run(ar, `book.putVariable('token','v-42'); book.getVariable('tone_id')`, nil, book.Origin); err != nil {
|
||||
t.Fatalf("book JS 执行失败: %v", err)
|
||||
}
|
||||
sess.close()
|
||||
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vars := parseBookVariableMap(stored.Variable)
|
||||
if vars["token"] != "v-42" {
|
||||
t.Fatalf("book.putVariable 未持久化: %q", stored.Variable)
|
||||
}
|
||||
if vars["tone_id"] != "keep" {
|
||||
t.Fatalf("已有变量被覆盖: %q", stored.Variable)
|
||||
}
|
||||
_ = srv
|
||||
}
|
||||
|
||||
// 灾难性回溯的正则必须在秒级返回,而不是挂死 goroutine。
|
||||
func TestRegexAnalyzerTimeoutOnCatastrophicBacktracking(t *testing.T) {
|
||||
// (a+)+$ 对 "aaaa...b" 是指数级回溯;没有 MatchTimeout 会永久卡住。
|
||||
evil := strings.Repeat("a", 40) + "b"
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
done <- rule.ApplyReplaceRegexString(evil, "##(a+)+$##X")
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("书源正则未在超时预算内返回(MatchTimeout 未生效)")
|
||||
}
|
||||
}
|
||||
|
||||
// 解压炸弹:超过输出上限时原样返回压缩字节(调用方按失败处理),不撑爆内存。
|
||||
func TestDecompressBodyRejectsBomb(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
// 128MB 的零字节压成几十 KB。
|
||||
chunk := make([]byte, 1<<20)
|
||||
for i := 0; i < 128; i++ {
|
||||
if _, err := gz.Write(chunk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
|
||||
out := helper.DecompressBody(resp, buf.Bytes())
|
||||
if len(out) == len(chunk)*128 {
|
||||
t.Fatal("解压炸弹应被上限拦截")
|
||||
}
|
||||
if !bytes.Equal(out, buf.Bytes()) {
|
||||
t.Fatal("超限时应原样返回压缩字节")
|
||||
}
|
||||
}
|
||||
|
||||
// 正常 gzip 响应仍应被正确解压(上限不误伤常规页面)。
|
||||
func TestDecompressBodyNormal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
payload := []byte("<html>hello</html>")
|
||||
if _, err := gz.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = gz.Close()
|
||||
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
|
||||
if out := helper.DecompressBody(resp, buf.Bytes()); string(out) != string(payload) {
|
||||
t.Fatalf("正常 gzip 解压失败: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 UTF-8(GBK)TXT:导入落盘为 UTF-8,按章节读取不再乱码/错位。
|
||||
func TestImportGBKTextBookReadsCorrectly(t *testing.T) {
|
||||
// 用临时 DataDir,避免本地书籍文件写进仓库目录。
|
||||
svc := newLocalBookService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 用 GBK 编码两章正文(解码后章节字节区间按 UTF-8 计算)。
|
||||
gbkBytes := func(s string) []byte {
|
||||
enc, err := htmlindex.Get("gbk")
|
||||
if err != nil {
|
||||
t.Skip("环境缺少 GBK 编码支持")
|
||||
}
|
||||
out, err := enc.NewEncoder().Bytes([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
text := "第一章 起点\n这是第一章的正文内容,包含中文标点。\n第二章 继续\n这是第二章的正文内容。\n"
|
||||
book, err := svc.ImportLocalBook(ctx, "u1", "gbk小说.txt", gbkBytes(text))
|
||||
if err != nil {
|
||||
t.Fatalf("导入 GBK 书籍失败: %v", err)
|
||||
}
|
||||
if book.Charset != "gbk" {
|
||||
t.Fatalf("应识别为 gbk,实际 %q", book.Charset)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil || len(chapters) < 2 {
|
||||
t.Fatalf("应切出至少 2 章: n=%d err=%v", len(chapters), err)
|
||||
}
|
||||
second, err := svc.readLocalChapter(book, chapters[1])
|
||||
if err != nil {
|
||||
t.Fatalf("读取第二章失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(second, "第二章的正文内容") {
|
||||
t.Fatalf("第二章内容不正确(编码错位): %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片 URL 尾部 options:拆分出请求头,且地址本身去掉选项段。
|
||||
func TestParseMediaOptions(t *testing.T) {
|
||||
raw := `https://img.example.com/a.jpg,{"headers":{"Referer":"https://site.example.com/","X-Token":"t1"},"retry":2}`
|
||||
base, opt, ok := rule.ParseMediaOptions(raw)
|
||||
if !ok {
|
||||
t.Fatal("应识别出选项段")
|
||||
}
|
||||
if base != "https://img.example.com/a.jpg" {
|
||||
t.Fatalf("基础地址错误: %q", base)
|
||||
}
|
||||
if opt.Headers["Referer"] != "https://site.example.com/" || opt.Headers["X-Token"] != "t1" {
|
||||
t.Fatalf("请求头解析错误: %+v", opt.Headers)
|
||||
}
|
||||
if opt.Retry == nil || *opt.Retry != 2 {
|
||||
t.Fatalf("retry 解析错误: %+v", opt.Retry)
|
||||
}
|
||||
// 无选项段的普通地址原样返回。
|
||||
if b, _, ok := rule.ParseMediaOptions("https://img.example.com/b.jpg"); ok || b != "https://img.example.com/b.jpg" {
|
||||
t.Fatalf("无选项地址不应被改写: %q ok=%v", b, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 代理地址改写后,图片选项头必须在媒体请求里真正生效。
|
||||
func TestFetchMediaAppliesOptionHeaders(t *testing.T) {
|
||||
var gotReferer, gotToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotReferer = r.Header.Get("Referer")
|
||||
gotToken = r.Header.Get("X-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("jpeg-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
book := &model.ReaderBook{Base: model.Base{ID: "b1"}, Origin: srv.URL, BookURL: srv.URL + "/book/1"}
|
||||
resp, err := svc.FetchMediaWithOptions(t.Context(), book, srv.URL+"/a.jpg", "", map[string]string{
|
||||
"Referer": "https://site.example.com/",
|
||||
"X-Token": "t1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("拉取失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if gotReferer != "https://site.example.com/" || gotToken != "t1" {
|
||||
t.Fatalf("选项请求头未生效: referer=%q token=%q", gotReferer, gotToken)
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyURL 去掉选项段后再签名:带选项与不带选项的同一张图得到同一签名。
|
||||
func TestProxyURLStripsMediaOptions(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
plain := svc.ProxyURL("book-1", "https://img.example.com/a.jpg")
|
||||
withOptions := svc.ProxyURL("book-1", `https://img.example.com/a.jpg,{"headers":{"Referer":"https://s/"}}`)
|
||||
if plain != withOptions {
|
||||
t.Fatalf("选项段应被剥离后签名:\n%s\n%s", plain, withOptions)
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL("book-1", strings.TrimPrefix(strings.Split(plain, "&u=")[1], ""), "")
|
||||
if err == nil && raw != "" {
|
||||
_ = raw
|
||||
}
|
||||
// 拆出 u/s 校验签名可回推出干净地址。
|
||||
u := plain[strings.Index(plain, "&u=")+3:]
|
||||
if i := strings.Index(u, "&s="); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if decoded, err := svc.VerifyProxyURL("book-1", u, plain[strings.Index(plain, "&s=")+3:]); err != nil || decoded != "https://img.example.com/a.jpg" {
|
||||
t.Fatalf("签名校验失败: %q err=%v", decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// HLS 判定:带 query 的 m3u8 与 Content-Type 不标准都要识别;普通图片不能被误判。
|
||||
func TestLooksLikeHLSURL(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://a.com/index.m3u8": true,
|
||||
"https://a.com/index.m3u8?token=xx": true,
|
||||
"https://a.com/live/index.M3U": true,
|
||||
"https://a.com/a.mp3": false,
|
||||
"https://a.com/m3u8/1": false,
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := looksLikeHLSURL(raw); got != want {
|
||||
t.Fatalf("looksLikeHLSURL(%q)=%v,期望 %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索分页。
|
||||
//
|
||||
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
|
||||
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
|
||||
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
|
||||
|
||||
func pagedSearchSourceJSON(serverURL string) string {
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": "分页源",
|
||||
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestSearchUsesRequestedPage(t *testing.T) {
|
||||
var gotPage string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPage = r.URL.Query().Get("page")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{"data": []map[string]string{{
|
||||
"book_name": fmt.Sprintf("第%s页的书", gotPage),
|
||||
"author": "作者",
|
||||
"book_url": "/book/" + gotPage,
|
||||
}}}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
|
||||
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{2, "2"},
|
||||
{5, "5"},
|
||||
{0, "1"}, // 非法页码退回第 1 页
|
||||
{-3, "1"}, // 负数同理
|
||||
} {
|
||||
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
|
||||
}
|
||||
if gotPage != tc.want {
|
||||
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
|
||||
}
|
||||
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
|
||||
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
|
||||
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
|
||||
func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
|
||||
|
||||
books, _, err := svc.Search(t.Context(), "书", nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "单页书" {
|
||||
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||
out, _ := json.Marshal(src)
|
||||
_ = prepareLoginSource(t, svc, string(out))
|
||||
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
|
||||
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
|
||||
|
||||
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
|
||||
t.Helper()
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": name,
|
||||
"searchUrl": serverURL + "/search?q={{key}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{
|
||||
"data": []map[string]string{{
|
||||
"book_name": bookName,
|
||||
"author": "作者",
|
||||
"book_url": "/book/1",
|
||||
}},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
srvA := scopeEchoServer(t, "甲书")
|
||||
defer srvA.Close()
|
||||
srvB := scopeEchoServer(t, "乙书")
|
||||
defer srvB.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
for _, raw := range []string{
|
||||
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||
} {
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sources, err := svc.ListSources(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idByName := map[string]string{}
|
||||
for _, s := range sources {
|
||||
idByName[s.Name] = s.ID
|
||||
}
|
||||
|
||||
names := func(books []SearchBook) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, b := range books {
|
||||
out[b.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("源A 不应失败: %+v", skipped)
|
||||
}
|
||||
if got := names(books); len(got) != 1 || !got["甲书"] {
|
||||
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
|
||||
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 {
|
||||
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ type BookSource struct {
|
||||
LoginCheckJS *string `json:"loginCheckJs"`
|
||||
JSLib *string `json:"jsLib"`
|
||||
BookSourceComment *string `json:"bookSourceComment"`
|
||||
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||
// CoverDecodeJs 封面 bytes 二次解密 JS(返回解密后的字节),
|
||||
// 对应 legado BookSource.coverDecodeJs。规则为空时封面零开销直通。
|
||||
CoverDecodeJs *string `json:"coverDecodeJs"`
|
||||
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||
RespondTime *int64 `json:"respondTime"`
|
||||
Weight *int `json:"weight"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
@@ -151,7 +154,10 @@ type ContentRule struct {
|
||||
SourceRegex *string `json:"sourceRegex"`
|
||||
ReplaceRegex *string `json:"replaceRegex"`
|
||||
ImageStyle *string `json:"imageStyle"`
|
||||
PayAction *string `json:"payAction"`
|
||||
// ImageDecode 正文图片 bytes 二次解密 JS(返回解密后的字节),
|
||||
// 对应 legado ContentRule.imageDecode。
|
||||
ImageDecode *string `json:"imageDecode"`
|
||||
PayAction *string `json:"payAction"`
|
||||
}
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
@@ -23,8 +24,12 @@ type sourceState struct {
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]string // domain → "k=v; k=v"
|
||||
loaded bool
|
||||
// cookies / cookieMu:domain → "k=v; k=v"。
|
||||
// cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写
|
||||
// Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。
|
||||
cookies map[string]string
|
||||
cookieMu sync.Mutex
|
||||
loaded bool
|
||||
|
||||
toasts []string
|
||||
browsers []rule.BrowserRequest
|
||||
@@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) {
|
||||
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||
|
||||
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
return st.getCookieKeyLocked(rawURL, key)
|
||||
}
|
||||
|
||||
// getCookieKeyLocked 的调用方需持有 cookieMu。
|
||||
func (st *sourceState) getCookieKeyLocked(rawURL, key string) string {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
@@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||
if merged == st.cookies[domain] {
|
||||
return
|
||||
@@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if _, ok := st.cookies[domain]; !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,12 +213,14 @@ func (st *sourceState) flush() {
|
||||
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
cookiesJSON := ""
|
||||
if len(st.cookies) > 0 {
|
||||
if b, err := json.Marshal(st.cookies); err == nil {
|
||||
cookiesJSON = string(b)
|
||||
}
|
||||
}
|
||||
st.cookieMu.Unlock()
|
||||
rec := &model.ReaderSourceState{
|
||||
SourceURL: st.sourceURL,
|
||||
Variable: st.variable,
|
||||
@@ -221,6 +239,8 @@ func (st *sourceState) flush() {
|
||||
|
||||
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||
func (st *sourceState) snapshotCookies() map[string]string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
out := make(map[string]string, len(st.cookies))
|
||||
for d, c := range st.cookies {
|
||||
out[d] = c
|
||||
@@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string {
|
||||
|
||||
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||
func (st *sourceState) clearCookies() {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if len(st.cookies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// tocHitCounter 统计目录地址被上游请求的次数(目录抓取去重的回归用)。
|
||||
type tocHitCounter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]int
|
||||
}
|
||||
|
||||
func (c *tocHitCounter) add(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.hits == nil {
|
||||
c.hits = map[string]int{}
|
||||
}
|
||||
c.hits[path]++
|
||||
}
|
||||
|
||||
func (c *tocHitCounter) count(path string) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.hits[path]
|
||||
}
|
||||
|
||||
// TestGetTocConcurrentCallsHitUpstreamOnce 同一本书的并发抓目录只打一次上游。
|
||||
//
|
||||
// 回归:换源、加入书架之后,服务端会预热目录(WarmUpBookChaptersAsync),阅读页在
|
||||
// 章节缓存为空时又会自己抓一次 /api/reader/toc,两次几乎同时到达——实测同一份目录
|
||||
// 被上游抓了两遍(3.7s 与 11.7s),章节也被写了两遍。
|
||||
func TestGetTocConcurrentCallsHitUpstreamOnce(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
counter.add(r.URL.Path)
|
||||
// 抓取期间让后到的调用方一定落在同一次在飞请求上。
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
|
||||
tocURL := srv.URL + "/book/1/toc.html"
|
||||
|
||||
// 第 0 个调用方按 source_id 找源(阅读页/详情页的形态),其余按 source_url 找
|
||||
// (服务端预热传的形态):两条入口都要落在同一次在飞请求上。
|
||||
const callers = 3
|
||||
var wg sync.WaitGroup
|
||||
chapters := make([][]TocChapter, callers)
|
||||
errs := make([]error, callers)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
id := ""
|
||||
if i == 0 {
|
||||
id = sourceID
|
||||
}
|
||||
chapters[i], errs[i] = svc.GetToc(context.Background(), "u1", id, srv.URL, book.BookURL, tocURL)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < callers; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, errs[i])
|
||||
}
|
||||
if len(chapters[i]) != 2 {
|
||||
t.Fatalf("第 %d 个调用方拿到 %d 章,期望 2", i, len(chapters[i]))
|
||||
}
|
||||
}
|
||||
if n := counter.count("/book/1/toc.html"); n != 1 {
|
||||
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget 预热传空 toc_url、阅读页传
|
||||
// book_url(前端 `toc_url || book_url` 的兜底)指的是同一份目录,必须落在同一次抓取上。
|
||||
func TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
counter.add(r.URL.Path)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL) // TocURL 留空,模拟还没写回目录地址
|
||||
if book.TocURL != "" {
|
||||
t.Fatalf("前置条件不成立:测试书的 toc_url 应为空,实际 %q", book.TocURL)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
start := make(chan struct{})
|
||||
for i, tocURL := range []string{"", book.BookURL} { // 预热形态 / 阅读页兜底形态
|
||||
wg.Add(1)
|
||||
go func(i int, tocURL string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[i] = svc.GetToc(context.Background(), "u1", "", srv.URL, book.BookURL, tocURL)
|
||||
}(i, tocURL)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n := counter.count("/book/1/toc.html"); n != 1 {
|
||||
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocDedupeKeepsDifferentBooksSeparate 不同的书不能被合并成一次抓取:
|
||||
// 单飞的 key 里必须带上书本地址。
|
||||
func TestGetTocDedupeKeepsDifferentBooksSeparate(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/toc.html"):
|
||||
counter.add(r.URL.Path)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book1 := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
|
||||
book2 := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/2",
|
||||
Name: "第二本",
|
||||
}
|
||||
if err := svc.repo.CreateBook(t.Context(), book2); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
targets := []struct{ bookURL, tocURL string }{
|
||||
{book1.BookURL, srv.URL + "/book/1/toc.html"},
|
||||
{book2.BookURL, srv.URL + "/book/2/toc.html"},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(targets))
|
||||
start := make(chan struct{})
|
||||
for i, tg := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, tg struct{ bookURL, tocURL string }) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[i] = svc.GetToc(context.Background(), "u1", sourceID, srv.URL, tg.bookURL, tg.tocURL)
|
||||
}(i, tg)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 本书抓目录失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/book/1/toc.html", "/book/2/toc.html"} {
|
||||
if n := counter.count(path); n != 1 {
|
||||
t.Fatalf("%s 被请求 %d 次,期望 1 次", path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ list
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ type SchedulerService struct {
|
||||
|
||||
imagesPolicyProvider func() ImageCachePolicy
|
||||
|
||||
// readerContentCleaner 阅读正文缓存的清理钩子(由阅读模块注入)。
|
||||
readerContentCleaner func(context.Context)
|
||||
|
||||
segments *MediaSegmentService
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -86,6 +89,13 @@ func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
|
||||
return ImageCachePolicy{}
|
||||
}
|
||||
|
||||
// SetReaderContentCleaner 注入阅读正文缓存的清理钩子(TTL 过期 + 容量 LRU)。
|
||||
// 阅读模块自身持有缓存目录与索引(见 reader 包的 pruneReaderContentCache),
|
||||
// 调度器只负责按小时触发;未注入时不注册该任务。
|
||||
func (s *SchedulerService) SetReaderContentCleaner(fn func(context.Context)) {
|
||||
s.readerContentCleaner = fn
|
||||
}
|
||||
|
||||
// scheduledJob is one recurring task.
|
||||
type scheduledJob struct {
|
||||
name string
|
||||
@@ -148,6 +158,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobCleanImageCache,
|
||||
},
|
||||
}
|
||||
// 阅读正文缓存清理只在注入钩子后注册(见 SetReaderContentCleaner)。
|
||||
if s.readerContentCleaner != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
name: "reader_content_cleanup",
|
||||
interval: 1 * time.Hour,
|
||||
run: s.jobCleanReaderContentCache,
|
||||
})
|
||||
}
|
||||
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
|
||||
if s.segments != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
|
||||
@@ -177,6 +177,15 @@ func (s *SchedulerService) jobTelegramExpiryWarning(ctx context.Context) error {
|
||||
return s.expiryWatcher.RunOnce(ctx)
|
||||
}
|
||||
|
||||
// jobCleanReaderContentCache 触发阅读正文缓存的 TTL / 容量淘汰。
|
||||
func (s *SchedulerService) jobCleanReaderContentCache(ctx context.Context) error {
|
||||
if s.readerContentCleaner == nil {
|
||||
return nil
|
||||
}
|
||||
s.readerContentCleaner(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
|
||||
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
|
||||
@@ -210,6 +210,10 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
}
|
||||
return policy
|
||||
})
|
||||
// 阅读正文缓存清理:由阅读模块自己按 TTL + 容量 LRU 淘汰(见 reader_content_cache.go)。
|
||||
if b.c.Reader != nil {
|
||||
b.c.Scheduler.SetReaderContentCleaner(b.c.Reader.PruneContentCache)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
@@ -237,6 +241,15 @@ func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
|
||||
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthProvider(func(ctx context.Context, host string) (string, bool) {
|
||||
return b.c.EmbyRemote.RemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthRefresher(func(ctx context.Context, host string) (string, error) {
|
||||
return b.c.EmbyRemote.RefreshRemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package service — refresh token 轮换的并发保护与幂等复用。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,刷新成功后旧 token 立即作废。但真实
|
||||
// 客户端天然会并发使用同一个令牌:
|
||||
//
|
||||
// - 同一个标签页里,WebSocket 重连与 axios 401 拦截器会各发一次刷新;
|
||||
// - 多个标签页共享同一份 localStorage 里的 refresh token;
|
||||
// - 容器重启/部署后,多个页面同时重新加载并刷新。
|
||||
//
|
||||
// 此前第二个请求必然收到 401(revoked),前端把 401 当成会话彻底失效,
|
||||
// 清掉本地令牌并跳登录页——外部表现就是「每次部署之后都要重新登录」。
|
||||
//
|
||||
// 本文件提供两层保护:
|
||||
//
|
||||
// 1. refreshFlights:同一个 token 的并发刷新共享同一次轮换结果;
|
||||
// 2. rotations:轮换后的宽限期内重复提交同一个旧 token,返回同一次轮换
|
||||
// 产生的令牌对(幂等),超过宽限期仍按已撤销处理。
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// refreshTokenReuseGrace 是 refresh token 轮换后的幂等复用宽限期。
|
||||
//
|
||||
// 取值只需覆盖「并发请求 + 客户端一次重试」的量级,越短越好:宽限期内
|
||||
// 持有旧 token 的一方仍能换取同一个会话,超过则按 token 复用检测处理。
|
||||
const refreshTokenReuseGrace = 60 * time.Second
|
||||
|
||||
// rotatedRefreshToken 保存一次轮换产生的令牌对,供宽限期内幂等复用。
|
||||
//
|
||||
// 这里必须保留 refresh token 明文——客户端要用它继续会话,而哈希无法反推。
|
||||
// 条目只存在于内存、随进程结束消失,并在宽限期结束后回收,不落库。
|
||||
type rotatedRefreshToken struct {
|
||||
userID string
|
||||
pair *TokenPair
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// refreshFlight 是一次进行中的刷新,供并发请求共享结果。
|
||||
type refreshFlight struct {
|
||||
done chan struct{}
|
||||
pair *TokenPair
|
||||
err error
|
||||
}
|
||||
|
||||
// currentTime 返回当前时间;测试可注入固定时间源。
|
||||
func (s *TokenService) currentTime() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// startRefreshFlight 尝试成为某个 refresh token 的首个刷新者。
|
||||
// 返回 (flight, true) 表示调用方负责执行刷新并调用 finishRefreshFlight;
|
||||
// 返回 (flight, false) 表示已有刷新在途,需要等待它的结果。
|
||||
func (s *TokenService) startRefreshFlight(tokenHash string) (*refreshFlight, bool) {
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
if s.refreshFlights == nil {
|
||||
s.refreshFlights = make(map[string]*refreshFlight)
|
||||
}
|
||||
if existing, ok := s.refreshFlights[tokenHash]; ok {
|
||||
return existing, false
|
||||
}
|
||||
flight := &refreshFlight{done: make(chan struct{})}
|
||||
s.refreshFlights[tokenHash] = flight
|
||||
return flight, true
|
||||
}
|
||||
|
||||
// finishRefreshFlight 发布刷新结果并唤醒等待者。
|
||||
func (s *TokenService) finishRefreshFlight(tokenHash string, pair *TokenPair, err error) {
|
||||
s.rotateMu.Lock()
|
||||
flight, ok := s.refreshFlights[tokenHash]
|
||||
if ok {
|
||||
delete(s.refreshFlights, tokenHash)
|
||||
flight.pair = pair
|
||||
flight.err = err
|
||||
}
|
||||
s.rotateMu.Unlock()
|
||||
if ok {
|
||||
// 关闭前已写入结果,等待方读取到的是完整的 happens-before 结果。
|
||||
close(flight.done)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRefreshFlight 等待并发刷新完成并返回它的结果。
|
||||
func (s *TokenService) waitForRefreshFlight(flight *refreshFlight) (*TokenPair, error) {
|
||||
if flight == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
<-flight.done
|
||||
if flight.err != nil {
|
||||
return nil, flight.err
|
||||
}
|
||||
if flight.pair == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
return flight.pair, nil
|
||||
}
|
||||
|
||||
// rememberRotation 记录一次轮换的结果,并回收已过期的条目。
|
||||
func (s *TokenService) rememberRotation(tokenHash, userID string, pair *TokenPair) {
|
||||
if s == nil || pair == nil || strings.TrimSpace(tokenHash) == "" {
|
||||
return
|
||||
}
|
||||
now := s.currentTime()
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
if s.rotations == nil {
|
||||
s.rotations = make(map[string]rotatedRefreshToken)
|
||||
}
|
||||
for hash, entry := range s.rotations {
|
||||
if !now.Before(entry.expiresAt) {
|
||||
delete(s.rotations, hash)
|
||||
}
|
||||
}
|
||||
s.rotations[tokenHash] = rotatedRefreshToken{
|
||||
userID: userID,
|
||||
pair: pair,
|
||||
expiresAt: now.Add(refreshTokenReuseGrace),
|
||||
}
|
||||
}
|
||||
|
||||
// reusedRotation 在宽限期内返回同一次轮换的令牌对。
|
||||
func (s *TokenService) reusedRotation(tokenHash, userID string) (*TokenPair, bool) {
|
||||
if s == nil {
|
||||
return nil, false
|
||||
}
|
||||
now := s.currentTime()
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
entry, ok := s.rotations[tokenHash]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !now.Before(entry.expiresAt) {
|
||||
delete(s.rotations, tokenHash)
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && entry.userID != "" && entry.userID != userID {
|
||||
return nil, false
|
||||
}
|
||||
return entry.pair, true
|
||||
}
|
||||
|
||||
// forgetRotationsForUser 丢弃某个用户的复用条目。
|
||||
// 显式登出/被踢下线必须立即生效,不能靠宽限期内的旧令牌继续换新。
|
||||
func (s *TokenService) forgetRotationsForUser(userID string) {
|
||||
if s == nil || strings.TrimSpace(userID) == "" {
|
||||
return
|
||||
}
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
for hash, entry := range s.rotations {
|
||||
if entry.userID == userID {
|
||||
delete(s.rotations, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newRotationTestService(t *testing.T) (*TokenService, *repository.Container, *gorm.DB) {
|
||||
t.Helper()
|
||||
db := newServiceTestDB(t, &model.User{}, &model.RefreshToken{}, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
return NewTokenService(cfg, zap.NewNop(), repos), repos, db
|
||||
}
|
||||
|
||||
func seedRotationUser(t *testing.T, repos *repository.Container) *model.User {
|
||||
t.Helper()
|
||||
u := &model.User{Username: "race", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestConcurrentRefreshSharesSingleRotation 复现真实客户端的并发刷新:
|
||||
// WebSocket 重连与 401 拦截器、多个标签页会同时用同一个 refresh token 刷新。
|
||||
// 之前第二个请求必然拿到 401 revoked,前端据此清空会话(部署后被迫重新登录)。
|
||||
// 现在并发请求共享同一次轮换,全部成功且拿到同一对令牌。
|
||||
func TestConcurrentRefreshSharesSingleRotation(t *testing.T) {
|
||||
svc, repos, db := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const concurrency = 8
|
||||
pairs := make([]*TokenPair, concurrency)
|
||||
errs := make([]error, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
pairs[idx], errs[idx] = svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("concurrent refresh %d failed: %v", i, errs[i])
|
||||
}
|
||||
if pairs[i].AccessToken != pairs[0].AccessToken || pairs[i].RefreshToken != pairs[0].RefreshToken {
|
||||
t.Fatalf("concurrent refresh %d returned a different pair", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 只应该产生一个新的活跃 refresh token,而不是每个请求各轮换一次。
|
||||
var active int64
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("user_id = ? AND revoked = ?", u.ID, false).
|
||||
Count(&active).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active != 1 {
|
||||
t.Fatalf("active refresh tokens = %d, want 1", active)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReuseAfterRotationReturnsSamePair 验证轮换完成后(并发窗口已
|
||||
// 结束)重复提交同一个旧令牌仍然是幂等的,客户端重试不会掉登录。
|
||||
func TestRefreshReuseAfterRotationReturnsSamePair(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reused, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse within grace window: %v", err)
|
||||
}
|
||||
if reused.RefreshToken != first.RefreshToken || reused.AccessToken != first.AccessToken {
|
||||
t.Fatal("reuse must return the pair issued by the first rotation")
|
||||
}
|
||||
// 复用的是新令牌,仍然可以继续轮换。
|
||||
if _, err := svc.Refresh(t.Context(), reused.RefreshToken); err != nil {
|
||||
t.Fatalf("refreshed token must stay usable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRotationReuseIsPerToken 验证复用表不会跨令牌串号:
|
||||
// 另一个令牌(例如被设备上限淘汰的那个)不会被误判为可复用。
|
||||
func TestRefreshRotationReuseIsPerToken(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
rotated, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 主动撤销另一个令牌(模拟登出/被踢下线),它不应享有复用宽限。
|
||||
if err := repos.RefreshToken.Revoke(t.Context(), repository.HashToken(other.RefreshToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), other.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("revoked token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshGraceWindowExpires 验证宽限期是有限的:超过之后旧令牌
|
||||
// 依旧被拒绝,管理员重置密码/踢下线等撤销语义不受影响。
|
||||
func TestRefreshGraceWindowExpires(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
svc.now = func() time.Time { return base.Add(refreshTokenReuseGrace + time.Second) }
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("error after grace window = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeAllDropsRotationReuse 验证登出/被踢下线立即生效:
|
||||
// 复用宽限期不能成为旧令牌继续换新的后门。
|
||||
func TestRevokeAllDropsRotationReuse(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RevokeAll(t.Context(), u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("old token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("rotated token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,31 @@ type TokenService struct {
|
||||
// 因为 refresh token 从未落库而被判定无效,被强制踢回登录页,
|
||||
// 表现就是「经常登录报错」。
|
||||
delayedStores map[string]pendingRefreshToken
|
||||
|
||||
// now 是可替换的时间源(测试用);为 nil 时退回 time.Now。
|
||||
now func() time.Time
|
||||
|
||||
// rotateMu 保护 refreshFlights / rotations。
|
||||
rotateMu sync.Mutex
|
||||
// refreshFlights 记录「正在进行的刷新」:同一个 refresh token 被并发
|
||||
// 提交时,只有第一个请求去轮换,其余等待并共享同一个结果。
|
||||
refreshFlights map[string]*refreshFlight
|
||||
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
|
||||
// 幂等复用(见 token_refresh_rotation.go)。
|
||||
rotations map[string]rotatedRefreshToken
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
|
||||
return &TokenService{cfg: cfg, log: log, repo: repo, delayedStores: make(map[string]pendingRefreshToken)}
|
||||
return &TokenService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
delayedStores: make(map[string]pendingRefreshToken),
|
||||
now: time.Now,
|
||||
refreshFlights: make(map[string]*refreshFlight),
|
||||
rotations: make(map[string]rotatedRefreshToken),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenPair 包含访问令牌和刷新令牌。
|
||||
@@ -168,9 +188,32 @@ func (s *TokenService) generateRefreshToken() (string, error) {
|
||||
}
|
||||
|
||||
// Refresh 使用 Refresh Token 轮换获取新的令牌对。
|
||||
//
|
||||
// refresh token 是一次性凭证,但真实客户端会并发使用同一个令牌(同一标签
|
||||
// 页的 WebSocket 重连与 401 拦截器、多个标签页、容器重启后同时刷新的多个
|
||||
// 页面)。因此这里做两件事:
|
||||
// 1. 同一个 token 的并发刷新共享同一次轮换(single-flight);
|
||||
// 2. 轮换后在宽限期内重复提交同一个 token,返回同一次轮换的令牌对。
|
||||
//
|
||||
// 二者共同保证「重复刷新不会把已经成功的会话打成 401 revoked」。
|
||||
func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
|
||||
flight, leader := s.startRefreshFlight(tokenHash)
|
||||
if !leader {
|
||||
// 已有一次刷新在途:等它的结果,不再拿同一个一次性凭证轮换第二次。
|
||||
return s.waitForRefreshFlight(flight)
|
||||
}
|
||||
|
||||
pair, err := s.refreshOnce(ctx, tokenHash)
|
||||
s.finishRefreshFlight(tokenHash, pair, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*TokenPair, error) {
|
||||
// 查找 Refresh Token 记录
|
||||
rt, err := s.repo.RefreshToken.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
@@ -180,7 +223,12 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
// 登录高峰/扫描写压力下,refresh token 可能还在后台补写队列里
|
||||
// 没来得及落库。此时令牌对客户端而言是合法的,不能判无效。
|
||||
pending, ok := s.pendingDelayedStore(tokenHash)
|
||||
if !ok || time.Now().After(pending.ExpiresAt) {
|
||||
if !ok || !s.currentTime().Before(pending.ExpiresAt) {
|
||||
// 也可能是「刚轮换完但从未落库」的令牌(同上,行本身不存在),
|
||||
// 宽限期内同样幂等复用。
|
||||
if pair, reused := s.reusedRotation(tokenHash, ""); reused {
|
||||
return pair, nil
|
||||
}
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
rt = &model.RefreshToken{
|
||||
@@ -192,6 +240,11 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
|
||||
// 检查是否已撤销
|
||||
if rt.Revoked {
|
||||
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
|
||||
// 客户端拿到 revoked 401 后清空整个会话。
|
||||
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
|
||||
return pair, nil
|
||||
}
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
|
||||
@@ -211,7 +264,7 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
if user.ExpiredAt != nil && s.currentTime().After(*user.ExpiredAt) {
|
||||
return nil, ErrUserExpired
|
||||
}
|
||||
|
||||
@@ -222,11 +275,21 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
s.untrackDelayedStore(rt.UserID, tokenHash)
|
||||
|
||||
// 签发新的令牌对
|
||||
return s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 必须在结束 flight 之前记住本次轮换:否则等待中的请求会在 flight 与
|
||||
// 复用表之间的空档里查不到记录,把并发刷新误判为 revoked。
|
||||
s.rememberRotation(tokenHash, user.ID, pair)
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// RevokeAll 撤销用户的所有 Refresh Token(用于登出)。
|
||||
func (s *TokenService) RevokeAll(ctx context.Context, userID string) error {
|
||||
// 复用条目必须一起丢弃:否则登出/被踢下线后的宽限期内,
|
||||
// 旧令牌仍能换回一对有效令牌。
|
||||
s.forgetRotationsForUser(userID)
|
||||
return s.repo.RefreshToken.RevokeByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,12 +45,46 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
|
||||
if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" {
|
||||
t.Fatalf("Refresh returned incomplete pair: %+v", pair)
|
||||
}
|
||||
// 轮换后旧令牌应从 pending 表移除,不能再次使用。
|
||||
// 轮换后旧令牌应从 pending 表移除。
|
||||
if _, still := svc.pendingDelayedStore(hash); still {
|
||||
t.Fatal("rotated pending token still tracked")
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
|
||||
t.Fatal("rotated pending token should not refresh twice")
|
||||
// 宽限期内重复提交同一个旧令牌返回同一次轮换的结果(幂等),
|
||||
// 而不是把并发/重试的客户端打成 401 并清空会话。
|
||||
reused, err := svc.Refresh(t.Context(), refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse within grace window should be idempotent: %v", err)
|
||||
}
|
||||
if reused.AccessToken != pair.AccessToken || reused.RefreshToken != pair.RefreshToken {
|
||||
t.Fatal("reuse should return the same rotated pair")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRejectsRotatedTokenAfterGraceWindow 验证宽限期结束后,
|
||||
// 旧令牌仍按已撤销处理(token 复用检测语义不变)。
|
||||
func TestRefreshRejectsRotatedTokenAfterGraceWindow(t *testing.T) {
|
||||
repos := newTokenTestRepo(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
svc := NewTokenService(cfg, zap.NewNop(), repos)
|
||||
|
||||
u := &model.User{Username: "u1", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
|
||||
t.Fatalf("first refresh: %v", err)
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
svc.now = func() time.Time { return base.Add(2 * refreshTokenReuseGrace) }
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("expired reuse error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { appRoutes, type AppRoute } from './appRoutes'
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||
|
||||
@@ -93,6 +94,8 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
|
||||
<ReaderMiniPlayer />
|
||||
</Suspense>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
|
||||
+23
-23
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
import type { RefreshOutcome } from '../utils/authRefresh'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and routes 401s back to the login page.
|
||||
// requests and refreshes it on 401. Only an explicit server-side rejection of
|
||||
// the refresh token sends the user back to the login page; transient failures
|
||||
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
@@ -92,33 +95,30 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await useAuthStore.getState().tokenRefresh()
|
||||
if (refreshed) {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
onTokenRefreshed(newToken || '')
|
||||
isRefreshing = false
|
||||
return api(originalRequest)
|
||||
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
|
||||
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
|
||||
// 超时只是暂时不可用,保留本地令牌等下一次重试。
|
||||
const outcome: RefreshOutcome = await useAuthStore
|
||||
.getState()
|
||||
.refreshSession()
|
||||
.catch((): RefreshOutcome => 'transient')
|
||||
isRefreshing = false
|
||||
|
||||
if (outcome === 'refreshed') {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(refreshError)
|
||||
onTokenRefreshed(newToken || '')
|
||||
return api(originalRequest)
|
||||
}
|
||||
|
||||
onTokenRefreshFailed(err)
|
||||
if (outcome === 'invalid') {
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(err)
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
+74
-6
@@ -118,8 +118,47 @@ export interface ReaderChapterContent {
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
|
||||
comments?: ReaderContentComment[]
|
||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
/** 该音轨是 HLS 播放列表(m3u8),前端据此走 hls.js 而不是原生 audio。 */
|
||||
hls?: boolean
|
||||
}
|
||||
|
||||
/** 批量取正文的单章结果(对应服务端 ContentBatchItem)。 */
|
||||
export interface ReaderContentBatchItem {
|
||||
chapter_index: number
|
||||
type?: 'text' | 'audio' | 'image'
|
||||
content?: string
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
hls?: boolean
|
||||
comments?: ReaderContentComment[]
|
||||
/** 由服务端持久缓存直接命中(本次未访问书源)。 */
|
||||
cached?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 批量取正文的汇总(对应服务端 ContentBatchResult)。 */
|
||||
export interface ReaderContentBatchResult {
|
||||
items: ReaderContentBatchItem[]
|
||||
stats: { hit: number; miss: number; failed: number }
|
||||
}
|
||||
|
||||
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
|
||||
export interface ReaderContentComment {
|
||||
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
|
||||
line: number
|
||||
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
|
||||
count: number
|
||||
/** 评论地址(书源 showCmt 的第一个参数)。 */
|
||||
url: string
|
||||
/** 段评 / 本章说。 */
|
||||
label?: string
|
||||
/** 独占一行的整块评论(章末「本章说」)。 */
|
||||
block?: boolean
|
||||
}
|
||||
|
||||
export interface ReaderReplaceRule {
|
||||
@@ -174,6 +213,8 @@ export interface ReaderBrowserPage {
|
||||
title: string
|
||||
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||
mode: 'wait' | 'open'
|
||||
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
|
||||
seq: number
|
||||
/** iframe 承载地址(同源,带签名)。 */
|
||||
page_url: string
|
||||
refetch: boolean
|
||||
@@ -223,6 +264,9 @@ export const readerAPI = {
|
||||
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
||||
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
||||
api.post('/reader/browser/result', body).then((r) => r.data),
|
||||
// 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
|
||||
openContentComment: (body: { book_id: string; url: string; title?: string }) =>
|
||||
api.post<ReaderBrowserPage>('/reader/comments/open', body).then((r) => r.data),
|
||||
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
||||
browserXHR: (body: {
|
||||
id: string
|
||||
@@ -245,11 +289,14 @@ export const readerAPI = {
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
// page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。
|
||||
search: (key: string, sourceIds: string[] = [], page = 1) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
{ key, source_ids: sourceIds, page },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
@@ -315,11 +362,16 @@ export const readerAPI = {
|
||||
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
|
||||
/**
|
||||
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
|
||||
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
|
||||
*/
|
||||
refreshBooksToc: () =>
|
||||
api
|
||||
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
|
||||
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
|
||||
'/reader/shelf/refresh-toc',
|
||||
{},
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
/**
|
||||
* 换源:把书架里的书切到另一个书源。
|
||||
@@ -338,9 +390,25 @@ export const readerAPI = {
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
|
||||
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
|
||||
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
|
||||
api
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
|
||||
params: { chapter },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
signal,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// 批量取正文(窗口预取 / 离线缓存):单章失败不中断整批,命中持久缓存的章节零网络开销。
|
||||
bookContentBatch: (id: string, chapterIndexes: number[], signal?: AbortSignal) =>
|
||||
api
|
||||
.post<ReaderContentBatchResult>(
|
||||
`/reader/books/${id}/content-batch`,
|
||||
{ chapter_indexes: chapterIndexes },
|
||||
{ timeout: LONG_REQUEST_TIMEOUT, signal },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
|
||||
+50
-31
@@ -1,37 +1,56 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
// 令牌刷新 API 模块。
|
||||
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
const REFRESH_ENDPOINT = '/api/auth/refresh'
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
|
||||
// 不要用默认的长超时把页面卡在等待里。
|
||||
const REFRESH_TIMEOUT_MS = 15_000
|
||||
|
||||
// 刷新访问令牌。
|
||||
//
|
||||
// 后端响应封装在 { code, message, data } 里,需解包 .data。
|
||||
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
|
||||
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: RefreshTokenResponse
|
||||
}>('/auth/refresh', { refresh_token: refreshToken })
|
||||
const body = resp.data
|
||||
if (!body || !body.data || !body.data.token) {
|
||||
throw new Error(body?.message || 'refresh failed')
|
||||
/**
|
||||
* 用 refresh token 换取新的令牌对。
|
||||
*
|
||||
* 刻意使用 fetch 而不是共享的 axios 实例:
|
||||
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
|
||||
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
|
||||
*
|
||||
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
|
||||
*/
|
||||
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
|
||||
throw new RefreshRequestError('refresh request failed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
|
||||
| null
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
|
||||
}
|
||||
|
||||
const token = body?.data?.token
|
||||
const nextRefreshToken = body?.data?.refresh_token
|
||||
if (!token || !nextRefreshToken) {
|
||||
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
|
||||
// 不要据此清空用户会话。
|
||||
throw new RefreshRequestError('malformed refresh response')
|
||||
}
|
||||
return { token, refresh_token: nextRefreshToken }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { BookGroupTab } from '../utils/readerBookGroups'
|
||||
|
||||
/**
|
||||
* 书架分组栏(对齐影视模块的 LibraryTagBar):
|
||||
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
|
||||
* 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。
|
||||
*
|
||||
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
|
||||
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type LoadMoreSentinelProps = {
|
||||
/** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */
|
||||
error?: string
|
||||
onRetry?: () => void
|
||||
label?: string
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调
|
||||
* onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。
|
||||
*
|
||||
* 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会
|
||||
* 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空
|
||||
* 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。
|
||||
*/
|
||||
export function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
error = '',
|
||||
onRetry,
|
||||
label = '加载更多',
|
||||
loadingLabel = '加载中…',
|
||||
}: LoadMoreSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore || error) return
|
||||
// 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore, error])
|
||||
|
||||
if (!hasMore) return null
|
||||
|
||||
const showRetry = !!error && !!onRetry
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
|
||||
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={showRetry ? onRetry : onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? loadingLabel : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,10 +41,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
const card = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
@@ -120,26 +120,34 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact
|
||||
? 'truncate text-xs'
|
||||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||||
{media.video_codec && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
@@ -151,8 +159,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
<div className="group relative block h-full w-full">
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
@@ -166,8 +174,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
<div className="group relative block h-full">
|
||||
<Link to={href} state={linkState} className="block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -178,7 +186,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
<Link to={href} state={linkState} className="group block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
// 手机上单卡只有约 100px 宽,间距收到 gap-3 给标题和徽标多留一点宽度。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
'grid grid-cols-3 gap-3 sm:gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
@@ -105,7 +106,7 @@ export function VirtualMediaGrid({
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-3 sm:pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { decideDoubleTap } from './useDoubleTap.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`useDoubleTap: ${name}`)
|
||||
}
|
||||
|
||||
// 空闲时的第一下:进入等待窗口,单击不立刻触发(留给定时器)。
|
||||
const first = decideDoubleTap('none', 'tap')
|
||||
check('first tap opens a window', first.window === 'tap')
|
||||
check('first tap does not fire immediately', first.fire === null)
|
||||
|
||||
// 窗口里的第二下:判成双击并立刻触发(必须在事件里同步执行,见实现注释)。
|
||||
const second = decideDoubleTap('tap', 'tap')
|
||||
check('second tap fires double', second.fire === 'double')
|
||||
check('second tap closes the window', second.window === 'none')
|
||||
|
||||
// 菜单遮罩关菜单:只开窗口,不触发单击,也不影响状态语义。
|
||||
const armed = decideDoubleTap('none', 'arm')
|
||||
check('arm opens a window', armed.window === 'armed')
|
||||
check('arm never fires a single', armed.fire === null)
|
||||
|
||||
// 关掉菜单后紧接的第二下要算双击,否则菜单一开一关就永远进不了全屏。
|
||||
check('tap after arm fires double', decideDoubleTap('armed', 'tap').fire === 'double')
|
||||
|
||||
// 已经在双击窗口里再 arm:续期,窗口保持 armed。
|
||||
check('arm during window stays armed', decideDoubleTap('armed', 'arm').window === 'armed')
|
||||
check('arm during tap window stays armed', decideDoubleTap('tap', 'arm').window === 'armed')
|
||||
|
||||
console.log('useDoubleTap.test.ts ok')
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/** 两次点击间隔在这个毫秒数以内算一次双击。 */
|
||||
export const DOUBLE_TAP_MS = 280
|
||||
|
||||
/**
|
||||
* 双击窗口的状态。
|
||||
*
|
||||
* - `none` 空闲,没有待判定的点击
|
||||
* - `tap` 刚单击过一次,还等第二下;窗口期满仍没有第二下就触发单击
|
||||
* - `armed` 只开了双击窗口(菜单遮罩关掉菜单时用),期满什么也不触发
|
||||
*/
|
||||
export type DoubleTapWindow = 'none' | 'tap' | 'armed'
|
||||
|
||||
/** 一次动作立刻要触发的效果;null 表示只更新窗口状态、不立刻触发(留给定时器)。 */
|
||||
export type DoubleTapFire = 'single' | 'double' | null
|
||||
|
||||
export interface DoubleTapDecision {
|
||||
window: DoubleTapWindow
|
||||
fire: DoubleTapFire
|
||||
}
|
||||
|
||||
/**
|
||||
* 双击窗口的纯决策(不碰计时器,便于单测):给定当前窗口状态和这次动作,
|
||||
* 得出窗口进入什么状态、要不要立刻触发回调。
|
||||
*
|
||||
* `tap` = 用户点了一下中间区域;`arm` = 只开窗口不算点击(关菜单时调用)。
|
||||
*/
|
||||
export function decideDoubleTap(window: DoubleTapWindow, action: 'tap' | 'arm'): DoubleTapDecision {
|
||||
if (action === 'arm') {
|
||||
// 只开窗口:已在窗口里就续期,否则新开一个 armed 窗口,永远不触发单击。
|
||||
return { window: 'armed', fire: null }
|
||||
}
|
||||
// 已经有窗口(无论是单击待定的 tap 还是刚 arm 的)→ 第二下到达,判成双击。
|
||||
if (window !== 'none') return { window: 'none', fire: 'double' }
|
||||
// 第一下:进入 tap 窗口等第二下,单击由窗口期满的定时器触发。
|
||||
return { window: 'tap', fire: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 中间区域的「单击 / 双击」识别(手机上用来区分呼出菜单和进出全屏)。
|
||||
*
|
||||
* 中间区域原先单击就是呼出菜单,现在双击还要切全屏,两个动作落在同一块屏幕上,
|
||||
* 就得先等一小会儿看有没有第二下:第一下把窗口置为 `tap`,第二下在窗口内到达就
|
||||
* 取消单击、改判双击。**双击回调必须在第二次点击的事件里同步执行**——浏览器只认
|
||||
* 用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。
|
||||
*
|
||||
* 返回三个入口:
|
||||
* - `tap()`:分区点击。`enabled` 为假(桌面端,或 iOS 这种不支持元素全屏的浏览器)
|
||||
* 时不做任何延迟,单击立刻生效,端上呼出菜单的手感与改动前完全一致。
|
||||
* - `arm()`:只开一个双击窗口、不排单击。菜单打开时整屏遮罩会先吃掉那次点击
|
||||
* (见 ReaderViewPage 的关闭遮罩),遮罩把菜单关掉的同时调它,紧跟着的第二下
|
||||
* 才能落进窗口被判成双击,而不是又当成单击把菜单重新打开。
|
||||
* - `cancel()`:丢掉待判定的点击。用户点了中间又立刻去翻页时,别让那次单击
|
||||
* 在窗口期满时把菜单弹出来。
|
||||
*/
|
||||
export function useDoubleTap({
|
||||
onDoubleTap,
|
||||
onSingleTap,
|
||||
enabled,
|
||||
delay = DOUBLE_TAP_MS,
|
||||
}: {
|
||||
onDoubleTap: () => void
|
||||
onSingleTap: () => void
|
||||
enabled: boolean
|
||||
delay?: number
|
||||
}): { tap: () => void; arm: () => void; cancel: () => void } {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const windowRef = useRef<DoubleTapWindow>('none')
|
||||
// 回调每次渲染都可能是新闭包,用 ref 兜住,免得已排队的定时器跟着旧闭包一起失效。
|
||||
const doubleRef = useRef(onDoubleTap)
|
||||
const singleRef = useRef(onSingleTap)
|
||||
useEffect(() => {
|
||||
doubleRef.current = onDoubleTap
|
||||
singleRef.current = onSingleTap
|
||||
})
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 取消待判定的点击并回到空闲。 */
|
||||
const cancel = useCallback(() => {
|
||||
clear()
|
||||
windowRef.current = 'none'
|
||||
}, [clear])
|
||||
|
||||
/** 开一个双击窗口:期满后,tap 窗口触发单击,armed 窗口什么也不做。 */
|
||||
const startWindow = useCallback(
|
||||
(kind: DoubleTapWindow) => {
|
||||
clear()
|
||||
windowRef.current = kind
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const wasTap = windowRef.current === 'tap'
|
||||
windowRef.current = 'none'
|
||||
if (wasTap) singleRef.current()
|
||||
}, delay)
|
||||
},
|
||||
[clear, delay],
|
||||
)
|
||||
|
||||
// 卸载时清掉待执行的单击,避免离开页面后还蹦一下。
|
||||
useEffect(() => cancel, [cancel])
|
||||
// 双击被关掉(如切到桌面布局)时,丢弃已排队的点击。
|
||||
useEffect(() => {
|
||||
if (!enabled) cancel()
|
||||
}, [enabled, cancel])
|
||||
|
||||
const tap = useCallback(() => {
|
||||
if (!enabled) {
|
||||
singleRef.current()
|
||||
return
|
||||
}
|
||||
const decision = decideDoubleTap(windowRef.current, 'tap')
|
||||
if (decision.fire === 'double') {
|
||||
// 第二下:取消待定的单击,改判双击(同步执行,保住用户手势上下文)。
|
||||
cancel()
|
||||
doubleRef.current()
|
||||
return
|
||||
}
|
||||
startWindow(decision.window)
|
||||
}, [enabled, cancel, startWindow])
|
||||
|
||||
const arm = useCallback(() => {
|
||||
if (!enabled) return
|
||||
startWindow(decideDoubleTap(windowRef.current, 'arm').window)
|
||||
}, [enabled, startWindow])
|
||||
|
||||
return { tap, arm, cancel }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
|
||||
|
||||
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
|
||||
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
||||
//
|
||||
// 判定规则(和纵向滚动共存的关键):
|
||||
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
|
||||
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
|
||||
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
|
||||
// 短距离当点击处理,交给下面的分区点击。
|
||||
//
|
||||
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
|
||||
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
|
||||
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
|
||||
// (慢慢拖过去也是拖动)。
|
||||
//
|
||||
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
||||
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
||||
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
||||
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
||||
|
||||
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
|
||||
const DIRECTION_SLOP = 12
|
||||
|
||||
interface HorizontalSwipeOptions {
|
||||
/** 从左往右滑(手指向右移)。 */
|
||||
onSwipeRight: () => void
|
||||
/** 从右往左滑(手指向左移)。 */
|
||||
onSwipeLeft: () => void
|
||||
/** 触发翻页所需的最小水平位移(px)。 */
|
||||
minDistance?: number
|
||||
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
||||
dominance?: number
|
||||
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
|
||||
maxDuration?: number
|
||||
/**
|
||||
* 跟手拖拽开始(方向判定通过、第一次 onDrag 之前)调一次。
|
||||
* 宿主在这里记下跟手层当前的实际位移:上一次翻页的归位动画可能还没滑完,
|
||||
* 新手势要接着那点残余位移继续,否则中途接管会把残余一笔抹掉,画面跳一格。
|
||||
*/
|
||||
onDragStart?: () => void
|
||||
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
|
||||
onDrag?: (dx: number) => void
|
||||
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
|
||||
onDragEnd?: (dx: number) => void
|
||||
}
|
||||
|
||||
interface HorizontalSwipeHandlers {
|
||||
onTouchStart: (e: ReactTouchEvent) => void
|
||||
onTouchMove: (e: ReactTouchEvent) => void
|
||||
onTouchEnd: (e: ReactTouchEvent) => void
|
||||
onTouchCancel: () => void
|
||||
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
||||
consumeSwipe: () => boolean
|
||||
}
|
||||
|
||||
export function useHorizontalSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
minDistance = 45,
|
||||
dominance = 1.2,
|
||||
maxDuration = 800,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
||||
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
||||
const consumedRef = useRef(false)
|
||||
/** 本次手势是否已判定为横滑并在跟手。 */
|
||||
const draggingRef = useRef(false)
|
||||
const lastDxRef = useRef(0)
|
||||
|
||||
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
|
||||
consumedRef.current = false
|
||||
draggingRef.current = false
|
||||
lastDxRef.current = 0
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
const t = e.touches[0]
|
||||
if (!start || !t) return
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (!draggingRef.current) {
|
||||
if (!onDrag) return
|
||||
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
|
||||
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) {
|
||||
startRef.current = null
|
||||
return
|
||||
}
|
||||
draggingRef.current = true
|
||||
onDragStart?.()
|
||||
}
|
||||
lastDxRef.current = dx
|
||||
onDrag?.(dx)
|
||||
},
|
||||
[onDragStart, onDrag, dominance],
|
||||
)
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
startRef.current = null
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false
|
||||
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
|
||||
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
|
||||
consumedRef.current = true
|
||||
const t = e.changedTouches[0]
|
||||
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
|
||||
const dx = start && t ? t.clientX - start.x : lastDxRef.current
|
||||
onDragEnd?.(dx)
|
||||
return
|
||||
}
|
||||
const t = e.changedTouches[0]
|
||||
if (!start || !t) return
|
||||
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (Date.now() - start.t > maxDuration) return
|
||||
if (Math.abs(dx) < minDistance) return
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) return
|
||||
|
||||
consumedRef.current = true
|
||||
if (dx > 0) onSwipeRight()
|
||||
else onSwipeLeft()
|
||||
},
|
||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
|
||||
)
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
startRef.current = null
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
|
||||
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
|
||||
onDragEnd?.(0)
|
||||
}, [onDragEnd])
|
||||
|
||||
const consumeSwipe = useCallback(() => {
|
||||
if (!consumedRef.current) return false
|
||||
consumedRef.current = false
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 按设备能力判断「手机/平板」:没有真正的鼠标悬停能力的就是触摸设备。
|
||||
// 应用里已有同样的做法(PlayerControls / Vr360Stage 的 isHoverlessDevice),
|
||||
// 这里做成 hook:媒体查询在字体缩放、外接鼠标插拔、chrome 设备模拟时会变化,
|
||||
// 跟随 change 事件回写,比渲染时读一次更稳。
|
||||
const HOVERLESS_QUERY = '(hover: none)'
|
||||
|
||||
function queryHoverless(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
||||
return window.matchMedia(HOVERLESS_QUERY).matches
|
||||
}
|
||||
|
||||
/** 当前设备是否是触摸设备(手机/平板)。桌面端(带鼠标)返回 false。 */
|
||||
export function useIsTouchDevice(): boolean {
|
||||
const [touch, setTouch] = useState(queryHoverless)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||
const media = window.matchMedia(HOVERLESS_QUERY)
|
||||
const onChange = () => setTouch(media.matches)
|
||||
onChange()
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return touch
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
@@ -52,13 +52,24 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
let timer: number | undefined
|
||||
let reconnectAttempts = 0
|
||||
|
||||
// 过期的 token 握手必然 401。先走刷新流程:成功会更新 token 并让本
|
||||
// effect 重建连接;「暂时不可用」(部署/重启窗口的网络故障)时保留
|
||||
// 会话继续退避重试;只有服务端明确判定凭证失效才会停止重连。
|
||||
const refreshExpiredToken = () => {
|
||||
void useAuthStore
|
||||
.getState()
|
||||
.tokenRefresh()
|
||||
.then((refreshed) => {
|
||||
if (closed || refreshed) return
|
||||
if (!useAuthStore.getState().refreshToken) return
|
||||
timer = window.setTimeout(open, SLOW_RECONNECT_INTERVAL)
|
||||
})
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
|
||||
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
|
||||
// 让本 effect 重建连接,失败则清空会话停止重连。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
@@ -82,7 +93,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
reconnectAttempts += 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { LoadMoreSentinel } from '../components/LoadMoreSentinel'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid'
|
||||
import type { Media } from '../types'
|
||||
@@ -67,57 +68,11 @@ export function LibraryMediaSections({
|
||||
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
|
||||
)}
|
||||
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
<LoadMoreSentinel hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore) return
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore])
|
||||
|
||||
if (!hasMore) return null
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
|
||||
+106
-11
@@ -81,6 +81,10 @@ type PlaybackProgressSession = {
|
||||
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
||||
const SKIP_NOTICE_MS = 6000
|
||||
|
||||
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
|
||||
const CLOUD_HLS_RELOAD_MAX = 3
|
||||
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -185,6 +189,17 @@ export function PlayerPage() {
|
||||
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const cloudRetryRef = useRef(5)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
// 云 HLS 会话重建计数(时间戳)。115 云转码的会话只存在服务端内存里:服务
|
||||
// 重新部署、会话过期(30 分钟无活动)、上游地址失效都会让播放中途拿到
|
||||
// 404/410。这种情况下重新拉一次 master.m3u8 建个新会话即可续播,没必要直接
|
||||
// 掉到本地转码(这台机器本地转码只有 0.6x,等于播放报废)。窗口内限制次数,
|
||||
// 避免上游真的坏了时无限重连。
|
||||
const cloudReloadsRef = useRef<number[]>([])
|
||||
const [cloudReloadNonce, setCloudReloadNonce] = useState(0)
|
||||
// 切回直连/原画时把当前位置带过去:HLS 卸载会把 video.currentTime 归零,
|
||||
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
|
||||
const pendingDirectStartRef = useRef(0)
|
||||
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -488,6 +503,8 @@ export function PlayerPage() {
|
||||
setCloudWaitMessage('')
|
||||
setCloudWaitStartedAt(0)
|
||||
pendingSeekRef.current = null
|
||||
cloudReloadsRef.current = []
|
||||
pendingDirectStartRef.current = 0
|
||||
if (cloudPollTimerRef.current) {
|
||||
clearTimeout(cloudPollTimerRef.current)
|
||||
cloudPollTimerRef.current = null
|
||||
@@ -953,6 +970,38 @@ export function PlayerPage() {
|
||||
[playbackInfo, setPlaybackMode],
|
||||
)
|
||||
|
||||
/**
|
||||
* 115 云 HLS 中途断流后重建会话。
|
||||
*
|
||||
* 115 的云端转码会话只存在服务端内存里:服务重新部署、会话到期(30 分钟无活动)、
|
||||
* 上游分片地址失效,都会让正在播放的客户端在后续分片上拿到 404/410。重新请求一次
|
||||
* master.m3u8 就会建出新会话,从当前位置继续播即可——比直接退回本地转码好得多
|
||||
* (低配宿主机本地转码往往追不上播放速度,等于不能看)。
|
||||
*
|
||||
* 窗口内超过次数上限返回 false,由调用方走本地转码兜底,避免上游真的坏了时无限重连。
|
||||
*/
|
||||
const reloadCloudHls = useCallback(
|
||||
(position: number): boolean => {
|
||||
const now = Date.now()
|
||||
const recent = cloudReloadsRef.current.filter((at) => now - at < CLOUD_HLS_RELOAD_WINDOW_MS)
|
||||
if (recent.length >= CLOUD_HLS_RELOAD_MAX) {
|
||||
cloudReloadsRef.current = recent
|
||||
return false
|
||||
}
|
||||
recent.push(now)
|
||||
cloudReloadsRef.current = recent
|
||||
// 云 HLS 是整条时间轴,起播后按绝对位置跳一下,别让用户回到片头。
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setHlsSource('cloud')
|
||||
setPlaybackMode('hls')
|
||||
setCloudReloadNonce((v) => v + 1)
|
||||
return true
|
||||
},
|
||||
[setPlaybackMode],
|
||||
)
|
||||
|
||||
const startCloudTranscode = useCallback(
|
||||
async (definition: number) => {
|
||||
if (!mediaId) return
|
||||
@@ -1126,7 +1175,10 @@ export function PlayerPage() {
|
||||
// hosts and remote STRM sources regularly need more than hls.js's
|
||||
// 10s default, which otherwise aborts a healthy transcode.
|
||||
manifestLoadingTimeOut: 60_000,
|
||||
manifestLoadingMaxRetry: 1,
|
||||
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
|
||||
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
|
||||
// 直接掉到本地转码。
|
||||
manifestLoadingMaxRetry: 4,
|
||||
})
|
||||
try {
|
||||
video.currentTime = 0
|
||||
@@ -1160,6 +1212,11 @@ export function PlayerPage() {
|
||||
if (data.fatal) {
|
||||
if (hlsSource === 'cloud') {
|
||||
const position = ref.current?.currentTime || 0
|
||||
// 先尝试重建云会话续播;只有连着失败到上限才退回本地转码。
|
||||
if (reloadCloudHls(position)) {
|
||||
toast('115 云端播放中断,正在重新连接…', { duration: 2200 })
|
||||
return
|
||||
}
|
||||
setHlsUnavailable(false)
|
||||
switchToLocalHLS(position)
|
||||
toast.error('115 云端播放失败,切换本地转码')
|
||||
@@ -1211,12 +1268,24 @@ export function PlayerPage() {
|
||||
if (video.src !== absoluteURL) {
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
|
||||
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
|
||||
// 保住当前位置。两个来源:显式切回直连/原画时提前记下的目标位置
|
||||
// (HLS 卸载会把 video.currentTime 归零,所以不能只读 video.currentTime),
|
||||
// 以及 VR 进出时切换播放源地址的情形。
|
||||
const resumeAt = Math.max(
|
||||
pendingDirectStartRef.current,
|
||||
video.currentTime > 2 ? video.currentTime : 0,
|
||||
)
|
||||
pendingDirectStartRef.current = 0
|
||||
if (resumeAt > 0) {
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
|
||||
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
|
||||
if (isStrmMedia(currentMedia)) {
|
||||
seekStrmDirectTo(resumeAt)
|
||||
return
|
||||
}
|
||||
try {
|
||||
video.currentTime = resumeAt
|
||||
} catch {
|
||||
@@ -1243,6 +1312,7 @@ export function PlayerPage() {
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
clearFallbackTimer,
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
hlsUnavailable,
|
||||
@@ -1250,6 +1320,8 @@ export function PlayerPage() {
|
||||
mediaId,
|
||||
mode,
|
||||
playbackProvider,
|
||||
reloadCloudHls,
|
||||
seekStrmDirectTo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
switchToLocalHLS,
|
||||
@@ -1398,8 +1470,9 @@ export function PlayerPage() {
|
||||
const modeRef = useRef(mode)
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
hlsSourceRef.current = hlsSource
|
||||
modeRef.current = mode
|
||||
}, [hlsStartSec, mode])
|
||||
}, [hlsStartSec, hlsSource, mode])
|
||||
|
||||
// Persist resume position every 10 seconds while playing, and immediately upon
|
||||
// pause/page hide/unmount. Bind after mediaId is available because
|
||||
@@ -1412,16 +1485,21 @@ export function PlayerPage() {
|
||||
lastSentRef.current = 0
|
||||
}
|
||||
|
||||
// 本地 HLS 的播放列表 t=0 对应 hlsStartSec,云 HLS 的播放列表本身就是整条
|
||||
// 时间轴(起点恒为 0)。以前两种模式都叠加 hlsStartSec:只要本地转码残留过
|
||||
// 一个非 0 起点再切到云端,上报的进度和总时长就会被整体抬高(实测把 7624s
|
||||
// 的片子记成 8346s 并直接标成「已看完」)。
|
||||
const timelineOffsetSec = () =>
|
||||
modeRef.current === 'hls' && hlsSourceRef.current === 'local' ? hlsStartSecRef.current : 0
|
||||
|
||||
const absolutePositionMs = () => {
|
||||
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const absolute = currentStartSec + (video.currentTime || 0)
|
||||
const absolute = timelineOffsetSec() + (video.currentTime || 0)
|
||||
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
|
||||
}
|
||||
const absoluteDurationMs = () => {
|
||||
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const mediaDur = mediaRef.current?.duration_sec || 0
|
||||
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
|
||||
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
|
||||
const totalSec = Math.max(mediaDur, timelineOffsetSec() + streamDur)
|
||||
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
|
||||
}
|
||||
const send = (keepalive: boolean) => {
|
||||
@@ -1782,16 +1860,27 @@ export function PlayerPage() {
|
||||
toast('该媒体为直连播放,无需且不支持转码')
|
||||
return
|
||||
}
|
||||
const video = ref.current
|
||||
// HLS 卸载会把 video.currentTime 归零,所以先按当前模式换算成绝对秒数记下来。
|
||||
const position = video
|
||||
? mode === 'hls' && hlsSource === 'local'
|
||||
? hlsStartSec + video.currentTime
|
||||
: video.currentTime
|
||||
: 0
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
if (next === 'hls') {
|
||||
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
|
||||
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||
enterHlsPlayback(position)
|
||||
} else {
|
||||
setCloudWaiting(false)
|
||||
// 直连要等 loadedmetadata 再跳(见播放方式接线 effect),这里先把目标位置存下来。
|
||||
pendingDirectStartRef.current = position
|
||||
}
|
||||
setPlaybackMode(next)
|
||||
}, [
|
||||
enterHlsPlayback,
|
||||
hlsSource,
|
||||
hlsStartSec,
|
||||
isDirectStream,
|
||||
mode,
|
||||
setPlaybackMode,
|
||||
@@ -1989,16 +2078,22 @@ export function PlayerPage() {
|
||||
const selectPlaybackQuality = useCallback(
|
||||
(quality: PlaybackQuality) => {
|
||||
const video = ref.current
|
||||
const position = video?.currentTime || 0
|
||||
const localTime = video?.currentTime || 0
|
||||
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
|
||||
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
|
||||
if (quality.source === 'original') {
|
||||
setCloudWaiting(false)
|
||||
setHlsSource('local')
|
||||
// 切回原始文件直连:位置由直连分支的 loadedmetadata 跳转负责带过去。
|
||||
pendingDirectStartRef.current = position
|
||||
setPlaybackMode('direct')
|
||||
return
|
||||
}
|
||||
if (quality.source === 'cloud') {
|
||||
setSelectedQuality(quality.id)
|
||||
setHlsSource('cloud')
|
||||
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
|
||||
cloudReloadsRef.current = []
|
||||
if (quality.available) {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
@@ -2025,7 +2120,7 @@ export function PlayerPage() {
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[setPlaybackMode, startCloudTranscode],
|
||||
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
|
||||
)
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
ListMusic,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Timer,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import type { ReaderChapter } from '../../api/reader'
|
||||
import {
|
||||
AUDIO_CREDITS_MAX,
|
||||
AUDIO_SPEEDS,
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
@@ -30,35 +14,12 @@ import { readerCoverSrc } from '../../utils/readerCover'
|
||||
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||
//
|
||||
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
|
||||
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
|
||||
|
||||
interface ReaderAudioPanelProps {
|
||||
src: string
|
||||
title: string
|
||||
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
|
||||
cover: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
initialPos: number // 恢复进度(秒)
|
||||
/** 片头跳过秒数(Book.openCredits)。 */
|
||||
openCredits: number
|
||||
/** 片尾跳过秒数(Book.closeCredits)。 */
|
||||
closeCredits: number
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number | null
|
||||
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
|
||||
hasNext: boolean
|
||||
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
onProgress: (seconds: number) => void
|
||||
/**
|
||||
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
|
||||
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
|
||||
*/
|
||||
onCommitProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
@@ -84,56 +45,34 @@ function fmtCountdown(sec: number): string {
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
cover,
|
||||
theme,
|
||||
initialPos,
|
||||
openCredits,
|
||||
closeCredits,
|
||||
chapters,
|
||||
chapterIndex,
|
||||
hasNext,
|
||||
transcoding = false,
|
||||
onProgress,
|
||||
onCommitProgress,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onSelectChapter,
|
||||
onEnded,
|
||||
onCreditsChange,
|
||||
onToggleMenu,
|
||||
}: ReaderAudioPanelProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||
const restoredRef = useRef(false)
|
||||
const skippedEndRef = useRef(false)
|
||||
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
|
||||
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
|
||||
const engine = useMemo(() => getReaderAudioEngine(), [])
|
||||
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const chapters = useReaderAudioStore((s) => s.chapters)
|
||||
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
const audioError = useReaderAudioStore((s) => s.error)
|
||||
const cur = useReaderAudioStore((s) => s.cur)
|
||||
const dur = useReaderAudioStore((s) => s.dur)
|
||||
const speed = useReaderAudioStore((s) => s.speed)
|
||||
const transcoding = useReaderAudioStore((s) => s.transcoding)
|
||||
const openCredits = useReaderAudioStore((s) => s.openCredits)
|
||||
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
|
||||
const timerActive = useReaderAudioStore((s) => s.timerActive)
|
||||
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
|
||||
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
|
||||
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [cur, setCur] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||
const [audioError, setAudioError] = useState('')
|
||||
const [sheet, setSheet] = useState<Sheet>('none')
|
||||
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
|
||||
const [groupIndex, setGroupIndex] = useState(0)
|
||||
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
|
||||
const lastTimeRef = useRef(0)
|
||||
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
|
||||
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
|
||||
const commitRef = useRef(onCommitProgress)
|
||||
useEffect(() => {
|
||||
commitRef.current = onCommitProgress
|
||||
}, [onCommitProgress])
|
||||
|
||||
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||
const [coverOK, setCoverOK] = useState(true)
|
||||
useEffect(() => {
|
||||
@@ -142,168 +81,22 @@ export function ReaderAudioPanel({
|
||||
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||
const [timerLeft, setTimerLeft] = useState(0)
|
||||
const [timerActive, setTimerActive] = useState(false)
|
||||
const [timerPreset, setTimerPreset] = useState(defaultTimer)
|
||||
const timerDeadlineRef = useRef(0)
|
||||
|
||||
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
|
||||
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
|
||||
useEffect(() => {
|
||||
setCreditDraft({ open: openCredits, close: closeCredits })
|
||||
}, [openCredits, closeCredits])
|
||||
|
||||
// ── 换源/换章:重建播放器 ──
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
restoredRef.current = false
|
||||
skippedEndRef.current = false
|
||||
setLoading(true)
|
||||
setAudioError('')
|
||||
setCur(0)
|
||||
setDur(0)
|
||||
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
|
||||
lastTimeRef.current = 0
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
const setup = async () => {
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (cancelled) return
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(audio)
|
||||
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoading(false)
|
||||
})
|
||||
hls = inst
|
||||
hlsRef.current = inst
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = src // Safari 原生 HLS
|
||||
}
|
||||
} else {
|
||||
audio.src = src
|
||||
}
|
||||
}
|
||||
void setup()
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.playbackRate = speed
|
||||
}, [speed])
|
||||
|
||||
// ── 离开这一章时补报进度 ──
|
||||
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
|
||||
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
|
||||
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
|
||||
// 因此不会把上一章的位置写到新章节头上。
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
|
||||
}
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('pagehide', flush)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('pagehide', flush)
|
||||
flush()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 定时关闭 ──
|
||||
const applyTimer = useCallback(
|
||||
(minutes: number) => {
|
||||
setDefaultTimer(minutes)
|
||||
setTimerPreset(minutes)
|
||||
if (minutes <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerLeft(0)
|
||||
setTimerActive(false)
|
||||
return
|
||||
}
|
||||
timerDeadlineRef.current = Date.now() + minutes * 60_000
|
||||
setTimerLeft(minutes * 60)
|
||||
setTimerActive(true)
|
||||
},
|
||||
[setDefaultTimer],
|
||||
)
|
||||
|
||||
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
const timerBootRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (timerBootRef.current) return
|
||||
timerBootRef.current = true
|
||||
if (defaultTimer > 0) applyTimer(defaultTimer)
|
||||
}, [applyTimer, defaultTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerActive) return
|
||||
const id = window.setInterval(() => {
|
||||
const audio = audioRef.current
|
||||
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
|
||||
if (audio && audio.paused) {
|
||||
timerDeadlineRef.current += 1000
|
||||
return
|
||||
}
|
||||
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
|
||||
setTimerLeft(left)
|
||||
if (left <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerActive(false)
|
||||
audio?.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [timerActive])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play().catch(() => undefined)
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
const seekBy = useCallback((delta: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
|
||||
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
|
||||
audio.currentTime = next
|
||||
setCur(next)
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback((seconds: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = seconds
|
||||
setCur(seconds)
|
||||
}, [])
|
||||
|
||||
const changeSpeed = (v: number) => {
|
||||
setSpeed(v)
|
||||
}
|
||||
const toggle = () => engine.toggle()
|
||||
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
|
||||
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
|
||||
const changeSpeed = (v: number) => engine.setSpeed(v)
|
||||
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
|
||||
|
||||
const saveCredits = (open: number, close: number) => {
|
||||
setCreditDraft({ open, close })
|
||||
onCreditsChange(open, close)
|
||||
engine.setCredits(open, close)
|
||||
}
|
||||
|
||||
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||
@@ -326,14 +119,11 @@ export function ReaderAudioPanel({
|
||||
}
|
||||
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
|
||||
setSheet(next)
|
||||
}
|
||||
|
||||
const chapterList = useMemo(
|
||||
() => chapters.map((c, i) => ({ ...c, i })),
|
||||
[chapters],
|
||||
)
|
||||
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
|
||||
|
||||
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
|
||||
@@ -446,7 +236,7 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevChapter}
|
||||
onClick={() => engine.prev()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="上一章"
|
||||
title="上一章"
|
||||
@@ -492,7 +282,7 @@ export function ReaderAudioPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextChapter}
|
||||
onClick={() => engine.next()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="下一章"
|
||||
title="下一章"
|
||||
@@ -602,7 +392,7 @@ export function ReaderAudioPanel({
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex)}
|
||||
rangeChanged={(range) =>
|
||||
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||
}
|
||||
@@ -614,7 +404,7 @@ export function ReaderAudioPanel({
|
||||
onClick={() => {
|
||||
if (row.is_volume) return
|
||||
closeSheet()
|
||||
onSelectChapter(row.i)
|
||||
engine.selectChapter(row.i)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
row.is_volume ? 'font-bold opacity-70' : ''
|
||||
@@ -764,71 +554,6 @@ export function ReaderAudioPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||
const code = e.currentTarget.error?.code
|
||||
setLoading(false)
|
||||
setAudioError(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget
|
||||
setDur(audio.duration)
|
||||
setLoading(false)
|
||||
audio.playbackRate = speed
|
||||
if (!restoredRef.current) {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
if (initialPos > 0 && (total === 0 || initialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = initialPos
|
||||
setCur(initialPos)
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
setCur(openCredits)
|
||||
}
|
||||
}
|
||||
restoredRef.current = true
|
||||
void audio.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const audio = e.currentTarget
|
||||
const t = audio.currentTime
|
||||
lastTimeRef.current = t
|
||||
setCur(t)
|
||||
onProgress(t)
|
||||
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||
if (
|
||||
closeCredits > 0 &&
|
||||
!skippedEndRef.current &&
|
||||
Number.isFinite(audio.duration) &&
|
||||
audio.duration > closeCredits + 1 &&
|
||||
t >= audio.duration - closeCredits
|
||||
) {
|
||||
skippedEndRef.current = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
onEnded()
|
||||
}
|
||||
}}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, R
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { pickChapterIndex } from './bookDetailModel'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
|
||||
@@ -24,6 +25,8 @@ export default function ReaderBookPage() {
|
||||
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
|
||||
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
|
||||
const [sourceType, setSourceType] = useState(0)
|
||||
const [tocExpanded, setTocExpanded] = useState(false)
|
||||
const [introExpanded, setIntroExpanded] = useState(false)
|
||||
// 换源:候选源来自按书名重新搜索的结果
|
||||
@@ -58,6 +61,16 @@ export default function ReaderBookPage() {
|
||||
.catch(() => setChapters([]))
|
||||
}, [bookURL, sourceURL])
|
||||
|
||||
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
|
||||
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
|
||||
useEffect(() => {
|
||||
if (!sourceURL) return
|
||||
readerAPI
|
||||
.listSources()
|
||||
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
|
||||
.catch(() => undefined)
|
||||
}, [sourceURL])
|
||||
|
||||
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
|
||||
useEffect(() => {
|
||||
const tocURL = info?.toc_url
|
||||
@@ -74,7 +87,16 @@ export default function ReaderBookPage() {
|
||||
|
||||
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
|
||||
|
||||
const startReading = async () => {
|
||||
/**
|
||||
* 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。
|
||||
*/
|
||||
const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise<number> => {
|
||||
const cached = await readerAPI.listChapters(bookID).catch(() => [])
|
||||
return pickChapterIndex(cached, chapter)
|
||||
}
|
||||
|
||||
// chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。
|
||||
const startReading = async (chapter?: ReaderTocChapter) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
let book = shelfBook
|
||||
@@ -84,7 +106,7 @@ export default function ReaderBookPage() {
|
||||
source_id: originID,
|
||||
origin: sourceURL,
|
||||
origin_name: originName || info?.name || '',
|
||||
origin_type: 0,
|
||||
origin_type: sourceType,
|
||||
book_url: bookURL,
|
||||
latest_chapter: info?.latest_chapter ?? '',
|
||||
},
|
||||
@@ -93,7 +115,9 @@ export default function ReaderBookPage() {
|
||||
cover_url: cover,
|
||||
})
|
||||
}
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
// 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。
|
||||
const index = chapter ? await resolveChapterIndex(book.id, chapter) : null
|
||||
navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
|
||||
} finally {
|
||||
@@ -261,11 +285,11 @@ export default function ReaderBookPage() {
|
||||
<Trash2 size={13} className="mr-1 inline" /> 移出书架
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
|
||||
<button type="button" onClick={() => startReading()} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
|
||||
<BookOpen size={13} className="mr-1 inline" /> 加入书架
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
|
||||
<button type="button" onClick={() => startReading()} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
|
||||
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
|
||||
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
|
||||
</button>
|
||||
@@ -306,6 +330,16 @@ export default function ReaderBookPage() {
|
||||
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
|
||||
{c.is_volume ? (
|
||||
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
|
||||
) : c.url ? (
|
||||
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => startReading(c)}
|
||||
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{c.title}
|
||||
</button>
|
||||
) : (
|
||||
c.title
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { rememberImageSize } from '../../utils/comicSpread'
|
||||
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
|
||||
import type { ComicImageFit } from '../../stores/readerSettings'
|
||||
|
||||
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
|
||||
//
|
||||
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
|
||||
// 这里只负责把它们并排摆好、各自撑满视口高度。
|
||||
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
|
||||
|
||||
interface ReaderComicProps {
|
||||
images: string[]
|
||||
@@ -14,17 +23,42 @@ interface ReaderComicProps {
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
/**
|
||||
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
|
||||
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
|
||||
*/
|
||||
draggable?: boolean
|
||||
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
|
||||
stageRef?: RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
function ComicImage({
|
||||
src,
|
||||
theme,
|
||||
fit = false,
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
eager = false,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
// fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
|
||||
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
|
||||
fit?: boolean
|
||||
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
|
||||
half?: boolean
|
||||
// imageFit:滚动模式的显示尺寸档位。
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
|
||||
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
|
||||
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
|
||||
eager?: boolean
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
@@ -34,23 +68,43 @@ function ComicImage({
|
||||
setState('loading')
|
||||
}, [src])
|
||||
|
||||
const loaded = state === 'ok'
|
||||
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
|
||||
const sizing = fit
|
||||
? {
|
||||
className: 'block max-h-full w-auto max-w-full object-contain',
|
||||
style: loaded ? {} : { minHeight: '10rem' },
|
||||
}
|
||||
: comicImageSizing(imageFit, viewportHeight, loaded)
|
||||
|
||||
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
|
||||
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
|
||||
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
|
||||
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
|
||||
style={{
|
||||
backgroundColor: theme.bg,
|
||||
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
|
||||
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
|
||||
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
|
||||
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
|
||||
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
|
||||
setState('ok')
|
||||
}}
|
||||
onError={() => setState('error')}
|
||||
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
|
||||
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
|
||||
className={sizing.className}
|
||||
style={sizing.style}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div
|
||||
@@ -72,10 +126,27 @@ function ComicImage({
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default', draggable = false, stageRef }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
// 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
|
||||
// root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
|
||||
// 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
|
||||
const [viewportHeight, setViewportHeight] = useState(0)
|
||||
|
||||
const selfSized = isSelfSizedComicFit(imageFit)
|
||||
useEffect(() => {
|
||||
// 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
|
||||
if (mode !== 'scroll' || !selfSized) return
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const update = () => setViewportHeight(el.clientHeight)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [mode, selfSized])
|
||||
|
||||
// 滚动模式:恢复进度(图片序号)并上报当前图
|
||||
useEffect(() => {
|
||||
@@ -98,6 +169,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
restoredRef.current = false
|
||||
}, [mode])
|
||||
|
||||
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
|
||||
useSmoothWheelScroll(scrollRef, mode === 'scroll')
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
@@ -107,11 +181,44 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
|
||||
const idx = Math.min(Math.max(page, 0), images.length - 1)
|
||||
// 翻页模式:翻页由外层点击区驱动(page 语义 = 图片序号)。
|
||||
// 双页铺开时外层按「屏」给好成员(spread),这里只负责并排摆开。
|
||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
||||
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||
const paired = idxs.length > 1
|
||||
|
||||
if (!draggable) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 跟手拖拽:当前页左右各摆一页,整条舞台由外层直接平移(stageRef),
|
||||
// 于是手指横滑时左右两页就在屏幕边上,滑到一半松手能接着滑完。
|
||||
// 外面再套一层 overflow-hidden:桌面端正文列限宽 900px 居中,两侧那两页要是
|
||||
// 溢出到窗口里会在留白处露出来。
|
||||
//
|
||||
// 单元格按图片序号做 key、位置用 left 百分比铺开,而不是固定三个「上一页/当前/下一页」
|
||||
// 槽位换内容:翻页时变的只是 left 偏移,<img> 的 src 不变,浏览器不会重新解码、
|
||||
// 也不会先闪一下转圈。两侧那两页同时充当下一页/上一页的预取。
|
||||
const cells = [page - 1, page, page + 1].filter((i) => i >= 0 && i < images.length)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ComicImage key={images[idx]} src={images[idx]} theme={theme} fit />
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div ref={stageRef} className="relative h-full w-full">
|
||||
{cells.map((i) => (
|
||||
<div
|
||||
key={images[i]}
|
||||
className="absolute inset-y-0 flex items-center justify-center"
|
||||
style={{ left: `${(i - page) * 100}%`, width: '100%' }}
|
||||
>
|
||||
<ComicImage src={images[i]} theme={theme} fit eager={i !== page} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -145,15 +252,17 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width
|
||||
if (x < 0.3) {
|
||||
onZone('left')
|
||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else if (x > 0.7) {
|
||||
onZone('right')
|
||||
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else {
|
||||
onZone('center')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComicImage src={src} theme={theme} />
|
||||
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
|
||||
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
|
||||
{i + 1} / {images.length}
|
||||
</p>
|
||||
|
||||
@@ -250,7 +250,11 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
if (res.total === 0) {
|
||||
toast.success('没有需要更新的网络书籍', { id: toastId })
|
||||
} else if (res.failed > 0) {
|
||||
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
|
||||
// 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档
|
||||
const parts = [`${res.updated} 本有新章节`]
|
||||
if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`)
|
||||
parts.push(`${res.failed} 本失败,可在日志中查看书源报错`)
|
||||
toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' })
|
||||
} else {
|
||||
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Pause, Play, X } from 'lucide-react'
|
||||
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 全局听书迷你条。
|
||||
//
|
||||
// 播放引擎是常驻单例(见 readerAudioEngine.ts),离开阅读页后音频仍在播;这个条
|
||||
// 保证在其他页面也能暂停/回到阅读,否则用户会「听到声音但找不到控制」。
|
||||
// 面板所在的阅读页内部不显示它(那里有完整播放面板),避免两套控制打架。
|
||||
export function ReaderMiniPlayer() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const track = useReaderAudioStore((s) => s.track)
|
||||
const bookId = useReaderAudioStore((s) => s.bookId)
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const bookName = useReaderAudioStore((s) => s.bookName)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
|
||||
const onReaderView = bookId !== '' && location.pathname === `/reader/view/${bookId}`
|
||||
if (!track || !bookId || onReaderView || status === 'idle') return null
|
||||
|
||||
const engine = getReaderAudioEngine()
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-3 z-[60] flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]/95 p-2 shadow-2xl backdrop-blur-md lg:inset-x-auto lg:right-6 lg:w-96"
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 3.75rem)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/reader/view/${bookId}`)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
title="回到阅读页"
|
||||
>
|
||||
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-brand-soft)]">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-bold text-[var(--app-text)]">{title || bookName}</span>
|
||||
<span className="block truncate text-[10px] text-[var(--app-muted)]">{bookName || 'MeBox 听书'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.toggle()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm"
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : playing ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.stop()}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
aria-label="停止播放"
|
||||
title="停止播放"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,32 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||
import { LoadMoreSentinel } from '../../components/LoadMoreSentinel'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from '../../utils/searchBooks'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
// 结果按页取:滚到底自动请求下一页并增量合并,直到某一页不再带来新书。
|
||||
|
||||
/** 单次搜索最多翻到第几页。书源分页规则千奇百怪,个别源会一直返回新条目,
|
||||
* 不设上限时用户一直往下滚就会一直发请求(每次都要把所有源再搜一遍)。 */
|
||||
const MAX_SEARCH_PAGES = 20
|
||||
|
||||
/** 这本搜索结果是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(searchBookKey(book.name, book.author))) return true
|
||||
if (book.book_url && shelf.has(book.book_url)) return true
|
||||
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
|
||||
}
|
||||
|
||||
export default function ReaderSearchPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -21,20 +39,86 @@ export default function ReaderSearchPage() {
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
const [showSkipped, setShowSkipped] = useState(false)
|
||||
const [adding, setAdding] = useState('')
|
||||
// 滚动加载:page 是已加载到的页码;hasMore 为 false 表示到底了(或还没搜过)。
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [moreError, setMoreError] = useState('')
|
||||
// loadMore 的在途标记(见 loadMore 里的说明),比 loadingMore 状态更早生效。
|
||||
const loadingMoreRef = useRef(false)
|
||||
// 每次新搜索 +1:在飞的 loadMore 回来时若已经换了关键词(searchSeq 已变),
|
||||
// 就把那一页结果丢掉,避免旧关键词的第 N 页被并进新列表。
|
||||
const searchSeq = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
|
||||
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
|
||||
const [sources, setSources] = useState<ReaderSource[]>([])
|
||||
const [showScope, setShowScope] = useState(false)
|
||||
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
|
||||
const activeScopeIds = useMemo(
|
||||
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
|
||||
[scopeIds, enabledSources],
|
||||
)
|
||||
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
|
||||
// 避免已保存子集在加载瞬间被错标成「全部书源」。
|
||||
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
|
||||
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
|
||||
|
||||
useEffect(() => {
|
||||
readerAPI.listSources().then(setSources).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
|
||||
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
readerAPI
|
||||
.listBooks()
|
||||
.then((books) => {
|
||||
const keys = new Set<string>()
|
||||
for (const b of books) {
|
||||
keys.add(searchBookKey(b.name, b.author))
|
||||
if (b.book_url) keys.add(b.book_url)
|
||||
}
|
||||
setShelfKeys(keys)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
|
||||
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
|
||||
// 且刚加进书架的书也会立刻从列表消失。
|
||||
const visibleBooks = useMemo(
|
||||
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
|
||||
[books, shelfKeys],
|
||||
)
|
||||
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
|
||||
|
||||
const doSearch = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || searching) return
|
||||
searchSeq.current++
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
setLoadingMore(false)
|
||||
setMoreError('')
|
||||
try {
|
||||
const res = await readerAPI.search(kw)
|
||||
setBooks(res.books ?? [])
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds, 1)
|
||||
const list = res.books ?? []
|
||||
setBooks(list)
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
setSkipped(res.skipped ?? [])
|
||||
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
|
||||
setPage(1)
|
||||
// 首页一条都没有 → 后面也不会有;有结果就先假定还有下一页。
|
||||
// 书源是否支持分页只有它自己知道({{page}} 也可能藏在 URL 的 <js> 里),
|
||||
// 所以这里不预判:多花一次「第 2 页」请求,由 loadMore 发现这一页没带来新书时收口。
|
||||
setHasMore(list.length > 0)
|
||||
if (list.length === 0) toast.error('所有书源都没有找到结果')
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
|
||||
toast.error(msg)
|
||||
@@ -43,6 +127,47 @@ export default function ReaderSearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底加载下一页。每页都会把范围内的书源重新搜一遍(后端按页下发),
|
||||
// 因此这里要注意两件事:
|
||||
// 1. 结果必须增量合并而不是替换——不支持 {{page}} 的源会在每一页重复返回首页结果;
|
||||
// 2. 合并后没有新书(重复内容或该页为空)就说明到底了,否则用户会一直滚一直转圈。
|
||||
const loadMore = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || !hasMore || loadingMore || searching || books === null) return
|
||||
// loadingMore 是渲染后才生效的状态:同一帧里观察器可能回调两次,用 ref 兜住,
|
||||
// 避免把同一页请求两次(每次翻页都要把所有源再搜一遍,重复请求不便宜)。
|
||||
if (loadingMoreRef.current) return
|
||||
loadingMoreRef.current = true
|
||||
const next = page + 1
|
||||
if (next > MAX_SEARCH_PAGES) {
|
||||
setHasMore(false)
|
||||
loadingMoreRef.current = false
|
||||
return
|
||||
}
|
||||
setLoadingMore(true)
|
||||
setMoreError('')
|
||||
const seq = searchSeq.current
|
||||
try {
|
||||
const res = await readerAPI.search(kw, scopeIds, next)
|
||||
// 期间用户又发起了一次搜索:这一页属于旧关键词,直接丢弃。
|
||||
if (searchSeq.current !== seq) return
|
||||
const incoming = res.books ?? []
|
||||
const merged = mergeSearchBooks(books, incoming)
|
||||
setBooks(merged)
|
||||
setSkipped((prev) => mergeSearchSkipped(prev, res.skipped ?? []))
|
||||
setPage(next)
|
||||
if (merged.length === books.length) setHasMore(false)
|
||||
} catch (e) {
|
||||
if (searchSeq.current !== seq) return
|
||||
// 不弹 toast 打断浏览:把失败原因挂在哨兵上,改由用户点重试。
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载下一页失败'
|
||||
setMoreError(msg)
|
||||
} finally {
|
||||
loadingMoreRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
|
||||
const autoSearchedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -52,12 +177,20 @@ export default function ReaderSearchPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
||||
const addToShelf = async (book: ReaderSearchBook) => {
|
||||
const origin = book.origins[0]
|
||||
if (!origin) return
|
||||
setAdding(book.book_url)
|
||||
try {
|
||||
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
||||
toast.success(`《${book.name}》已加入书架`)
|
||||
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||
setShelfKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(searchBookKey(book.name, book.author))
|
||||
if (origin.book_url) next.add(origin.book_url)
|
||||
return next
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
||||
toast.error(msg)
|
||||
@@ -113,6 +246,22 @@ export default function ReaderSearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScope(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
|
||||
>
|
||||
<ListFilter size={13} />
|
||||
搜索范围
|
||||
<span className="text-brand-600">{scopeLabel}</span>
|
||||
</button>
|
||||
{scopeAll && enabledSources.length > 0 && (
|
||||
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searching && (
|
||||
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={22} />
|
||||
@@ -120,9 +269,19 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && books !== null && (
|
||||
{!searching && visibleBooks !== null && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{books.map((book) => (
|
||||
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
|
||||
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||||
已隐藏 {hiddenOnShelf} 本已在书架里的书
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.length === 0 && (
|
||||
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
|
||||
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.map((book) => (
|
||||
<div
|
||||
key={`${book.name}|${book.author}`}
|
||||
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
||||
@@ -178,6 +337,19 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */}
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMore}
|
||||
error={moreError}
|
||||
onRetry={loadMore}
|
||||
loadingLabel={`正在搜索第 ${page + 1} 页…`}
|
||||
/>
|
||||
{!hasMore && !moreError && visibleBooks.length > 0 && (
|
||||
<p className="py-2 text-center text-2xs text-[var(--app-subtle)]">已经到底了</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
<button
|
||||
@@ -225,6 +397,18 @@ export default function ReaderSearchPage() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScope && (
|
||||
<SearchScopeDialog
|
||||
sources={sources}
|
||||
selectedIds={scopeIds}
|
||||
onConfirm={(ids) => {
|
||||
setSearchScopeIds(ids)
|
||||
setShowScope(false)
|
||||
}}
|
||||
onClose={() => setShowScope(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1105
-152
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user