mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
bug处理
This commit is contained in:
@@ -28,6 +28,9 @@ type ReaderBookSource struct {
|
|||||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||||
HasLogin bool `gorm:"-" json:"has_login"`
|
HasLogin bool `gorm:"-" json:"has_login"`
|
||||||
|
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||||
|
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||||
|
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)。
|
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
|
||||||
browserMu sync.Mutex
|
browserMu sync.Mutex
|
||||||
browserPending map[string]*pendingBrowser
|
browserPending map[string]*pendingBrowser
|
||||||
|
|
||||||
|
// limiter 单源限速(书源 concurrentRate)。
|
||||||
|
limiter *sourceRateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReaderService 创建服务。
|
// NewReaderService 创建服务。
|
||||||
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
||||||
return &ReaderService{
|
return &ReaderService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
repo: repos.Reader,
|
repo: repos.Reader,
|
||||||
http: helper.NewSiteHTTPClient(30, true),
|
http: helper.NewSiteHTTPClient(30, true),
|
||||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
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)")
|
return 0, fmt.Errorf("未识别到有效书源(支持 JSON 数组/对象或 Base64)")
|
||||||
}
|
}
|
||||||
imported := 0
|
imported := 0
|
||||||
for _, raw := range sources {
|
var failures []string
|
||||||
|
for i, raw := range sources {
|
||||||
bs, err := ParseBookSource(raw)
|
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
|
continue
|
||||||
}
|
}
|
||||||
// 已存在则更新,否则新建(按书源 URL 去重)
|
// 已存在则更新,否则新建(按书源 URL 去重)
|
||||||
@@ -124,14 +134,23 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
|
|||||||
existing.LastCheckAt = &now
|
existing.LastCheckAt = &now
|
||||||
if err := s.repo.UpdateSource(ctx, existing); err == nil {
|
if err := s.repo.UpdateSource(ctx, existing); err == nil {
|
||||||
imported++
|
imported++
|
||||||
|
} else {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
record.LastCheckAt = &now
|
record.LastCheckAt = &now
|
||||||
if err := s.repo.CreateSource(ctx, record); err == nil {
|
if err := s.repo.CreateSource(ctx, record); err == nil {
|
||||||
imported++
|
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
|
return imported, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +226,21 @@ func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSour
|
|||||||
}
|
}
|
||||||
for i := range sources {
|
for i := range sources {
|
||||||
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
|
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
|
||||||
|
sources[i].NeedsBrowser = rawSourceNeedsBrowser(sources[i].RawJSON)
|
||||||
}
|
}
|
||||||
return sources, nil
|
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 两个字段判断登录能力。
|
// rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。
|
||||||
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
|
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
|
||||||
func rawSourceHasLogin(rawJSON string) bool {
|
func rawSourceHasLogin(rawJSON string) bool {
|
||||||
@@ -249,10 +279,6 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
|
|||||||
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
|
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
|
||||||
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
|
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
|
||||||
func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) {
|
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)`
|
// 请求目标是含 query 的完整 URL:对应 legado 的 `get(urlNoQuery, encodedQuery)`
|
||||||
// (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进
|
// (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进
|
||||||
// req.URL。早期这里误用 URLNoQuery,导致所有「参数写在 query 里」的 GET
|
// 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)
|
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
|
||||||
if err != nil {
|
buildRequest := func() (*http.Request, error) {
|
||||||
return "", "", 0, err
|
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)
|
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
|
||||||
}
|
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
|
||||||
for k, v := range req.Headers {
|
attempts := 1
|
||||||
httpReq.Header.Set(k, v)
|
if req.Retry != nil && *req.Retry > 0 {
|
||||||
}
|
attempts += *req.Retry
|
||||||
// Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压,
|
if attempts > maxRequestAttempts {
|
||||||
// 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。
|
attempts = maxRequestAttempts
|
||||||
// 书源 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp, err := s.http.Do(httpReq)
|
var (
|
||||||
if err != nil {
|
resp *http.Response
|
||||||
return "", "", 0, err
|
data []byte
|
||||||
}
|
)
|
||||||
defer resp.Body.Close()
|
for attempt := 1; ; attempt++ {
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
httpReq, err := buildRequest()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", resp.StatusCode, err
|
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)
|
data = helper.DecompressBody(resp, data)
|
||||||
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
|
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
|
||||||
@@ -429,6 +496,11 @@ func (sess *sourceSession) close() {
|
|||||||
|
|
||||||
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
|
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
|
||||||
func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) {
|
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())
|
body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body, finalURL, code, err
|
return body, finalURL, code, err
|
||||||
@@ -572,17 +644,45 @@ func (sess *sourceSession) srcName() string {
|
|||||||
return srcNameOf(sess.src, sess.bs)
|
return srcNameOf(sess.src, sess.bs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// headerJSON 书源级请求头 JSON。
|
// headerJSON 书源级请求头(JSON 文本)。
|
||||||
func (sess *sourceSession) headerJSON() string {
|
//
|
||||||
|
// 对应 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 != "" {
|
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 把书源级请求头合并进请求(不覆盖已显式设置的值)。
|
// applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。
|
||||||
func (sess *sourceSession) applyHeaders(req *rule.Request) {
|
func (sess *sourceSession) applyHeaders(req *rule.Request, runner *rule.JSRunner) {
|
||||||
raw := sess.headerJSON()
|
raw := sess.headerJSON(runner)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -792,7 +892,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
|||||||
if req.Unsupported != nil {
|
if req.Unsupported != nil {
|
||||||
return nil, req.Unsupported
|
return nil, req.Unsupported
|
||||||
}
|
}
|
||||||
sess.applyHeaders(req)
|
sess.applyHeaders(req, runner)
|
||||||
body, finalURL, _, err := sess.fetch(req)
|
body, finalURL, _, err := sess.fetch(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return books, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1035,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
|
|||||||
if req.Unsupported != nil {
|
if req.Unsupported != nil {
|
||||||
return nil, req.Unsupported
|
return nil, req.Unsupported
|
||||||
}
|
}
|
||||||
sess.applyHeaders(req)
|
sess.applyHeaders(req, runner)
|
||||||
body, finalURL, _, err := sess.fetch(req)
|
body, finalURL, _, err := sess.fetch(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1122,32 +1230,101 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
|
|||||||
}
|
}
|
||||||
sess := s.newSession(ctx, src, bs)
|
sess := s.newSession(ctx, src, bs)
|
||||||
defer sess.close()
|
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)
|
runner := sess.runner("", 0)
|
||||||
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
|
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, -1, err
|
return nil, nil, -1, err
|
||||||
}
|
}
|
||||||
if req.Unsupported != nil {
|
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)
|
body, finalURL, _, err := sess.fetch(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, -1, err
|
return nil, nil, -1, err
|
||||||
}
|
}
|
||||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||||
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
||||||
|
|
||||||
elements, err := ar.GetElements(SPtr(tr.ChapterList))
|
elements, err := ar.GetElements(SPtr(tr.ChapterList))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, -1, err
|
return nil, nil, -1, err
|
||||||
}
|
}
|
||||||
declaredType := -1
|
declaredType := -1
|
||||||
if t, ok := ar.BookTypeOverride(); ok {
|
if t, ok := ar.BookTypeOverride(); ok {
|
||||||
declaredType = normalizeBookType(t)
|
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
|
var chapters []TocChapter
|
||||||
for i, el := range elements {
|
for _, el := range elements {
|
||||||
title, err := ar.GetString(SPtr(tr.ChapterName), el, false)
|
title, err := ar.GetString(SPtr(tr.ChapterName), el, false)
|
||||||
if err != nil || title == "" {
|
if err != nil || title == "" {
|
||||||
continue
|
continue
|
||||||
@@ -1164,14 +1341,14 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
|
|||||||
}
|
}
|
||||||
updateTime, _ := ar.GetString(SPtr(tr.UpdateTime), el, false)
|
updateTime, _ := ar.GetString(SPtr(tr.UpdateTime), el, false)
|
||||||
chapters = append(chapters, TocChapter{
|
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 {
|
if t, ok := ar.BookTypeOverride(); ok {
|
||||||
declaredType = normalizeBookType(t)
|
declaredType = normalizeBookType(t)
|
||||||
}
|
}
|
||||||
return chapters, declaredType, nil
|
return chapters, nextURLs, declaredType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
|
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
|
||||||
@@ -1297,7 +1474,7 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook,
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
|
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
||||||
@@ -1315,19 +1492,6 @@ const (
|
|||||||
mediaFetchRetryDelay = 400 * time.Millisecond
|
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。
|
// sourceReferer 把书源的 origin 转成可用的默认 Referer。
|
||||||
//
|
//
|
||||||
// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是
|
// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是
|
||||||
@@ -1421,7 +1585,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
if req.Unsupported != nil {
|
if req.Unsupported != nil {
|
||||||
return nil, req.Unsupported
|
return nil, req.Unsupported
|
||||||
}
|
}
|
||||||
sess.applyHeaders(req)
|
sess.applyHeaders(req, runner)
|
||||||
body, finalURL, _, err := sess.fetch(req)
|
body, finalURL, _, err := sess.fetch(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1430,14 +1594,19 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||||
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if t, ok := ar.BookTypeOverride(); ok {
|
if t, ok := ar.BookTypeOverride(); ok {
|
||||||
declaredType = normalizeBookType(t)
|
declaredType = normalizeBookType(t)
|
||||||
}
|
}
|
||||||
parts = append(parts, strings.Join(list, "\n"))
|
parts = append(parts, page)
|
||||||
if SPtr(cr.NextContentURL) == "" {
|
if SPtr(cr.NextContentURL) == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1656,46 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
return out, nil
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
// imageRefsInLine 取出一行正文里的图片地址。
|
// imageRefsInLine 取出一行正文里的图片地址。
|
||||||
//
|
//
|
||||||
// - 正文规则给的就是地址 → 原样返回;
|
// - 正文规则给的就是地址 → 原样返回;
|
||||||
@@ -1974,6 +2183,13 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
|||||||
for i, img := range out.Images {
|
for i, img := range out.Images {
|
||||||
out.Images[i] = s.ProxyURL(book.ID, img)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
result = strings.Split(s, "\n")
|
||||||
}
|
}
|
||||||
if isUrl {
|
if isUrl {
|
||||||
|
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||||
|
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||||
|
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||||
var urlList []string
|
var urlList []string
|
||||||
if lst, ok := result.([]string); ok {
|
for _, u := range resultStrings(result) {
|
||||||
for _, u := range lst {
|
abs := a.absolutize(u)
|
||||||
abs := a.absolutize(u)
|
if abs != "" && !containsStr(urlList, abs) {
|
||||||
if abs != "" && !containsStr(urlList, abs) {
|
urlList = append(urlList, abs)
|
||||||
urlList = append(urlList, abs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return urlList, nil
|
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)。
|
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||||
if ruleStr == "" {
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
// 本文件对应 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 {
|
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||||
return vm.ToValue(defaultWebViewUA)
|
return vm.ToValue(defaultWebViewUA)
|
||||||
})
|
})
|
||||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
// 见 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)) })
|
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` 是同一对象) ──
|
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||||
if r.state != nil {
|
if r.state != nil {
|
||||||
@@ -496,21 +526,26 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
|||||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
// 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 {
|
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{
|
for _, name := range []string{
|
||||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||||
"openVideoPlayer", "getVerificationCode",
|
"openVideoPlayer", "getVerificationCode",
|
||||||
|
} {
|
||||||
|
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||||
"getZipStringContent", "getZipByteArrayContent",
|
"getZipStringContent", "getZipByteArrayContent",
|
||||||
"getRarStringContent", "get7zStringContent",
|
"getRarStringContent", "get7zStringContent",
|
||||||
} {
|
} {
|
||||||
set(name, unsupported(name))
|
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return o
|
return o
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ var (
|
|||||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||||
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
|
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package rule
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ func jsonRead(root any, path string) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||||
|
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||||
|
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||||
func jsonValueToString(ob any) string {
|
func jsonValueToString(ob any) string {
|
||||||
switch t := ob.(type) {
|
switch t := ob.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@@ -51,7 +55,63 @@ func jsonValueToString(ob any) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
default:
|
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 表示该请求依赖当前阶段不支持的能力,
|
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||||
Unsupported error
|
Unsupported error
|
||||||
|
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||||
|
Retry *int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||||
@@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.Charset = option.Charset
|
req.Charset = option.Charset
|
||||||
|
req.Retry = option.Retry
|
||||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||||
if option.Type != "" {
|
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
|
package reader
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool {
|
|||||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
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>…< 包裹)。
|
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||||
@@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string {
|
|||||||
|
|
||||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||||
func stripJSWrapper(s string) string {
|
func stripJSWrapper(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
out, _ := stripJSWrapperOK(s)
|
||||||
if strings.HasPrefix(s, "@js:") {
|
return out
|
||||||
return s[len("@js:"):]
|
}
|
||||||
|
|
||||||
|
// 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 搜索规则。
|
// SearchRule 搜索规则。
|
||||||
@@ -110,6 +138,9 @@ type TocRule struct {
|
|||||||
ChapterURL *string `json:"chapterUrl"`
|
ChapterURL *string `json:"chapterUrl"`
|
||||||
IsVolume *string `json:"isVolume"`
|
IsVolume *string `json:"isVolume"`
|
||||||
UpdateTime *string `json:"updateTime"`
|
UpdateTime *string `json:"updateTime"`
|
||||||
|
// NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它
|
||||||
|
// 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。
|
||||||
|
NextTocURL *string `json:"nextTocUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentRule 正文规则。
|
// ContentRule 正文规则。
|
||||||
@@ -125,25 +156,33 @@ type ContentRule struct {
|
|||||||
|
|
||||||
// ExploreRule 发现规则。
|
// ExploreRule 发现规则。
|
||||||
type ExploreRule struct {
|
type ExploreRule struct {
|
||||||
BookList *string `json:"bookList"`
|
BookList *string `json:"bookList"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Author *string `json:"author"`
|
Author *string `json:"author"`
|
||||||
Kind *string `json:"kind"`
|
Kind *string `json:"kind"`
|
||||||
WordCount *string `json:"wordCount"`
|
WordCount *string `json:"wordCount"`
|
||||||
LastChapter *string `json:"lastChapter"`
|
LastChapter *string `json:"lastChapter"`
|
||||||
Intro *string `json:"intro"`
|
Intro *string `json:"intro"`
|
||||||
CoverURL *string `json:"coverUrl"`
|
CoverURL *string `json:"coverUrl"`
|
||||||
BookURL *string `json:"bookUrl"`
|
BookURL *string `json:"bookUrl"`
|
||||||
ExploreURL *string `json:"exploreUrl"`
|
ExploreURL *string `json:"exploreUrl"`
|
||||||
ExploreKinds *string `json:"exploreKinds"`
|
ExploreKinds *string `json:"exploreKinds"`
|
||||||
CheckKeyWord *string `json:"checkKeyWord"`
|
CheckKeyWord *string `json:"checkKeyWord"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseBookSource 将书源 JSON 解析为结构体。
|
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||||
|
//
|
||||||
|
// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。
|
||||||
|
// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败,
|
||||||
|
// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。
|
||||||
func ParseBookSource(raw string) (*BookSource, error) {
|
func ParseBookSource(raw string) (*BookSource, error) {
|
||||||
var bs BookSource
|
var bs BookSource
|
||||||
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
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
|
bs.RawJSON = raw
|
||||||
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||||
@@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) {
|
|||||||
return &bs, nil
|
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` 对象)。
|
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||||
func (b *BookSource) SourceProps() map[string]any {
|
func (b *BookSource) SourceProps() map[string]any {
|
||||||
if b.RawJSON == "" {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user