mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dacfec7492 | |||
| b0f1ab39b9 | |||
| 5242d54a06 | |||
| e961969207 | |||
| b8b02cb3a7 | |||
| 60cfad47e5 | |||
| 47c8bfdd6c | |||
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c | |||
| 2c6646beea | |||
| 83ec494a36 | |||
| b8648f0049 |
@@ -33,13 +33,13 @@
|
||||
- 紧凑列表: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 `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/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 +94,12 @@
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 滚动模式的滚轮(`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+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,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))
|
||||
|
||||
@@ -249,6 +252,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 +295,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 +360,15 @@ 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"`
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -373,6 +402,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||
// 「Cannot read properties of null」。空目录统一给 []。
|
||||
if chapters == nil {
|
||||
chapters = []reader.TocChapter{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ type ReaderBookSource struct {
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -286,8 +286,12 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc
|
||||
if ck := state.CookieForRequest(source); ck != "" {
|
||||
headers["Cookie"] = ck
|
||||
}
|
||||
// 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法,
|
||||
// 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。
|
||||
if headers["Referer"] == "" {
|
||||
headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/"
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
headers["Referer"] = referer
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,12 +86,18 @@ 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
|
||||
@@ -108,21 +117,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 +190,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,6 +212,7 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
cookies: cookies,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
@@ -180,6 +225,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 +359,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,7 +371,7 @@ 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)
|
||||
}
|
||||
@@ -348,8 +395,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 +404,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 +468,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"
|
||||
@@ -449,6 +507,11 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, 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 +562,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"
|
||||
@@ -716,6 +781,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 +795,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 +824,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 +863,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,6 +881,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
ID: e.id,
|
||||
Title: e.request.Title,
|
||||
Mode: e.mode,
|
||||
Seq: e.seq,
|
||||
PageURL: s.browserPageURL(e.id),
|
||||
Refetch: e.request.Refetch,
|
||||
SourceID: e.sourceID,
|
||||
|
||||
@@ -201,7 +201,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 +266,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 +313,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 +390,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,107 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 HTML。
|
||||
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。
|
||||
func TestImageRefsInLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}},
|
||||
{"双引号 img", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
|
||||
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
|
||||
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
|
||||
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
|
||||
{"img 没有 src 时跳过", `<img alt="空">`, nil},
|
||||
{"非 img 的 HTML 片段跳过", `</div>`, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := imageRefsInLine(c.line)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Fatalf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址,
|
||||
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
|
||||
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
page := `<html><body><div class="pages">
|
||||
<img src="/img/1.webp"><img src="/img/2.webp">
|
||||
</div></body></html>`
|
||||
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(page))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "漫画测试源",
|
||||
"bookSourceType": 64,
|
||||
"ruleContent": { "content": "class.pages@html" }
|
||||
}`, srv.URL)
|
||||
sourceID := importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "漫画测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "航海王",
|
||||
Type: 2, // 图片
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
_ = sourceID
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "image" {
|
||||
t.Fatalf("类型 = %q,期望 image", out.Type)
|
||||
}
|
||||
if len(out.Images) != 2 {
|
||||
t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
// 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头),
|
||||
// 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。
|
||||
if !strings.HasPrefix(img, "/api/reader/media?") {
|
||||
t.Fatalf("第 %d 张不是代理地址:%q", i, img)
|
||||
}
|
||||
encoded := img[strings.Index(img, "u=")+2:]
|
||||
if end := strings.IndexByte(encoded, '&'); end >= 0 {
|
||||
encoded = encoded[:end]
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 张地址解不开:%q", i, img)
|
||||
}
|
||||
want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1)
|
||||
if string(raw) != want {
|
||||
t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,55 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法:
|
||||
//
|
||||
// $..url
|
||||
// <js>result.split("\n").map(x=>'<img src="'+x+'">').join("\n")</js>
|
||||
//
|
||||
// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串
|
||||
// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则),
|
||||
// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。
|
||||
func TestContentRuleJSReceivesJoinedString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
const srcURL = "https://www.mangacopy.com/"
|
||||
const contentRule = "$..url\n<js>result.split(\"\\n\").map(x=>'<img src=\"'+x+'\">').join(\"\\n\")</js>"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srcURL,
|
||||
RuleContent: &ContentRule{Content: strPtr(contentRule)},
|
||||
Header: &header,
|
||||
}
|
||||
|
||||
body := `{"results":{"chapter":{"contents":[` +
|
||||
`{"url":"https://img.example.com/1.webp"},` +
|
||||
`{"url":"https://img.example.com/2.webp"}]}}}`
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x")
|
||||
|
||||
got, err := ar.GetString(contentRule, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("正文规则求值失败: %v", err)
|
||||
}
|
||||
want := "<img src=\"https://img.example.com/1.webp\">\n<img src=\"https://img.example.com/2.webp\">"
|
||||
if got != want {
|
||||
t.Fatalf("正文 = %q\n期望 %q", got, want)
|
||||
}
|
||||
// 图片提取(漫画分支):每行一个 <img>,应抽出 2 个真实地址
|
||||
refs := 0
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
refs += len(imageRefsInLine(line))
|
||||
}
|
||||
if refs != 2 {
|
||||
t.Fatalf("从正文抽出 %d 张图,期望 2", refs)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -501,6 +501,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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); 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)
|
||||
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); 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); 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); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
|
||||
@@ -41,3 +47,88 @@ func TestProxyURLSignRoundTrip(t *testing.T) {
|
||||
t.Fatalf("double wrap: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceReferer 验证默认 Referer 只在 origin 是真正的 http(s) 书源地址时
|
||||
// 才生成。聚合类书源的 origin 是显示名(如「光遇聚合」),拿它当 Referer 会被
|
||||
// 图床判盗链,返回一张「请到本网站阅读」的占位图。
|
||||
func TestSourceReferer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{"普通书源地址", "https://www.example.com", "https://www.example.com/"},
|
||||
{"带尾斜杠", "https://www.example.com/", "https://www.example.com/"},
|
||||
{"带子路径", "https://www.example.com/site", "https://www.example.com/site/"},
|
||||
{"聚合书源显示名", "光遇聚合", ""},
|
||||
{"空值", "", ""},
|
||||
{"只有空白", " ", ""},
|
||||
{"非 http 协议", "ftp://example.com", ""},
|
||||
{"没有主机", "https://", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sourceReferer(c.origin); got != c.want {
|
||||
t.Errorf("%s: sourceReferer(%q) = %q,期望 %q", c.name, c.origin, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaRetriesTransientFailure 验证图床偶发 403(并发限流)会被重试掉,
|
||||
// 否则漫画一屏并发取图时总有几页留成破图。
|
||||
func TestFetchMediaRetriesTransientFailure(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) < 3 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
_, _ = w.Write([]byte("real-image-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/1.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d,期望 200(前两次 403 应被重试掉)", resp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if string(data) != "real-image-bytes" {
|
||||
t.Fatalf("body = %q", data)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaDoesNotRetryNotFound 验证确定性的 4xx(如图片不存在)不做无谓重试,
|
||||
// 避免首屏白白多等两轮退避。
|
||||
func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/missing.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("状态码 = %d,期望 404", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRateLimiter 单源限速。
|
||||
//
|
||||
// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封
|
||||
// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。
|
||||
// 对应 legado 的 ConcurrentRateLimiter,支持两种写法:
|
||||
//
|
||||
// "1000" 每次请求至少间隔 1000ms
|
||||
// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔)
|
||||
//
|
||||
// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。
|
||||
type sourceRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
slots map[string]time.Time
|
||||
}
|
||||
|
||||
// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。
|
||||
const maxRateGapMs = 30000
|
||||
|
||||
func newSourceRateLimiter() *sourceRateLimiter {
|
||||
return &sourceRateLimiter{slots: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// wait 按源限速等待;key 为空或 rate 无法解析时不等待。
|
||||
func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) {
|
||||
gap := parseConcurrentRate(rate)
|
||||
if gap <= 0 || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
slot := now
|
||||
if next, ok := l.slots[key]; ok && next.After(now) {
|
||||
slot = next
|
||||
}
|
||||
l.slots[key] = slot.Add(gap)
|
||||
l.mu.Unlock()
|
||||
sleepWithContext(ctx, slot.Sub(now))
|
||||
}
|
||||
|
||||
// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。
|
||||
func parseConcurrentRate(rate string) time.Duration {
|
||||
rate = strings.TrimSpace(rate)
|
||||
if rate == "" {
|
||||
return 0
|
||||
}
|
||||
gapMs := 0
|
||||
if i := strings.IndexByte(rate, '/'); i > 0 {
|
||||
count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i]))
|
||||
interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:]))
|
||||
if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = interval / count
|
||||
} else {
|
||||
n, err := strconv.Atoi(rate)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = n
|
||||
}
|
||||
if gapMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
if gapMs > maxRateGapMs {
|
||||
gapMs = maxRateGapMs
|
||||
}
|
||||
return time.Duration(gapMs) * time.Millisecond
|
||||
}
|
||||
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseConcurrentRate 书源 concurrentRate 的两种写法。
|
||||
func TestParseConcurrentRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"abc", 0},
|
||||
{"1000", 1000 * time.Millisecond},
|
||||
{" 500 ", 500 * time.Millisecond},
|
||||
{"3/1000", 333 * time.Millisecond},
|
||||
{"1/2000", 2000 * time.Millisecond},
|
||||
{"3/0", 0},
|
||||
{"0/1000", 0},
|
||||
{"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseConcurrentRate(c.in); got != c.want {
|
||||
t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。
|
||||
func TestRateLimiterSpacesRequests(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
l.wait(t.Context(), "src", "120") // 120ms
|
||||
l.wait(t.Context(), "src", "120")
|
||||
if elapsed := time.Since(start); elapsed < 110*time.Millisecond {
|
||||
t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed)
|
||||
}
|
||||
// 不同源互不影响
|
||||
start = time.Now()
|
||||
l.wait(t.Context(), "other", "500")
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("换源后不该等待,实际 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。
|
||||
func TestRateLimiterIgnoresEmptyRate(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
l.wait(t.Context(), "src", "")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("未声明限速却等待了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryableStatus 只有可恢复的失败才重试。
|
||||
func TestRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 429, 500, 502, 503, 504} {
|
||||
if !retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 301, 400, 401, 404, 410} {
|
||||
if retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 不该重试", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+850
-134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestRewriteContentImageMarkers 文本型漫画源正文里的 <img> 要转成前端认识的 [img] 标记,
|
||||
// 且地址要绝对化、过签名代理(有些图需要书源防盗链头才取得到)。
|
||||
func TestRewriteContentImageMarkers(t *testing.T) {
|
||||
proxy := func(u string) string { return "/proxy?u=" + url.QueryEscape(u) }
|
||||
content := "第一章开始\n" +
|
||||
"<img src=\"https://img.example.com/1.webp\">\n" +
|
||||
"<img src='/img/2.webp'/>\n" +
|
||||
"中间夹了图片 <img src=\"https://img.example.com/3.webp\"> 的这一行不动\n" +
|
||||
"结束"
|
||||
got := rewriteContentImageMarkers(content, "https://site.example.com/ch/1", proxy)
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("行数 = %d,期望 5\n%s", len(lines), got)
|
||||
}
|
||||
if lines[0] != "第一章开始" || lines[4] != "结束" {
|
||||
t.Fatalf("普通文本行被改动: %q / %q", lines[0], lines[4])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], imgMarkerPrefix) {
|
||||
t.Fatalf("第 2 行没有变成 [img] 标记: %q", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[1], url.QueryEscape("https://img.example.com/1.webp")) {
|
||||
t.Fatalf("第 2 行地址没走代理: %q", lines[1])
|
||||
}
|
||||
// 相对地址按章节地址绝对化
|
||||
if !strings.Contains(lines[2], url.QueryEscape("https://site.example.com/img/2.webp")) {
|
||||
t.Fatalf("相对图片地址没有绝对化: %q", lines[2])
|
||||
}
|
||||
// 段落中间的内嵌图片保持原样,避免破坏文本源语义
|
||||
if !strings.Contains(lines[3], "<img src=") {
|
||||
t.Fatalf("段落中间的 <img> 不该被改写: %q", lines[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteContentImageMarkersWithoutProxy 不需要代理时(如预览链路)直接落到绝对地址。
|
||||
func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
got := rewriteContentImageMarkers(`<img src="https://img.example.com/1.webp">`, "https://site.example.com/ch/1", nil)
|
||||
if got != imgMarkerPrefix+"https://img.example.com/1.webp" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
// 没有 <img> 的内容原样返回(含 null 字节之外的普通文本)
|
||||
const plain = "纯文本\n第二行"
|
||||
if got := rewriteContentImageMarkers(plain, "", nil); got != plain {
|
||||
t.Fatalf("无图内容被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
mk := func(kw string) *BookSource {
|
||||
return &BookSource{RuleSearch: &SearchRule{CheckKeyWord: &kw}}
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"登录武林系统", "登录武林系统"},
|
||||
{" 空格清理 ", "空格清理"},
|
||||
{"", ""},
|
||||
{"http://example.com", ""},
|
||||
{"a::b", ""},
|
||||
{"a++b", ""},
|
||||
{"a--b", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mk(c.in).SearchCheckKeyWord(); got != c.want {
|
||||
t.Errorf("SearchCheckKeyWord(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if (&BookSource{}).SearchCheckKeyWord() != "" {
|
||||
t.Fatal("没有 ruleSearch 时应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchReportsCheckKeyWordMiss 搜索 0 条且响应里没有书源声明的校验关键字时,
|
||||
// 必须给出「疑似风控」的原因,而不是静默返回 0 条 —— 用户才知道是源的问题。
|
||||
func TestSearchReportsCheckKeyWordMiss(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 风控/失效时站点返回的就是这种没有结果的响应
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err == nil {
|
||||
t.Fatal("0 结果且校验关键字缺失时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), kw) {
|
||||
t.Fatalf("错误信息里应带上校验关键字,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchKeepsEmptyResultWhenKeywordPresent 响应里有关键字(站点正常,只是没有匹配的书)
|
||||
// 时不该报错,只是结果为空。
|
||||
func TestSearchKeepsEmptyResultWhenKeywordPresent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"hint":"登录武林系统","results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
books, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("站点正常时不该报错: %v", err)
|
||||
}
|
||||
if len(books) != 0 {
|
||||
t.Fatalf("结果数 = %d,期望 0", len(books))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -332,13 +332,14 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
result = strings.Split(s, "\n")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
if lst, ok := result.([]string); ok {
|
||||
for _, u := range lst {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
@@ -363,6 +364,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
@@ -52,7 +53,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)
|
||||
}
|
||||
@@ -92,7 +93,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)
|
||||
}
|
||||
@@ -401,9 +402,38 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(defaultWebViewUA)
|
||||
})
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||
// 见 zh_convert.go 的说明。
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。
|
||||
set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) })
|
||||
// toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。
|
||||
set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) })
|
||||
// toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。
|
||||
set("toURL", func(call goja.FunctionCall) goja.Value {
|
||||
base := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
base = call.Arguments[1].String()
|
||||
}
|
||||
obj, err := newJSURLObject(vm, stringArg(call, 0), base)
|
||||
if err != nil {
|
||||
bridgeErr("toURL", err)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
// logType:调试用,打印值的类型(对应 legado 的日志实现)。
|
||||
set("logType", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) {
|
||||
r.cfg.Log("null")
|
||||
} else {
|
||||
r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export()))
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
@@ -496,21 +526,26 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
||||
// 服务端没有对应页面,谎报成功会让书源走错分支
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚
|
||||
// (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。
|
||||
unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason)))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
} {
|
||||
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, "需要本地文件/压缩包能力"))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -11,5 +11,5 @@ var (
|
||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||
)
|
||||
|
||||
@@ -177,6 +177,22 @@ 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)
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -375,11 +375,20 @@ 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 写进去的持久值。
|
||||
var jsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
mem map[string]string
|
||||
}{m: map[string]string{}, mem: map[string]string{}}
|
||||
|
||||
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
|
||||
const jsCacheMaxEntries = 4096
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
@@ -388,37 +397,52 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
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()
|
||||
|
||||
// 两套存储共用同一份读写实现,只有落点不同。
|
||||
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return 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(*store) >= jsCacheMaxEntries {
|
||||
*store = map[string]string{}
|
||||
}
|
||||
(*store)[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(jsCache.m) >= 4096 {
|
||||
jsCache.m = map[string]string{}
|
||||
}
|
||||
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := (*store)[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
jsCache.m[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := jsCache.m[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
}
|
||||
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(*store, key)
|
||||
jsCache.mu.Unlock()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
set("put", putTo(&jsCache.m))
|
||||
set("get", getFrom(&jsCache.m))
|
||||
set("delete", deleteFrom(&jsCache.m))
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory)
|
||||
set("putMemory", putTo(&jsCache.mem))
|
||||
set("getFromMemory", getFrom(&jsCache.mem))
|
||||
return o
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,8 @@ func jsonRead(root any, path string) any {
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
@@ -51,7 +55,63 @@ func jsonValueToString(ob any) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return anyToString(t)
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Request struct {
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
@@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZhToSimplified 常用繁体字要真的转成简体。
|
||||
// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换,
|
||||
// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。
|
||||
func TestZhToSimplified(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"島忍者神龜": "岛忍者神龟",
|
||||
"航海王": "航海王",
|
||||
"我們的書": "我们的书",
|
||||
"學習園地": "学习园地",
|
||||
"": "",
|
||||
"abc123": "abc123", // 非中文原样返回
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToSimplified(in); got != want {
|
||||
t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhToTraditional 反向转换。
|
||||
func TestZhToTraditional(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"岛忍者神龟": "島忍者神龜",
|
||||
"我们的书": "我們的書",
|
||||
"学习园地": "學習園地",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToTraditional(in); got != want {
|
||||
t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。
|
||||
func TestZhConvertLeavesPlainTextAlone(t *testing.T) {
|
||||
const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴☠️"
|
||||
if got := zhToSimplified(s); got != s {
|
||||
t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。
|
||||
func TestZhDictParsed(t *testing.T) {
|
||||
zhInit()
|
||||
if len(zhT2S) < 3000 {
|
||||
t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S))
|
||||
}
|
||||
if len(zhS2T) < 3000 {
|
||||
t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T))
|
||||
}
|
||||
// 空格/竖线不属于任何键
|
||||
if _, ok := zhT2S[' ']; ok {
|
||||
t.Fatal("词典里混进了分隔符")
|
||||
}
|
||||
if strings.ContainsRune(zhT2SData, '\uFFFD') {
|
||||
t.Fatal("词典数据含替换符,生成过程有损")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。
|
||||
//
|
||||
// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把
|
||||
// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。
|
||||
// 解析不出来时原样返回,不做猜测。
|
||||
var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`)
|
||||
|
||||
var (
|
||||
zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
||||
zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000}
|
||||
zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000}
|
||||
)
|
||||
|
||||
// numChapter 把标题里第一处数字规整成阿拉伯数字。
|
||||
func numChapter(s string) string {
|
||||
loc := chapterTokenRe.FindStringIndex(s)
|
||||
if loc == nil {
|
||||
return s
|
||||
}
|
||||
n, ok := parseCNSNum(s[loc[0]:loc[1]])
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:]
|
||||
}
|
||||
|
||||
// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。
|
||||
func parseCNSNum(tok string) (int, bool) {
|
||||
if tok == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(tok); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来
|
||||
if digitsOnly(tok) {
|
||||
n := 0
|
||||
for _, r := range tok {
|
||||
n = n*10 + zhDigits[r]
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
total, section, num := 0, 0, 0
|
||||
seen := false
|
||||
for _, r := range tok {
|
||||
if d, ok := zhDigits[r]; ok {
|
||||
num = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if u, ok := zhUnits[r]; ok {
|
||||
if num == 0 {
|
||||
num = 1 // 「十二」这类省略了前导一的写法
|
||||
}
|
||||
section += num * u
|
||||
num = 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if big, ok := zhBigUnits[r]; ok {
|
||||
section += num
|
||||
if section == 0 {
|
||||
section = 1
|
||||
}
|
||||
total += section * big
|
||||
section, num = 0, 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !seen {
|
||||
return 0, false
|
||||
}
|
||||
return total + section + num, true
|
||||
}
|
||||
|
||||
// digitsOnly 判断字符串是否只由中文数字字符组成。
|
||||
func digitsOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := zhDigits[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。
|
||||
// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。
|
||||
func TestNumChapter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第一百零八章": "第108章",
|
||||
"第十二话": "第12话",
|
||||
"第3话": "第3话",
|
||||
"第一话 冒险的序幕": "第1话 冒险的序幕",
|
||||
"一〇二": "102",
|
||||
"两千零一": "2001",
|
||||
"航海王": "航海王", // 没有数字:原样返回
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := numChapter(in); got != want {
|
||||
t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。
|
||||
func TestJavaExtraHelpers(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
|
||||
v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.randomUUID() 失败: %v", err)
|
||||
}
|
||||
id := anyToString(v)
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("randomUUID 返回值不像 UUID: %q", id)
|
||||
}
|
||||
|
||||
v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.toNumChapter() 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第108章" {
|
||||
t.Fatalf("toNumChapter = %q,期望 第108章", got)
|
||||
}
|
||||
|
||||
// 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义)
|
||||
if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil {
|
||||
t.Fatal("java.deviceID() 应当抛异常")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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)
|
||||
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); 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"]})
|
||||
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)
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 {
|
||||
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool {
|
||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// SearchCheckKeyWord 书源在搜索规则里声明的校验关键字(legado SearchRule.checkKeyWord)。
|
||||
//
|
||||
// 源的作者用它证明「这个地址返回的确实是正常搜索结果」:校验时搜这个关键字,
|
||||
// 响应里应该出现它;被风控/换域名后响应里就没有了。legado 的 getCheckKeyword
|
||||
// 对含 http / :: / ++ / -- 的值不认(那是地址或扩展标记,不是关键字),这里保持一致。
|
||||
func (b *BookSource) SearchCheckKeyWord() string {
|
||||
if b == nil || b.RuleSearch == nil {
|
||||
return ""
|
||||
}
|
||||
kw := strings.TrimSpace(SPtr(b.RuleSearch.CheckKeyWord))
|
||||
if kw == "" || strings.Contains(kw, "http") || strings.Contains(kw, "::") ||
|
||||
strings.Contains(kw, "++") || strings.Contains(kw, "--") {
|
||||
return ""
|
||||
}
|
||||
return kw
|
||||
}
|
||||
|
||||
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||
@@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string {
|
||||
|
||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
func stripJSWrapper(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "@js:") {
|
||||
return s[len("@js:"):]
|
||||
out, _ := stripJSWrapperOK(s)
|
||||
return out
|
||||
}
|
||||
|
||||
// stripJSWrapperOK 与 stripJSWrapper 相同,但额外告知是否命中 JS 包装。
|
||||
// 「可能是 JSON 也可能是 JS 规则」的字段(如书源 header)需要区分这两者。
|
||||
func stripJSWrapperOK(s string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return trimmed[len("@js:"):], true
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body, true
|
||||
default:
|
||||
return trimmed, false
|
||||
}
|
||||
if strings.HasPrefix(s, "<js>") {
|
||||
s = s[len("<js>"):]
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchRule 搜索规则。
|
||||
@@ -110,6 +138,9 @@ type TocRule struct {
|
||||
ChapterURL *string `json:"chapterUrl"`
|
||||
IsVolume *string `json:"isVolume"`
|
||||
UpdateTime *string `json:"updateTime"`
|
||||
// NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它
|
||||
// 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。
|
||||
NextTocURL *string `json:"nextTocUrl"`
|
||||
}
|
||||
|
||||
// ContentRule 正文规则。
|
||||
@@ -125,25 +156,33 @@ type ContentRule struct {
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
type ExploreRule struct {
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
}
|
||||
|
||||
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||
//
|
||||
// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。
|
||||
// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败,
|
||||
// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。
|
||||
func ParseBookSource(raw string) (*BookSource, error) {
|
||||
var bs BookSource
|
||||
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
||||
return nil, err
|
||||
var relaxed BookSource
|
||||
if err2 := json.Unmarshal([]byte(normalizeSourceJSON(raw)), &relaxed); err2 != nil {
|
||||
return nil, err // 报原始错误:字段位置更贴近用户看到的 JSON
|
||||
}
|
||||
bs = relaxed
|
||||
}
|
||||
bs.RawJSON = raw
|
||||
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||
@@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) {
|
||||
return &bs, nil
|
||||
}
|
||||
|
||||
// sourceNumericFields 是书源里可能被写成字符串的数字字段。
|
||||
// 例:{"lastUpdateTime":"1788449879889"}(阅读本体导出/第三方源站的常见写法)。
|
||||
var sourceNumericFields = []string{
|
||||
"customOrder", "weight", "bookSourceType", "lastUpdateTime", "respondTime",
|
||||
}
|
||||
|
||||
// sourceRuleObjectFields 在 legado 里是对象,但部分导出工具把空规则写成 []。
|
||||
// 例:{"ruleExplore":[]}(空发现规则)。
|
||||
var sourceRuleObjectFields = []string{
|
||||
"ruleExplore", "ruleSearch", "ruleBookInfo", "ruleToc", "ruleContent",
|
||||
}
|
||||
|
||||
// normalizeSourceJSON 消化书源 JSON 与 legado 实体之间的形状差异:
|
||||
// - 数字字段被写成字符串 → 转成数字;
|
||||
// - 规则对象被写成空数组 [] → 转成空对象 {}。
|
||||
//
|
||||
// 只动顶层已知字段,规则 JS 字符串原样保留。解析不出来就原样返回,交给调用方报错。
|
||||
func normalizeSourceJSON(raw string) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return raw
|
||||
}
|
||||
changed := false
|
||||
for _, key := range sourceNumericFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
continue // 本来就是数字(或其它类型):不掺和
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
// 空字符串当成「没有这个字段」,否则会报类型错误
|
||||
delete(fields, key)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(text, 10, 64); err != nil {
|
||||
continue // 不是整数(如 "1.0"):不猜,让标准解析去报错
|
||||
}
|
||||
fields[key] = json.RawMessage(text)
|
||||
changed = true
|
||||
}
|
||||
for _, key := range sourceRuleObjectFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
fields[key] = json.RawMessage("{}")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||
func (b *BookSource) SourceProps() map[string]any {
|
||||
if b.RawJSON == "" {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRawSourceNeedsBrowser webView/webjs 类书源要在列表里先标出来
|
||||
// (服务端没有无头浏览器,这类源注定跑不通,不该等用户点进去才报错)。
|
||||
func TestRawSourceNeedsBrowser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"webView 选项", `{"searchUrl":"https://a.com/s,{webView:true}"}`, true},
|
||||
{"webjs 规则", `{"ruleContent":{"content":"@webjs:getDom()"}}`, true},
|
||||
{"规则里提到 webJs", `{"ruleToc":{"chapterList":"<js>if(supportWebJs){}</js>"}}`, true},
|
||||
{"普通源", `{"searchUrl":"https://a.com/s?q={{key}}","ruleSearch":{"bookList":"$.list[*]"}}`, false},
|
||||
{"空", ``, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rawSourceNeedsBrowser(c.raw); got != c.want {
|
||||
t.Errorf("%s: rawSourceNeedsBrowser = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// TestApplyHeadersEvaluatesJSHeader 覆盖拷贝漫画(优++)这类书源:
|
||||
// header 是 @js: 规则,运行时才用 baseUrl 拼出 platform/version/referer。
|
||||
// 此前 applyHeaders 只做 json.Unmarshal,JS 形态被整体丢掉,请求少了这些
|
||||
// 必需头,上游同样回 200 但结果是空列表 —— 表现为「源能导入却搜不到内容」。
|
||||
func TestApplyHeadersEvaluatesJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nJSON.stringify({\"platform\":\"1\",\"version\":\"9.9.9\",\"referer\":baseUrl})"
|
||||
src := &model.ReaderBookSource{
|
||||
Name: "拷贝漫画(优++)",
|
||||
SourceURL: "https://www.mangacopy.com/",
|
||||
Header: header,
|
||||
}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, BookSourceName: src.Name, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://api.mangacopy.com/api/v3/search/comic", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("火影", 1))
|
||||
|
||||
if got := req.Headers["platform"]; got != "1" {
|
||||
t.Fatalf("platform = %q,期望 \"1\"(JS 形态的 header 没有被执行)", got)
|
||||
}
|
||||
if got := req.Headers["version"]; got != "9.9.9" {
|
||||
t.Fatalf("version = %q,期望 \"9.9.9\"", got)
|
||||
}
|
||||
// baseUrl 必须绑成书源地址(对应 legado getHeaderMap 里的 getKey())
|
||||
if got := req.Headers["referer"]; got != "https://www.mangacopy.com/" {
|
||||
t.Fatalf("referer = %q,期望书源地址", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersKeepsPlainJSONHeader 直接写 JSON 的 header 不能回归。
|
||||
func TestApplyHeadersKeepsPlainJSONHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
header := `{"User-Agent":"MyBox/1.0","X-Token":"abc"}`
|
||||
src := &model.ReaderBookSource{Name: "纯 JSON 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if got := req.Headers["User-Agent"]; got != "MyBox/1.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
if got := req.Headers["X-Token"]; got != "abc" {
|
||||
t.Fatalf("X-Token = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersSurvivesBrokenJSHeader 坏掉的 header JS 不能 panic,
|
||||
// 也不该把半成品请求头发出去。
|
||||
func TestApplyHeadersSurvivesBrokenJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nthrow new Error('boom')"
|
||||
src := &model.ReaderBookSource{Name: "坏 header 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if len(req.Headers) != 0 {
|
||||
t.Fatalf("求值失败的 header 不应写入任何头,实际: %v", req.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// messySourceJSON 复刻真实「拷贝漫画(优++)」源文件的形状:
|
||||
// lastUpdateTime 是字符串、respondTime 是数字、ruleExplore 是空数组、
|
||||
// ruleBookInfo/ruleContent 里同时有数字与 JS 字符串。
|
||||
// 这类文件在阅读 App 里能导入,在这里曾经整体解析失败。
|
||||
const messySourceJSON = `[{
|
||||
"bookSourceComment": "//By情无羁25.04.09 使用需要魔法",
|
||||
"bookSourceGroup": "",
|
||||
"bookSourceName": "拷贝漫画(优++)",
|
||||
"bookSourceType": 0,
|
||||
"bookSourceUrl": "https://www.mangacopy.com/",
|
||||
"customOrder": 208,
|
||||
"enabled": true,
|
||||
"enabledCookieJar": false,
|
||||
"enabledExplore": true,
|
||||
"exploreUrl": "@js:\nsort = [];\nJSON.stringify(sort)",
|
||||
"header": "@js:\nJSON.stringify({\"platform\":\"1\",\"referer\":baseUrl})",
|
||||
"lastUpdateTime": "1788449879889",
|
||||
"respondTime": 181130,
|
||||
"ruleBookInfo": {"init": "$..comic", "intro": "$..brief"},
|
||||
"ruleContent": {"content": "$..url\n<js>result</js>", "imageStyle": "FULL"},
|
||||
"ruleExplore": [],
|
||||
"ruleSearch": {"bookList": "$..list[*]", "name": "$.name"},
|
||||
"ruleToc": {"chapterList": "$..list[*]", "chapterName": "name"},
|
||||
"searchUrl": "https://api.mangacopy.com/api/v3/search/comic?q={{key}}",
|
||||
"weight": 0
|
||||
}]`
|
||||
|
||||
// TestImportSourcesAcceptsMessySource 覆盖用户的真实场景:数字写法的字符串字段
|
||||
// (lastUpdateTime)与空数组写法的规则对象(ruleExplore)不该让整条书源导入失败。
|
||||
func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
imported, err := svc.ImportSources(ctx, messySourceJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("导入数量 = %d,期望 1", imported)
|
||||
}
|
||||
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
|
||||
if err != nil || bs == nil {
|
||||
t.Fatalf("按 URL 查不到导入的书源: %v", err)
|
||||
}
|
||||
if bs.Name != "拷贝漫画(优++)" {
|
||||
t.Fatalf("书源名 = %q", bs.Name)
|
||||
}
|
||||
// 关键字段要真的解出来,而不是「解成功了但字段全空」
|
||||
parsed, err := ParseBookSource(bs.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("重新解析失败: %v", err)
|
||||
}
|
||||
if parsed.LastUpdateTime == nil || *parsed.LastUpdateTime != 1788449879889 {
|
||||
t.Fatalf("lastUpdateTime = %v,期望 1788449879889", parsed.LastUpdateTime)
|
||||
}
|
||||
if parsed.RuleExplore == nil {
|
||||
t.Fatal("ruleExplore 应当被当成空规则对象,而不是解析失败")
|
||||
}
|
||||
if SPtr(parsed.RuleSearch.BookList) != "$..list[*]" {
|
||||
t.Fatalf("ruleSearch.bookList = %q", SPtr(parsed.RuleSearch.BookList))
|
||||
}
|
||||
if SPtr(parsed.RuleContent.ImageStyle) != "FULL" {
|
||||
t.Fatalf("ruleContent.imageStyle = %q", SPtr(parsed.RuleContent.ImageStyle))
|
||||
}
|
||||
// 落库的 raw 必须是原文(源 JS 可能依赖原样字段),不能被改写。
|
||||
// 这里特意按原文里的「冒号后有空格」写法比对:一旦被重新序列化就对不上了。
|
||||
if !strings.Contains(bs.RawJSON, `"lastUpdateTime": "1788449879889"`) {
|
||||
t.Fatalf("RawJSON 被改写了,应当保留原始文本;实际: %s", bs.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSourceJSON 单独锁定宽容规则本身。
|
||||
func TestNormalizeSourceJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 期望解析后可用的字段值 / 是否报错
|
||||
}{
|
||||
{"数字字符串", `{"weight":"12"}`, "12"},
|
||||
{"已经是数字", `{"weight":12}`, "12"},
|
||||
{"空字符串当缺省", `{"weight":""}`, "<nil>"},
|
||||
{"非整数不猜", `{"weight":"1.5"}`, "<err>"},
|
||||
{"空数组规则", `{"ruleExplore":[]}`, "obj"},
|
||||
{"正常规则不受影响", `{"ruleExplore":{"bookList":"a"}}`, "obj"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeSourceJSON(c.in)
|
||||
switch c.want {
|
||||
case "obj":
|
||||
if !strings.Contains(got, "{}") && !strings.Contains(got, `"bookList":"a"`) {
|
||||
t.Errorf("%s: normalizeSourceJSON(%s) = %s", c.name, c.in, got)
|
||||
}
|
||||
case "<nil>":
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight != nil {
|
||||
t.Errorf("%s: weight 应当为 nil,实际 %v", c.name, *bs.Weight)
|
||||
}
|
||||
case "<err>":
|
||||
if _, err := ParseBookSource(got); err == nil {
|
||||
t.Errorf("%s: 期望解析报错(不做无根据的猜测)", c.name)
|
||||
}
|
||||
default:
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight == nil || *bs.Weight != 12 {
|
||||
t.Errorf("%s: weight = %v,期望 12", c.name, bs.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBookSourceRejectsGarbage 真的坏掉的 JSON 仍要报错,不能因为宽容而静默吞掉。
|
||||
func TestParseBookSourceRejectsGarbage(t *testing.T) {
|
||||
if _, err := ParseBookSource(`{"bookSourceUrl":123}`); err == nil {
|
||||
t.Fatal("bookSourceUrl 是数字时应当报错")
|
||||
}
|
||||
if _, err := ParseBookSource(`{not json`); err == nil {
|
||||
t.Fatal("非法 JSON 应当报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSourcesReportsReasonWhenNothingImported 全部失败时必须把原因带回前端,
|
||||
// 否则界面只会弹「成功导入 0 个书源」,用户无从下手。
|
||||
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("一条都没导入时应当返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "解析失败") {
|
||||
t.Fatalf("错误信息应说明是解析失败,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestGetTocFollowsNextTocUrl 长书目录按 offset/limit 分页时必须翻页取全。
|
||||
// 这是「目录少章」的根因:nextTocUrl 没实现时只会拿到第一页
|
||||
// (实测某漫画站点 total=399/limit=100,MeBox 只得到 100 章)。
|
||||
func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"count":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"2": `{"count":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源(拷贝漫画)一致的写法:用 baseUrl 拼下一页地址
|
||||
nextTocURL := `<js>baseUrl.indexOf('offset=0') >= 0 ? baseUrl.replace('offset=0','offset=2') : ''</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "分页目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第二页没有被抓取)", len(chapters))
|
||||
}
|
||||
for i, want := range []string{"第1话", "第2话", "第3话", "第4话"} {
|
||||
if chapters[i].Title != want {
|
||||
t.Errorf("章节[%d] = %q,期望 %q", i, chapters[i].Title, want)
|
||||
}
|
||||
if chapters[i].Index != i {
|
||||
t.Errorf("章节[%d] 的 Index = %d,应按翻页顺序重排", i, chapters[i].Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlListsAllPagesFromFirstPage 书源只在第一页能算出完整后续页列表
|
||||
// (拷贝漫画的写法:规则锚定 offset=0,第二页上算出来还是同一批地址)。
|
||||
// 这种源必须把所有后续页一次性入队,否则只翻一页就停。
|
||||
func TestGetTocNextTocUrlListsAllPagesFromFirstPage(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"total":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"100": `{"total":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
hits = append(hits, offset)
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"total":4,"list":[]}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源一致:一次列出全部后续页,且锚点写死 offset=0(在第二页上等于原地踏步)
|
||||
nextTocURL := `<js>
|
||||
list=[];
|
||||
for(i=0;i<=1;i++){list.push(baseUrl.replace('offset=0','offset='+100*i))}
|
||||
list
|
||||
</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "全量下一页测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第一页列出的后续页没有被全部抓取)", len(chapters))
|
||||
}
|
||||
// 每页只抓一次(队列去重)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("上游请求次数 = %d(%v),期望 2", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlSelfReferenceStops nextTocUrl 指回当前页时必须停下,
|
||||
// 不能靠「规则永远给下一页」把自己转死。
|
||||
func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"list":[{"name":"第1话","url":"/c/1"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 永远返回同一页地址(用 baseUrl 原样返回)
|
||||
nextTocURL := `<js>baseUrl</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "自引用目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 1 {
|
||||
t.Fatalf("章节数 = %d,期望 1", len(chapters))
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("上游被请求 %d 次,自引用时应只请求 1 次", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// newTocFallbackBook 建一本书架记录:只存 book_url,toc_url 留空,
|
||||
// 模拟聚合类书源加入书架时的形态。
|
||||
func newTocFallbackBook(t *testing.T, svc *ReaderService, serverURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: serverURL,
|
||||
OriginName: "测试源",
|
||||
BookURL: serverURL + "/book/1",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(t.Context(), book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
// 聚合类书源(光遇聚合一类)把目录地址放在详情结果里,加书架时只存了 book_url。
|
||||
// 调用方又习惯把 book_url 当目录地址兜底(前端 toc_url || book_url),于是目录规则
|
||||
// 拿到的是书籍详情页 → 0 章 → 阅读页显示「目录为空」,漫画根本进不去。
|
||||
//
|
||||
// GetToc 必须自己补一次详情、用详情里的 tocUrl 重抓,并把地址写回书架。
|
||||
func TestGetTocFallsBackToBookInfoTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, "")
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应回退到详情里的 tocUrl 抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
|
||||
// 目录地址要写回书架:之后书架/阅读页/更新目录都不用再走一次详情
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stored.TocURL, "/book/1/toc") {
|
||||
t.Fatalf("目录地址未写回: %q", stored.TocURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 前端会把空 toc_url 兜底成 book_url 传进来,这条路径同样要能拿到目录。
|
||||
func TestGetTocTreatsBookURLAsMissingTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.BookURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("book_url 兜底的目录地址也应回退到详情,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
|
||||
// 正常源(详情给了独立目录页、调用方也传了)不能被这次改动影响。
|
||||
func TestGetTocKeepsGivenTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
book.TocURL = srv.URL + "/book/1/toc.html"
|
||||
if err := svc.repo.UpdateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.TocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应正常抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+48
-10
@@ -118,10 +118,26 @@ export interface ReaderChapterContent {
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
|
||||
comments?: ReaderContentComment[]
|
||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
}
|
||||
|
||||
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
|
||||
export interface ReaderContentComment {
|
||||
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
|
||||
line: number
|
||||
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
|
||||
count: number
|
||||
/** 评论地址(书源 showCmt 的第一个参数)。 */
|
||||
url: string
|
||||
/** 段评 / 本章说。 */
|
||||
label?: string
|
||||
/** 独占一行的整块评论(章末「本章说」)。 */
|
||||
block?: boolean
|
||||
}
|
||||
|
||||
export interface ReaderReplaceRule {
|
||||
id: string
|
||||
name: string
|
||||
@@ -174,6 +190,8 @@ export interface ReaderBrowserPage {
|
||||
title: string
|
||||
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||
mode: 'wait' | 'open'
|
||||
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
|
||||
seq: number
|
||||
/** iframe 承载地址(同源,带签名)。 */
|
||||
page_url: string
|
||||
refetch: boolean
|
||||
@@ -223,6 +241,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 +266,13 @@ export const readerAPI = {
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
search: (key: string, sourceIds: string[] = []) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
{ key, source_ids: sourceIds },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
@@ -257,15 +280,17 @@ export const readerAPI = {
|
||||
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
|
||||
api
|
||||
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data.chapters),
|
||||
.get<{ chapters: ReaderTocChapter[] | null }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
// 后端把 nil 目录编码成 null;调用方一律按数组处理,
|
||||
// 否则 `chapters.length` 会抛 TypeError(页面只剩一句 JS 报错)。
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
|
||||
api
|
||||
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 书架 ──
|
||||
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
|
||||
listBooks: () => api.get<{ books: ReaderBook[] | null }>('/reader/books').then((r) => r.data.books ?? []),
|
||||
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||
@@ -313,11 +338,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),
|
||||
/**
|
||||
* 换源:把书架里的书切到另一个书源。
|
||||
@@ -331,12 +361,20 @@ export const readerAPI = {
|
||||
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||
api.put(`/reader/books/${id}/audio-config`, body),
|
||||
listChapters: (id: string) =>
|
||||
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||
api
|
||||
.get<{ chapters: ReaderChapter[] | null }>(`/reader/books/${id}/chapters`)
|
||||
.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),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
|
||||
+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,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,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,27 +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 { Virtuoso } from 'react-virtuoso'
|
||||
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> 播直链。
|
||||
@@ -29,30 +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
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
@@ -78,43 +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,
|
||||
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 speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||
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 [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)
|
||||
|
||||
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||
const [coverOK, setCoverOK] = useState(true)
|
||||
useEffect(() => {
|
||||
@@ -123,145 +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)
|
||||
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])
|
||||
|
||||
// ── 定时关闭 ──
|
||||
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' }
|
||||
@@ -277,12 +112,29 @@ export function ReaderAudioPanel({
|
||||
|
||||
const closeSheet = () => setSheet('none')
|
||||
|
||||
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
|
||||
const openSheet = (next: Sheet) => {
|
||||
if (sheet === next) {
|
||||
setSheet('none')
|
||||
return
|
||||
}
|
||||
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
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 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
|
||||
const chapterGroups = useMemo(() => buildChapterGroups(chapterList.length), [chapterList.length])
|
||||
|
||||
const jumpToChapterGroup = (next: number) => {
|
||||
const group = chapterGroups[next]
|
||||
if (!group) return
|
||||
setGroupIndex(next)
|
||||
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -384,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="上一章"
|
||||
@@ -430,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="下一章"
|
||||
@@ -504,18 +356,46 @@ export function ReaderAudioPanel({
|
||||
{sheet === 'chapters' && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
|
||||
className="flex items-center gap-2 border-b px-4 py-3 text-xs font-bold"
|
||||
style={{ borderColor: theme.text + '22' }}
|
||||
>
|
||||
<span>章节({chapters.length})</span>
|
||||
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
|
||||
<span className="shrink-0">章节({chapters.length})</span>
|
||||
{chapterGroups.length > 1 && (
|
||||
<select
|
||||
value={groupIndex}
|
||||
onChange={(e) => jumpToChapterGroup(Number(e.target.value))}
|
||||
aria-label="按区间快速定位章节"
|
||||
title="按区间快速定位章节"
|
||||
className="min-w-0 flex-1 rounded-lg border bg-transparent px-2 py-1 text-2xs font-normal outline-none"
|
||||
style={{ borderColor: theme.text + '33', color: theme.text }}
|
||||
>
|
||||
{chapterGroups.map((group) => (
|
||||
<option
|
||||
key={group.index}
|
||||
value={group.index}
|
||||
style={{ color: '#111827', backgroundColor: '#ffffff' }}
|
||||
>
|
||||
{group.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSheet}
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[45vh] min-h-0">
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex)}
|
||||
rangeChanged={(range) =>
|
||||
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||
}
|
||||
itemContent={(_i, row) => {
|
||||
const isCurrent = row.i === chapterIndex
|
||||
return (
|
||||
@@ -524,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' : ''
|
||||
@@ -674,70 +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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,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 +60,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
|
||||
@@ -84,7 +96,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 ?? '',
|
||||
},
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } 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,40 +23,118 @@ interface ReaderComicProps {
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
}
|
||||
|
||||
function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) {
|
||||
function ComicImage({
|
||||
src,
|
||||
theme,
|
||||
fit = false,
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
|
||||
fit?: boolean
|
||||
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
|
||||
half?: boolean
|
||||
// imageFit:滚动模式的显示尺寸档位。
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
// 换章后 src 变了要重新回到 loading:否则上一章加载失败的第 N 张会一直
|
||||
// 显示「图片加载失败」,而 key 不变(滚动模式按序号做 key)不会重挂载。
|
||||
useEffect(() => {
|
||||
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 w-full">
|
||||
<div
|
||||
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"
|
||||
alt=""
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
|
||||
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
|
||||
setState('ok')
|
||||
}}
|
||||
onError={() => setState('error')}
|
||||
className={sizing.className}
|
||||
style={sizing.style}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
style={{ color: theme.text }}
|
||||
>
|
||||
<Loader2 className="animate-spin opacity-50" size={22} />
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' ? (
|
||||
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
|
||||
{state === 'error' && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs opacity-60"
|
||||
style={{ color: theme.text }}
|
||||
>
|
||||
图片加载失败
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onError={() => setState('error')}
|
||||
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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' }: 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(() => {
|
||||
@@ -70,6 +157,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 }}>
|
||||
@@ -79,11 +169,16 @@ 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
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -125,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
|
||||
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
|
||||
function shelfBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
|
||||
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(shelfBookKey(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()
|
||||
@@ -23,13 +41,60 @@ export default function ReaderSearchPage() {
|
||||
const [adding, setAdding] = useState('')
|
||||
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(shelfBookKey(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
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
try {
|
||||
const res = await readerAPI.search(kw)
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds)
|
||||
setBooks(res.books ?? [])
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
@@ -52,12 +117,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(shelfBookKey(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 +186,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 +209,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"
|
||||
@@ -225,6 +324,18 @@ export default function ReaderSearchPage() {
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScope && (
|
||||
<SearchScopeDialog
|
||||
sources={sources}
|
||||
selectedIds={scopeIds}
|
||||
onConfirm={(ids) => {
|
||||
setSearchScopeIds(ids)
|
||||
setShowScope(false)
|
||||
}}
|
||||
onClose={() => setShowScope(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,22 +5,38 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
Columns2,
|
||||
LayoutList,
|
||||
ListEnd,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
Moon,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import {
|
||||
readerAPI,
|
||||
type ReaderBook,
|
||||
type ReaderBrowserPage,
|
||||
type ReaderChapter,
|
||||
type ReaderChapterContent,
|
||||
type ReaderContentComment,
|
||||
type ReaderSearchOrigin,
|
||||
} from '../../api/reader'
|
||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import BrowserPanel from './BrowserPanel'
|
||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||
import { ReaderComic } from './ReaderComic'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
|
||||
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||
@@ -48,6 +64,21 @@ const WHEEL_TURN_COOLDOWN = 220
|
||||
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
|
||||
const WHEEL_GESTURE_GAP = 180
|
||||
|
||||
/**
|
||||
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
|
||||
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
|
||||
*/
|
||||
const COMIC_SPREAD_MIN_WIDTH = 900
|
||||
|
||||
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
|
||||
const NO_IMAGES: string[] = []
|
||||
|
||||
/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
|
||||
interface ReaderParagraphBlock {
|
||||
text: string
|
||||
comments: ReaderContentComment[]
|
||||
}
|
||||
|
||||
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||
if (i !== -1) return i
|
||||
@@ -71,11 +102,17 @@ export default function ReaderViewPage() {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
||||
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
|
||||
const [tocGroupIndex, setTocGroupIndex] = useState(0)
|
||||
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
|
||||
const [switchOpen, setSwitchOpen] = useState(false)
|
||||
const [switchLoading, setSwitchLoading] = useState(false)
|
||||
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0)
|
||||
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
|
||||
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
const topBarRef = useRef<HTMLDivElement>(null)
|
||||
const [menuInset, setMenuInset] = useState(0)
|
||||
@@ -85,6 +122,8 @@ export default function ReaderViewPage() {
|
||||
const wheelLastEventRef = useRef(0)
|
||||
const wheelLastTurnRef = useRef(0)
|
||||
const wheelSwipeLockedRef = useRef(false)
|
||||
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
// 分页状态
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
@@ -104,6 +143,47 @@ export default function ReaderViewPage() {
|
||||
const pendingPosRef = useRef(0)
|
||||
const pendingEndRef = useRef(false)
|
||||
|
||||
// ── 漫画双页铺开(桌面端) ──
|
||||
// 窗口宽度决定双页是否可用:偏好在 settings 里(用户可关),但窗口不够宽时
|
||||
// 即使开着也退回单页,所以这里额外跟一个窗口宽度。
|
||||
const [windowWidth, setWindowWidth] = useState(() => window.innerWidth)
|
||||
useEffect(() => {
|
||||
const onResize = () => setWindowWidth(window.innerWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
|
||||
const comicDoublePage =
|
||||
contentType === 'image' &&
|
||||
settings.pageMode === 'page' &&
|
||||
settings.comicDoublePage &&
|
||||
windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
// 分组依赖每张图的原始宽高(横跨两页的宽图要独占一屏),探测窗口跟着当前页走
|
||||
const { spreads: comicSpreads, spreadIndexOf: comicSpreadIndexOf } = useComicSpreads(
|
||||
comicImages,
|
||||
comicDoublePage,
|
||||
comicPage,
|
||||
)
|
||||
/** 当前是第几「屏」。进度仍按图片序号记,屏序号只用于翻页与渲染。 */
|
||||
const comicSpreadIndex =
|
||||
comicDoublePage && comicSpreads.length > 0
|
||||
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
||||
: 0
|
||||
|
||||
// ── 漫画滚动模式的图片显示尺寸 ──
|
||||
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
||||
// 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
|
||||
const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
|
||||
/** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
|
||||
const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
|
||||
/**
|
||||
* 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
|
||||
* 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
|
||||
* 900px 的列会把它们再压回去。
|
||||
*/
|
||||
const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
|
||||
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -135,11 +215,28 @@ export default function ReaderViewPage() {
|
||||
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
|
||||
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
|
||||
let idx = firstReadableIndex(chs)
|
||||
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
|
||||
const explicitChapter = Number.isInteger(qChapter) && !!chs[qChapter] && !chs[qChapter].is_volume
|
||||
if (explicitChapter) idx = qChapter
|
||||
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||
idx = b.dur_chapter_index
|
||||
}
|
||||
pendingPosRef.current = b.dur_chapter_pos ?? 0
|
||||
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
|
||||
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
|
||||
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
|
||||
const live = useReaderAudioStore.getState()
|
||||
if (
|
||||
!explicitChapter &&
|
||||
live.bookId === b.id &&
|
||||
live.track !== '' &&
|
||||
(live.status === 'playing' || live.status === 'loading' || live.status === 'paused') &&
|
||||
live.chapterIndex !== idx &&
|
||||
chs[live.chapterIndex]
|
||||
) {
|
||||
idx = live.chapterIndex
|
||||
// 位置由引擎自己掌握,服务端那份属于上一章,别写过去
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
setChapterIndex(idx)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || '加载失败')
|
||||
@@ -153,20 +250,33 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [bookId, reloadKey])
|
||||
|
||||
// 正文缓存的键带上书源标识(origin + book_url):换源后在途的旧源响应即使晚到,
|
||||
// 也只会落在旧键上,不会被新源读到。聚合源的不同子源共用同一个 origin(子源写在
|
||||
// book_url 里),所以不能只用 origin。
|
||||
const contentCacheKey = useCallback(
|
||||
(index: number) => `${book?.origin ?? ''}\u0000${book?.book_url ?? ''}\u0000${index}`,
|
||||
[book?.origin, book?.book_url],
|
||||
)
|
||||
|
||||
// ── 加载章节正文(带缓存与下一章预取) ──
|
||||
useEffect(() => {
|
||||
if (chapterIndex === null || !book || chapters.length === 0) return
|
||||
const ch = chapters[chapterIndex]
|
||||
if (!ch) return
|
||||
let cancelled = false
|
||||
// 换章/换源/离开页面时取消在途请求:慢源一章要等十几秒,不取消就会白等旧源的
|
||||
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
|
||||
const ac = new AbortController()
|
||||
;(async () => {
|
||||
setContent(null)
|
||||
setLoadingStage('content')
|
||||
try {
|
||||
const cacheKey = String(chapterIndex)
|
||||
const cacheKey = contentCacheKey(chapterIndex)
|
||||
let ct = contentCache.current.get(cacheKey)
|
||||
if (!ct) {
|
||||
ct = await readerAPI.bookContent(book.id, chapterIndex)
|
||||
ct = await readerAPI.bookContent(book.id, chapterIndex, ac.signal)
|
||||
// 请求期间切了章/换了源:结果已作废,既不用也不必入缓存。
|
||||
if (cancelled) return
|
||||
contentCache.current.set(cacheKey, ct)
|
||||
}
|
||||
if (cancelled) return
|
||||
@@ -182,6 +292,22 @@ export default function ReaderViewPage() {
|
||||
setRestorePos(savedPos)
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
// 听书:把这一章交给常驻播放引擎。引擎是全局单例,离开本页也不会被卸载,
|
||||
// 因此换章/离开路由/息屏都继续播(面板卸载不再掐播放)。
|
||||
if (ct.type === 'audio' && ct.tracks && ct.tracks.length > 0) {
|
||||
getReaderAudioEngine().loadChapter({
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
cover: book.cover_url ?? '',
|
||||
chapters,
|
||||
chapterIndex,
|
||||
track: ct.tracks[0],
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: book.open_credits ?? 0,
|
||||
closeCredits: book.close_credits ?? 0,
|
||||
initialPos: savedPos,
|
||||
})
|
||||
}
|
||||
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
|
||||
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
|
||||
// 刚恢复的进度又写成 0,退出重进就从头开始。
|
||||
@@ -189,10 +315,14 @@ export default function ReaderViewPage() {
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
|
||||
.catch(() => undefined)
|
||||
// 预取下一章
|
||||
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
|
||||
readerAPI
|
||||
.bookContent(book.id, chapterIndex + 1)
|
||||
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
|
||||
.bookContent(book.id, chapterIndex + 1, ac.signal)
|
||||
.then((c) => {
|
||||
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
|
||||
if (cancelled) return
|
||||
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -206,8 +336,9 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
ac.abort()
|
||||
}
|
||||
}, [chapterIndex, book, chapters])
|
||||
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
|
||||
|
||||
// ── 分页排版(CSS 多栏 + 平移) ──
|
||||
const relayout = useCallback(() => {
|
||||
@@ -254,6 +385,11 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [content, settings.pageMode])
|
||||
|
||||
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
|
||||
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
|
||||
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
|
||||
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
|
||||
|
||||
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
|
||||
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
|
||||
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
|
||||
@@ -339,14 +475,46 @@ export default function ReaderViewPage() {
|
||||
setChapterIndex(idx)
|
||||
}, [chapters.length])
|
||||
|
||||
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
|
||||
const hasNextAudioChapter = useMemo(() => {
|
||||
if (chapterIndex === null) return false
|
||||
for (let i = chapterIndex + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}, [chapterIndex, chapters])
|
||||
// ── 目录面板的分段跳转(对齐听书面板的区间下拉) ──
|
||||
// 上千章的目录按 100 章一组切开,顶部下拉选区间即整段跳转(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——一屏能扫完,多一个控件只是噪声。
|
||||
const tocGroups = useMemo(() => buildChapterGroups(chapters.length), [chapters.length])
|
||||
|
||||
const jumpToTocGroup = useCallback(
|
||||
(next: number) => {
|
||||
const group = tocGroups[next]
|
||||
if (!group) return
|
||||
setTocGroupIndex(next)
|
||||
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
|
||||
},
|
||||
[tocGroups],
|
||||
)
|
||||
|
||||
// 面板开关:打开目录时把区间下拉对齐到当前章节所在组。上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
const openPanel = useCallback(
|
||||
(next: 'none' | 'toc' | 'style') => {
|
||||
if (panel === next) {
|
||||
setPanel('none')
|
||||
return
|
||||
}
|
||||
if (next === 'toc') setTocGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
|
||||
setPanel(next)
|
||||
},
|
||||
[chapterIndex, chapters.length, panel],
|
||||
)
|
||||
|
||||
// 听书:引擎自己也可能切章(播完自动下一章、锁屏/耳机按键上一章下一章),把它的
|
||||
// 当前章节回写进本页,目录与菜单才不会停在旧章节上。只认本书,避免串到别的书。
|
||||
const audioChapterIndex = useReaderAudioStore((s) =>
|
||||
book && s.bookId === book.id ? s.chapterIndex : null,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (contentType !== 'audio' || audioChapterIndex === null) return
|
||||
if (audioChapterIndex === chapterIndex) return
|
||||
pendingEndRef.current = false
|
||||
setChapterIndex(audioChapterIndex)
|
||||
}, [audioChapterIndex, chapterIndex, contentType])
|
||||
|
||||
// ── 换源(对应 legado 阅读页的「换源」) ──
|
||||
//
|
||||
@@ -396,22 +564,20 @@ export default function ReaderViewPage() {
|
||||
// 本地导入的书没有书源,不显示换源入口
|
||||
const canSwitchSource = !!book && !book.is_local
|
||||
|
||||
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
|
||||
const saveAudioCredits = useCallback(
|
||||
(open: number, close: number) => {
|
||||
if (!book) return
|
||||
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
|
||||
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
},
|
||||
[book],
|
||||
)
|
||||
// 听书:片头/片尾跳过秒数的落库改由播放引擎负责(它持有当前书 ID,离开本页也能写)。
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(-1)
|
||||
getReaderAudioEngine().prev()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
// 双页:一次退一屏;进度落在该屏的首张图上
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex > 0) setComicPage(comicSpreads[comicSpreadIndex - 1][0])
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
}
|
||||
if (comicPage > 0) setComicPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
@@ -422,14 +588,19 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page > 0) setPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
}, [contentType, settings.pageMode, comicPage, page, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(1)
|
||||
getReaderAudioEngine().next()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex < comicSpreads.length - 1) setComicPage(comicSpreads[comicSpreadIndex + 1][0])
|
||||
else goChapter(1)
|
||||
return
|
||||
}
|
||||
const n = media?.images?.length ?? 0
|
||||
if (comicPage < n - 1) setComicPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
@@ -443,7 +614,22 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
|
||||
|
||||
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
|
||||
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
|
||||
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
|
||||
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
||||
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
||||
const handleZoneTap = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
|
||||
if (x < 0.3) goPrev()
|
||||
else if (x > 0.7) goNext()
|
||||
else setMenuOpen((v) => !v)
|
||||
},
|
||||
[goPrev, goNext],
|
||||
)
|
||||
|
||||
// ── 键盘(桌面端) ──
|
||||
useEffect(() => {
|
||||
@@ -465,12 +651,13 @@ export default function ReaderViewPage() {
|
||||
}, [goPrev, goNext, menuOpen, panel])
|
||||
|
||||
// ── 鼠标滚轮翻页(仅翻页模式) ──
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式的滚轮不在这里处理,
|
||||
// 由 useSmoothWheelScroll 接管做平滑惯性滚动。
|
||||
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
|
||||
useEffect(() => {
|
||||
const el = readerRef.current
|
||||
if (!el) return
|
||||
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
|
||||
// 滚动模式/听书面板/漫画滚动交给各自的滚动逻辑;菜单打开时不翻页
|
||||
if (settings.pageMode !== 'page' || menuOpen) return
|
||||
if (contentType !== 'text' && contentType !== 'image') return
|
||||
|
||||
@@ -566,15 +753,59 @@ export default function ReaderViewPage() {
|
||||
},
|
||||
}
|
||||
})()
|
||||
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
|
||||
// 正文按行组织成「段落块」:保留原始行号(段评锚点按行号挂靠),
|
||||
// 只丢掉既无文字又无段评的空行。
|
||||
const blocks = useMemo<ReaderParagraphBlock[]>(() => {
|
||||
const lines = (content ?? '').split('\n')
|
||||
const byLine = new Map<number, ReaderContentComment[]>()
|
||||
for (const c of media?.type === 'text' ? (media.comments ?? []) : []) {
|
||||
const list = byLine.get(c.line)
|
||||
if (list) list.push(c)
|
||||
else byLine.set(c.line, [c])
|
||||
}
|
||||
const out: ReaderParagraphBlock[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const text = lines[i].trim()
|
||||
const comments = byLine.get(i) ?? []
|
||||
if (text === '' && comments.length === 0) continue
|
||||
out.push({ text, comments })
|
||||
}
|
||||
return out
|
||||
}, [content, media])
|
||||
|
||||
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
|
||||
const renderParagraph = (line: string, key: number) => {
|
||||
if (line.startsWith(IMG_MARK)) {
|
||||
// 打开段评:服务端带书源 Cookie 抓取评论页,这里用 iframe 承载。
|
||||
// 前端直接 window.open 会以未登录身份访问,评论接口拿不到数据。
|
||||
const openComment = useCallback(
|
||||
async (comment: ReaderContentComment) => {
|
||||
if (!book) return
|
||||
if (!comment.url) {
|
||||
toast('这条评论没有可用地址')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await readerAPI.openContentComment({
|
||||
book_id: book.id,
|
||||
url: comment.url,
|
||||
title: comment.label || '段评',
|
||||
})
|
||||
setBrowserPage(page)
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '打开段评失败',
|
||||
)
|
||||
}
|
||||
},
|
||||
[book],
|
||||
)
|
||||
|
||||
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
|
||||
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
|
||||
const { text, comments } = block
|
||||
if (text.startsWith(IMG_MARK)) {
|
||||
return (
|
||||
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
|
||||
<img
|
||||
src={line.slice(IMG_MARK.length)}
|
||||
src={text.slice(IMG_MARK.length)}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
|
||||
@@ -582,9 +813,44 @@ export default function ReaderViewPage() {
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const standalone = text === '' && comments.length > 0
|
||||
return (
|
||||
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
||||
{line}
|
||||
<p
|
||||
key={key}
|
||||
style={{
|
||||
textIndent: text ? '2em' : undefined,
|
||||
marginBottom: settings.paragraphSpacing,
|
||||
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{comments.map((c, ci) => (
|
||||
<button
|
||||
key={`${c.line}-${ci}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
|
||||
// 点气泡会顺带翻页或呼出菜单。
|
||||
e.stopPropagation()
|
||||
void openComment(c)
|
||||
}}
|
||||
title={c.label || '段评'}
|
||||
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
|
||||
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
|
||||
style={{
|
||||
borderColor: theme.accent,
|
||||
color: theme.accent,
|
||||
fontSize: Math.max(10, settings.fontSize - 4),
|
||||
lineHeight: 1,
|
||||
padding: '2px 6px',
|
||||
verticalAlign: 'middle',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={11} />
|
||||
{c.count > 0 && <span>{c.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -605,29 +871,10 @@ export default function ReaderViewPage() {
|
||||
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||
{/* 正文视口 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
|
||||
{contentType === 'audio' ? (
|
||||
media && media.tracks && media.tracks.length > 0 ? (
|
||||
<ReaderAudioPanel
|
||||
src={media.tracks[0]}
|
||||
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||
cover={book?.cover_url ?? ''}
|
||||
theme={theme}
|
||||
initialPos={restorePos}
|
||||
openCredits={book?.open_credits ?? 0}
|
||||
closeCredits={book?.close_credits ?? 0}
|
||||
chapters={chapters}
|
||||
chapterIndex={chapterIndex}
|
||||
hasNext={hasNextAudioChapter}
|
||||
transcoding={media.transcoding ?? false}
|
||||
onProgress={throttledMediaSave}
|
||||
onPrevChapter={() => goChapter(-1)}
|
||||
onNextChapter={() => goChapter(1)}
|
||||
onSelectChapter={jumpToChapter}
|
||||
onEnded={() => goChapter(1)}
|
||||
onCreditsChange={saveAudioCredits}
|
||||
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||
/>
|
||||
<ReaderAudioPanel theme={theme} onToggleMenu={() => setMenuOpen((v) => !v)} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
|
||||
@@ -635,10 +882,12 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
) : contentType === 'image' ? (
|
||||
<ReaderComic
|
||||
images={media?.images ?? []}
|
||||
images={comicImages}
|
||||
theme={theme}
|
||||
mode={settings.pageMode}
|
||||
page={comicPage}
|
||||
imageFit={comicScrollFit}
|
||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
@@ -653,8 +902,16 @@ export default function ReaderViewPage() {
|
||||
) : settings.pageMode === 'page' ? (
|
||||
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
|
||||
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
|
||||
不改高度也就不触发重新分页) */
|
||||
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
|
||||
不改高度也就不触发重新分页)。
|
||||
relative + z-index + pointer-events-none:正文容器带 transform,
|
||||
本身就形成一个层叠上下文,段评气泡的 z-index 只在容器内部生效,
|
||||
会被后面的点击覆盖层压住。把整层抬到覆盖层之上并关闭指针事件,
|
||||
只让气泡自己 pointer-events:auto,气泡可点、其余位置照样透给
|
||||
覆盖层翻页/呼出菜单。 */
|
||||
<div
|
||||
className={`pointer-events-none relative z-[5] h-full px-4 py-2 ${MENU_SHIFT}`}
|
||||
style={menuShiftStyle}
|
||||
>
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
@@ -669,13 +926,14 @@ export default function ReaderViewPage() {
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (chapterIndex === null) return
|
||||
@@ -688,17 +946,17 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
|
||||
className={`h-full overflow-y-auto overscroll-contain px-4 ${MENU_SHIFT}`}
|
||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||
>
|
||||
<div className="py-4">
|
||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载 / 错误 / 空内容态 */}
|
||||
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
|
||||
{(loadingStage !== null || (content !== null && blocks.length === 0)) && contentType === 'text' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
|
||||
{loadingStage !== null ? (
|
||||
<Loader2 className="animate-spin opacity-60" size={24} />
|
||||
@@ -715,8 +973,10 @@ export default function ReaderViewPage() {
|
||||
onClick={() => {
|
||||
setError('')
|
||||
if (chapterIndex !== null) {
|
||||
contentCache.current.delete(String(chapterIndex))
|
||||
setChapterIndex(chapterIndex)
|
||||
contentCache.current.delete(contentCacheKey(chapterIndex))
|
||||
// 用 reload 计数触发重取:把 chapterIndex 设成同一个值不会让
|
||||
// effect 重跑,以前点「重试」只是把错误提示清掉了。
|
||||
setContentReloadKey((v) => v + 1)
|
||||
}
|
||||
}}
|
||||
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
|
||||
@@ -728,8 +988,11 @@ export default function ReaderViewPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
|
||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
|
||||
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
|
||||
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
|
||||
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
|
||||
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
|
||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||
<button
|
||||
type="button"
|
||||
@@ -811,11 +1074,13 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
|
||||
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。
|
||||
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
|
||||
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
|
||||
<div className="flex pt-1" style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
{
|
||||
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||
label: settings.night ? '日间' : '夜间',
|
||||
@@ -826,6 +1091,17 @@ export default function ReaderViewPage() {
|
||||
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||
},
|
||||
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
|
||||
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
|
||||
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
? [
|
||||
{
|
||||
icon: <Columns2 size={18} />,
|
||||
label: settings.comicDoublePage ? '单页' : '双页',
|
||||
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canSwitchSource
|
||||
? [
|
||||
{
|
||||
@@ -842,7 +1118,7 @@ export default function ReaderViewPage() {
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs">{item.label}</span>
|
||||
@@ -850,7 +1126,7 @@ export default function ReaderViewPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
|
||||
{panel === 'style' && (
|
||||
<div
|
||||
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
|
||||
@@ -904,6 +1180,30 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
|
||||
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
|
||||
{showComicImageFit && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="opacity-70">图片尺寸</span>
|
||||
{COMIC_IMAGE_FITS.map((item) => {
|
||||
const active = settings.comicImageFit === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => settings.setComicImageFit(item.id)}
|
||||
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
|
||||
style={{
|
||||
borderColor: active ? theme.accent : theme.text + '44',
|
||||
color: active ? theme.accent : theme.text,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -927,15 +1227,39 @@ export default function ReaderViewPage() {
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<p className="flex-1 truncate text-sm font-bold">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-bold">
|
||||
{book?.name ?? '目录'}
|
||||
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
||||
</p>
|
||||
{tocGroups.length > 1 && (
|
||||
<select
|
||||
value={tocGroupIndex}
|
||||
onChange={(e) => jumpToTocGroup(Number(e.target.value))}
|
||||
aria-label="按区间快速定位章节"
|
||||
title="按区间快速定位章节"
|
||||
className="shrink-0 rounded-lg border bg-transparent px-2 py-1 text-2xs outline-none"
|
||||
style={{ borderColor: theme.text + '33', color: theme.text }}
|
||||
>
|
||||
{tocGroups.map((group) => (
|
||||
<option
|
||||
key={group.index}
|
||||
value={group.index}
|
||||
style={{ color: '#111827', backgroundColor: '#ffffff' }}
|
||||
>
|
||||
{group.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapters}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
rangeChanged={(range) =>
|
||||
setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
|
||||
}
|
||||
itemContent={(index, ch) => {
|
||||
const isCurrent = index === chapterIndex
|
||||
return (
|
||||
@@ -975,6 +1299,9 @@ export default function ReaderViewPage() {
|
||||
onClose={() => setSwitchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 段评评论页:复用书源页面的承载面板(带书源 Cookie 由服务端抓取) */}
|
||||
{browserPage && <BrowserPanel page={browserPage} onClose={() => setBrowserPage(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Minus, Search, X } from 'lucide-react'
|
||||
|
||||
import type { ReaderSource } from '../../api/reader'
|
||||
|
||||
/**
|
||||
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
|
||||
*
|
||||
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
|
||||
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
|
||||
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
|
||||
*
|
||||
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
|
||||
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
|
||||
*/
|
||||
export function SearchScopeDialog({
|
||||
sources,
|
||||
selectedIds,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
sources: ReaderSource[]
|
||||
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
|
||||
selectedIds: string[]
|
||||
onConfirm: (ids: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
|
||||
|
||||
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const valid = selectedIds.filter((id) => enabledIds.includes(id))
|
||||
return new Set(valid.length > 0 ? valid : enabledIds)
|
||||
})
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const keyword = filter.trim().toLowerCase()
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
sources.filter(
|
||||
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
|
||||
),
|
||||
[sources, keyword],
|
||||
)
|
||||
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ReaderSource[]>()
|
||||
for (const s of visible) {
|
||||
const key = (s.group ?? '').trim() || '未分组'
|
||||
const list = map.get(key)
|
||||
if (list) list.push(s)
|
||||
else map.set(key, [s])
|
||||
}
|
||||
return [...map.entries()].map(([name, items]) => ({ name, items }))
|
||||
}, [visible])
|
||||
|
||||
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
|
||||
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleGroup = (ids: string[]) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allIn = ids.every((id) => next.has(id))
|
||||
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
|
||||
return next
|
||||
})
|
||||
|
||||
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
|
||||
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
|
||||
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
|
||||
const clearVisible = () =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
visibleEnabledIds.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
|
||||
const confirm = () => {
|
||||
const picked = enabledIds.filter((id) => selected.has(id))
|
||||
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
|
||||
onConfirm(picked.length === enabledIds.length ? [] : picked)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
|
||||
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="过滤书源名 / 分组"
|
||||
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
|
||||
全选
|
||||
</button>
|
||||
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||
{sources.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => {
|
||||
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
|
||||
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
|
||||
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={groupEnabledIds.length === 0}
|
||||
onClick={() => toggleGroup(groupEnabledIds)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
|
||||
>
|
||||
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
|
||||
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
|
||||
</button>
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
disabled={!src.enabled}
|
||||
onClick={() => toggle(src.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
|
||||
{!src.enabled && (
|
||||
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
|
||||
<span className="flex-1 text-2xs text-[var(--app-muted)]">
|
||||
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline text-xs">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
|
||||
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
|
||||
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
} ${disabled ? 'opacity-40' : ''}`}
|
||||
>
|
||||
{checked && <Check size={11} strokeWidth={3} />}
|
||||
{!checked && partial && <Minus size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -70,8 +70,16 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
const pollBrowserPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await readerAPI.browserPending(sourceId)
|
||||
const next = pages.find((p) => !handledPages.current.has(p.id))
|
||||
if (next) setBrowserPage(next)
|
||||
// 只取本次还没处理过的页面,并按 seq 取最新登记的那个。
|
||||
// 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序
|
||||
// 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上
|
||||
// (光遇聚合「更新书源」残留后串页就是这么来的)。
|
||||
const next = pages
|
||||
.filter((p) => !handledPages.current.has(p.id))
|
||||
.reduce<ReaderBrowserPage | null>((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null)
|
||||
if (!next) return
|
||||
// 已经在展示的页面不要被更旧的页面替换掉
|
||||
setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next))
|
||||
} catch {
|
||||
// 轮询失败不打断正在执行的动作
|
||||
}
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
// 听书播放引擎:模块级单例,持有真正的 <audio>(不进 React 树,也不进任何路由)。
|
||||
//
|
||||
// 为什么要脱离组件:音频面板原先挂在 ReaderViewPage 里,路由一变组件卸载,清理
|
||||
// 逻辑就 removeAttribute('src') + load(),播放当场断掉;手机息屏、系统回收标签页
|
||||
// 时更没有任何东西证明「这个页面正在播媒体」。把播放器做成常驻单例后:
|
||||
// - 路由切换不影响播放;
|
||||
// - Media Session 让锁屏/通知栏/耳机按键接管控制(Android Chrome 会据此判定
|
||||
// 页面处于活跃媒体会话,后台存活率明显更高);
|
||||
// - 定时关闭改由 timeupdate(后台播放时仍触发)+ 低频 ticker 驱动,不再依赖
|
||||
// 被节流的 setInterval 来「到点暂停」。
|
||||
//
|
||||
// 进度上报也在这里:引擎自己知道当前书/章,离开页面、暂停、切章时都能补报一次,
|
||||
// 不依赖调用方在卸载时回调。
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { readerAPI, type ReaderChapter } from '../../api/reader'
|
||||
import { useReaderAudioStore, resetReaderAudio } from '../../stores/readerAudio'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
|
||||
/** 播放进度上报节流(毫秒):退出最多丢 5 秒。 */
|
||||
const PROGRESS_SAVE_INTERVAL_MS = 5_000
|
||||
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
|
||||
const SEEK_STEP = 15
|
||||
|
||||
export interface LoadChapterInput {
|
||||
bookId: string
|
||||
bookName: string
|
||||
cover: string
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number
|
||||
/** 当前章的音轨地址(已签名)。 */
|
||||
track: string
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
/** 恢复进度(秒);0 表示从头。 */
|
||||
initialPos: number
|
||||
}
|
||||
|
||||
class ReaderAudioEngine {
|
||||
private audio: HTMLAudioElement
|
||||
/** hls.js 实例(只用到 destroy)。 */
|
||||
private hls: { destroy: () => void } | null = null
|
||||
/** 元数据就绪后是否已经套用过进度/片头跳转。 */
|
||||
private restored = false
|
||||
/** 本章是否已经触发过片尾跳过,避免 timeupdate 反复触发。 */
|
||||
private skippedEnd = false
|
||||
/** 最近一次播放位置(秒):节流窗口外补报进度时要用。 */
|
||||
private lastTime = 0
|
||||
private lastSavedAt = 0
|
||||
/** 上一次落库的进度标识(书|章|秒),相同则不重复请求。 */
|
||||
private savedKey = ''
|
||||
/** 定时关闭截止时间戳(毫秒),0 表示未开启。 */
|
||||
private timerDeadline = 0
|
||||
/** 暂停开始的毫秒时间戳,0 表示当前在播(定时倒计时只在播放中走)。 */
|
||||
private pausedAt = 0
|
||||
private timerTicker: number | null = null
|
||||
/** 一次性的初始进度,等 metadata 就绪后消费。 */
|
||||
private pendingInitialPos = 0
|
||||
/** 是否已经套用过「上次的定时设置」,只在首次加载时套一次。 */
|
||||
private timerBooted = false
|
||||
/** 正在拆卸音源(避免拆卸触发的 pause 事件被当成用户暂停)。 */
|
||||
private detaching = false
|
||||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||||
private loadToken = 0
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'metadata'
|
||||
this.audio = audio
|
||||
this.bindAudio()
|
||||
this.bindLifecycle()
|
||||
this.bindSettings()
|
||||
this.bindMediaSession()
|
||||
}
|
||||
|
||||
// ─── 对外 API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载并播放一章。同一本书同一章重复调用(阅读页 effect 重跑、目录同步回写)
|
||||
* 会被忽略,避免把正在播的进度重置掉。
|
||||
*/
|
||||
loadChapter(input: LoadChapterInput): void {
|
||||
const s = useReaderAudioStore.getState()
|
||||
if (
|
||||
s.bookId === input.bookId &&
|
||||
s.chapterIndex === input.chapterIndex &&
|
||||
s.track === input.track &&
|
||||
(s.status === 'loading' || s.status === 'playing' || s.status === 'paused')
|
||||
) {
|
||||
return
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
|
||||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||||
useReaderAudioStore.setState({
|
||||
bookId: input.bookId,
|
||||
bookName: input.bookName,
|
||||
cover: input.cover,
|
||||
chapters: input.chapters,
|
||||
chapterIndex: input.chapterIndex,
|
||||
chapterTitle,
|
||||
track: input.track,
|
||||
transcoding: input.transcoding,
|
||||
openCredits: input.openCredits,
|
||||
closeCredits: input.closeCredits,
|
||||
hasNext: this.computeHasNext(input.chapters, input.chapterIndex),
|
||||
status: 'loading',
|
||||
error: '',
|
||||
cur: 0,
|
||||
dur: 0,
|
||||
})
|
||||
|
||||
this.restored = false
|
||||
this.skippedEnd = false
|
||||
this.lastTime = 0
|
||||
this.lastSavedAt = 0
|
||||
this.savedKey = ''
|
||||
this.pendingInitialPos = Math.max(0, input.initialPos)
|
||||
|
||||
// 进场套用上次的定时设置(对应 legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
if (!this.timerBooted) {
|
||||
this.timerBooted = true
|
||||
const preset = useReaderSettingsStore.getState().audioTimerMinutes
|
||||
if (preset > 0) this.applyTimer(preset)
|
||||
}
|
||||
|
||||
void this.attachSource(input.track)
|
||||
}
|
||||
|
||||
play(): void {
|
||||
void this.audio.play().catch(() => {
|
||||
// 自动播放被浏览器拦下(典型是 iOS):退回暂停态,让用户点按钮手动触发,
|
||||
// 而不是一直停在「加载中」。
|
||||
if (useReaderAudioStore.getState().status === 'loading') {
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.audio.pause()
|
||||
// 还没触发 play 事件时(status 仍是 loading),下面的 pause 事件处理会跳过状态
|
||||
// 判定;这里补一次,保证按钮不会一直转圈。
|
||||
if (useReaderAudioStore.getState().status === 'loading') {
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.audio.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
seekBy(delta: number): void {
|
||||
const total = Number.isFinite(this.audio.duration)
|
||||
? this.audio.duration
|
||||
: this.audio.currentTime + Math.abs(delta)
|
||||
this.seekTo(Math.min(Math.max(0, this.audio.currentTime + delta), Math.max(0, total - 0.2)))
|
||||
}
|
||||
|
||||
seekTo(seconds: number): void {
|
||||
if (!Number.isFinite(seconds)) return
|
||||
this.audio.currentTime = Math.max(0, seconds)
|
||||
this.lastTime = this.audio.currentTime
|
||||
useReaderAudioStore.setState({ cur: this.audio.currentTime })
|
||||
this.updateSessionPosition()
|
||||
}
|
||||
|
||||
/** 倍速改动写回 settings store,订阅回调负责应用到 <audio>。 */
|
||||
setSpeed(v: number): void {
|
||||
useReaderSettingsStore.getState().setAudioSpeed(v)
|
||||
}
|
||||
|
||||
/** 片头/片尾跳过秒数:立刻生效并按书持久化。 */
|
||||
setCredits(open: number, close: number): void {
|
||||
useReaderAudioStore.setState({ openCredits: open, closeCredits: close })
|
||||
const { bookId } = useReaderAudioStore.getState()
|
||||
if (!bookId) return
|
||||
readerAPI.saveAudioConfig(bookId, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** 定时关闭:0 表示关闭定时。 */
|
||||
applyTimer(minutes: number): void {
|
||||
useReaderSettingsStore.getState().setAudioTimerMinutes(minutes)
|
||||
useReaderAudioStore.setState({ timerPreset: minutes })
|
||||
if (minutes <= 0) {
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
this.stopTimerTicker()
|
||||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||||
return
|
||||
}
|
||||
this.timerDeadline = Date.now() + minutes * 60_000
|
||||
// 暂停期间不倒计时:记下暂停起点,恢复播放时顺延截止时间
|
||||
this.pausedAt = this.audio.paused ? Date.now() : 0
|
||||
useReaderAudioStore.setState({ timerLeft: minutes * 60, timerActive: true })
|
||||
this.startTimerTicker()
|
||||
}
|
||||
|
||||
selectChapter(index: number): void {
|
||||
void this.goToChapter(index, 0)
|
||||
}
|
||||
|
||||
next(): void {
|
||||
const i = this.neighborChapterIndex(1)
|
||||
if (i === null) return
|
||||
void this.goToChapter(i, 0)
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
const i = this.neighborChapterIndex(-1)
|
||||
if (i === null) return
|
||||
void this.goToChapter(i, 0)
|
||||
}
|
||||
|
||||
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.detachSource()
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||||
this.timerBooted = false
|
||||
this.stopTimerTicker()
|
||||
this.setSessionMetadata(null)
|
||||
this.setSessionPlaybackState('none')
|
||||
resetReaderAudio()
|
||||
}
|
||||
|
||||
/** 立即补报一次进度(不吃节流窗口)。 */
|
||||
commitProgress(): void {
|
||||
this.lastSavedAt = 0
|
||||
this.persistProgress()
|
||||
}
|
||||
|
||||
// ─── 音源装载 ─────────────────────────────────────────────────────────
|
||||
|
||||
private async attachSource(src: string): Promise<void> {
|
||||
const token = ++this.loadToken
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
if (!src) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||||
if (token !== this.loadToken) return
|
||||
const Hls = mod.default
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
if (data.fatal) this.fail('音频加载失败,请检查网络或源文件是否还在')
|
||||
})
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(this.audio)
|
||||
this.hls = inst
|
||||
return
|
||||
}
|
||||
// Safari 原生 HLS
|
||||
this.audio.src = src
|
||||
return
|
||||
}
|
||||
this.audio.src = src
|
||||
}
|
||||
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
private async goToChapter(index: number, initialPos: number): Promise<void> {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(bookId, index)
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.loadChapter({
|
||||
bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters,
|
||||
chapterIndex: index,
|
||||
track,
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos,
|
||||
})
|
||||
} catch (e) {
|
||||
this.fail((e as Error).message || '章节加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
useReaderAudioStore.setState({ status: 'error', error: message })
|
||||
this.setSessionPlaybackState('paused')
|
||||
}
|
||||
|
||||
// ─── <audio> 事件 ─────────────────────────────────────────────────────
|
||||
|
||||
private bindAudio(): void {
|
||||
const audio = this.audio
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
useReaderAudioStore.setState({ status: 'playing' })
|
||||
this.setSessionPlaybackState('playing')
|
||||
// 恢复播放:把暂停时长顺延到定时截止时间
|
||||
if (this.pausedAt > 0) {
|
||||
this.timerDeadline += Date.now() - this.pausedAt
|
||||
this.pausedAt = 0
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
if (this.detaching) return
|
||||
// 只认「播放中 -> 暂停」这一种。换源/停止时 load() 也会抛出 pause 事件,
|
||||
// 那些时刻 status 是 loading/idle,直接忽略;否则会把刚进入的加载态改成暂停,
|
||||
// 并给新章节写一条位置为 0 的进度。
|
||||
if (useReaderAudioStore.getState().status !== 'playing') return
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
this.commitProgress()
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||||
this.commitProgress()
|
||||
if (useReaderAudioStore.getState().hasNext) {
|
||||
this.next()
|
||||
return
|
||||
}
|
||||
// 最后一章播完:ended 不会触发 pause,倒计时/暂停态要在这里自己落定
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
})
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
useReaderAudioStore.setState({ dur: total })
|
||||
if (!this.restored) {
|
||||
const { openCredits } = useReaderAudioStore.getState()
|
||||
if (this.pendingInitialPos > 0 && (total === 0 || this.pendingInitialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = this.pendingInitialPos
|
||||
useReaderAudioStore.setState({ cur: this.pendingInitialPos })
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
useReaderAudioStore.setState({ cur: openCredits })
|
||||
}
|
||||
this.pendingInitialPos = 0
|
||||
}
|
||||
this.restored = true
|
||||
audio.playbackRate = useReaderAudioStore.getState().speed
|
||||
this.play()
|
||||
})
|
||||
|
||||
audio.addEventListener('durationchange', () => {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
useReaderAudioStore.setState({ dur: total })
|
||||
this.updateSessionPosition()
|
||||
})
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const t = audio.currentTime
|
||||
this.lastTime = t
|
||||
useReaderAudioStore.setState({ cur: t })
|
||||
this.updateSessionPosition()
|
||||
this.reportProgress()
|
||||
this.checkCreditsEnd()
|
||||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||||
this.checkTimer()
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
const code = audio.error?.code
|
||||
this.fail(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 页面生命周期:只负责补报进度,绝不暂停播放 ───────────────────────
|
||||
|
||||
private bindLifecycle(): void {
|
||||
const flush = () => this.commitProgress()
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
window.addEventListener('pagehide', flush)
|
||||
}
|
||||
|
||||
private bindSettings(): void {
|
||||
const apply = () => {
|
||||
const { audioSpeed } = useReaderSettingsStore.getState()
|
||||
this.audio.playbackRate = audioSpeed
|
||||
useReaderAudioStore.setState({ speed: audioSpeed })
|
||||
}
|
||||
apply()
|
||||
// 引擎是常驻单例,订阅不需要解除
|
||||
useReaderSettingsStore.subscribe(apply)
|
||||
}
|
||||
|
||||
// ─── 进度 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private reportProgress(): void {
|
||||
const now = Date.now()
|
||||
if (now - this.lastSavedAt < PROGRESS_SAVE_INTERVAL_MS) return
|
||||
this.lastSavedAt = now
|
||||
this.persistProgress()
|
||||
}
|
||||
|
||||
private persistProgress(): void {
|
||||
const { bookId, chapterIndex, chapterTitle } = useReaderAudioStore.getState()
|
||||
if (!bookId || this.lastTime <= 0) return
|
||||
const pos = Math.max(0, Math.floor(this.lastTime))
|
||||
const key = `${bookId}|${chapterIndex}|${pos}`
|
||||
if (key === this.savedKey) return
|
||||
this.savedKey = key
|
||||
readerAPI.saveProgress(bookId, { chapter_index: chapterIndex, pos, chapter_title: chapterTitle }).catch(() => undefined)
|
||||
}
|
||||
|
||||
// ─── 片头片尾 ─────────────────────────────────────────────────────────
|
||||
|
||||
private checkCreditsEnd(): void {
|
||||
const { closeCredits, hasNext } = useReaderAudioStore.getState()
|
||||
const audio = this.audio
|
||||
if (closeCredits <= 0 || this.skippedEnd) return
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= closeCredits + 1) return
|
||||
if (audio.currentTime < audio.duration - closeCredits) return
|
||||
this.skippedEnd = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
this.next()
|
||||
}
|
||||
|
||||
// ─── 定时关闭 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** 剩余秒数:暂停期间以暂停起点为准,倒计时不会在暂停时溜走。 */
|
||||
private timerLeftSeconds(): number {
|
||||
if (this.timerDeadline === 0) return 0
|
||||
const now = this.pausedAt > 0 ? this.pausedAt : Date.now()
|
||||
return Math.max(0, Math.round((this.timerDeadline - now) / 1000))
|
||||
}
|
||||
|
||||
private checkTimer(): void {
|
||||
const s = useReaderAudioStore.getState()
|
||||
if (!s.timerActive || this.timerDeadline === 0) return
|
||||
const left = this.timerLeftSeconds()
|
||||
if (left !== s.timerLeft) useReaderAudioStore.setState({ timerLeft: left })
|
||||
// 暂停中不判到期:恢复播放时截止时间已经顺延过
|
||||
if (left <= 0 && this.pausedAt === 0) {
|
||||
this.timerDeadline = 0
|
||||
this.stopTimerTicker()
|
||||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||||
this.audio.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}
|
||||
|
||||
private startTimerTicker(): void {
|
||||
this.stopTimerTicker()
|
||||
this.timerTicker = window.setInterval(() => this.checkTimer(), 1000)
|
||||
}
|
||||
|
||||
private stopTimerTicker(): void {
|
||||
if (this.timerTicker !== null) {
|
||||
window.clearInterval(this.timerTicker)
|
||||
this.timerTicker = null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Media Session(锁屏 / 通知栏 / 耳机按键) ────────────────────────
|
||||
|
||||
private session(): MediaSession | null {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return null
|
||||
return navigator.mediaSession
|
||||
}
|
||||
|
||||
private setSessionMetadata(track: string | null): void {
|
||||
const session = this.session()
|
||||
if (!session || typeof MediaMetadata === 'undefined') return
|
||||
if (track === null) {
|
||||
session.metadata = null
|
||||
return
|
||||
}
|
||||
const { bookName, chapterTitle, cover } = useReaderAudioStore.getState()
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
const artwork: MediaImage[] = coverSrc
|
||||
? [{ src: new URL(coverSrc, window.location.origin).href, sizes: '512x512' }]
|
||||
: []
|
||||
try {
|
||||
session.metadata = new MediaMetadata({
|
||||
title: chapterTitle || bookName || 'MeBox 听书',
|
||||
artist: bookName || 'MeBox',
|
||||
album: 'MeBox 听书',
|
||||
artwork,
|
||||
})
|
||||
} catch {
|
||||
// 个别浏览器对 artwork 类型挑剔,失败不影响播放
|
||||
}
|
||||
}
|
||||
|
||||
private setSessionPlaybackState(state: MediaSessionPlaybackState): void {
|
||||
const session = this.session()
|
||||
if (!session) return
|
||||
try {
|
||||
session.playbackState = state
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
private updateSessionPosition(): void {
|
||||
const session = this.session()
|
||||
if (!session || typeof session.setPositionState !== 'function') return
|
||||
const { dur } = useReaderAudioStore.getState()
|
||||
const position = this.audio.currentTime
|
||||
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(position)) return
|
||||
const rate = this.audio.playbackRate
|
||||
try {
|
||||
session.setPositionState({
|
||||
duration: dur,
|
||||
position: Math.min(Math.max(0, position), dur),
|
||||
playbackRate: Number.isFinite(rate) && rate > 0 ? rate : 1,
|
||||
})
|
||||
} catch {
|
||||
// position 越界等情况下浏览器会抛错,忽略
|
||||
}
|
||||
}
|
||||
|
||||
private bindMediaSession(): void {
|
||||
const session = this.session()
|
||||
if (!session) return
|
||||
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
||||
['play', () => this.play()],
|
||||
['pause', () => this.pause()],
|
||||
['stop', () => this.stop()],
|
||||
['seekbackward', () => this.seekBy(-SEEK_STEP)],
|
||||
['seekforward', () => this.seekBy(SEEK_STEP)],
|
||||
['previoustrack', () => this.prev()],
|
||||
['nexttrack', () => this.next()],
|
||||
[
|
||||
'seekto',
|
||||
(details) => {
|
||||
if (typeof details.seekTime === 'number') this.seekTo(details.seekTime)
|
||||
},
|
||||
],
|
||||
]
|
||||
for (const [action, handler] of handlers) {
|
||||
try {
|
||||
session.setActionHandler(action, handler)
|
||||
} catch {
|
||||
// 该动作在当前浏览器不支持时会抛错,逐个忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private neighborChapterIndex(dir: 1 | -1): number | null {
|
||||
const { chapters, chapterIndex } = useReaderAudioStore.getState()
|
||||
for (let i = chapterIndex + dir; i >= 0 && i < chapters.length; i += dir) {
|
||||
if (!chapters[i].is_volume) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private computeHasNext(chapters: ReaderChapter[], index: number): boolean {
|
||||
for (let i = index + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let engine: ReaderAudioEngine | null = null
|
||||
|
||||
/**
|
||||
* 取全局听书引擎(首次调用时才构造,避免没听过书的应用也创建一个 <audio>)。
|
||||
*/
|
||||
export function getReaderAudioEngine(): ReaderAudioEngine {
|
||||
if (!engine) engine = new ReaderAudioEngine()
|
||||
return engine
|
||||
}
|
||||
+70
-40
@@ -2,7 +2,12 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
import { refreshToken } from '../api/refresh'
|
||||
import { requestRefreshTokens } from '../api/refresh'
|
||||
import {
|
||||
createBrowserExclusiveRunner,
|
||||
createRefreshCoordinator,
|
||||
type RefreshOutcome,
|
||||
} from '../utils/authRefresh'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
@@ -16,50 +21,65 @@ interface AuthState {
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
logout: () => void
|
||||
/**
|
||||
* 刷新会话的唯一入口(同标签页 + 跨标签页收敛成一次请求)。
|
||||
*
|
||||
* 返回的 outcome 决定调用方怎么处理:
|
||||
* - 'refreshed' 已拿到新令牌,可重试原请求;
|
||||
* - 'invalid' 服务端明确判定凭证失效,本地会话已清空,需重新登录;
|
||||
* - 'transient' 网络/服务暂时不可用,本地会话保留,稍后重试即可。
|
||||
*/
|
||||
refreshSession: () => Promise<RefreshOutcome>
|
||||
/** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
|
||||
tokenRefresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export const AUTH_STORAGE_KEY = 'mebox-auth'
|
||||
|
||||
// 跨标签页互斥:多个标签页共享同一份 refresh token,
|
||||
// 只允许其中一个真正发起刷新。
|
||||
const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
tokenRefresh: async () => {
|
||||
const rt = get().refreshToken
|
||||
if (!rt) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resp = await refreshToken(rt)
|
||||
set({
|
||||
token: resp.token,
|
||||
refreshToken: resp.refresh_token
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Refresh failed, need to logout
|
||||
set({ token: null, refreshToken: null, user: null, tier: 'free' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mebox-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
(set, get) => {
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => get().token,
|
||||
readRefreshToken: () => get().refreshToken,
|
||||
applyTokens: (tokens) =>
|
||||
set({ token: tokens.token, refreshToken: tokens.refresh_token }),
|
||||
clearSession: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
request: requestRefreshTokens,
|
||||
runExclusive: runRefreshExclusive,
|
||||
// 其他标签页刷新成功后会写回 localStorage;重新读进来,避免本标签页
|
||||
// 继续持有已被服务端轮换作废的旧 refresh token。
|
||||
syncFromStorage: () => useAuthStore.persist.rehydrate(),
|
||||
})
|
||||
|
||||
return {
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
refreshSession,
|
||||
tokenRefresh: async () => (await refreshSession()) === 'refreshed',
|
||||
}
|
||||
},
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
tier: state.tier
|
||||
}),
|
||||
@@ -67,6 +87,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
),
|
||||
)
|
||||
|
||||
// storage 事件只在其他标签页写入时触发:登出/轮换后同步本地会话,
|
||||
// 否则本标签页会继续用已失效的令牌发请求。
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === AUTH_STORAGE_KEY) {
|
||||
void useAuthStore.persist.rehydrate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if user is authenticated
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().token !== null
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type { ReaderChapter } from '../api/reader'
|
||||
|
||||
// 听书全局播放状态(刻意不持久化)。
|
||||
//
|
||||
// 真正的播放器是模块级单例(见 pages/reader/readerAudioEngine.ts),React 组件只
|
||||
// 读写这份状态、通过引擎的方法控制播放。这样音频不依附于任何路由:离开阅读页、
|
||||
// 换到别的书、甚至回到影视首页,播放都继续,由 Media Session 提供锁屏/通知栏控制。
|
||||
export type ReaderAudioStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
|
||||
|
||||
export interface ReaderAudioState {
|
||||
/** 当前音轨所属书籍;空串表示没有加载任何音轨。 */
|
||||
bookId: string
|
||||
bookName: string
|
||||
cover: string
|
||||
chapterIndex: number
|
||||
chapterTitle: string
|
||||
chapters: ReaderChapter[]
|
||||
/** 当前音轨地址(已签名,可直接交给 <audio> 或 hls.js)。 */
|
||||
track: string
|
||||
/** 该音轨由服务端转码,首次播放需要等待转码完成。 */
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
hasNext: boolean
|
||||
status: ReaderAudioStatus
|
||||
/** 加载/播放失败的可见原因(空串表示无错误)。 */
|
||||
error: string
|
||||
/** 当前播放位置(秒)。 */
|
||||
cur: number
|
||||
/** 总时长(秒),元数据未就绪时为 0。 */
|
||||
dur: number
|
||||
/** 播放倍速,与 readerSettings 的 audioSpeed 保持一致。 */
|
||||
speed: number
|
||||
timerActive: boolean
|
||||
/** 定时剩余秒数,0 表示未开启。 */
|
||||
timerLeft: number
|
||||
timerPreset: number
|
||||
}
|
||||
|
||||
const initialState: ReaderAudioState = {
|
||||
bookId: '',
|
||||
bookName: '',
|
||||
cover: '',
|
||||
chapterIndex: 0,
|
||||
chapterTitle: '',
|
||||
chapters: [],
|
||||
track: '',
|
||||
transcoding: false,
|
||||
openCredits: 0,
|
||||
closeCredits: 0,
|
||||
hasNext: false,
|
||||
status: 'idle',
|
||||
error: '',
|
||||
cur: 0,
|
||||
dur: 0,
|
||||
speed: 1,
|
||||
timerActive: false,
|
||||
timerLeft: 0,
|
||||
timerPreset: 0,
|
||||
}
|
||||
|
||||
export const useReaderAudioStore = create<ReaderAudioState>(() => ({ ...initialState }))
|
||||
|
||||
/** 引擎专用:整块重置回空闲态(停止播放时调用)。 */
|
||||
export function resetReaderAudio(): void {
|
||||
useReaderAudioStore.setState({ ...initialState })
|
||||
}
|
||||
@@ -45,6 +45,30 @@ export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed'
|
||||
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
|
||||
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
|
||||
|
||||
/**
|
||||
* 漫画图片的显示尺寸档位(上下滚动模式用)。
|
||||
*
|
||||
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
|
||||
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
|
||||
* 和 comicDoublePage 一样只存本机、不参与账号同步。
|
||||
*
|
||||
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
|
||||
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
|
||||
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
|
||||
* - `long` 适应长边:整页完整可见,宽高都不超出视口
|
||||
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
|
||||
*/
|
||||
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
|
||||
|
||||
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
|
||||
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'width', label: '适应宽度' },
|
||||
{ id: 'height', label: '适应高度' },
|
||||
{ id: 'long', label: '适应长边' },
|
||||
{ id: 'original', label: '原图' },
|
||||
]
|
||||
|
||||
interface ReaderSettingsState {
|
||||
// 首页模式切换(影视 / 阅读)
|
||||
homeMode: ReaderHomeMode
|
||||
@@ -77,6 +101,23 @@ interface ReaderSettingsState {
|
||||
pageMode: ReaderPageMode
|
||||
setPageMode: (mode: ReaderPageMode) => void
|
||||
|
||||
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
|
||||
comicDoublePage: boolean
|
||||
setComicDoublePage: (on: boolean) => void
|
||||
|
||||
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
|
||||
comicImageFit: ComicImageFit
|
||||
setComicImageFit: (fit: ComicImageFit) => void
|
||||
|
||||
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
|
||||
/**
|
||||
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
|
||||
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
|
||||
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
|
||||
*/
|
||||
searchScopeIds: string[]
|
||||
setSearchScopeIds: (ids: string[]) => void
|
||||
|
||||
fontSize: number
|
||||
setFontSize: (size: number) => void
|
||||
|
||||
@@ -136,6 +177,22 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
pageMode: 'page',
|
||||
setPageMode: (pageMode) => set({ pageMode }),
|
||||
|
||||
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
|
||||
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
|
||||
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
|
||||
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
|
||||
comicDoublePage: true,
|
||||
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
||||
|
||||
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
|
||||
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
|
||||
// 点「原图」。
|
||||
comicImageFit: 'default',
|
||||
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
|
||||
|
||||
searchScopeIds: [],
|
||||
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
|
||||
|
||||
fontSize: 20,
|
||||
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
||||
|
||||
@@ -162,7 +219,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
|
||||
*
|
||||
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
|
||||
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
|
||||
* 取决于屏幕宽度和分辨率。
|
||||
*/
|
||||
export interface ReaderSettingsProfile {
|
||||
theme_id: string
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 刷新令牌并发保护与失败分类的回归测试。
|
||||
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
} from './authRefresh.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`authRefresh: ${name}`)
|
||||
}
|
||||
|
||||
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
|
||||
const got = await promise
|
||||
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
|
||||
}
|
||||
|
||||
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
|
||||
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
|
||||
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
|
||||
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
|
||||
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
|
||||
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
|
||||
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
|
||||
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
let release: (() => void) | null = null
|
||||
const run = createSingleFlightRunner(async () => {
|
||||
calls += 1
|
||||
const current = calls
|
||||
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
|
||||
if (current === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
const first = run()
|
||||
const second = run()
|
||||
check('single flight shares one promise', first === second)
|
||||
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
|
||||
queueMicrotask(() => release?.())
|
||||
check('single flight result', (await first) === 1)
|
||||
check('single flight stayed single', calls === 1)
|
||||
check('single flight re-runs after settle', (await run()) === 2)
|
||||
}
|
||||
|
||||
// ── 协调器 ────────────────────────────────────────────────────────────────
|
||||
function coordinatorHarness(overrides: {
|
||||
refreshToken?: string | null
|
||||
accessToken?: string | null
|
||||
request?: (token: string) => Promise<RefreshTokens>
|
||||
onApply?: (tokens: RefreshTokens) => void
|
||||
onClear?: () => void
|
||||
syncFromStorage?: () => void
|
||||
}) {
|
||||
const state = {
|
||||
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
|
||||
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
|
||||
applied: [] as RefreshTokens[],
|
||||
cleared: 0,
|
||||
requests: [] as string[],
|
||||
}
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => state.accessToken,
|
||||
readRefreshToken: () => state.refreshToken,
|
||||
applyTokens: (tokens) => {
|
||||
state.applied.push(tokens)
|
||||
state.accessToken = tokens.token
|
||||
state.refreshToken = tokens.refresh_token
|
||||
overrides.onApply?.(tokens)
|
||||
},
|
||||
clearSession: () => {
|
||||
state.cleared += 1
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
overrides.onClear?.()
|
||||
},
|
||||
request: async (token) => {
|
||||
state.requests.push(token)
|
||||
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
|
||||
},
|
||||
runExclusive: (task) => task(),
|
||||
syncFromStorage: () => overrides.syncFromStorage?.(),
|
||||
})
|
||||
return { state, refreshSession }
|
||||
}
|
||||
|
||||
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({})
|
||||
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
|
||||
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
|
||||
check('concurrent refresh sends one request', state.requests.length === 1)
|
||||
check('concurrent refresh applies once', state.applied.length === 1)
|
||||
}
|
||||
|
||||
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('bad gateway', 502)
|
||||
},
|
||||
})
|
||||
await expectOutcome('gateway error', refreshSession(), 'transient')
|
||||
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('network down')
|
||||
},
|
||||
})
|
||||
await expectOutcome('network error', refreshSession(), 'transient')
|
||||
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
|
||||
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('refresh token revoked', 401)
|
||||
},
|
||||
})
|
||||
await expectOutcome('revoked token', refreshSession(), 'invalid')
|
||||
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
|
||||
}
|
||||
|
||||
// 没有 refresh token 时直接判定失效,不发请求。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
|
||||
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
|
||||
check('missing refresh token sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = 'rt-other-tab'
|
||||
state.accessToken = 'at-other-tab'
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
|
||||
check('other tab rotation sends no request', state.requests.length === 0)
|
||||
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
|
||||
}
|
||||
|
||||
// 另一个标签页登出了:本标签页也应判定失效。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = null
|
||||
state.accessToken = null
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
|
||||
check('other tab logout sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
|
||||
{
|
||||
let attempt = 0
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw new RefreshRequestError('network down')
|
||||
return { token: 'at-retry', refresh_token: 'rt-retry' }
|
||||
},
|
||||
})
|
||||
await expectOutcome('first transient attempt', refreshSession(), 'transient')
|
||||
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
|
||||
check('retry used the same token', state.requests[1] === 'rt-1')
|
||||
check('retry applied new tokens', state.refreshToken === 'rt-retry')
|
||||
}
|
||||
|
||||
console.log('authRefresh.test.ts ok')
|
||||
@@ -0,0 +1,148 @@
|
||||
// 刷新令牌的并发保护与失败分类。
|
||||
//
|
||||
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
|
||||
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
|
||||
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
|
||||
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
|
||||
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
|
||||
//
|
||||
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
|
||||
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
|
||||
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
|
||||
export class RefreshRequestError extends Error {
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'RefreshRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
|
||||
*
|
||||
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
|
||||
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
|
||||
* 否则一次瞬时故障就会把用户永久踢到登录页。
|
||||
*/
|
||||
export function refreshFailureOutcome(status?: number): RefreshOutcome {
|
||||
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 400 || status === 401 || status === 403) {
|
||||
return 'invalid'
|
||||
}
|
||||
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
|
||||
return 'transient'
|
||||
}
|
||||
|
||||
/** 从抛出的错误里取出 HTTP 状态码。 */
|
||||
export function httpStatusOf(error: unknown): number | undefined {
|
||||
if (error instanceof RefreshRequestError) return error.status
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
|
||||
*
|
||||
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
|
||||
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
|
||||
*/
|
||||
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
|
||||
let inflight: Promise<T> | null = null
|
||||
return () => {
|
||||
if (inflight) return inflight
|
||||
inflight = Promise.resolve()
|
||||
.then(task)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨标签页互斥执行(Web Locks API)。
|
||||
*
|
||||
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
|
||||
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
|
||||
* 重新读取 localStorage,直接复用刚轮换出来的会话。
|
||||
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
|
||||
*/
|
||||
export function createBrowserExclusiveRunner(
|
||||
lockName: string,
|
||||
): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
return <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
||||
if (!locks || typeof locks.request !== 'function') return task()
|
||||
try {
|
||||
return locks.request(lockName, () => task()) as Promise<T>
|
||||
} catch {
|
||||
return task()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
applyTokens: (tokens: RefreshTokens) => void
|
||||
clearSession: () => void
|
||||
request: (refreshToken: string) => Promise<RefreshTokens>
|
||||
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
|
||||
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
|
||||
syncFromStorage: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
|
||||
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
|
||||
*/
|
||||
export function createRefreshCoordinator(
|
||||
deps: RefreshCoordinatorDeps,
|
||||
): () => Promise<RefreshOutcome> {
|
||||
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
|
||||
const tokenAtEntry = deps.readRefreshToken()
|
||||
// 没有 refresh token 就无从恢复会话。
|
||||
if (!tokenAtEntry) return 'invalid'
|
||||
|
||||
return deps.runExclusive(async () => {
|
||||
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
|
||||
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
|
||||
await deps.syncFromStorage()
|
||||
const current = deps.readRefreshToken()
|
||||
if (!current) return 'invalid'
|
||||
if (current !== tokenAtEntry) {
|
||||
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
|
||||
return deps.readAccessToken() ? 'refreshed' : 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
deps.applyTokens(await deps.request(current))
|
||||
return 'refreshed'
|
||||
} catch (error) {
|
||||
const outcome = refreshFailureOutcome(httpStatusOf(error))
|
||||
if (outcome === 'invalid') deps.clearSession()
|
||||
return outcome
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
|
||||
|
||||
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`chapterGroups: ${name}`)
|
||||
}
|
||||
|
||||
check('默认组大小是 100', CHAPTER_GROUP_SIZE === 100)
|
||||
|
||||
// ── 切组 ──
|
||||
check('空目录没有分组', buildChapterGroups(0).length === 0)
|
||||
check('负数没有分组', buildChapterGroups(-5).length === 0)
|
||||
check('overflow 值没有分组', buildChapterGroups(Number.POSITIVE_INFINITY).length === 0)
|
||||
check('不足一组时只有一组', (() => {
|
||||
const g = buildChapterGroups(37)
|
||||
return g.length === 1 && g[0].start === 0 && g[0].end === 36 && g[0].label === '1-37'
|
||||
})())
|
||||
check('正好一组时只有一组', buildChapterGroups(100).length === 1)
|
||||
check('101 条切成两组', (() => {
|
||||
const g = buildChapterGroups(101)
|
||||
return g.length === 2 && g[1].start === 100 && g[1].end === 100 && g[1].label === '101-101'
|
||||
})())
|
||||
check('1317 条切成 14 组', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.length === 14 && g[13].label === '1301-1317' && g[13].end === 1316
|
||||
})())
|
||||
check('相邻组首尾相接不重叠', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.every((item, i) => i === 0 || item.start === g[i - 1].end + 1)
|
||||
})())
|
||||
check('组序号连续且从 0 起', buildChapterGroups(250).map((x) => x.index).join(',') === '0,1,2')
|
||||
check('自定义组大小生效', (() => {
|
||||
const g = buildChapterGroups(10, 4)
|
||||
return g.length === 3 && g[1].label === '5-8' && g[2].label === '9-10'
|
||||
})())
|
||||
check('非法组大小不产生分组', buildChapterGroups(10, 0).length === 0)
|
||||
|
||||
// ── 下标 → 组 ──
|
||||
check('第 0 条在第 0 组', chapterGroupIndexOf(0, 1317) === 0)
|
||||
check('第 99 条仍在第 0 组', chapterGroupIndexOf(99, 1317) === 0)
|
||||
check('第 100 条进入第 1 组', chapterGroupIndexOf(100, 1317) === 1)
|
||||
check('末条落在末组', chapterGroupIndexOf(1316, 1317) === 13)
|
||||
check('负数夹到第 0 组', chapterGroupIndexOf(-3, 1317) === 0)
|
||||
check('越界夹到末组', chapterGroupIndexOf(99999, 1317) === 13)
|
||||
check('非数字当作第 0 组', chapterGroupIndexOf(Number.NaN, 1317) === 0)
|
||||
check('空目录返回 0 组', chapterGroupIndexOf(5, 0) === 0)
|
||||
check('与切组分段一致', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.every((item) => chapterGroupIndexOf(item.start, 1317) === item.index)
|
||||
})())
|
||||
|
||||
console.log('chapterGroups.test.ts ok')
|
||||
@@ -0,0 +1,49 @@
|
||||
// 超长目录的「按段分组」纯逻辑。
|
||||
//
|
||||
// 起因:一本书动辄上千章(如 1317 章),目录面板只能一屏十来个条目地滑,
|
||||
// 想跳到中后段只能长按滚动条反复拖。这里把条目按固定大小切成若干组,
|
||||
// 面板顶部给出一个区间下拉(1-100 / 101-200 ...),选中即整段跳转。
|
||||
|
||||
/** 每组条目数。100 章一屏下拉里可读,长度也够短。 */
|
||||
export const CHAPTER_GROUP_SIZE = 100
|
||||
|
||||
export type ChapterGroup = {
|
||||
/** 组序号,0 起。 */
|
||||
index: number
|
||||
/** 组内起始条目下标(含)。 */
|
||||
start: number
|
||||
/** 组内结束条目下标(含)。 */
|
||||
end: number
|
||||
/** 下拉展示用区间文案,如 "1-100";末组不足 100 时自然变短("1301-1317")。 */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 把 total 个条目按 size 切成若干组;total 非正数时返回空数组。 */
|
||||
export function buildChapterGroups(total: number, size: number = CHAPTER_GROUP_SIZE): ChapterGroup[] {
|
||||
if (!Number.isFinite(total) || total <= 0) return []
|
||||
if (!Number.isFinite(size) || size <= 0) return []
|
||||
const count = Math.ceil(total / size)
|
||||
const groups: ChapterGroup[] = []
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const start = i * size
|
||||
const end = Math.min(total - 1, start + size - 1)
|
||||
groups.push({ index: i, start, end, label: `${start + 1}-${end + 1}` })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目下标落在第几组。用于两处:
|
||||
* 打开面板时定位到当前章节所在组;滚动时把下拉显示同步到可见区间。
|
||||
* 越界(负数 / 超过总数)一律夹到有效范围内,避免下拉出现空值。
|
||||
*/
|
||||
export function chapterGroupIndexOf(
|
||||
itemIndex: number,
|
||||
total: number,
|
||||
size: number = CHAPTER_GROUP_SIZE,
|
||||
): number {
|
||||
const groupCount = Math.ceil(Math.max(0, total) / size)
|
||||
if (groupCount <= 0) return 0
|
||||
const raw = Math.floor((Number.isFinite(itemIndex) ? itemIndex : 0) / size)
|
||||
return Math.min(Math.max(raw, 0), groupCount - 1)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`comicImageFit: ${name}`)
|
||||
}
|
||||
|
||||
// ── 是否由图片自身约束尺寸 ──
|
||||
const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
|
||||
check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
|
||||
check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
|
||||
check('height 是自尺寸档位', isSelfSizedComicFit('height'))
|
||||
check('long 是自尺寸档位', isSelfSizedComicFit('long'))
|
||||
check('original 是自尺寸档位', isSelfSizedComicFit('original'))
|
||||
check(
|
||||
'自尺寸档位与列宽档位互斥且覆盖全部取值',
|
||||
ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
|
||||
)
|
||||
|
||||
// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
|
||||
for (const fit of ['default', 'width'] as ComicImageFit[]) {
|
||||
const loaded = comicImageSizing(fit, 800, true)
|
||||
check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
|
||||
check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
|
||||
check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
|
||||
check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
|
||||
check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
|
||||
}
|
||||
|
||||
// ── height:一屏一页 ──
|
||||
check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
|
||||
check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
|
||||
check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
|
||||
check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
|
||||
check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
|
||||
|
||||
// ── long:整页完整可见 ──
|
||||
check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
|
||||
check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
|
||||
check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
|
||||
check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
|
||||
|
||||
// ── original:原始像素 1:1 ──
|
||||
check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
|
||||
check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
|
||||
check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
|
||||
|
||||
// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
|
||||
for (const fit of ['height', 'long'] as ComicImageFit[]) {
|
||||
check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
}
|
||||
check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 class/style(纯逻辑,便于单测)。
|
||||
//
|
||||
// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
|
||||
// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
|
||||
//
|
||||
// 两种控制方式:
|
||||
// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
|
||||
// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
|
||||
// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
|
||||
|
||||
/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
|
||||
const PLACEHOLDER_MIN_HEIGHT = '10rem'
|
||||
|
||||
export interface ComicImageSizing {
|
||||
className: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
|
||||
export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
|
||||
return fit === 'height' || fit === 'long' || fit === 'original'
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动模式下漫画图片的尺寸样式。
|
||||
*
|
||||
* @param fit 显示尺寸档位。
|
||||
* @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
|
||||
* (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
|
||||
* @param loaded 图片是否已加载完成,未完成时给个占位高度。
|
||||
*/
|
||||
export function comicImageSizing(
|
||||
fit: ComicImageFit,
|
||||
viewportHeight: number,
|
||||
loaded: boolean,
|
||||
): ComicImageSizing {
|
||||
const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
|
||||
// height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
|
||||
const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
|
||||
|
||||
switch (fit) {
|
||||
case 'height':
|
||||
// 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
|
||||
return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
|
||||
case 'long':
|
||||
// 整页完整可见:长边贴合视口,宽高都不超出
|
||||
return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
|
||||
case 'original':
|
||||
// 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
|
||||
return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
|
||||
default:
|
||||
// default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
|
||||
return { className: 'block w-full', style: placeholder }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 漫画「双页铺开」的纯逻辑与图片尺寸探测。
|
||||
//
|
||||
// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验
|
||||
// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、
|
||||
// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。
|
||||
//
|
||||
// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组,
|
||||
// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL,
|
||||
// 这里就自己探测并缓存(<img> 渲染时也会顺带回填,能省掉一次请求)。
|
||||
|
||||
/** 图片原始像素尺寸。 */
|
||||
export interface ImageSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 宽高比达到这个值即视为「跨页宽图」。普通页约 0.6~0.7,跨页图约 1.3~1.5。 */
|
||||
const WIDE_RATIO = 1.1
|
||||
|
||||
/** 已探测到的尺寸,按 URL 缓存(null = 探测失败,当竖版处理),跨章节复用。 */
|
||||
const sizeCache = new Map<string, ImageSize | null>()
|
||||
/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */
|
||||
const inflight = new Map<string, Promise<ImageSize | null>>()
|
||||
|
||||
/** 渲染中的 <img> onLoad 顺带回填尺寸,省掉一次探测请求。 */
|
||||
export function rememberImageSize(src: string, width: number, height: number): void {
|
||||
if (!src || !width || !height) return
|
||||
sizeCache.set(src, { width, height })
|
||||
}
|
||||
|
||||
/** 该 URL 是否已经探测过(成功的记尺寸,失败的记 null)。 */
|
||||
export function hasImageSize(src: string): boolean {
|
||||
return sizeCache.has(src)
|
||||
}
|
||||
|
||||
/** 探测一张图片的原始尺寸;失败(404 / 被拦 / 解码失败)返回 null。 */
|
||||
export function loadImageSize(src: string): Promise<ImageSize | null> {
|
||||
const cached = sizeCache.get(src)
|
||||
if (cached !== undefined) return Promise.resolve(cached)
|
||||
const running = inflight.get(src)
|
||||
if (running) return running
|
||||
const task = new Promise<ImageSize | null>((resolve) => {
|
||||
const img = new Image()
|
||||
const settle = (size: ImageSize | null) => {
|
||||
sizeCache.set(src, size)
|
||||
inflight.delete(src)
|
||||
resolve(size)
|
||||
}
|
||||
img.onload = () =>
|
||||
settle(
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? { width: img.naturalWidth, height: img.naturalHeight }
|
||||
: null,
|
||||
)
|
||||
img.onerror = () => settle(null)
|
||||
img.src = src
|
||||
})
|
||||
inflight.set(src, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** 该图是否横跨两页(宽明显大于高)。尺寸未知时按竖版处理,宁可先配对。 */
|
||||
export function isWideImage(src: string): boolean {
|
||||
const size = sizeCache.get(src)
|
||||
if (!size) return false
|
||||
return size.width / size.height >= WIDE_RATIO
|
||||
}
|
||||
|
||||
/** 一「屏」要显示的图片序号:一张=独占的宽图/单页,两张=左右并排的一对。 */
|
||||
export type ComicSpread = number[]
|
||||
|
||||
/**
|
||||
* 把整章图片按双页铺开分组:连续的竖版页两两成对,宽图独占一组并打断前后配对。
|
||||
* 尺寸还没探测出来的图先按竖版参与配对,探测完成后再重新分组(见 useComicSpreads)。
|
||||
*/
|
||||
export function buildSpreads(srcs: string[]): ComicSpread[] {
|
||||
const spreads: ComicSpread[] = []
|
||||
let i = 0
|
||||
while (i < srcs.length) {
|
||||
if (isWideImage(srcs[i])) {
|
||||
spreads.push([i])
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
// 收集一段连续的竖版页,两两成对;落单的最后一张自己一屏(常见于章尾)
|
||||
const run: number[] = []
|
||||
while (i < srcs.length && !isWideImage(srcs[i])) {
|
||||
run.push(i)
|
||||
i += 1
|
||||
}
|
||||
for (let k = 0; k < run.length; k += 2) spreads.push(run.slice(k, k + 2))
|
||||
}
|
||||
return spreads
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片序号 → 所在屏序号。进度是按图片序号记的,翻页要换算成「当前在第几屏」,
|
||||
* 并且不管展开/收起双页都稳定(只由图片序号推出来,不额外存一屏序号)。
|
||||
*/
|
||||
export function buildSpreadIndexOf(spreads: ComicSpread[], total: number): number[] {
|
||||
const map = new Array<number>(Math.max(0, total)).fill(0)
|
||||
spreads.forEach((spread, si) => {
|
||||
for (const idx of spread) {
|
||||
if (idx >= 0 && idx < map.length) map[idx] = si
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
@@ -66,13 +66,14 @@ const noGroups = buildBookGroupTabs(books, [])
|
||||
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
|
||||
|
||||
const tabs = buildBookGroupTabs(books, groups)
|
||||
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
|
||||
check('tabs = 全部 + groups + 未分组(未分组殿后)', tabs.map((t) => t.name).join(',') === '全部,科幻,在读,未分组')
|
||||
check('全部 counts all books', tabs[0].count === 4)
|
||||
check('未分组 counts unclaimed', tabs[1].count === 1)
|
||||
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
|
||||
check('ungrouped is the last tab', tabs[tabs.length - 1].kind === 'ungrouped')
|
||||
check('未分组 counts unclaimed', tabs[3].count === 1)
|
||||
check('group counts only existing books', tabs[1].count === 2 && tabs[2].count === 1)
|
||||
check('stale ids do not inflate counts', (() => {
|
||||
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
|
||||
return withStale[2].count === 1 && withStale[1].count === 3
|
||||
return withStale[1].count === 1 && withStale[2].count === 3
|
||||
})())
|
||||
|
||||
// ── 排序与选中回落 ──
|
||||
|
||||
@@ -204,7 +204,9 @@ export function filterBooksByGroup<T extends { id: string }>(
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
|
||||
* 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。
|
||||
* 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会
|
||||
* 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。
|
||||
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
|
||||
*/
|
||||
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
|
||||
@@ -219,12 +221,6 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
|
||||
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
|
||||
]
|
||||
if (groups.length === 0) return tabs
|
||||
tabs.push({
|
||||
id: UNGROUPED_GROUP_ID,
|
||||
name: '未分组',
|
||||
count: books.length - claimed.size,
|
||||
kind: 'ungrouped',
|
||||
})
|
||||
for (const group of groups) {
|
||||
tabs.push({
|
||||
id: group.name,
|
||||
@@ -233,6 +229,12 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
|
||||
kind: 'group',
|
||||
})
|
||||
}
|
||||
tabs.push({
|
||||
id: UNGROUPED_GROUP_ID,
|
||||
name: '未分组',
|
||||
count: books.length - claimed.size,
|
||||
kind: 'ungrouped',
|
||||
})
|
||||
return tabs
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user