Compare commits

..

6 Commits

Author SHA1 Message Date
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
truewhile c4e9926352 优化 2026-10-02 18:44:07 +08:00
truewhile f3b88fad7c 优化 2026-10-02 17:56:16 +08:00
truewhile 2c6646beea bug处理 2026-10-02 15:14:36 +08:00
truewhile 83ec494a36 bug处理 2026-10-02 13:14:29 +08:00
truewhile b8648f0049 bug处理 2026-10-02 12:02:29 +08:00
47 changed files with 4009 additions and 206 deletions
+6 -1
View File
@@ -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)**
+8 -1
View File
@@ -333,13 +333,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 +375,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})
}
}
+3
View File
@@ -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 存储的
+5 -1
View File
@@ -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
}
+107
View File
@@ -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,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)
}
}
+5
View File
@@ -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
}
+5 -5
View File
@@ -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") {
+91
View File
@@ -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)
}
}
+111
View File
@@ -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)
}
}
}
+568 -101
View File
@@ -47,16 +47,20 @@ type ReaderService struct {
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
browserMu sync.Mutex
browserPending map[string]*pendingBrowser
// limiter 单源限速(书源 concurrentRate)。
limiter *sourceRateLimiter
}
// NewReaderService 创建服务。
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
return &ReaderService{
cfg: cfg,
log: log,
repo: repos.Reader,
http: helper.NewSiteHTTPClient(30, true),
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
cfg: cfg,
log: log,
repo: repos.Reader,
http: helper.NewSiteHTTPClient(30, true),
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
limiter: newSourceRateLimiter(),
}
}
@@ -83,9 +87,15 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
return 0, fmt.Errorf("未识别到有效书源(支持 JSON 数组/对象或 Base64)")
}
imported := 0
for _, raw := range sources {
var failures []string
for i, raw := range sources {
bs, err := ParseBookSource(raw)
if err != nil || bs.BookSourceURL == "" {
if err != nil {
failures = append(failures, fmt.Sprintf("第 %d 条解析失败: %v", i+1, err))
continue
}
if bs.BookSourceURL == "" {
failures = append(failures, fmt.Sprintf("第 %d 条缺少 bookSourceUrl", i+1))
continue
}
// 已存在则更新,否则新建(按书源 URL 去重)
@@ -124,14 +134,23 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
existing.LastCheckAt = &now
if err := s.repo.UpdateSource(ctx, existing); err == nil {
imported++
} else {
failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err))
}
continue
}
record.LastCheckAt = &now
if err := s.repo.CreateSource(ctx, record); err == nil {
imported++
} else {
failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err))
}
}
// 一条都没进来时不能只回 0:前端只会弹一句「成功导入 0 个书源」,
// 用户完全不知道哪里不对。把第一条失败原因带回去,让界面能说清楚。
if imported == 0 && len(failures) > 0 {
return 0, fmt.Errorf("书源导入失败:%s", failures[0])
}
return imported, nil
}
@@ -207,10 +226,21 @@ func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSour
}
for i := range sources {
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
sources[i].NeedsBrowser = rawSourceNeedsBrowser(sources[i].RawJSON)
}
return sources, nil
}
// rawSourceNeedsBrowser 粗判书源是否依赖 WebView(webView 请求选项 / webjs 规则)。
//
// 服务端没有无头浏览器,这类源注定跑不通;列表先标出来,用户不用等到报错才知道。
// 用字符串粗扫而不是完整解析:这些标记只出现在规则/选项里,误报代价也只是多一个提示。
func rawSourceNeedsBrowser(rawJSON string) bool {
lower := strings.ToLower(rawJSON)
return strings.Contains(lower, "webview") || strings.Contains(lower, "webjs") ||
strings.Contains(lower, "\"webview\"")
}
// rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
func rawSourceHasLogin(rawJSON string) bool {
@@ -249,10 +279,6 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) {
var bodyReader io.Reader
if req.Body != "" {
bodyReader = strings.NewReader(req.Body)
}
// 请求目标是含 query 的完整 URL:对应 legado 的 `get(urlNoQuery, encodedQuery)`
// (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进
// req.URL。早期这里误用 URLNoQuery,导致所有「参数写在 query 里」的 GET
@@ -283,36 +309,77 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
}
}
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
if err != nil {
return "", "", 0, err
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
buildRequest := func() (*http.Request, error) {
var bodyReader io.Reader
if req.Body != "" {
bodyReader = strings.NewReader(req.Body)
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
// Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压,
// 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。
// 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。
helper.StripAcceptEncoding(httpReq.Header)
if req.Method == "POST" {
switch {
case req.IsForm:
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
case req.IsJSON:
httpReq.Header.Set("Content-Type", "application/json")
}
}
return httpReq, nil
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
// Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压,
// 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。
// 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。
helper.StripAcceptEncoding(httpReq.Header)
if req.Method == "POST" {
switch {
case req.IsForm:
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
case req.IsJSON:
httpReq.Header.Set("Content-Type", "application/json")
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
attempts := 1
if req.Retry != nil && *req.Retry > 0 {
attempts += *req.Retry
if attempts > maxRequestAttempts {
attempts = maxRequestAttempts
}
}
resp, err := s.http.Do(httpReq)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return "", "", resp.StatusCode, err
var (
resp *http.Response
data []byte
)
for attempt := 1; ; attempt++ {
httpReq, err := buildRequest()
if err != nil {
return "", "", 0, err
}
resp, err = s.http.Do(httpReq)
if err != nil {
if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
continue
}
return "", "", 0, err
}
data, err = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
_ = resp.Body.Close()
if err != nil {
if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
continue
}
return "", "", resp.StatusCode, err
}
if attempt < attempts && retryableStatus(resp.StatusCode) {
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
return "", "", resp.StatusCode, nil
}
continue
}
break
}
data = helper.DecompressBody(resp, data)
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
@@ -429,6 +496,11 @@ func (sess *sourceSession) close() {
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) {
// 单源限速(书源 concurrentRate):挂在这里,规则请求与书源 JS 的 java.ajax
// 都会经过,保证同一个源不会并发轰炸站点。
if sess.svc.limiter != nil {
sess.svc.limiter.wait(sess.ctx, sess.srcURL(), SPtr(sess.bs.ConcurrentRate))
}
body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies())
if err != nil {
return body, finalURL, code, err
@@ -572,17 +644,45 @@ func (sess *sourceSession) srcName() string {
return srcNameOf(sess.src, sess.bs)
}
// headerJSON 书源级请求头 JSON。
func (sess *sourceSession) headerJSON() string {
// headerJSON 书源级请求头(JSON 文本)。
//
// 对应 legado BaseSource.getHeaderMap:header 有两种写法 —— 直接的 JSON 对象,
// 或者是 @js:/<js> 规则在运行时拼出来(拷贝漫画就是后者:用 baseUrl 拼 referer、
// 带上 platform/version)。之前这里只做 json.Unmarshal,JS 形态会被整体丢掉,
// 请求少了这些必需头,上游照样回 200,但结果是空列表 —— 表现为「书源能导入、
// 却搜不到任何内容」。JS 求值失败时返回空串,宁可不带自定义头也不发半成品。
func (sess *sourceSession) headerJSON(runner *rule.JSRunner) string {
raw := ""
if sess.src != nil && sess.src.Header != "" {
return sess.src.Header
raw = sess.src.Header
} else {
raw = SPtr(sess.bs.Header)
}
return SPtr(sess.bs.Header)
js, isJS := stripJSWrapperOK(raw)
if !isJS {
return raw
}
if runner == nil {
return ""
}
// baseUrl 绑成书源地址:legado 在 getHeaderMap 里用的也是 getKey()(bookSourceUrl)。
v, err := runner.Run(nil, js, nil, sess.srcURL())
if err != nil {
if sess.svc.log != nil {
sess.svc.log.Warn("reader: 执行书源请求头规则失败",
zap.String("source", sess.srcName()), zap.Error(err))
}
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。
func (sess *sourceSession) applyHeaders(req *rule.Request) {
raw := sess.headerJSON()
func (sess *sourceSession) applyHeaders(req *rule.Request, runner *rule.JSRunner) {
raw := sess.headerJSON(runner)
if raw == "" {
return
}
@@ -637,17 +737,17 @@ type searchHit struct {
}
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, []SearchSkipped, error) {
//
// sourceIDs 是本次的搜索范围(对应 legado SearchScope.getBookSourceParts):
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, nil, err
}
var enabled []model.ReaderBookSource
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
enabled := enabledSourcesForScope(sources, sourceIDs)
if len(enabled) == 0 {
return nil, nil, fmt.Errorf("没有已启用的书源")
}
@@ -683,6 +783,34 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [
return mergeSearchResults(hits, key), skipped, nil
}
// enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。
// 空范围 = 全部启用;范围里的书源不存在或已停用时忽略;全被忽略则退回全部启用。
func enabledSourcesForScope(sources []model.ReaderBookSource, sourceIDs []string) []model.ReaderBookSource {
enabled := make([]model.ReaderBookSource, 0, len(sources))
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
if len(sourceIDs) == 0 {
return enabled
}
want := make(map[string]struct{}, len(sourceIDs))
for _, id := range sourceIDs {
want[id] = struct{}{}
}
scoped := make([]model.ReaderBookSource, 0, len(enabled))
for _, src := range enabled {
if _, ok := want[src.ID]; ok {
scoped = append(scoped, src)
}
}
if len(scoped) == 0 {
return enabled
}
return scoped
}
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
type SearchSkipped struct {
SourceID string `json:"source_id"`
@@ -792,7 +920,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
if req.Unsupported != nil {
return nil, req.Unsupported
}
sess.applyHeaders(req)
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
@@ -838,6 +966,14 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
}},
})
}
// 一条结果都没有时,用书源声明的校验关键字判断是「真没这本书」还是「被风控了」。
// legado 用 checkKeyWord 做同样的事(源失效/被拦截时响应里不会出现它)。
// 这里只把它当成失败原因的提示,不改变「有结果就返回结果」的行为。
if len(books) == 0 {
if kw := bs.SearchCheckKeyWord(); kw != "" && !strings.Contains(body, kw) {
return nil, fmt.Errorf("搜索结果为空,且响应未包含校验关键字「%s」(源可能已失效或触发风控)", kw)
}
}
return books, nil
}
@@ -927,7 +1063,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
if req.Unsupported != nil {
return nil, req.Unsupported
}
sess.applyHeaders(req)
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
@@ -1011,8 +1147,20 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL,
return nil, err
}
chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL)
if err != nil {
return nil, err
if err != nil || len(chapters) == 0 {
// 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 gydetail 信封):
// 加书架时只存了 book_url,调用方又把 book_url 当目录地址传进来,规则返回的
// 是书籍详情(没有章节),表现为「目录为空」——漫画就是卡在这里。
// 对齐 legado:目录地址在详情结果里,补走一次详情规则取 tocUrl 再抓,并写回书架。
if detailTocURL := s.deriveTocURLFromDetail(ctx, src, bs, bookURL, tocURL); detailTocURL != "" {
if retried, declared2, retryErr := s.getTocFrom(ctx, src, bs, bookURL, detailTocURL); retryErr == nil && len(retried) > 0 {
chapters, declared, err = retried, declared2, nil
s.persistBookTocURL(ctx, userID, src.SourceURL, bookURL, detailTocURL)
}
}
if err != nil {
return nil, err
}
}
// 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。
// 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。
@@ -1032,6 +1180,40 @@ func latestChapterTitleOf(chapters []TocChapter) string {
return ""
}
// deriveTocURLFromDetail 走一次详情规则,取书源声明的目录地址。
//
// 详情规则没配、或解析出的 tocUrl 与 bookUrl 相同(说明书源就是用书籍页当目录页)时
// 返回空串,调用方按原样处理、不做多余请求。
func (s *ReaderService) deriveTocURLFromDetail(
ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, currentTocURL string,
) string {
detail, err := s.getBookInfoFrom(ctx, src, bs, bookURL)
if err != nil || detail == nil {
return ""
}
derived := strings.TrimSpace(detail.TocURL)
if derived == "" || derived == strings.TrimSpace(bookURL) || derived == strings.TrimSpace(currentTocURL) {
return ""
}
return derived
}
// persistBookTocURL 把详情里解析出的目录地址写回书架记录。
// 只有第一次抓目录要多走一次详情,之后书架、阅读页、更新目录都直接用这个地址。
func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, bookURL, tocURL string) {
if userID == "" || origin == "" || bookURL == "" || tocURL == "" {
return
}
book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
if err != nil || book == nil || book.TocURL == tocURL {
return
}
book.TocURL = tocURL
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 写回目录地址失败", zap.String("book", book.ID), zap.Error(err))
}
}
// applyTocMeta 把目录阶段得到的信息写回书架记录:
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
@@ -1076,32 +1258,101 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
declaredType := -1
var chapters []TocChapter
seenChapter := map[string]bool{}
visited := map[string]bool{}
// 用队列收集待抓页:书源有两种写法 —— 有的只给「下一页」,有的一次给出全部
// 后续页(拷贝漫画就是后者,且它的规则锚定 offset=0,只有第一页能算出完整列表)。
// 每页都再看一次 nextTocUrl 并去重入队,两种写法都能覆盖。
queue := []string{tocURL}
fetched := 0
for len(queue) > 0 && fetched < maxTocPages {
current := queue[0]
queue = queue[1:]
if current == "" || visited[current] {
continue
}
visited[current] = true
fetched++
pageChapters, nextURLs, declared, err := s.fetchTocPage(ctx, sess, bs, current, bookURL)
if err != nil {
// 第一页失败才算整体失败;后续页失败保留已经拿到的章节
if fetched == 1 {
return nil, -1, err
}
continue
}
if declared >= 0 {
declaredType = declared
}
for _, ch := range pageChapters {
if seenChapter[ch.URL] {
continue
}
seenChapter[ch.URL] = true
ch.Index = len(chapters)
chapters = append(chapters, ch)
}
for _, u := range nextURLs {
if u != "" && !visited[u] {
queue = append(queue, u)
}
}
}
return chapters, declaredType, nil
}
// maxTocPages 目录翻页上限。书源的 nextTocUrl 规则可能给出自引用或极长的链路
// (legado 靠协程取消兜底),这里用硬上限 + 已访问集合双重保护。
const maxTocPages = 50
// fetchTocPage 抓一页目录,返回该页章节、书源声明的后续页地址、声明的书籍类型。
// 对应 legado BookChapterList.analyzeChapterList(含 nextTocUrl 处理)。
func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, bs *BookSource, tocURL, bookURL string) ([]TocChapter, []string, int, error) {
tr := bs.RuleToc
runner := sess.runner("", 0)
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
if err != nil {
return nil, -1, err
return nil, nil, -1, err
}
if req.Unsupported != nil {
return nil, -1, req.Unsupported
return nil, nil, -1, req.Unsupported
}
sess.applyHeaders(req)
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, -1, err
return nil, nil, -1, err
}
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, nil, "", 0)
elements, err := ar.GetElements(SPtr(tr.ChapterList))
if err != nil {
return nil, -1, err
return nil, nil, -1, err
}
declaredType := -1
if t, ok := ar.BookTypeOverride(); ok {
declaredType = normalizeBookType(t)
}
// 下一页目录:对应 legado 的 getStringList(nextTocUrl, isUrl=true),并排除当前页
// 地址。规则可能一次给出全部后续页(拷贝漫画就是这种写法),也可能每页只给一个。
var nextURLs []string
if SPtr(tr.NextTocURL) != "" {
if urls, err := ar.GetStringList(SPtr(tr.NextTocURL), nil, true); err == nil {
for _, u := range urls {
if u != "" && u != finalURL && u != tocURL {
nextURLs = append(nextURLs, u)
}
}
}
}
var chapters []TocChapter
for i, el := range elements {
for _, el := range elements {
title, err := ar.GetString(SPtr(tr.ChapterName), el, false)
if err != nil || title == "" {
continue
@@ -1118,14 +1369,14 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
}
updateTime, _ := ar.GetString(SPtr(tr.UpdateTime), el, false)
chapters = append(chapters, TocChapter{
Index: i, Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime,
Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime,
})
}
// 章节规则可能逐条执行,取最后一次声明(书源是在元素循环前设置的)。
if t, ok := ar.BookTypeOverride(); ok {
declaredType = normalizeBookType(t)
}
return chapters, declaredType, nil
return chapters, nextURLs, declaredType, nil
}
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
@@ -1176,49 +1427,114 @@ func (s *ReaderService) VerifyProxyURL(bookID, encoded, sig string) (string, err
// FetchMedia 服务端拉取媒体资源(携带书源级请求头与 Referer,支持 Range 透传)。
// 调用方负责关闭 resp.Body。
func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
// 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给
// 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩,
// 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。
helper.StripAcceptEncoding(httpReq.Header)
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
// 请求构造抽成闭包:http.Request 不可复用,重试时必须重建。
buildRequest := func() (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
// 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给
// 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩,
// 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。
helper.StripAcceptEncoding(httpReq.Header)
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
}
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
if s.repo != nil {
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
if s.repo != nil {
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(rawURL); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(rawURL); ck != "" {
httpReq.Header.Set("Cookie", ck)
// 默认 Referer 只能用「真正的 http(s) 书源地址」。
// legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」)
// 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并
// 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。
// 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header
// 里自己声明的 Referer 优先级更高,不受这里影响。
if httpReq.Header.Get("Referer") == "" {
if referer := sourceReferer(book.Origin); referer != "" {
httpReq.Header.Set("Referer", referer)
}
}
if rangeHeader != "" {
httpReq.Header.Set("Range", rangeHeader)
}
return httpReq, nil
}
if httpReq.Header.Get("Referer") == "" && book.Origin != "" {
httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/")
var lastErr error
for attempt := 1; attempt <= mediaFetchAttempts; attempt++ {
if attempt > 1 {
// 图床在高并发拉取时会偶发 403/429(实测同一张图稍后重试即可成功)。
// 漫画一屏会并发取多张,零星失败就会在页面上留几个破图,因此做少量退避重试。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(mediaFetchRetryDelay * time.Duration(attempt-1)):
}
}
httpReq, err := buildRequest()
if err != nil {
return nil, err
}
resp, err := s.http.Do(httpReq)
if err != nil {
lastErr = err
continue
}
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
return resp, nil
}
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
lastErr = fmt.Errorf("upstream returned %s", resp.Status)
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
_ = resp.Body.Close()
}
if rangeHeader != "" {
httpReq.Header.Set("Range", rangeHeader)
return nil, lastErr
}
const (
// mediaFetchAttempts 媒体拉取最多尝试次数(含首次)。
mediaFetchAttempts = 3
// mediaFetchRetryDelay 重试退避基数,实际等待为 (attempt-1) 倍。
mediaFetchRetryDelay = 400 * time.Millisecond
)
// sourceReferer 把书源的 origin 转成可用的默认 Referer。
//
// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是
// 「显示名」,用它当 Referer 会被图床当成盗链(见 FetchMedia 中的注释),
// 此时返回空串表示不发 Referer。
func sourceReferer(origin string) string {
origin = strings.TrimSpace(origin)
if origin == "" {
return ""
}
return s.http.Do(httpReq)
u, err := url.Parse(origin)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return ""
}
return strings.TrimSuffix(origin, "/") + "/"
}
// RewritePlaylist 重写 m3u8 播放列表:分片与密钥地址改写为签名代理地址。
@@ -1297,7 +1613,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
if req.Unsupported != nil {
return nil, req.Unsupported
}
sess.applyHeaders(req)
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
@@ -1306,14 +1622,19 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, nil, "", 0)
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
// 正文规则用 getString 求值(对应 legado BookContent:analyzeRule.getString(contentRule.content))。
// 关键差别出在 JS 段:getStringList 会把上一段的结果按「列表」交给 JS,而 legado
// 交给 JS 的是按 \n 拼好的字符串。拷贝漫画的正文规则正是 result.split("\n"),
// 喂成列表就报 Object has no member 'split',整章图片都取不到。
// 对 jsoup / xpath 规则,getString 与 getStringList+join 结果一致,文本源不受影响。
page, err := ar.GetString(SPtr(cr.Content), nil, false)
if err != nil {
return nil, err
}
if t, ok := ar.BookTypeOverride(); ok {
declaredType = normalizeBookType(t)
}
parts = append(parts, strings.Join(list, "\n"))
parts = append(parts, page)
if SPtr(cr.NextContentURL) == "" {
break
}
@@ -1348,8 +1669,12 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
case 2: // 漫画/图片
out.Type = "image"
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Images = append(out.Images, abs)
// 漫画源的正文规则常直接给 <img src="…"> 的 HTML(一个标签一行)。
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图,所以先抽 src。
for _, ref := range imageRefsInLine(line) {
if abs := rule.GetAbsoluteURL(lastFinalURL, ref); abs != "" {
out.Images = append(out.Images, abs)
}
}
}
out.ImageStyle = SPtr(cr.ImageStyle)
@@ -1359,6 +1684,132 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
return out, nil
}
// rewriteContentImageMarkers 把网络文本正文里「整行就是 <img src="…">」的内容改写成
// [img]<地址> 标记行,前端据此渲染成图片。
//
// 对应 legado 的 ruleContent.imageStyle:文本型漫画源(bookSourceType=0,但正文规则
// 直接给 <img> 标签,如拷贝漫画)就是靠它把图片显示出来的;不转换的话读者看到的是
// 原始的 <img src="..."> 文本。只处理「整行是标签」的行,不碰正常文本源里夹在段落
// 中间的内嵌图片,避免改变原有语义。
func rewriteContentImageMarkers(content, baseURL string, proxy func(string) string) string {
if !strings.Contains(content, "<img") {
return content
}
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
changed := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), "<img") {
if refs := imageRefsInLine(trimmed); len(refs) > 0 {
for _, ref := range refs {
abs := rule.GetAbsoluteURL(baseURL, ref)
if abs == "" {
continue
}
if proxy != nil {
abs = proxy(abs)
}
out = append(out, imgMarkerPrefix+abs)
changed = true
}
continue
}
}
out = append(out, line)
}
if !changed {
return content
}
return strings.Join(out, "\n")
}
// imageMarkersOnly 判断一章正文是不是「整章都是图片」:非空行全部是 [img] 标记行。
//
// 文本型漫画源(拷贝漫画等,bookSourceType=0 但正文规则给 <img>,见
// rewriteContentImageMarkers)和图片版 EPUB 的正文就是这样一连串的标记行。
// 这类章节本质上就是漫画,必须按漫画渲染:文本阅读器的分页把每张图当成一列,
// 一屏只看得到一张,桌面端也没法两页并排——正是「漫画没法双页铺开」的根因。
// 所以这里识别出来之后由调用方把类型改成 image,交给漫画阅读器。
//
// 只认「非空行全是标记」:混了正文的章节一律保持 text,绝不能把文字吃掉。
// 返回的地址已经是签名代理地址(调用点在此之前刚做过改写)。
func imageMarkersOnly(content string) ([]string, bool) {
var images []string
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
// 空行和一个孤立的 HTML 标签(<div>/</div> 之类,正文规则给 outerHTML 时很常见)
// 都没有可读文字,不影响判断。
if trimmed == "" || isHTMLTagOnly(trimmed) {
continue
}
if !strings.HasPrefix(trimmed, imgMarkerPrefix) {
return nil, false
}
addr := strings.TrimSpace(strings.TrimPrefix(trimmed, imgMarkerPrefix))
if addr == "" {
return nil, false
}
images = append(images, addr)
}
return images, len(images) > 0
}
// isHTMLTagOnly 判断整行是不是一个孤立的 HTML 标签(<div>、</div>、<br> 之类)。
// 即「<」开头、「>」结尾,且尖括号内是标签名的形状(字母开头,后面可带属性)。
// 标签名两侧的空白(源码里常见的 < div > 这类手写残渣)一并忽略。
func isHTMLTagOnly(line string) bool {
if len(line) < 3 || line[0] != '<' || line[len(line)-1] != '>' {
return false
}
inner := strings.TrimSpace(strings.TrimPrefix(line[1:len(line)-1], "/"))
if inner == "" {
return false
}
for i, r := range inner {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
continue
case i == 0:
// 首字符不是字母:<3、<!-- 注释 --> 之类,一律不当标签
return false
default:
// 标签名之后(属性、空白、自闭合斜杠)都算标签
return true
}
}
return true
}
// imageRefsInLine 取出一行正文里的图片地址。
//
// - 正文规则给的就是地址 → 原样返回;
// - 给的是 <img src="…">(一行可能有多个标签)→ 按标签抽 src,
// legado 的 ruleContent.imageStyle 缺省也是这个语义;
// - 给的是别的 HTML 片段(<div>/</div> 之类,规则返回的 outerHTML 换行后很常见)
// → 跳过,否则会被当成相对地址拼出一个取不回的「图」。
func imageRefsInLine(line string) []string {
matches := imgTagPattern.FindAllStringSubmatch(line, -1)
if len(matches) == 0 {
if strings.ContainsRune(line, '<') {
return nil
}
return []string{line}
}
out := make([]string, 0, len(matches))
for _, m := range matches {
// imgTagPattern 的捕获组:整段标签 / 双引号内的 src / 单引号内的 src
ref := strings.TrimSpace(m[2])
if ref == "" {
ref = strings.TrimSpace(m[3])
}
if ref != "" {
out = append(out, ref)
}
}
return out
}
func splitURLLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
@@ -1817,6 +2268,22 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
for i, img := range out.Images {
out.Images[i] = s.ProxyURL(book.ID, img)
}
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记(同样走签名代理,
// 这样需要防盗链头的图片也能显示)。见 rewriteContentImageMarkers 的说明。
if out.Type == "text" && strings.Contains(out.Content, "<img") {
out.Content = rewriteContentImageMarkers(out.Content, ch.URL, func(u string) string {
return s.ProxyURL(book.ID, u)
})
}
// 整章都是 [img] 标记 → 这就是一章漫画,改成图片类型交给漫画阅读器。
// 若还按文本下发,前端会把它当文字按列分页,一屏只有一张图(双页铺开也无从谈起)。
if out.Type == "text" {
if imgs, ok := imageMarkersOnly(out.Content); ok {
out.Type = "image"
out.Images = imgs
out.Content = ""
}
}
return out, nil
}
@@ -0,0 +1,225 @@
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,
},
}
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])
}
}
}
}
// 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))
}
}
+33 -6
View File
@@ -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)
}
}
+41 -6
View File
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/dop251/goja"
"github.com/google/uuid"
)
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
@@ -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
+1 -1
View File
@@ -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 内使用")
)
+61 -1
View File
@@ -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)
}
}
+89
View File
@@ -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("非绝对地址应当报错")
}
}
+3
View File
@@ -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("词典数据含替换符,生成过程有损")
}
}
+93
View File
@@ -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() 应当抛异常")
}
}
+3 -3
View File
@@ -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)
}
}
+127 -23
View File
@@ -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)
}
}
@@ -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)
}
}
+99
View File
@@ -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))
}
}
+12 -6
View File
@@ -245,11 +245,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 +259,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),服务端解析目录后加入书架。 */
@@ -331,7 +335,9 @@ 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) =>
+70
View File
@@ -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 }
}
+104
View File
@@ -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])
}
+96 -5
View File
@@ -12,7 +12,7 @@ import {
Timer,
X,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { ReaderChapter } from '../../api/reader'
import {
@@ -21,6 +21,7 @@ import {
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { readerCoverSrc } from '../../utils/readerCover'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
@@ -48,6 +49,11 @@ interface ReaderAudioPanelProps {
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
transcoding?: boolean
onProgress: (seconds: number) => void
/**
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
*/
onCommitProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
@@ -91,6 +97,7 @@ export function ReaderAudioPanel({
hasNext,
transcoding = false,
onProgress,
onCommitProgress,
onPrevChapter,
onNextChapter,
onSelectChapter,
@@ -102,6 +109,8 @@ export function ReaderAudioPanel({
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
const skippedEndRef = useRef(false)
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
const tocRef = useRef<VirtuosoHandle>(null)
const speed = useReaderSettingsStore((s) => s.audioSpeed)
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
@@ -115,6 +124,16 @@ export function ReaderAudioPanel({
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
const [groupIndex, setGroupIndex] = useState(0)
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
const lastTimeRef = useRef(0)
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
const commitRef = useRef(onCommitProgress)
useEffect(() => {
commitRef.current = onCommitProgress
}, [onCommitProgress])
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
@@ -145,6 +164,8 @@ export function ReaderAudioPanel({
setAudioError('')
setCur(0)
setDur(0)
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
lastTimeRef.current = 0
let cancelled = false
let hls: { destroy: () => void } | null = null
@@ -184,6 +205,27 @@ export function ReaderAudioPanel({
if (audio) audio.playbackRate = speed
}, [speed])
// ── 离开这一章时补报进度 ──
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
// 因此不会把上一章的位置写到新章节头上。
useEffect(() => {
const flush = () => {
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pagehide', flush)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pagehide', flush)
flush()
}
}, [])
// ── 定时关闭 ──
const applyTimer = useCallback(
(minutes: number) => {
@@ -277,13 +319,33 @@ 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 ?? 0, chapters.length))
setSheet(next)
}
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
className="relative flex h-full flex-col overflow-hidden"
@@ -504,18 +566,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)}
rangeChanged={(range) =>
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
}
itemContent={(_i, row) => {
const isCurrent = row.i === chapterIndex
return (
@@ -717,6 +807,7 @@ export function ReaderAudioPanel({
onTimeUpdate={(e) => {
const audio = e.currentTarget
const t = audio.currentTime
lastTimeRef.current = t
setCur(t)
onProgress(t)
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
+116 -21
View File
@@ -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>
+118 -7
View File
@@ -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>
)
}
+204 -17
View File
@@ -5,6 +5,7 @@ import {
ArrowLeft,
ArrowLeftRight,
BookOpen,
Columns2,
LayoutList,
ListEnd,
Loader2,
@@ -14,10 +15,13 @@ import {
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 { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
@@ -48,6 +52,15 @@ const WHEEL_TURN_COOLDOWN = 220
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
const WHEEL_GESTURE_GAP = 180
/**
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
*/
const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -71,6 +84,8 @@ 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)
@@ -85,6 +100,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 +121,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
@@ -254,6 +312,11 @@ export default function ReaderViewPage() {
}
}, [content, settings.pageMode])
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
@@ -339,6 +402,35 @@ export default function ReaderViewPage() {
setChapterIndex(idx)
}, [chapters.length])
// ── 目录面板的分段跳转(对齐听书面板的区间下拉) ──
// 上千章的目录按 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 hasNextAudioChapter = useMemo(() => {
if (chapterIndex === null) return false
@@ -412,6 +504,12 @@ export default function ReaderViewPage() {
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,7 +520,7 @@ 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') {
@@ -430,6 +528,11 @@ export default function ReaderViewPage() {
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 +546,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 +583,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
@@ -605,7 +724,7 @@ 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
@@ -621,6 +740,7 @@ export default function ReaderViewPage() {
hasNext={hasNextAudioChapter}
transcoding={media.transcoding ?? false}
onProgress={throttledMediaSave}
onCommitProgress={savePos}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onSelectChapter={jumpToChapter}
@@ -635,10 +755,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)
}}
@@ -676,6 +798,7 @@ export default function ReaderViewPage() {
) : (
<div
ref={scrollRef}
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
onScroll={(e) => {
const el = e.currentTarget
if (chapterIndex === null) return
@@ -688,7 +811,7 @@ 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">
@@ -728,7 +851,10 @@ export default function ReaderViewPage() {
)}
</div>
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
@@ -811,11 +937,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 +954,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 +981,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 +989,7 @@ export default function ReaderViewPage() {
))}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
{panel === 'style' && (
<div
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
@@ -904,6 +1043,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 +1090,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 (
+219
View File
@@ -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>
)
}
+60 -1
View File
@@ -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
+53
View File
@@ -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')
+49
View File
@@ -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)
}
+55
View File
@@ -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)
+59
View File
@@ -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 }
}
}
+108
View File
@@ -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
}