mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b88fad7c | |||
| 2c6646beea |
@@ -94,7 +94,11 @@
|
||||
- 正文留边:左右 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` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- **文本型漫画源按图片下发**(`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)**
|
||||
|
||||
@@ -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 存储的
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -792,7 +892,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 +938,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 +1035,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
|
||||
@@ -1122,32 +1230,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
|
||||
@@ -1164,14 +1341,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 章节内容(按类型返回文本/音频/图片)。
|
||||
@@ -1297,7 +1474,7 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook,
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
|
||||
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
|
||||
return resp, nil
|
||||
}
|
||||
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
||||
@@ -1315,19 +1492,6 @@ const (
|
||||
mediaFetchRetryDelay = 400 * time.Millisecond
|
||||
)
|
||||
|
||||
// mediaRetryableStatus 判断上游状态码是否值得重试。
|
||||
// 403/429 是图床并发限流的表现,5xx 是上游抖动。
|
||||
func mediaRetryableStatus(code int) bool {
|
||||
switch code {
|
||||
case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sourceReferer 把书源的 origin 转成可用的默认 Referer。
|
||||
//
|
||||
// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是
|
||||
@@ -1421,7 +1585,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
|
||||
@@ -1430,14 +1594,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
|
||||
}
|
||||
@@ -1487,6 +1656,103 @@ 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 取出一行正文里的图片地址。
|
||||
//
|
||||
// - 正文规则给的就是地址 → 原样返回;
|
||||
@@ -1974,6 +2240,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))
|
||||
}
|
||||
}
|
||||
@@ -332,13 +332,14 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
result = strings.Split(s, "\n")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
if lst, ok := result.([]string); ok {
|
||||
for _, u := range lst {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
@@ -363,6 +364,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
@@ -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
|
||||
|
||||
@@ -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 内使用")
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,8 @@ func jsonRead(root any, path string) any {
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
@@ -51,7 +55,63 @@ func jsonValueToString(ob any) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return anyToString(t)
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Request struct {
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
@@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZhToSimplified 常用繁体字要真的转成简体。
|
||||
// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换,
|
||||
// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。
|
||||
func TestZhToSimplified(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"島忍者神龜": "岛忍者神龟",
|
||||
"航海王": "航海王",
|
||||
"我們的書": "我们的书",
|
||||
"學習園地": "学习园地",
|
||||
"": "",
|
||||
"abc123": "abc123", // 非中文原样返回
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToSimplified(in); got != want {
|
||||
t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhToTraditional 反向转换。
|
||||
func TestZhToTraditional(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"岛忍者神龟": "島忍者神龜",
|
||||
"我们的书": "我們的書",
|
||||
"学习园地": "學習園地",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToTraditional(in); got != want {
|
||||
t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。
|
||||
func TestZhConvertLeavesPlainTextAlone(t *testing.T) {
|
||||
const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴☠️"
|
||||
if got := zhToSimplified(s); got != s {
|
||||
t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。
|
||||
func TestZhDictParsed(t *testing.T) {
|
||||
zhInit()
|
||||
if len(zhT2S) < 3000 {
|
||||
t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S))
|
||||
}
|
||||
if len(zhS2T) < 3000 {
|
||||
t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T))
|
||||
}
|
||||
// 空格/竖线不属于任何键
|
||||
if _, ok := zhT2S[' ']; ok {
|
||||
t.Fatal("词典里混进了分隔符")
|
||||
}
|
||||
if strings.ContainsRune(zhT2SData, '\uFFFD') {
|
||||
t.Fatal("词典数据含替换符,生成过程有损")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。
|
||||
//
|
||||
// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把
|
||||
// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。
|
||||
// 解析不出来时原样返回,不做猜测。
|
||||
var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`)
|
||||
|
||||
var (
|
||||
zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
||||
zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000}
|
||||
zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000}
|
||||
)
|
||||
|
||||
// numChapter 把标题里第一处数字规整成阿拉伯数字。
|
||||
func numChapter(s string) string {
|
||||
loc := chapterTokenRe.FindStringIndex(s)
|
||||
if loc == nil {
|
||||
return s
|
||||
}
|
||||
n, ok := parseCNSNum(s[loc[0]:loc[1]])
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:]
|
||||
}
|
||||
|
||||
// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。
|
||||
func parseCNSNum(tok string) (int, bool) {
|
||||
if tok == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(tok); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来
|
||||
if digitsOnly(tok) {
|
||||
n := 0
|
||||
for _, r := range tok {
|
||||
n = n*10 + zhDigits[r]
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
total, section, num := 0, 0, 0
|
||||
seen := false
|
||||
for _, r := range tok {
|
||||
if d, ok := zhDigits[r]; ok {
|
||||
num = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if u, ok := zhUnits[r]; ok {
|
||||
if num == 0 {
|
||||
num = 1 // 「十二」这类省略了前导一的写法
|
||||
}
|
||||
section += num * u
|
||||
num = 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if big, ok := zhBigUnits[r]; ok {
|
||||
section += num
|
||||
if section == 0 {
|
||||
section = 1
|
||||
}
|
||||
total += section * big
|
||||
section, num = 0, 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !seen {
|
||||
return 0, false
|
||||
}
|
||||
return total + section + num, true
|
||||
}
|
||||
|
||||
// digitsOnly 判断字符串是否只由中文数字字符组成。
|
||||
func digitsOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := zhDigits[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。
|
||||
// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。
|
||||
func TestNumChapter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第一百零八章": "第108章",
|
||||
"第十二话": "第12话",
|
||||
"第3话": "第3话",
|
||||
"第一话 冒险的序幕": "第1话 冒险的序幕",
|
||||
"一〇二": "102",
|
||||
"两千零一": "2001",
|
||||
"航海王": "航海王", // 没有数字:原样返回
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := numChapter(in); got != want {
|
||||
t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。
|
||||
func TestJavaExtraHelpers(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
|
||||
v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.randomUUID() 失败: %v", err)
|
||||
}
|
||||
id := anyToString(v)
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("randomUUID 返回值不像 UUID: %q", id)
|
||||
}
|
||||
|
||||
v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.toNumChapter() 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第108章" {
|
||||
t.Fatalf("toNumChapter = %q,期望 第108章", got)
|
||||
}
|
||||
|
||||
// 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义)
|
||||
if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil {
|
||||
t.Fatal("java.deviceID() 应当抛异常")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { rememberImageSize } from '../../utils/comicSpread'
|
||||
|
||||
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
|
||||
//
|
||||
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
|
||||
// 这里只负责把它们并排摆好、各自撑满视口高度。
|
||||
|
||||
interface ReaderComicProps {
|
||||
images: string[]
|
||||
@@ -14,17 +20,22 @@ interface ReaderComicProps {
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
|
||||
spread?: number[] | null
|
||||
}
|
||||
|
||||
function ComicImage({
|
||||
src,
|
||||
theme,
|
||||
fit = false,
|
||||
half = false,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
// fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
|
||||
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
|
||||
fit?: boolean
|
||||
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
|
||||
half?: boolean
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
@@ -40,14 +51,25 @@ function ComicImage({
|
||||
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
|
||||
style={{
|
||||
backgroundColor: theme.bg,
|
||||
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
|
||||
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
|
||||
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
|
||||
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
|
||||
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
|
||||
setState('ok')
|
||||
}}
|
||||
onError={() => setState('error')}
|
||||
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
|
||||
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
|
||||
@@ -72,7 +94,7 @@ function ComicImage({
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
@@ -98,6 +120,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
restoredRef.current = false
|
||||
}, [mode])
|
||||
|
||||
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
|
||||
useSmoothWheelScroll(scrollRef, mode === 'scroll')
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
@@ -107,11 +132,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} fit />
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
Columns2,
|
||||
LayoutList,
|
||||
ListEnd,
|
||||
Loader2,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||
@@ -49,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
|
||||
@@ -109,6 +121,34 @@ 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
|
||||
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -259,6 +299,11 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [content, settings.pageMode])
|
||||
|
||||
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
|
||||
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
|
||||
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
|
||||
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
|
||||
|
||||
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
|
||||
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
|
||||
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
|
||||
@@ -446,6 +491,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
|
||||
@@ -456,7 +507,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') {
|
||||
@@ -464,6 +515,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)
|
||||
@@ -477,7 +533,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(() => {
|
||||
@@ -499,12 +570,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
|
||||
|
||||
@@ -639,7 +711,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 ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
|
||||
{contentType === 'audio' ? (
|
||||
media && media.tracks && media.tracks.length > 0 ? (
|
||||
<ReaderAudioPanel
|
||||
@@ -670,10 +742,11 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
) : contentType === 'image' ? (
|
||||
<ReaderComic
|
||||
images={media?.images ?? []}
|
||||
images={comicImages}
|
||||
theme={theme}
|
||||
mode={settings.pageMode}
|
||||
page={comicPage}
|
||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
@@ -711,6 +784,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
|
||||
@@ -723,7 +797,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">
|
||||
@@ -763,7 +837,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
|
||||
@@ -846,8 +923,10 @@ 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: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
@@ -861,6 +940,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
|
||||
? [
|
||||
{
|
||||
@@ -877,7 +967,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>
|
||||
|
||||
@@ -77,6 +77,10 @@ interface ReaderSettingsState {
|
||||
pageMode: ReaderPageMode
|
||||
setPageMode: (mode: ReaderPageMode) => void
|
||||
|
||||
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
|
||||
comicDoublePage: boolean
|
||||
setComicDoublePage: (on: boolean) => void
|
||||
|
||||
fontSize: number
|
||||
setFontSize: (size: number) => void
|
||||
|
||||
@@ -136,6 +140,13 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
pageMode: 'page',
|
||||
setPageMode: (pageMode) => set({ pageMode }),
|
||||
|
||||
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
|
||||
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
|
||||
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
|
||||
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
|
||||
comicDoublePage: true,
|
||||
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
||||
|
||||
fontSize: 20,
|
||||
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
||||
|
||||
@@ -162,7 +173,8 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
|
||||
*
|
||||
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
|
||||
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
|
||||
*/
|
||||
export interface ReaderSettingsProfile {
|
||||
theme_id: string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user