mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
bug处理
This commit is contained in:
@@ -28,6 +28,9 @@ type ReaderBookSource struct {
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法:
|
||||
//
|
||||
// $..url
|
||||
// <js>result.split("\n").map(x=>'<img src="'+x+'">').join("\n")</js>
|
||||
//
|
||||
// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串
|
||||
// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则),
|
||||
// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。
|
||||
func TestContentRuleJSReceivesJoinedString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
const srcURL = "https://www.mangacopy.com/"
|
||||
const contentRule = "$..url\n<js>result.split(\"\\n\").map(x=>'<img src=\"'+x+'\">').join(\"\\n\")</js>"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srcURL,
|
||||
RuleContent: &ContentRule{Content: strPtr(contentRule)},
|
||||
Header: &header,
|
||||
}
|
||||
|
||||
body := `{"results":{"chapter":{"contents":[` +
|
||||
`{"url":"https://img.example.com/1.webp"},` +
|
||||
`{"url":"https://img.example.com/2.webp"}]}}}`
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x")
|
||||
|
||||
got, err := ar.GetString(contentRule, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("正文规则求值失败: %v", err)
|
||||
}
|
||||
want := "<img src=\"https://img.example.com/1.webp\">\n<img src=\"https://img.example.com/2.webp\">"
|
||||
if got != want {
|
||||
t.Fatalf("正文 = %q\n期望 %q", got, want)
|
||||
}
|
||||
// 图片提取(漫画分支):每行一个 <img>,应抽出 2 个真实地址
|
||||
refs := 0
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
refs += len(imageRefsInLine(line))
|
||||
}
|
||||
if refs != 2 {
|
||||
t.Fatalf("从正文抽出 %d 张图,期望 2", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRateLimiter 单源限速。
|
||||
//
|
||||
// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封
|
||||
// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。
|
||||
// 对应 legado 的 ConcurrentRateLimiter,支持两种写法:
|
||||
//
|
||||
// "1000" 每次请求至少间隔 1000ms
|
||||
// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔)
|
||||
//
|
||||
// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。
|
||||
type sourceRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
slots map[string]time.Time
|
||||
}
|
||||
|
||||
// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。
|
||||
const maxRateGapMs = 30000
|
||||
|
||||
func newSourceRateLimiter() *sourceRateLimiter {
|
||||
return &sourceRateLimiter{slots: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// wait 按源限速等待;key 为空或 rate 无法解析时不等待。
|
||||
func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) {
|
||||
gap := parseConcurrentRate(rate)
|
||||
if gap <= 0 || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
slot := now
|
||||
if next, ok := l.slots[key]; ok && next.After(now) {
|
||||
slot = next
|
||||
}
|
||||
l.slots[key] = slot.Add(gap)
|
||||
l.mu.Unlock()
|
||||
sleepWithContext(ctx, slot.Sub(now))
|
||||
}
|
||||
|
||||
// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。
|
||||
func parseConcurrentRate(rate string) time.Duration {
|
||||
rate = strings.TrimSpace(rate)
|
||||
if rate == "" {
|
||||
return 0
|
||||
}
|
||||
gapMs := 0
|
||||
if i := strings.IndexByte(rate, '/'); i > 0 {
|
||||
count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i]))
|
||||
interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:]))
|
||||
if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = interval / count
|
||||
} else {
|
||||
n, err := strconv.Atoi(rate)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = n
|
||||
}
|
||||
if gapMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
if gapMs > maxRateGapMs {
|
||||
gapMs = maxRateGapMs
|
||||
}
|
||||
return time.Duration(gapMs) * time.Millisecond
|
||||
}
|
||||
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseConcurrentRate 书源 concurrentRate 的两种写法。
|
||||
func TestParseConcurrentRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"abc", 0},
|
||||
{"1000", 1000 * time.Millisecond},
|
||||
{" 500 ", 500 * time.Millisecond},
|
||||
{"3/1000", 333 * time.Millisecond},
|
||||
{"1/2000", 2000 * time.Millisecond},
|
||||
{"3/0", 0},
|
||||
{"0/1000", 0},
|
||||
{"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseConcurrentRate(c.in); got != c.want {
|
||||
t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。
|
||||
func TestRateLimiterSpacesRequests(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
l.wait(t.Context(), "src", "120") // 120ms
|
||||
l.wait(t.Context(), "src", "120")
|
||||
if elapsed := time.Since(start); elapsed < 110*time.Millisecond {
|
||||
t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed)
|
||||
}
|
||||
// 不同源互不影响
|
||||
start = time.Now()
|
||||
l.wait(t.Context(), "other", "500")
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("换源后不该等待,实际 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。
|
||||
func TestRateLimiterIgnoresEmptyRate(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
l.wait(t.Context(), "src", "")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("未声明限速却等待了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryableStatus 只有可恢复的失败才重试。
|
||||
func TestRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 429, 500, 502, 503, 504} {
|
||||
if !retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 301, 400, 401, 404, 410} {
|
||||
if retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 不该重试", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,20 @@ type ReaderService struct {
|
||||
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
|
||||
browserMu sync.Mutex
|
||||
browserPending map[string]*pendingBrowser
|
||||
|
||||
// limiter 单源限速(书源 concurrentRate)。
|
||||
limiter *sourceRateLimiter
|
||||
}
|
||||
|
||||
// NewReaderService 创建服务。
|
||||
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
||||
return &ReaderService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
limiter: newSourceRateLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +87,15 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
|
||||
return 0, fmt.Errorf("未识别到有效书源(支持 JSON 数组/对象或 Base64)")
|
||||
}
|
||||
imported := 0
|
||||
for _, raw := range sources {
|
||||
var failures []string
|
||||
for i, raw := range sources {
|
||||
bs, err := ParseBookSource(raw)
|
||||
if err != nil || bs.BookSourceURL == "" {
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("第 %d 条解析失败: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
if bs.BookSourceURL == "" {
|
||||
failures = append(failures, fmt.Sprintf("第 %d 条缺少 bookSourceUrl", i+1))
|
||||
continue
|
||||
}
|
||||
// 已存在则更新,否则新建(按书源 URL 去重)
|
||||
@@ -124,14 +134,23 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
|
||||
existing.LastCheckAt = &now
|
||||
if err := s.repo.UpdateSource(ctx, existing); err == nil {
|
||||
imported++
|
||||
} else {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
record.LastCheckAt = &now
|
||||
if err := s.repo.CreateSource(ctx, record); err == nil {
|
||||
imported++
|
||||
} else {
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err))
|
||||
}
|
||||
}
|
||||
// 一条都没进来时不能只回 0:前端只会弹一句「成功导入 0 个书源」,
|
||||
// 用户完全不知道哪里不对。把第一条失败原因带回去,让界面能说清楚。
|
||||
if imported == 0 && len(failures) > 0 {
|
||||
return 0, fmt.Errorf("书源导入失败:%s", failures[0])
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
@@ -207,10 +226,21 @@ func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSour
|
||||
}
|
||||
for i := range sources {
|
||||
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
|
||||
sources[i].NeedsBrowser = rawSourceNeedsBrowser(sources[i].RawJSON)
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// rawSourceNeedsBrowser 粗判书源是否依赖 WebView(webView 请求选项 / webjs 规则)。
|
||||
//
|
||||
// 服务端没有无头浏览器,这类源注定跑不通;列表先标出来,用户不用等到报错才知道。
|
||||
// 用字符串粗扫而不是完整解析:这些标记只出现在规则/选项里,误报代价也只是多一个提示。
|
||||
func rawSourceNeedsBrowser(rawJSON string) bool {
|
||||
lower := strings.ToLower(rawJSON)
|
||||
return strings.Contains(lower, "webview") || strings.Contains(lower, "webjs") ||
|
||||
strings.Contains(lower, "\"webview\"")
|
||||
}
|
||||
|
||||
// rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。
|
||||
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
|
||||
func rawSourceHasLogin(rawJSON string) bool {
|
||||
@@ -249,10 +279,6 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
|
||||
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
|
||||
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
|
||||
func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) {
|
||||
var bodyReader io.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = strings.NewReader(req.Body)
|
||||
}
|
||||
// 请求目标是含 query 的完整 URL:对应 legado 的 `get(urlNoQuery, encodedQuery)`
|
||||
// (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进
|
||||
// req.URL。早期这里误用 URLNoQuery,导致所有「参数写在 query 里」的 GET
|
||||
@@ -283,36 +309,77 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
}
|
||||
}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
|
||||
buildRequest := func() (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = strings.NewReader(req.Body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
// Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压,
|
||||
// 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。
|
||||
// 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。
|
||||
helper.StripAcceptEncoding(httpReq.Header)
|
||||
if req.Method == "POST" {
|
||||
switch {
|
||||
case req.IsForm:
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
case req.IsJSON:
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
return httpReq, nil
|
||||
}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
// Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压,
|
||||
// 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。
|
||||
// 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。
|
||||
helper.StripAcceptEncoding(httpReq.Header)
|
||||
if req.Method == "POST" {
|
||||
switch {
|
||||
case req.IsForm:
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
case req.IsJSON:
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
|
||||
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
|
||||
attempts := 1
|
||||
if req.Retry != nil && *req.Retry > 0 {
|
||||
attempts += *req.Retry
|
||||
if attempts > maxRequestAttempts {
|
||||
attempts = maxRequestAttempts
|
||||
}
|
||||
}
|
||||
resp, err := s.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return "", "", resp.StatusCode, err
|
||||
var (
|
||||
resp *http.Response
|
||||
data []byte
|
||||
)
|
||||
for attempt := 1; ; attempt++ {
|
||||
httpReq, err := buildRequest()
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
resp, err = s.http.Do(httpReq)
|
||||
if err != nil {
|
||||
if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
|
||||
continue
|
||||
}
|
||||
return "", "", 0, err
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
|
||||
continue
|
||||
}
|
||||
return "", "", resp.StatusCode, err
|
||||
}
|
||||
if attempt < attempts && retryableStatus(resp.StatusCode) {
|
||||
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
|
||||
return "", "", resp.StatusCode, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
data = helper.DecompressBody(resp, data)
|
||||
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
|
||||
@@ -429,6 +496,11 @@ func (sess *sourceSession) close() {
|
||||
|
||||
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
|
||||
func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) {
|
||||
// 单源限速(书源 concurrentRate):挂在这里,规则请求与书源 JS 的 java.ajax
|
||||
// 都会经过,保证同一个源不会并发轰炸站点。
|
||||
if sess.svc.limiter != nil {
|
||||
sess.svc.limiter.wait(sess.ctx, sess.srcURL(), SPtr(sess.bs.ConcurrentRate))
|
||||
}
|
||||
body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies())
|
||||
if err != nil {
|
||||
return body, finalURL, code, err
|
||||
@@ -572,17 +644,45 @@ func (sess *sourceSession) srcName() string {
|
||||
return srcNameOf(sess.src, sess.bs)
|
||||
}
|
||||
|
||||
// headerJSON 书源级请求头 JSON。
|
||||
func (sess *sourceSession) headerJSON() string {
|
||||
// headerJSON 书源级请求头(JSON 文本)。
|
||||
//
|
||||
// 对应 legado BaseSource.getHeaderMap:header 有两种写法 —— 直接的 JSON 对象,
|
||||
// 或者是 @js:/<js> 规则在运行时拼出来(拷贝漫画就是后者:用 baseUrl 拼 referer、
|
||||
// 带上 platform/version)。之前这里只做 json.Unmarshal,JS 形态会被整体丢掉,
|
||||
// 请求少了这些必需头,上游照样回 200,但结果是空列表 —— 表现为「书源能导入、
|
||||
// 却搜不到任何内容」。JS 求值失败时返回空串,宁可不带自定义头也不发半成品。
|
||||
func (sess *sourceSession) headerJSON(runner *rule.JSRunner) string {
|
||||
raw := ""
|
||||
if sess.src != nil && sess.src.Header != "" {
|
||||
return sess.src.Header
|
||||
raw = sess.src.Header
|
||||
} else {
|
||||
raw = SPtr(sess.bs.Header)
|
||||
}
|
||||
return SPtr(sess.bs.Header)
|
||||
js, isJS := stripJSWrapperOK(raw)
|
||||
if !isJS {
|
||||
return raw
|
||||
}
|
||||
if runner == nil {
|
||||
return ""
|
||||
}
|
||||
// baseUrl 绑成书源地址:legado 在 getHeaderMap 里用的也是 getKey()(bookSourceUrl)。
|
||||
v, err := runner.Run(nil, js, nil, sess.srcURL())
|
||||
if err != nil {
|
||||
if sess.svc.log != nil {
|
||||
sess.svc.log.Warn("reader: 执行书源请求头规则失败",
|
||||
zap.String("source", sess.srcName()), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
// applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。
|
||||
func (sess *sourceSession) applyHeaders(req *rule.Request) {
|
||||
raw := sess.headerJSON()
|
||||
func (sess *sourceSession) applyHeaders(req *rule.Request, runner *rule.JSRunner) {
|
||||
raw := sess.headerJSON(runner)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
@@ -792,7 +892,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req)
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -838,6 +938,14 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
||||
}},
|
||||
})
|
||||
}
|
||||
// 一条结果都没有时,用书源声明的校验关键字判断是「真没这本书」还是「被风控了」。
|
||||
// legado 用 checkKeyWord 做同样的事(源失效/被拦截时响应里不会出现它)。
|
||||
// 这里只把它当成失败原因的提示,不改变「有结果就返回结果」的行为。
|
||||
if len(books) == 0 {
|
||||
if kw := bs.SearchCheckKeyWord(); kw != "" && !strings.Contains(body, kw) {
|
||||
return nil, fmt.Errorf("搜索结果为空,且响应未包含校验关键字「%s」(源可能已失效或触发风控)", kw)
|
||||
}
|
||||
}
|
||||
return books, nil
|
||||
}
|
||||
|
||||
@@ -927,7 +1035,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req)
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1122,32 +1230,101 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
|
||||
declaredType := -1
|
||||
var chapters []TocChapter
|
||||
seenChapter := map[string]bool{}
|
||||
visited := map[string]bool{}
|
||||
// 用队列收集待抓页:书源有两种写法 —— 有的只给「下一页」,有的一次给出全部
|
||||
// 后续页(拷贝漫画就是后者,且它的规则锚定 offset=0,只有第一页能算出完整列表)。
|
||||
// 每页都再看一次 nextTocUrl 并去重入队,两种写法都能覆盖。
|
||||
queue := []string{tocURL}
|
||||
fetched := 0
|
||||
for len(queue) > 0 && fetched < maxTocPages {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
if current == "" || visited[current] {
|
||||
continue
|
||||
}
|
||||
visited[current] = true
|
||||
fetched++
|
||||
|
||||
pageChapters, nextURLs, declared, err := s.fetchTocPage(ctx, sess, bs, current, bookURL)
|
||||
if err != nil {
|
||||
// 第一页失败才算整体失败;后续页失败保留已经拿到的章节
|
||||
if fetched == 1 {
|
||||
return nil, -1, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if declared >= 0 {
|
||||
declaredType = declared
|
||||
}
|
||||
for _, ch := range pageChapters {
|
||||
if seenChapter[ch.URL] {
|
||||
continue
|
||||
}
|
||||
seenChapter[ch.URL] = true
|
||||
ch.Index = len(chapters)
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
for _, u := range nextURLs {
|
||||
if u != "" && !visited[u] {
|
||||
queue = append(queue, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters, declaredType, nil
|
||||
}
|
||||
|
||||
// maxTocPages 目录翻页上限。书源的 nextTocUrl 规则可能给出自引用或极长的链路
|
||||
// (legado 靠协程取消兜底),这里用硬上限 + 已访问集合双重保护。
|
||||
const maxTocPages = 50
|
||||
|
||||
// fetchTocPage 抓一页目录,返回该页章节、书源声明的后续页地址、声明的书籍类型。
|
||||
// 对应 legado BookChapterList.analyzeChapterList(含 nextTocUrl 处理)。
|
||||
func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, bs *BookSource, tocURL, bookURL string) ([]TocChapter, []string, int, error) {
|
||||
tr := bs.RuleToc
|
||||
runner := sess.runner("", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
return nil, nil, -1, err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return nil, -1, req.Unsupported
|
||||
return nil, nil, -1, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req)
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
return nil, nil, -1, err
|
||||
}
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
||||
|
||||
elements, err := ar.GetElements(SPtr(tr.ChapterList))
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
return nil, nil, -1, err
|
||||
}
|
||||
declaredType := -1
|
||||
if t, ok := ar.BookTypeOverride(); ok {
|
||||
declaredType = normalizeBookType(t)
|
||||
}
|
||||
|
||||
// 下一页目录:对应 legado 的 getStringList(nextTocUrl, isUrl=true),并排除当前页
|
||||
// 地址。规则可能一次给出全部后续页(拷贝漫画就是这种写法),也可能每页只给一个。
|
||||
var nextURLs []string
|
||||
if SPtr(tr.NextTocURL) != "" {
|
||||
if urls, err := ar.GetStringList(SPtr(tr.NextTocURL), nil, true); err == nil {
|
||||
for _, u := range urls {
|
||||
if u != "" && u != finalURL && u != tocURL {
|
||||
nextURLs = append(nextURLs, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chapters []TocChapter
|
||||
for i, el := range elements {
|
||||
for _, el := range elements {
|
||||
title, err := ar.GetString(SPtr(tr.ChapterName), el, false)
|
||||
if err != nil || title == "" {
|
||||
continue
|
||||
@@ -1164,14 +1341,14 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
|
||||
}
|
||||
updateTime, _ := ar.GetString(SPtr(tr.UpdateTime), el, false)
|
||||
chapters = append(chapters, TocChapter{
|
||||
Index: i, Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime,
|
||||
Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime,
|
||||
})
|
||||
}
|
||||
// 章节规则可能逐条执行,取最后一次声明(书源是在元素循环前设置的)。
|
||||
if t, ok := ar.BookTypeOverride(); ok {
|
||||
declaredType = normalizeBookType(t)
|
||||
}
|
||||
return chapters, declaredType, nil
|
||||
return chapters, nextURLs, declaredType, nil
|
||||
}
|
||||
|
||||
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
|
||||
@@ -1297,7 +1474,7 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook,
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
|
||||
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
|
||||
return resp, nil
|
||||
}
|
||||
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
||||
@@ -1315,19 +1492,6 @@ const (
|
||||
mediaFetchRetryDelay = 400 * time.Millisecond
|
||||
)
|
||||
|
||||
// mediaRetryableStatus 判断上游状态码是否值得重试。
|
||||
// 403/429 是图床并发限流的表现,5xx 是上游抖动。
|
||||
func mediaRetryableStatus(code int) bool {
|
||||
switch code {
|
||||
case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sourceReferer 把书源的 origin 转成可用的默认 Referer。
|
||||
//
|
||||
// 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是
|
||||
@@ -1421,7 +1585,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req)
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1430,14 +1594,19 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
||||
|
||||
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
|
||||
// 正文规则用 getString 求值(对应 legado BookContent:analyzeRule.getString(contentRule.content))。
|
||||
// 关键差别出在 JS 段:getStringList 会把上一段的结果按「列表」交给 JS,而 legado
|
||||
// 交给 JS 的是按 \n 拼好的字符串。拷贝漫画的正文规则正是 result.split("\n"),
|
||||
// 喂成列表就报 Object has no member 'split',整章图片都取不到。
|
||||
// 对 jsoup / xpath 规则,getString 与 getStringList+join 结果一致,文本源不受影响。
|
||||
page, err := ar.GetString(SPtr(cr.Content), nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := ar.BookTypeOverride(); ok {
|
||||
declaredType = normalizeBookType(t)
|
||||
}
|
||||
parts = append(parts, strings.Join(list, "\n"))
|
||||
parts = append(parts, page)
|
||||
if SPtr(cr.NextContentURL) == "" {
|
||||
break
|
||||
}
|
||||
@@ -1487,6 +1656,46 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rewriteContentImageMarkers 把网络文本正文里「整行就是 <img src="…">」的内容改写成
|
||||
// [img]<地址> 标记行,前端据此渲染成图片。
|
||||
//
|
||||
// 对应 legado 的 ruleContent.imageStyle:文本型漫画源(bookSourceType=0,但正文规则
|
||||
// 直接给 <img> 标签,如拷贝漫画)就是靠它把图片显示出来的;不转换的话读者看到的是
|
||||
// 原始的 <img src="..."> 文本。只处理「整行是标签」的行,不碰正常文本源里夹在段落
|
||||
// 中间的内嵌图片,避免改变原有语义。
|
||||
func rewriteContentImageMarkers(content, baseURL string, proxy func(string) string) string {
|
||||
if !strings.Contains(content, "<img") {
|
||||
return content
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
changed := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "<img") {
|
||||
if refs := imageRefsInLine(trimmed); len(refs) > 0 {
|
||||
for _, ref := range refs {
|
||||
abs := rule.GetAbsoluteURL(baseURL, ref)
|
||||
if abs == "" {
|
||||
continue
|
||||
}
|
||||
if proxy != nil {
|
||||
abs = proxy(abs)
|
||||
}
|
||||
out = append(out, imgMarkerPrefix+abs)
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !changed {
|
||||
return content
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// imageRefsInLine 取出一行正文里的图片地址。
|
||||
//
|
||||
// - 正文规则给的就是地址 → 原样返回;
|
||||
@@ -1974,6 +2183,13 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
for i, img := range out.Images {
|
||||
out.Images[i] = s.ProxyURL(book.ID, img)
|
||||
}
|
||||
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记(同样走签名代理,
|
||||
// 这样需要防盗链头的图片也能显示)。见 rewriteContentImageMarkers 的说明。
|
||||
if out.Type == "text" && strings.Contains(out.Content, "<img") {
|
||||
out.Content = rewriteContentImageMarkers(out.Content, ch.URL, func(u string) string {
|
||||
return s.ProxyURL(book.ID, u)
|
||||
})
|
||||
}
|
||||
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")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
if lst, ok := result.([]string); ok {
|
||||
for _, u := range lst {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
@@ -363,6 +364,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
@@ -401,9 +402,38 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(defaultWebViewUA)
|
||||
})
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||
// 见 zh_convert.go 的说明。
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。
|
||||
set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) })
|
||||
// toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。
|
||||
set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) })
|
||||
// toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。
|
||||
set("toURL", func(call goja.FunctionCall) goja.Value {
|
||||
base := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
base = call.Arguments[1].String()
|
||||
}
|
||||
obj, err := newJSURLObject(vm, stringArg(call, 0), base)
|
||||
if err != nil {
|
||||
bridgeErr("toURL", err)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
// logType:调试用,打印值的类型(对应 legado 的日志实现)。
|
||||
set("logType", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) {
|
||||
r.cfg.Log("null")
|
||||
} else {
|
||||
r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export()))
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
@@ -496,21 +526,26 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
||||
// 服务端没有对应页面,谎报成功会让书源走错分支
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚
|
||||
// (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。
|
||||
unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason)))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
} {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name))
|
||||
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||
}
|
||||
|
||||
return o
|
||||
|
||||
@@ -11,5 +11,5 @@ var (
|
||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,8 @@ func jsonRead(root any, path string) any {
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
@@ -51,7 +55,63 @@ func jsonValueToString(ob any) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return anyToString(t)
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Request struct {
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
@@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZhToSimplified 常用繁体字要真的转成简体。
|
||||
// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换,
|
||||
// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。
|
||||
func TestZhToSimplified(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"島忍者神龜": "岛忍者神龟",
|
||||
"航海王": "航海王",
|
||||
"我們的書": "我们的书",
|
||||
"學習園地": "学习园地",
|
||||
"": "",
|
||||
"abc123": "abc123", // 非中文原样返回
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToSimplified(in); got != want {
|
||||
t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhToTraditional 反向转换。
|
||||
func TestZhToTraditional(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"岛忍者神龟": "島忍者神龜",
|
||||
"我们的书": "我們的書",
|
||||
"学习园地": "學習園地",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToTraditional(in); got != want {
|
||||
t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。
|
||||
func TestZhConvertLeavesPlainTextAlone(t *testing.T) {
|
||||
const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴☠️"
|
||||
if got := zhToSimplified(s); got != s {
|
||||
t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。
|
||||
func TestZhDictParsed(t *testing.T) {
|
||||
zhInit()
|
||||
if len(zhT2S) < 3000 {
|
||||
t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S))
|
||||
}
|
||||
if len(zhS2T) < 3000 {
|
||||
t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T))
|
||||
}
|
||||
// 空格/竖线不属于任何键
|
||||
if _, ok := zhT2S[' ']; ok {
|
||||
t.Fatal("词典里混进了分隔符")
|
||||
}
|
||||
if strings.ContainsRune(zhT2SData, '\uFFFD') {
|
||||
t.Fatal("词典数据含替换符,生成过程有损")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。
|
||||
//
|
||||
// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把
|
||||
// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。
|
||||
// 解析不出来时原样返回,不做猜测。
|
||||
var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`)
|
||||
|
||||
var (
|
||||
zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
||||
zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000}
|
||||
zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000}
|
||||
)
|
||||
|
||||
// numChapter 把标题里第一处数字规整成阿拉伯数字。
|
||||
func numChapter(s string) string {
|
||||
loc := chapterTokenRe.FindStringIndex(s)
|
||||
if loc == nil {
|
||||
return s
|
||||
}
|
||||
n, ok := parseCNSNum(s[loc[0]:loc[1]])
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:]
|
||||
}
|
||||
|
||||
// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。
|
||||
func parseCNSNum(tok string) (int, bool) {
|
||||
if tok == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(tok); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来
|
||||
if digitsOnly(tok) {
|
||||
n := 0
|
||||
for _, r := range tok {
|
||||
n = n*10 + zhDigits[r]
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
total, section, num := 0, 0, 0
|
||||
seen := false
|
||||
for _, r := range tok {
|
||||
if d, ok := zhDigits[r]; ok {
|
||||
num = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if u, ok := zhUnits[r]; ok {
|
||||
if num == 0 {
|
||||
num = 1 // 「十二」这类省略了前导一的写法
|
||||
}
|
||||
section += num * u
|
||||
num = 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if big, ok := zhBigUnits[r]; ok {
|
||||
section += num
|
||||
if section == 0 {
|
||||
section = 1
|
||||
}
|
||||
total += section * big
|
||||
section, num = 0, 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !seen {
|
||||
return 0, false
|
||||
}
|
||||
return total + section + num, true
|
||||
}
|
||||
|
||||
// digitsOnly 判断字符串是否只由中文数字字符组成。
|
||||
func digitsOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := zhDigits[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。
|
||||
// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。
|
||||
func TestNumChapter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第一百零八章": "第108章",
|
||||
"第十二话": "第12话",
|
||||
"第3话": "第3话",
|
||||
"第一话 冒险的序幕": "第1话 冒险的序幕",
|
||||
"一〇二": "102",
|
||||
"两千零一": "2001",
|
||||
"航海王": "航海王", // 没有数字:原样返回
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := numChapter(in); got != want {
|
||||
t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。
|
||||
func TestJavaExtraHelpers(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
|
||||
v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.randomUUID() 失败: %v", err)
|
||||
}
|
||||
id := anyToString(v)
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("randomUUID 返回值不像 UUID: %q", id)
|
||||
}
|
||||
|
||||
v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.toNumChapter() 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第108章" {
|
||||
t.Fatalf("toNumChapter = %q,期望 第108章", got)
|
||||
}
|
||||
|
||||
// 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义)
|
||||
if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil {
|
||||
t.Fatal("java.deviceID() 应当抛异常")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool {
|
||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// SearchCheckKeyWord 书源在搜索规则里声明的校验关键字(legado SearchRule.checkKeyWord)。
|
||||
//
|
||||
// 源的作者用它证明「这个地址返回的确实是正常搜索结果」:校验时搜这个关键字,
|
||||
// 响应里应该出现它;被风控/换域名后响应里就没有了。legado 的 getCheckKeyword
|
||||
// 对含 http / :: / ++ / -- 的值不认(那是地址或扩展标记,不是关键字),这里保持一致。
|
||||
func (b *BookSource) SearchCheckKeyWord() string {
|
||||
if b == nil || b.RuleSearch == nil {
|
||||
return ""
|
||||
}
|
||||
kw := strings.TrimSpace(SPtr(b.RuleSearch.CheckKeyWord))
|
||||
if kw == "" || strings.Contains(kw, "http") || strings.Contains(kw, "::") ||
|
||||
strings.Contains(kw, "++") || strings.Contains(kw, "--") {
|
||||
return ""
|
||||
}
|
||||
return kw
|
||||
}
|
||||
|
||||
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||
@@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string {
|
||||
|
||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
func stripJSWrapper(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "@js:") {
|
||||
return s[len("@js:"):]
|
||||
out, _ := stripJSWrapperOK(s)
|
||||
return out
|
||||
}
|
||||
|
||||
// stripJSWrapperOK 与 stripJSWrapper 相同,但额外告知是否命中 JS 包装。
|
||||
// 「可能是 JSON 也可能是 JS 规则」的字段(如书源 header)需要区分这两者。
|
||||
func stripJSWrapperOK(s string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return trimmed[len("@js:"):], true
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body, true
|
||||
default:
|
||||
return trimmed, false
|
||||
}
|
||||
if strings.HasPrefix(s, "<js>") {
|
||||
s = s[len("<js>"):]
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchRule 搜索规则。
|
||||
@@ -110,6 +138,9 @@ type TocRule struct {
|
||||
ChapterURL *string `json:"chapterUrl"`
|
||||
IsVolume *string `json:"isVolume"`
|
||||
UpdateTime *string `json:"updateTime"`
|
||||
// NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它
|
||||
// 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。
|
||||
NextTocURL *string `json:"nextTocUrl"`
|
||||
}
|
||||
|
||||
// ContentRule 正文规则。
|
||||
@@ -125,25 +156,33 @@ type ContentRule struct {
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
type ExploreRule struct {
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
}
|
||||
|
||||
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||
//
|
||||
// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。
|
||||
// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败,
|
||||
// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。
|
||||
func ParseBookSource(raw string) (*BookSource, error) {
|
||||
var bs BookSource
|
||||
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
||||
return nil, err
|
||||
var relaxed BookSource
|
||||
if err2 := json.Unmarshal([]byte(normalizeSourceJSON(raw)), &relaxed); err2 != nil {
|
||||
return nil, err // 报原始错误:字段位置更贴近用户看到的 JSON
|
||||
}
|
||||
bs = relaxed
|
||||
}
|
||||
bs.RawJSON = raw
|
||||
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||
@@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) {
|
||||
return &bs, nil
|
||||
}
|
||||
|
||||
// sourceNumericFields 是书源里可能被写成字符串的数字字段。
|
||||
// 例:{"lastUpdateTime":"1788449879889"}(阅读本体导出/第三方源站的常见写法)。
|
||||
var sourceNumericFields = []string{
|
||||
"customOrder", "weight", "bookSourceType", "lastUpdateTime", "respondTime",
|
||||
}
|
||||
|
||||
// sourceRuleObjectFields 在 legado 里是对象,但部分导出工具把空规则写成 []。
|
||||
// 例:{"ruleExplore":[]}(空发现规则)。
|
||||
var sourceRuleObjectFields = []string{
|
||||
"ruleExplore", "ruleSearch", "ruleBookInfo", "ruleToc", "ruleContent",
|
||||
}
|
||||
|
||||
// normalizeSourceJSON 消化书源 JSON 与 legado 实体之间的形状差异:
|
||||
// - 数字字段被写成字符串 → 转成数字;
|
||||
// - 规则对象被写成空数组 [] → 转成空对象 {}。
|
||||
//
|
||||
// 只动顶层已知字段,规则 JS 字符串原样保留。解析不出来就原样返回,交给调用方报错。
|
||||
func normalizeSourceJSON(raw string) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return raw
|
||||
}
|
||||
changed := false
|
||||
for _, key := range sourceNumericFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
continue // 本来就是数字(或其它类型):不掺和
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
// 空字符串当成「没有这个字段」,否则会报类型错误
|
||||
delete(fields, key)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(text, 10, 64); err != nil {
|
||||
continue // 不是整数(如 "1.0"):不猜,让标准解析去报错
|
||||
}
|
||||
fields[key] = json.RawMessage(text)
|
||||
changed = true
|
||||
}
|
||||
for _, key := range sourceRuleObjectFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
fields[key] = json.RawMessage("{}")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||
func (b *BookSource) SourceProps() map[string]any {
|
||||
if b.RawJSON == "" {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRawSourceNeedsBrowser webView/webjs 类书源要在列表里先标出来
|
||||
// (服务端没有无头浏览器,这类源注定跑不通,不该等用户点进去才报错)。
|
||||
func TestRawSourceNeedsBrowser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"webView 选项", `{"searchUrl":"https://a.com/s,{webView:true}"}`, true},
|
||||
{"webjs 规则", `{"ruleContent":{"content":"@webjs:getDom()"}}`, true},
|
||||
{"规则里提到 webJs", `{"ruleToc":{"chapterList":"<js>if(supportWebJs){}</js>"}}`, true},
|
||||
{"普通源", `{"searchUrl":"https://a.com/s?q={{key}}","ruleSearch":{"bookList":"$.list[*]"}}`, false},
|
||||
{"空", ``, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rawSourceNeedsBrowser(c.raw); got != c.want {
|
||||
t.Errorf("%s: rawSourceNeedsBrowser = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// TestApplyHeadersEvaluatesJSHeader 覆盖拷贝漫画(优++)这类书源:
|
||||
// header 是 @js: 规则,运行时才用 baseUrl 拼出 platform/version/referer。
|
||||
// 此前 applyHeaders 只做 json.Unmarshal,JS 形态被整体丢掉,请求少了这些
|
||||
// 必需头,上游同样回 200 但结果是空列表 —— 表现为「源能导入却搜不到内容」。
|
||||
func TestApplyHeadersEvaluatesJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nJSON.stringify({\"platform\":\"1\",\"version\":\"9.9.9\",\"referer\":baseUrl})"
|
||||
src := &model.ReaderBookSource{
|
||||
Name: "拷贝漫画(优++)",
|
||||
SourceURL: "https://www.mangacopy.com/",
|
||||
Header: header,
|
||||
}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, BookSourceName: src.Name, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://api.mangacopy.com/api/v3/search/comic", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("火影", 1))
|
||||
|
||||
if got := req.Headers["platform"]; got != "1" {
|
||||
t.Fatalf("platform = %q,期望 \"1\"(JS 形态的 header 没有被执行)", got)
|
||||
}
|
||||
if got := req.Headers["version"]; got != "9.9.9" {
|
||||
t.Fatalf("version = %q,期望 \"9.9.9\"", got)
|
||||
}
|
||||
// baseUrl 必须绑成书源地址(对应 legado getHeaderMap 里的 getKey())
|
||||
if got := req.Headers["referer"]; got != "https://www.mangacopy.com/" {
|
||||
t.Fatalf("referer = %q,期望书源地址", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersKeepsPlainJSONHeader 直接写 JSON 的 header 不能回归。
|
||||
func TestApplyHeadersKeepsPlainJSONHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
header := `{"User-Agent":"MyBox/1.0","X-Token":"abc"}`
|
||||
src := &model.ReaderBookSource{Name: "纯 JSON 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if got := req.Headers["User-Agent"]; got != "MyBox/1.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
if got := req.Headers["X-Token"]; got != "abc" {
|
||||
t.Fatalf("X-Token = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersSurvivesBrokenJSHeader 坏掉的 header JS 不能 panic,
|
||||
// 也不该把半成品请求头发出去。
|
||||
func TestApplyHeadersSurvivesBrokenJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nthrow new Error('boom')"
|
||||
src := &model.ReaderBookSource{Name: "坏 header 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if len(req.Headers) != 0 {
|
||||
t.Fatalf("求值失败的 header 不应写入任何头,实际: %v", req.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// messySourceJSON 复刻真实「拷贝漫画(优++)」源文件的形状:
|
||||
// lastUpdateTime 是字符串、respondTime 是数字、ruleExplore 是空数组、
|
||||
// ruleBookInfo/ruleContent 里同时有数字与 JS 字符串。
|
||||
// 这类文件在阅读 App 里能导入,在这里曾经整体解析失败。
|
||||
const messySourceJSON = `[{
|
||||
"bookSourceComment": "//By情无羁25.04.09 使用需要魔法",
|
||||
"bookSourceGroup": "",
|
||||
"bookSourceName": "拷贝漫画(优++)",
|
||||
"bookSourceType": 0,
|
||||
"bookSourceUrl": "https://www.mangacopy.com/",
|
||||
"customOrder": 208,
|
||||
"enabled": true,
|
||||
"enabledCookieJar": false,
|
||||
"enabledExplore": true,
|
||||
"exploreUrl": "@js:\nsort = [];\nJSON.stringify(sort)",
|
||||
"header": "@js:\nJSON.stringify({\"platform\":\"1\",\"referer\":baseUrl})",
|
||||
"lastUpdateTime": "1788449879889",
|
||||
"respondTime": 181130,
|
||||
"ruleBookInfo": {"init": "$..comic", "intro": "$..brief"},
|
||||
"ruleContent": {"content": "$..url\n<js>result</js>", "imageStyle": "FULL"},
|
||||
"ruleExplore": [],
|
||||
"ruleSearch": {"bookList": "$..list[*]", "name": "$.name"},
|
||||
"ruleToc": {"chapterList": "$..list[*]", "chapterName": "name"},
|
||||
"searchUrl": "https://api.mangacopy.com/api/v3/search/comic?q={{key}}",
|
||||
"weight": 0
|
||||
}]`
|
||||
|
||||
// TestImportSourcesAcceptsMessySource 覆盖用户的真实场景:数字写法的字符串字段
|
||||
// (lastUpdateTime)与空数组写法的规则对象(ruleExplore)不该让整条书源导入失败。
|
||||
func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
imported, err := svc.ImportSources(ctx, messySourceJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("导入数量 = %d,期望 1", imported)
|
||||
}
|
||||
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
|
||||
if err != nil || bs == nil {
|
||||
t.Fatalf("按 URL 查不到导入的书源: %v", err)
|
||||
}
|
||||
if bs.Name != "拷贝漫画(优++)" {
|
||||
t.Fatalf("书源名 = %q", bs.Name)
|
||||
}
|
||||
// 关键字段要真的解出来,而不是「解成功了但字段全空」
|
||||
parsed, err := ParseBookSource(bs.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("重新解析失败: %v", err)
|
||||
}
|
||||
if parsed.LastUpdateTime == nil || *parsed.LastUpdateTime != 1788449879889 {
|
||||
t.Fatalf("lastUpdateTime = %v,期望 1788449879889", parsed.LastUpdateTime)
|
||||
}
|
||||
if parsed.RuleExplore == nil {
|
||||
t.Fatal("ruleExplore 应当被当成空规则对象,而不是解析失败")
|
||||
}
|
||||
if SPtr(parsed.RuleSearch.BookList) != "$..list[*]" {
|
||||
t.Fatalf("ruleSearch.bookList = %q", SPtr(parsed.RuleSearch.BookList))
|
||||
}
|
||||
if SPtr(parsed.RuleContent.ImageStyle) != "FULL" {
|
||||
t.Fatalf("ruleContent.imageStyle = %q", SPtr(parsed.RuleContent.ImageStyle))
|
||||
}
|
||||
// 落库的 raw 必须是原文(源 JS 可能依赖原样字段),不能被改写。
|
||||
// 这里特意按原文里的「冒号后有空格」写法比对:一旦被重新序列化就对不上了。
|
||||
if !strings.Contains(bs.RawJSON, `"lastUpdateTime": "1788449879889"`) {
|
||||
t.Fatalf("RawJSON 被改写了,应当保留原始文本;实际: %s", bs.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSourceJSON 单独锁定宽容规则本身。
|
||||
func TestNormalizeSourceJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 期望解析后可用的字段值 / 是否报错
|
||||
}{
|
||||
{"数字字符串", `{"weight":"12"}`, "12"},
|
||||
{"已经是数字", `{"weight":12}`, "12"},
|
||||
{"空字符串当缺省", `{"weight":""}`, "<nil>"},
|
||||
{"非整数不猜", `{"weight":"1.5"}`, "<err>"},
|
||||
{"空数组规则", `{"ruleExplore":[]}`, "obj"},
|
||||
{"正常规则不受影响", `{"ruleExplore":{"bookList":"a"}}`, "obj"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeSourceJSON(c.in)
|
||||
switch c.want {
|
||||
case "obj":
|
||||
if !strings.Contains(got, "{}") && !strings.Contains(got, `"bookList":"a"`) {
|
||||
t.Errorf("%s: normalizeSourceJSON(%s) = %s", c.name, c.in, got)
|
||||
}
|
||||
case "<nil>":
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight != nil {
|
||||
t.Errorf("%s: weight 应当为 nil,实际 %v", c.name, *bs.Weight)
|
||||
}
|
||||
case "<err>":
|
||||
if _, err := ParseBookSource(got); err == nil {
|
||||
t.Errorf("%s: 期望解析报错(不做无根据的猜测)", c.name)
|
||||
}
|
||||
default:
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight == nil || *bs.Weight != 12 {
|
||||
t.Errorf("%s: weight = %v,期望 12", c.name, bs.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBookSourceRejectsGarbage 真的坏掉的 JSON 仍要报错,不能因为宽容而静默吞掉。
|
||||
func TestParseBookSourceRejectsGarbage(t *testing.T) {
|
||||
if _, err := ParseBookSource(`{"bookSourceUrl":123}`); err == nil {
|
||||
t.Fatal("bookSourceUrl 是数字时应当报错")
|
||||
}
|
||||
if _, err := ParseBookSource(`{not json`); err == nil {
|
||||
t.Fatal("非法 JSON 应当报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSourcesReportsReasonWhenNothingImported 全部失败时必须把原因带回前端,
|
||||
// 否则界面只会弹「成功导入 0 个书源」,用户无从下手。
|
||||
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("一条都没导入时应当返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "解析失败") {
|
||||
t.Fatalf("错误信息应说明是解析失败,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestGetTocFollowsNextTocUrl 长书目录按 offset/limit 分页时必须翻页取全。
|
||||
// 这是「目录少章」的根因:nextTocUrl 没实现时只会拿到第一页
|
||||
// (实测某漫画站点 total=399/limit=100,MeBox 只得到 100 章)。
|
||||
func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"count":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"2": `{"count":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源(拷贝漫画)一致的写法:用 baseUrl 拼下一页地址
|
||||
nextTocURL := `<js>baseUrl.indexOf('offset=0') >= 0 ? baseUrl.replace('offset=0','offset=2') : ''</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "分页目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第二页没有被抓取)", len(chapters))
|
||||
}
|
||||
for i, want := range []string{"第1话", "第2话", "第3话", "第4话"} {
|
||||
if chapters[i].Title != want {
|
||||
t.Errorf("章节[%d] = %q,期望 %q", i, chapters[i].Title, want)
|
||||
}
|
||||
if chapters[i].Index != i {
|
||||
t.Errorf("章节[%d] 的 Index = %d,应按翻页顺序重排", i, chapters[i].Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlListsAllPagesFromFirstPage 书源只在第一页能算出完整后续页列表
|
||||
// (拷贝漫画的写法:规则锚定 offset=0,第二页上算出来还是同一批地址)。
|
||||
// 这种源必须把所有后续页一次性入队,否则只翻一页就停。
|
||||
func TestGetTocNextTocUrlListsAllPagesFromFirstPage(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"total":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"100": `{"total":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
hits = append(hits, offset)
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"total":4,"list":[]}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源一致:一次列出全部后续页,且锚点写死 offset=0(在第二页上等于原地踏步)
|
||||
nextTocURL := `<js>
|
||||
list=[];
|
||||
for(i=0;i<=1;i++){list.push(baseUrl.replace('offset=0','offset='+100*i))}
|
||||
list
|
||||
</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "全量下一页测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第一页列出的后续页没有被全部抓取)", len(chapters))
|
||||
}
|
||||
// 每页只抓一次(队列去重)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("上游请求次数 = %d(%v),期望 2", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlSelfReferenceStops nextTocUrl 指回当前页时必须停下,
|
||||
// 不能靠「规则永远给下一页」把自己转死。
|
||||
func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"list":[{"name":"第1话","url":"/c/1"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 永远返回同一页地址(用 baseUrl 原样返回)
|
||||
nextTocURL := `<js>baseUrl</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "自引用目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 1 {
|
||||
t.Fatalf("章节数 = %d,期望 1", len(chapters))
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("上游被请求 %d 次,自引用时应只请求 1 次", hits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user