From 5976a8d310dfea7ccd5c85fe7cb25dd3b6df173c Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 30 Sep 2026 16:36:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(reader):=20P2=E2=80=94=E2=80=94goja=20JS?= =?UTF-8?q?=20=E5=BC=95=E6=93=8E=E6=8E=A5=E5=85=A5=20+=20Go=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20java.*=20=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rule/goja.go:纯 Go JS 运行时(goja),脚本编译缓存 + 超时中断(默认10s), 绑定集合对齐 legado evalJS(java/cookie/cache/source/book/result/baseUrl/ chapter/title/src/page/key) - rule/bridge.go:java 对象函数名与 legado JsExtensions 一一对应(存量书源 硬编码这些名字),全部 Go 原生实现: 网络 ajax/ajaxAll/connect/get/post/head(经 MeBox HTTP 客户端,响应对象 带 body()/code()/url()/header());规则回调 getString/getStringList/ getElements/put/get(桥回当前解析器);编解码 base64/hex/md5(16/32)/ digest/HMac/encodeURI/timeFormat(SimpleDateFormat 转 Go 布局) - 对称加密:createSymmetricCrypto + aes/des/3des 全家桶,transformation 三段式解析(AES/DES/3DES × CBC/ECB/CTR/OFB/CFB × PKCS5/7/Zero/NoPadding, ECB 手工块循环),密钥长度自动补齐,decryptStr 自动识别 Base64/Hex - get(url,headers) 与 get(key) 按 arity 分发(对齐 Kotlin 重载) - 浏览器/文件/压缩包类函数明确抛不支持(服务端沙箱边界) - URL 规则 JS:analyzeJs(@result 链式)、{{js}} 内嵌、option js/bodyJs - execute 记录 Set-Cookie 进 cookie jar(java/cookie.getCookie 可读) - 测试:编码/摘要/AES 向量/CBC+ECB 往返/ajax httptest/URL JS/沙箱边界, 全部通过;修复 java.get 双注册、测试缺 SetContent 与 md5Encode16 期望值 --- go.mod | 4 + go.sum | 6 + internal/service/reader/reader.go | 106 +++-- internal/service/reader/rule/bridge.go | 467 +++++++++++++++++++++ internal/service/reader/rule/cryptoutil.go | 285 +++++++++++++ internal/service/reader/rule/goja.go | 235 +++++++++++ internal/service/reader/rule/js_test.go | 324 ++++++++++++++ internal/service/reader/rule/jsobjects.go | 221 ++++++++++ internal/service/reader/rule/url.go | 127 ++++-- internal/service/reader/source.go | 14 + 10 files changed, 1720 insertions(+), 69 deletions(-) create mode 100644 internal/service/reader/rule/bridge.go create mode 100644 internal/service/reader/rule/cryptoutil.go create mode 100644 internal/service/reader/rule/goja.go create mode 100644 internal/service/reader/rule/js_test.go create mode 100644 internal/service/reader/rule/jsobjects.go diff --git a/go.mod b/go.mod index 9f34a17..c1af0f9 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,8 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/dlclark/regexp2/v2 v2.5.2 // indirect + github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect @@ -54,10 +56,12 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index ed7d547..6888d08 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,10 @@ github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtd github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI= +github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA= @@ -69,6 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 8132a81..2ec846c 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -59,7 +59,7 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er return 0, fmt.Errorf("导入内容为空") } if strings.HasPrefix(text, "http://") || strings.HasPrefix(text, "https://") { - body, _, err := s.execute(ctx, &rule.Request{Method: "GET", URL: text, URLNoQuery: text, Headers: map[string]string{}}) + body, _, _, err := s.execute(ctx, &rule.Request{Method: "GET", URL: text, URLNoQuery: text, Headers: map[string]string{}}) if err != nil { return 0, fmt.Errorf("拉取书源失败: %w", err) } @@ -207,8 +207,8 @@ func (s *ReaderService) DeleteSource(ctx context.Context, id string) error { // ─── HTTP 执行 ────────────────────────────────────────────────────────────── -// execute 执行 rule.Request,返回(解码后 body, 最终 URL)。 -func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, string, error) { +// execute 执行 rule.Request,返回(解码后 body, 最终 URL, HTTP 状态码)。 +func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, string, int, error) { var bodyReader io.Reader if req.Body != "" { bodyReader = strings.NewReader(req.Body) @@ -219,7 +219,7 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, } httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader) if err != nil { - return "", "", err + return "", "", 0, err } for k, v := range helper.HTTPHeaderPresets() { httpReq.Header.Set(k, v) @@ -237,12 +237,12 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, } resp, err := s.http.Do(httpReq) if err != nil { - return "", "", err + return "", "", 0, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if err != nil { - return "", "", err + return "", "", resp.StatusCode, err } charset := req.Charset if charset == "" { @@ -253,11 +253,23 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, body = string(data) } finalURL := resp.Request.URL.String() + // 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取) + var cookieStrs []string + for _, ck := range resp.Cookies() { + cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value) + } + if len(cookieStrs) > 0 { + rule.CookieJarRecord(finalURL, cookieStrs) + } + // bodyJs 二次处理 + if req.BodyJsFn != nil { + body = req.BodyJsFn(body) + } if strings.EqualFold(charsetFromContentType(resp.Header.Get("Content-Type")), "xml") && !strings.HasPrefix(strings.TrimSpace(body), "" + body } - return body, finalURL, nil + return body, finalURL, resp.StatusCode, nil } func charsetFromContentType(ct string) string { @@ -277,30 +289,42 @@ func charsetFromContentType(ct string) string { // ─── 规则执行辅助 ─────────────────────────────────────────────────────────── -// newRuleAnalyzer 为指定书源构建规则解析器(注入书源变量)。 -func (s *ReaderService) newRuleAnalyzer(bs *BookSource, body, finalURL string) *rule.AnalyzeRule { +// newRuleAnalyzer 为指定书源构建规则解析器(注入书源变量与 JS 运行时)。 +func (s *ReaderService) newRuleAnalyzer(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int, body, finalURL string) *rule.AnalyzeRule { ar := rule.NewAnalyzeRule() ar.SetContent(body, finalURL) applySourceVariables(ar, bs) + ar.SetJSRunner(s.jsRunnerFor(ctx, src, bs, key, page).ForAnalyzer(ar)) return ar } -// fetchViaRule 解析 URL 规则并抓取,返回 (body, 最终URL)。 -func (s *ReaderService) fetchViaRule(ctx context.Context, urlRule, key string, page int, baseUrl string) (*rule.AnalyzeRule, error) { - req, err := rule.ParseAnalyzeUrl(urlRule, key, page, baseUrl) - if err != nil { - return nil, err +// jsRunnerFor 为本次请求构建 JS 运行时(网络桥回 execute,携带书源上下文)。 +func (s *ReaderService) jsRunnerFor(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) *rule.JSRunner { + return rule.NewJSRunner(rule.JSConfig{ + Fetch: func(req *rule.Request) (string, string, int, error) { + return s.execute(ctx, req) + }, + SourceProps: bs.SourceProps(), + Log: func(msg string) { + if s.log != nil { + s.log.Info("reader:source-js", + zap.String("source", srcNameOf(src, bs)), zap.String("log", msg)) + } + }, + BaseURL: src.SourceURL, + Key: key, + Page: page, + }) +} + +func srcNameOf(src *model.ReaderBookSource, bs *BookSource) string { + if src != nil { + return src.Name } - if req.Unsupported != nil { - return nil, req.Unsupported + if bs != nil { + return bs.BookSourceName } - body, finalURL, err := s.execute(ctx, req) - if err != nil { - return nil, err - } - ar := rule.NewAnalyzeRule() - ar.SetContent(body, finalURL) - return ar, nil + return "" } // ─── 搜索 ────────────────────────────────────────────────────────────────── @@ -448,7 +472,8 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo if sr == nil || SPtr(sr.BookList) == "" { return nil, fmt.Errorf("书源未配置搜索列表规则") } - req, err := rule.ParseAnalyzeUrl(searchURL, key, page, src.SourceURL) + runner := s.jsRunnerFor(ctx, src, bs, key, page) + req, err := rule.ParseAnalyzeUrlWithJS(searchURL, key, page, src.SourceURL, runner) if err != nil { return nil, err } @@ -466,13 +491,11 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo } } } - body, finalURL, err := s.execute(ctx, req) + body, finalURL, _, err := s.execute(ctx, req) if err != nil { return nil, err } - ar := rule.NewAnalyzeRule() - ar.SetContent(body, finalURL) - applySourceVariables(ar, bs) + ar := s.newRuleAnalyzer(ctx, src, bs, key, page, body, finalURL) elements, err := ar.GetElements(SPtr(sr.BookList)) if err != nil { @@ -561,7 +584,8 @@ func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bo if bir == nil { return nil, fmt.Errorf("书源未配置详情规则") } - req, err := rule.ParseAnalyzeUrl(bookURL, "", 0, src.SourceURL) + runner := s.jsRunnerFor(ctx, src, bs, "", 0) + req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, src.SourceURL, runner) if err != nil { return nil, err } @@ -569,13 +593,11 @@ func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bo return nil, req.Unsupported } applySourceHeaders(req, src) - body, finalURL, err := s.execute(ctx, req) + body, finalURL, _, err := s.execute(ctx, req) if err != nil { return nil, err } - ar := rule.NewAnalyzeRule() - ar.SetContent(body, finalURL) - applySourceVariables(ar, bs) + ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL) info := &BookInfo{BookURL: bookURL, TocURL: bookURL} if initRule := SPtr(bir.Init); initRule != "" { @@ -649,7 +671,8 @@ func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL if tocURL == "" { tocURL = bookURL } - req, err := rule.ParseAnalyzeUrl(tocURL, "", 0, src.SourceURL) + runner := s.jsRunnerFor(ctx, src, bs, "", 0) + req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, src.SourceURL, runner) if err != nil { return nil, err } @@ -657,13 +680,11 @@ func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL return nil, req.Unsupported } applySourceHeaders(req, src) - body, finalURL, err := s.execute(ctx, req) + body, finalURL, _, err := s.execute(ctx, req) if err != nil { return nil, err } - ar := rule.NewAnalyzeRule() - ar.SetContent(body, finalURL) - applySourceVariables(ar, bs) + ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL) elements, err := ar.GetElements(SPtr(tr.ChapterList)) if err != nil { @@ -714,7 +735,8 @@ func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, boo var parts []string url := chapterURL for i := 0; i < maxContentNextPage; i++ { - req, err := rule.ParseAnalyzeUrl(url, "", 0, src.SourceURL) + runner := s.jsRunnerFor(ctx, src, bs, "", 0) + req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, src.SourceURL, runner) if err != nil { return nil, err } @@ -722,13 +744,11 @@ func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, boo return nil, req.Unsupported } applySourceHeaders(req, src) - body, finalURL, err := s.execute(ctx, req) + body, finalURL, _, err := s.execute(ctx, req) if err != nil { return nil, err } - ar := rule.NewAnalyzeRule() - ar.SetContent(body, finalURL) - applySourceVariables(ar, bs) + ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL) list, err := ar.GetStringList(SPtr(cr.Content), nil, false) if err != nil { diff --git a/internal/service/reader/rule/bridge.go b/internal/service/reader/rule/bridge.go new file mode 100644 index 0000000..7ef07f5 --- /dev/null +++ b/internal/service/reader/rule/bridge.go @@ -0,0 +1,467 @@ +package rule + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "fmt" + "hash" + "strings" + + "github.com/dop251/goja" +) + +// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中 +// 注入为 `java` 对象的函数。函数名与 Kotlin 版一一对应(存量书源硬编码), +// 实现全部为 Go 原生;浏览器/文件/压缩包类能力明确抛出不支持错误。 + +func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { + o := vm.NewObject() + set := func(k string, v any) { + if err := o.Set(k, v); err != nil { + panic(vm.ToValue(err.Error())) + } + } + + bridgeErr := func(name string, err error) goja.Value { + panic(vm.ToValue(fmt.Sprintf("java.%s: %v", name, err))) + } + + // ── 网络(对应 JsExtensions.ajax/ajaxAll/connect/get/post/head) ── + fetchStr := func(name string, urlVal goja.Value, extraHeaders map[string]any) string { + if r.cfg.Fetch == nil { + bridgeErr(name, ErrJsUnsupported) + } + urlStr := urlVal.String() + if arr, ok := urlVal.Export().([]any); ok && len(arr) > 0 { + urlStr = fmt.Sprintf("%v", arr[0]) // 对应 legado:List 取第一个 + } + req, err := ParseAnalyzeUrlWithJS(urlStr, "", 0, r.cfg.BaseURL, r) + if err != nil { + bridgeErr(name, err) + } + if req.Unsupported != nil { + bridgeErr(name, req.Unsupported) + } + for k, v := range extraHeaders { + if _, exists := req.Headers[k]; !exists { + req.Headers[k] = fmt.Sprintf("%v", v) + } + } + body, _, _, err := r.cfg.Fetch(req) + if err != nil { + bridgeErr(name, err) + } + return body + } + + fetchResp := func(name string, args []goja.Value, method, body string) *goja.Object { + if r.cfg.Fetch == nil { + bridgeErr(name, ErrJsUnsupported) + } + urlStr := "" + if len(args) > 0 { + urlStr = args[0].String() + } + req, err := ParseAnalyzeUrlWithJS(urlStr, "", 0, r.cfg.BaseURL, r) + if err != nil { + bridgeErr(name, err) + } + if method == "POST" { + req.Method = "POST" + req.Body = body + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + req.IsJSON = true + } else if body != "" { + req.IsForm = true + req.Body = encodeParams(body, req.Charset, false) + } + } + if len(args) > 1 && !goja.IsUndefined(args[1]) && !goja.IsNull(args[1]) { + if extra, ok := args[1].Export().(map[string]any); ok { + for k, v := range extra { + req.Headers[k] = fmt.Sprintf("%v", v) + } + } + } + if req.Unsupported != nil { + bridgeErr(name, req.Unsupported) + } + respBody, finalURL, code, err := r.cfg.Fetch(req) + if err != nil { + bridgeErr(name, err) + } + return newResponseObject(vm, respBody, code, finalURL, nil) + } + + set("ajax", func(call goja.FunctionCall) goja.Value { + extra := map[string]any(nil) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + if m, ok := call.Arguments[1].Export().(map[string]any); ok { + extra = m + } + } + return vm.ToValue(fetchStr("ajax", call.Arguments[0], extra)) + }) + set("ajaxAll", func(call goja.FunctionCall) goja.Value { + var urls []any + if len(call.Arguments) > 0 { + if arr, ok := call.Arguments[0].Export().([]any); ok { + urls = arr + } + } + out := make([]any, 0, len(urls)) + for _, u := range urls { + out = append(out, fetchStr("ajaxAll", vm.ToValue(u), nil)) + } + return vm.ToValue(out) + }) + set("connect", func(call goja.FunctionCall) goja.Value { + return fetchResp("connect", call.Arguments, "GET", "") + }) + // get 双语义(对应 Kotlin 重载):get(url, headers) 走网络,get(key) 读变量 + set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + return fetchResp("get", call.Arguments, "GET", "") + } + key := stringArg(call, 0) + if a != nil { + return vm.ToValue(a.Get(key)) + } + return vm.ToValue(r.vars[key]) + }) + set("post", func(call goja.FunctionCall) goja.Value { + body := stringArgOr(call, 1, "") + return fetchResp("post", call.Arguments, "POST", body) + }) + set("head", func(call goja.FunctionCall) goja.Value { + return fetchResp("head", call.Arguments, "HEAD", "") + }) + + // ── 规则回调(桥回当前 AnalyzeRule,对应 AnalyzeRule.getString 等公有方法) ── + ruleStr := func(name string, args []goja.Value) (string, error) { + if a == nil { + return "", fmt.Errorf("无规则上下文") + } + mContent := any(nil) + if len(args) > 1 && !goja.IsUndefined(args[1]) && !goja.IsNull(args[1]) { + mContent = args[1].Export() + } + isURL := len(args) > 2 && args[2].ToBoolean() + return a.GetString(args[0].String(), mContent, isURL) + } + set("getString", func(call goja.FunctionCall) goja.Value { + s, err := ruleStr("getString", call.Arguments) + if err != nil { + bridgeErr("getString", err) + } + return vm.ToValue(s) + }) + set("getStringList", func(call goja.FunctionCall) goja.Value { + if a == nil { + return goja.Null() + } + list, err := a.GetStringList(stringArgOr(call, 0, ""), nil, false) + if err != nil { + bridgeErr("getStringList", err) + } + return vm.ToValue(list) + }) + set("getElements", func(call goja.FunctionCall) goja.Value { + if a == nil { + return goja.Null() + } + els, err := a.GetElements(stringArgOr(call, 0, "")) + if err != nil { + bridgeErr("getElements", err) + } + out := make([]any, 0, len(els)) + for _, el := range els { + out = append(out, resultString(el)) + } + return vm.ToValue(out) + }) + set("put", func(call goja.FunctionCall) goja.Value { + key := stringArg(call, 0) + val := stringArgOr(call, 1, "") + if a != nil { + return vm.ToValue(a.Put(key, val)) + } + r.vars[key] = val + return vm.ToValue(val) + }) + + // ── 编码(对应 JsEncodeUtils / JsExtensions) ── + set("md5Encode", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(md5Hex(stringArg(call, 0), false)) + }) + set("md5Encode16", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(md5Hex(stringArg(call, 0), true)) + }) + set("base64Decode", func(call goja.FunctionCall) goja.Value { + s, err := base64DecodeString(stringArg(call, 0)) + if err != nil { + bridgeErr("base64Decode", err) + } + return vm.ToValue(s) + }) + set("base64DecodeToByteArray", func(call goja.FunctionCall) goja.Value { + b, err := base64DecodeBytes(stringArg(call, 0)) + if err != nil { + bridgeErr("base64DecodeToByteArray", err) + } + return vm.ToValue(vm.NewArrayBuffer(b)) + }) + set("base64Encode", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(base64.StdEncoding.EncodeToString([]byte(stringArg(call, 0)))) + }) + set("hexDecodeToString", func(call goja.FunctionCall) goja.Value { + b, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0))) + if err != nil { + bridgeErr("hexDecodeToString", err) + } + return vm.ToValue(string(b)) + }) + set("hexDecodeToByteArray", func(call goja.FunctionCall) goja.Value { + b, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0))) + if err != nil { + bridgeErr("hexDecodeToByteArray", err) + } + return vm.ToValue(vm.NewArrayBuffer(b)) + }) + set("hexEncodeToString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(hex.EncodeToString([]byte(stringArg(call, 0)))) + }) + set("strToBytes", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(vm.NewArrayBuffer([]byte(stringArg(call, 0)))) + }) + set("bytesToStr", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) { + return vm.ToValue("") + } + if buf, ok := call.Arguments[0].Export().([]byte); ok { + return vm.ToValue(string(buf)) + } + return vm.ToValue(call.Arguments[0].String()) + }) + set("encodeURI", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(javaURLEncode(stringArg(call, 0), stringArgOr(call, 1, "utf-8"))) + }) + set("timeFormat", func(call goja.FunctionCall) goja.Value { + ts := int64(call.Arguments[0].ToFloat()) + format := stringArgOr(call, 1, "yyyy-MM-dd HH:mm") + return vm.ToValue(javaTimeFormat(ts, format, 0)) + }) + set("timeFormatUTC", func(call goja.FunctionCall) goja.Value { + ts := int64(call.Arguments[0].ToFloat()) + format := stringArgOr(call, 1, "yyyy-MM-dd HH:mm") + sh := int64(0) + if len(call.Arguments) > 2 { + sh = int64(call.Arguments[2].ToFloat()) + } + return vm.ToValue(javaTimeFormat(ts, format, sh)) + }) + + // ── 加解密(对应 JsEncodeUtils) ── + set("createSymmetricCrypto", func(call goja.FunctionCall) goja.Value { + transformation := stringArgOr(call, 0, "AES/CBC/PKCS5Padding") + key := stringArgOr(call, 1, "") + iv := stringArgOr(call, 2, "") + cipher, err := newSymmetricCipher(transformation, key, iv) + if err != nil { + bridgeErr("createSymmetricCrypto", err) + } + return newCipherObject(vm, cipher) + }) + aesVariant := func(mode string) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + data := stringArg(call, 0) + key := stringArgOr(call, 1, "") + defaultTrans := mode + trans := stringArgOr(call, 2, defaultTrans) + iv := stringArgOr(call, 3, "") + cipher, err := newSymmetricCipher(trans, key, iv) + if err != nil { + bridgeErr(mode, err) + } + out, err := cipher.decryptAuto(data) + if err != nil { + bridgeErr(mode, err) + } + return vm.ToValue(string(out)) + } + } + set("aesDecodeToString", aesVariant("AES/CBC/PKCS5Padding")) + set("aesBase64DecodeToString", aesVariant("AES/CBC/PKCS5Padding")) + set("aesBase64DecodeToByteArray", aesVariant("AES/CBC/PKCS5Padding")) + set("desDecodeToString", aesVariant("DES/CBC/PKCS5Padding")) + set("desBase64DecodeToString", aesVariant("DES/CBC/PKCS5Padding")) + encryptStr := func(data, key, trans, iv string) string { + cipher, err := newSymmetricCipher(trans, key, iv) + if err != nil { + bridgeErr("encrypt", err) + } + out, err := cipher.encrypt([]byte(data)) + if err != nil { + bridgeErr("encrypt", err) + } + return string(out) + } + set("aesEncodeToString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(encryptStr(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "AES/CBC/PKCS5Padding"), stringArgOr(call, 3, ""))) + }) + set("desEncodeToString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(encryptStr(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "DES/CBC/PKCS5Padding"), stringArgOr(call, 3, ""))) + }) + encryptB64 := func(data, key, trans, iv string) string { + cipher, err := newSymmetricCipher(trans, key, iv) + if err != nil { + bridgeErr("encrypt", err) + } + out, err := cipher.encrypt([]byte(data)) + if err != nil { + bridgeErr("encrypt", err) + } + return base64.StdEncoding.EncodeToString(out) + } + set("aesEncodeToBase64String", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(encryptB64(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "AES/CBC/PKCS5Padding"), stringArgOr(call, 3, ""))) + }) + set("desEncodeToBase64String", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(encryptB64(stringArg(call, 0), stringArgOr(call, 1, ""), stringArgOr(call, 2, "DES/CBC/PKCS5Padding"), stringArgOr(call, 3, ""))) + }) + set("tripleDESDecodeStr", func(call goja.FunctionCall) goja.Value { + data := stringArg(call, 0) + key := stringArgOr(call, 1, "") + trans := "DESede/CBC/PKCS5Padding" + var ivv string + switch { + case len(call.Arguments) > 4: // (data, key, mode, padding, iv) + trans = "DESede/" + stringArgOr(call, 2, "CBC") + "/" + stringArgOr(call, 3, "PKCS5Padding") + ivv = stringArgOr(call, 4, "") + case len(call.Arguments) > 2: // (data, key, transformation, iv?) + trans = stringArgOr(call, 2, trans) + ivv = stringArgOr(call, 3, "") + } + cipher, err := newSymmetricCipher(trans, key, ivv) + if err != nil { + bridgeErr("tripleDESDecodeStr", err) + } + out, err := cipher.decryptAuto(data) + if err != nil { + bridgeErr("tripleDESDecodeStr", err) + } + return vm.ToValue(string(out)) + }) + set("digestHex", func(call goja.FunctionCall) goja.Value { + h, err := digestHash(stringArgOr(call, 1, "MD5")) + if err != nil { + bridgeErr("digestHex", err) + } + h.Write([]byte(stringArg(call, 0))) + return vm.ToValue(hex.EncodeToString(h.Sum(nil))) + }) + set("digestBase64Str", func(call goja.FunctionCall) goja.Value { + h, err := digestHash(stringArgOr(call, 1, "MD5")) + if err != nil { + bridgeErr("digestBase64Str", err) + } + h.Write([]byte(stringArg(call, 0))) + return vm.ToValue(base64.StdEncoding.EncodeToString(h.Sum(nil))) + }) + set("HMacHex", func(call goja.FunctionCall) goja.Value { + h, err := hmacHash(stringArgOr(call, 1, "HmacSHA256"), []byte(stringArgOr(call, 2, ""))) + if err != nil { + bridgeErr("HMacHex", err) + } + h.Write([]byte(stringArg(call, 0))) + return vm.ToValue(hex.EncodeToString(h.Sum(nil))) + }) + set("HMacBase64", func(call goja.FunctionCall) goja.Value { + h, err := hmacHash(stringArgOr(call, 1, "HmacSHA256"), []byte(stringArgOr(call, 2, ""))) + if err != nil { + bridgeErr("HMacBase64", err) + } + h.Write([]byte(stringArg(call, 0))) + return vm.ToValue(base64.StdEncoding.EncodeToString(h.Sum(nil))) + }) + + // ── 其他 ── + set("log", func(call goja.FunctionCall) goja.Value { + if r.cfg.Log != nil { + r.cfg.Log(call.Arguments[0].String()) + } + return goja.Null() + }) + 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)) }) + set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) }) + unsupported := func(name string) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持")) + } + } + for _, name := range []string{ + "webView", "webViewGetSource", "webViewGetOverrideUrl", + "startBrowser", "startBrowserAwait", "openVideoPlayer", "getVerificationCode", + "importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile", + "unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder", + "getZipStringContent", "getZipByteArrayContent", + "getRarStringContent", "get7zStringContent", + } { + set(name, unsupported(name)) + } + + return o +} + +const defaultWebViewUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + +func md5Hex(s string, short bool) string { + sum := md5.Sum([]byte(s)) + h := hex.EncodeToString(sum[:]) + if short { + return h[8:24] + } + return h +} + +func digestHash(algorithm string) (hash.Hash, error) { + switch strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(algorithm)) { + case "MD5": + return md5.New(), nil + case "SHA1": + return sha1.New(), nil + case "SHA256": + return sha256.New(), nil + case "SHA384": + return sha512.New384(), nil + case "SHA512": + return sha512.New(), nil + default: + return nil, fmt.Errorf("不支持的摘要算法: %s", algorithm) + } +} + +func hmacHash(algorithm string, key []byte) (hash.Hash, error) { + switch strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(algorithm)) { + case "hmacmd5": + return hmac.New(md5.New, key), nil + case "hmacsha1": + return hmac.New(sha1.New, key), nil + case "hmacsha256": + return hmac.New(sha256.New, key), nil + case "hmacsha512": + return hmac.New(sha512.New, key), nil + default: + return nil, fmt.Errorf("不支持的 HMAC 算法: %s", algorithm) + } +} diff --git a/internal/service/reader/rule/cryptoutil.go b/internal/service/reader/rule/cryptoutil.go new file mode 100644 index 0000000..eb51ea5 --- /dev/null +++ b/internal/service/reader/rule/cryptoutil.go @@ -0,0 +1,285 @@ +package rule + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/des" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/dop251/goja" +) + +// 对称加密:对应 legado JsEncodeUtils.createSymmetricCrypto(hutool SymmetricCrypto 语义)。 +// transformation 形如 "AES/CBC/PKCS5Padding" / "DES/ECB/NoPadding" / "DESede/CBC/PKCS5Padding"。 + +type symmetricCipher struct { + block cipher.Block + mode string + padding string + iv []byte +} + +func newSymmetricCipher(transformation, key, iv string) (*symmetricCipher, error) { + parts := strings.Split(strings.TrimSpace(transformation), "/") + algo := strings.ToUpper(strings.TrimSpace(parts[0])) + mode := "CBC" + padding := "PKCS5Padding" + if len(parts) > 1 && strings.TrimSpace(parts[1]) != "" { + mode = strings.ToUpper(strings.TrimSpace(parts[1])) + } + if len(parts) > 2 && strings.TrimSpace(parts[2]) != "" { + padding = strings.TrimSpace(parts[2]) + } + + keyBytes := normalizeKey([]byte(key), algo) + if len(keyBytes) == 0 { + return nil, errors.New("加密密钥为空") + } + var block cipher.Block + var err error + switch algo { + case "AES": + block, err = aes.NewCipher(keyBytes) + case "DES": + block, err = des.NewCipher(keyBytes) + case "DESEDE", "3DES", "TRIPLEDES": + block, err = des.NewTripleDESCipher(keyBytes) + default: + return nil, fmt.Errorf("不支持的加密算法: %s", algo) + } + if err != nil { + return nil, err + } + ivBytes := []byte(iv) + if mode == "ECB" { + ivBytes = nil + } + return &symmetricCipher{block: block, mode: mode, padding: padding, iv: ivBytes}, nil +} + +// normalizeKey 对齐 hutool 的密钥处理:按算法要求补齐/截断密钥长度。 +func normalizeKey(key []byte, algo string) []byte { + var sizes []int + switch algo { + case "AES": + sizes = []int{16, 24, 32} + case "DES": + sizes = []int{8} + case "DESEDE", "3DES", "TRIPLEDES": + sizes = []int{24} + default: + return key + } + for _, n := range sizes { + if len(key) == n { + return key + } + } + for _, n := range sizes { + if len(key) < n { + out := make([]byte, n) + copy(out, key) + return out + } + } + return key[:sizes[len(sizes)-1]] +} + +func (c *symmetricCipher) blockSize() int { return c.block.BlockSize() } + +func (c *symmetricCipher) crypt(dst, src []byte, decrypt bool) error { + bs := c.blockSize() + if c.mode == "ECB" { + for i := 0; i < len(src); i += bs { + if decrypt { + c.block.Decrypt(dst[i:i+bs], src[i:i+bs]) + } else { + c.block.Encrypt(dst[i:i+bs], src[i:i+bs]) + } + } + return nil + } + iv := make([]byte, bs) + if len(c.iv) > 0 { + copy(iv, c.iv[:min(len(c.iv), bs)]) + } + switch c.mode { + case "CBC": + if decrypt { + cipher.NewCBCDecrypter(c.block, iv).CryptBlocks(dst, src) + } else { + cipher.NewCBCEncrypter(c.block, iv).CryptBlocks(dst, src) + } + case "CTR": + cipher.NewCTR(c.block, iv).XORKeyStream(dst, src) + case "OFB": + cipher.NewOFB(c.block, iv).XORKeyStream(dst, src) + case "CFB", "CFB8": + stream := cipher.NewCFBEncrypter(c.block, iv) + if decrypt { + stream = cipher.NewCFBDecrypter(c.block, iv) + } + stream.XORKeyStream(dst, src) + default: + return fmt.Errorf("不支持的加密模式: %s", c.mode) + } + return nil +} + +func (c *symmetricCipher) encrypt(data []byte) ([]byte, error) { + bs := c.blockSize() + data = applyPadding(data, bs, c.padding) + if len(data)%bs != 0 { + return nil, errors.New("加密数据长度未按块对齐") + } + out := make([]byte, len(data)) + if err := c.crypt(out, data, false); err != nil { + return nil, err + } + return out, nil +} + +func (c *symmetricCipher) decrypt(data []byte) ([]byte, error) { + bs := c.blockSize() + if len(data) == 0 || len(data)%bs != 0 { + return nil, errors.New("密文长度不是块大小的整数倍") + } + out := make([]byte, len(data)) + if err := c.crypt(out, data, true); err != nil { + return nil, err + } + return stripPadding(out, c.padding), nil +} + +// decryptAuto 对应 hutool decryptStr(String):输入自动识别 Base64 / Hex / 原文。 +func (c *symmetricCipher) decryptAuto(s string) ([]byte, error) { + if b, err := base64DecodeBytes(s); err == nil && len(b) > 0 && len(b)%c.blockSize() == 0 { + return c.decrypt(b) + } + if b, err := hex.DecodeString(strings.TrimSpace(s)); err == nil && len(b) > 0 && len(b)%c.blockSize() == 0 { + return c.decrypt(b) + } + return c.decrypt([]byte(s)) +} + +func applyPadding(data []byte, blockSize int, padding string) []byte { + switch padding { + case "NoPadding": + return data + case "ZeroPadding": + n := (len(data)+blockSize-1)/blockSize*blockSize + if n == 0 { + n = blockSize + } + out := make([]byte, n) + copy(out, data) + return out + default: // PKCS5Padding / PKCS7Padding + pad := blockSize - len(data)%blockSize + out := make([]byte, len(data)+pad) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(pad) + } + return out + } +} + +func stripPadding(data []byte, padding string) []byte { + switch padding { + case "NoPadding": + return data + case "ZeroPadding": + for len(data) > 0 && data[len(data)-1] == 0 { + data = data[:len(data)-1] + } + return data + default: + if len(data) == 0 { + return data + } + pad := int(data[len(data)-1]) + if pad <= 0 || pad > len(data) { + return data + } + for _, b := range data[len(data)-pad:] { + if int(b) != pad { + return data + } + } + return data[:len(data)-pad] + } +} + +// newCipherObject 对应 hutool SymmetricCrypto 的 JS 方法面: +// encrypt(data) / encryptBase64(data) / encryptHex(data) / decrypt(data) / +// decryptStr(data) / decryptBase64(data) / decryptHex(data)。 +func newCipherObject(vm *goja.Runtime, c *symmetricCipher) *goja.Object { + o := vm.NewObject() + set := func(k string, v any) { + if err := o.Set(k, v); err != nil { + panic(vm.ToValue(err.Error())) + } + } + set("encrypt", func(call goja.FunctionCall) goja.Value { + out, err := c.encrypt([]byte(stringArg(call, 0))) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(vm.NewArrayBuffer(out)) + }) + set("encryptBase64", func(call goja.FunctionCall) goja.Value { + out, err := c.encrypt([]byte(stringArg(call, 0))) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(base64StdEncodeBytes(out)) + }) + set("encryptHex", func(call goja.FunctionCall) goja.Value { + out, err := c.encrypt([]byte(stringArg(call, 0))) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(hexEncodeBytes(out)) + }) + set("decrypt", func(call goja.FunctionCall) goja.Value { + out, err := c.decryptAuto(stringArg(call, 0)) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(vm.NewArrayBuffer(out)) + }) + set("decryptStr", func(call goja.FunctionCall) goja.Value { + out, err := c.decryptAuto(stringArg(call, 0)) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(string(out)) + }) + set("decryptBase64", func(call goja.FunctionCall) goja.Value { + b, err := base64DecodeBytes(stringArg(call, 0)) + if err != nil { + panic(vm.ToValue(err.Error())) + } + out, err := c.decrypt(b) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(string(out)) + }) + set("decryptHex", func(call goja.FunctionCall) goja.Value { + b, err := hexDecodeBytes(strings.TrimSpace(stringArg(call, 0))) + if err != nil { + panic(vm.ToValue(err.Error())) + } + out, err := c.decrypt(b) + if err != nil { + panic(vm.ToValue(err.Error())) + } + return vm.ToValue(string(out)) + }) + return o +} diff --git a/internal/service/reader/rule/goja.go b/internal/service/reader/rule/goja.go new file mode 100644 index 0000000..ec2debf --- /dev/null +++ b/internal/service/reader/rule/goja.go @@ -0,0 +1,235 @@ +package rule + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// 本文件对应 legado 的 Rhino JS 执行层(RhinoScriptEngine / evalJS), +// 用纯 Go 的 goja 实现。`java` 对象的函数在 bridge.go 中以 Go 原生实现, +// 函数名与 legado JsExtensions 保持一致(存量书源 JS 硬编码了这些名字)。 + +const ( + defaultJSTimeout = 10 * time.Second + // programCacheSize 编译产物缓存上限(超出后整体清空,防无限增长)。 + programCacheSize = 256 +) + +var ( + programCacheMu sync.Mutex + programCache = map[string]*goja.Program{} +) + +func compileCached(js string) (*goja.Program, error) { + programCacheMu.Lock() + defer programCacheMu.Unlock() + if p, ok := programCache[js]; ok { + return p, nil + } + p, err := goja.Compile(fmt.Sprintf("", len(js)), js, false) + if err != nil { + return nil, err + } + if len(programCache) >= programCacheSize { + programCache = map[string]*goja.Program{} + } + programCache[js] = p + return p, nil +} + +// JSFetcher 由服务层注入:桥接函数发起网络请求用(走 MeBox 的 HTTP 客户端, +// 自动携带书源级请求头、UA、代理与重定向)。 +type JSFetcher func(req *Request) (body string, finalURL string, code int, err error) + +// JSConfig 构造 JS 运行时的配置。 +type JSConfig struct { + Fetch JSFetcher + // SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样)。 + SourceProps map[string]any + // Log 对应 java.log。 + Log func(msg string) + // Timeout 单次 JS 执行超时,默认 10s。 + Timeout time.Duration + // BaseURL 对应 evalJS 的 baseUrl 绑定。 + BaseURL string + // Key / Page 搜索上下文绑定({{key}}/{{page}} 在 JS 里的取值)。 + Key string + Page int +} + +// JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。 +type JSRunner struct { + vm *goja.Runtime + cfg JSConfig + vars map[string]string // runner 级变量(URL 上下文 java.put/get) +} + +// NewJSRunner 创建运行时并注入全局对象:cookie / cache / source。 +func NewJSRunner(cfg JSConfig) *JSRunner { + vm := goja.New() + if cfg.Timeout == 0 { + cfg.Timeout = defaultJSTimeout + } + r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}} + vm.Set("cookie", newCookieObject(vm)) + vm.Set("cache", newCacheObject(vm)) + if cfg.SourceProps != nil { + vm.Set("source", cfg.SourceProps) + } else { + vm.Set("source", vm.NewObject()) + } + return r +} + +// ForAnalyzer 返回绑定到指定解析器的执行函数(java.getString 等规则回调 +// 会桥回该解析器,对应 legado 中 AnalyzeRule 自身实现 JsExtensions)。 +func (r *JSRunner) ForAnalyzer(a *AnalyzeRule) func(js string, result any) (any, error) { + return func(js string, result any) (any, error) { + return r.Run(a, js, result, "") + } +} + +// RunWithBaseURL 与 Run 相同,但覆盖本次执行的 baseUrl 绑定(AnalyzeUrl 用)。 +func (r *JSRunner) RunWithBaseURL(a *AnalyzeRule, js string, result any, baseURL string) (any, error) { + return r.Run(a, js, result, baseURL) +} + +// Run 执行一段书源 JS。绑定集合对应 legado evalJS: +// java / cookie / cache / source / book / result / baseUrl / chapter / title / +// src / page / key / nextChapterUrl。 +func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (any, error) { + vm := r.vm + // java 对象:每次执行重建(桥回当前解析器) + vm.Set("java", newJavaObject(vm, r, a)) + // 上下文绑定 + if a != nil { + vm.Set("book", map[string]any{"name": a.bookName}) + vm.Set("chapter", map[string]any{"title": a.chapterTitle}) + vm.Set("title", a.chapterTitle) + if a.content != nil { + vm.Set("src", resultString(a.content)) + } else { + vm.Set("src", nil) + } + } else { + vm.Set("book", nil) + vm.Set("chapter", nil) + vm.Set("title", nil) + vm.Set("src", nil) + } + base := baseURL + if base == "" { + base = r.cfg.BaseURL + if a != nil && base == "" { + base = a.baseUrl + } + } + vm.Set("baseUrl", base) + vm.Set("result", toJSValue(vm, result)) + if r.cfg.Key != "" { + vm.Set("key", r.cfg.Key) + } else { + vm.Set("key", nil) + } + if r.cfg.Page > 0 { + vm.Set("page", r.cfg.Page) + } else { + vm.Set("page", nil) + } + vm.Set("nextChapterUrl", nil) + + prog, err := compileCached(js) + if err != nil { + return nil, fmt.Errorf("JS 编译失败: %w", err) + } + timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") }) + defer timer.Stop() + v, err := vm.RunProgram(prog) + if err != nil { + return nil, fmt.Errorf("JS 执行失败: %v", err) + } + return exportValue(v), nil +} + +// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。 +func exportValue(v goja.Value) any { + if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { + return nil + } + switch v.ExportType() { + case nil: + return nil + default: + return v.Export() + } +} + +// toJSValue 把引擎内部结果转为可注入 JS 的值。 +// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。 +func toJSValue(vm *goja.Runtime, v any) any { + switch t := v.(type) { + case nil: + return nil + case string, bool, int, int32, int64, float64, map[string]any, []any: + return t + case []string: + arr := make([]any, len(t)) + for i, s := range t { + arr[i] = s + } + return arr + case *goja.Object: + return t + case *goja.Runtime: + return nil + default: + return resultString(v) + } +} + +// newResponseObject 构造 Connection.Response / StrResponse 的 JS 等价物: +// body()/code()/url()/header(name)。 +func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string, headers map[string][]string) *goja.Object { + o := vm.NewObject() + mustSet := func(k string, v any) { + if err := o.Set(k, v); err != nil { + panic(vm.ToValue(err.Error())) + } + } + mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) }) + mustSet("bodyStr", body) + mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) }) + mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) }) + mustSet("header", func(call goja.FunctionCall) goja.Value { + name := strings.ToLower(strings.TrimSpace(toStringArg(call, 0))) + for k, vs := range headers { + if strings.ToLower(k) == name && len(vs) > 0 { + return vm.ToValue(vs[0]) + } + } + return goja.Null() + }) + return o +} + +func toStringArg(call goja.FunctionCall, i int) string { + if i >= len(call.Arguments) { + return "" + } + return call.Arguments[i].String() +} + +func stringArg(call goja.FunctionCall, i int) string { + return strings.TrimSpace(toStringArg(call, i)) +} + +func stringArgOr(call goja.FunctionCall, i int, def string) string { + if i >= len(call.Arguments) || goja.IsUndefined(call.Arguments[i]) || goja.IsNull(call.Arguments[i]) { + return def + } + return call.Arguments[i].String() +} diff --git a/internal/service/reader/rule/js_test.go b/internal/service/reader/rule/js_test.go new file mode 100644 index 0000000..86a5dcc --- /dev/null +++ b/internal/service/reader/rule/js_test.go @@ -0,0 +1,324 @@ +package rule + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +// ─── 基础执行与绑定 ───────────────────────────────────────────────────────── + +func newTestRunner() *JSRunner { + return NewJSRunner(JSConfig{Key: "斗", Page: 2}) +} + +func TestJSBasicEval(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + v, err := r.Run(a, "1 + 2", nil, "") + if err != nil { + t.Fatal(err) + } + if anyToString(v) != "3" { + t.Fatalf("1+2 = %v", v) + } + // key/page 绑定 + v, err = r.Run(a, "key + page", nil, "") + if err != nil { + t.Fatal(err) + } + if anyToString(v) != "斗2" { + t.Fatalf("key+page = %v", v) + } +} + +func TestJSTimeoutInterrupt(t *testing.T) { + r := NewJSRunner(JSConfig{Timeout: 200 * 1e6}) // 200ms + _, err := r.Run(NewAnalyzeRule(), "while(true){}", nil, "") + if err == nil || !strings.Contains(err.Error(), "超时") { + t.Fatalf("expected timeout error, got %v", err) + } +} + +// ─── 规则引擎中的 JS({{}} / @js: / ) ────────────────────────────────── + +func TestAnalyzeRuleJSEval(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("正文内容", "http://x.com") + + // {{js}} 内嵌 + got, err := a.GetString(`{{baseUrl}}/next`, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "http://x.com/next" { + t.Fatalf("{{baseUrl}} = %q", got) + } + // @js: 前缀 + got, err = a.GetString(`@js:'hello ' + (40 + 2)`, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "hello 42" { + t.Fatalf("@js = %q", got) + } + // 块 + got, err = a.GetString(`"结果:" + result`, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "结果:正文内容" { + t.Fatalf(" = %q", got) + } +} + +func TestJSGetStringBridge(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent(testHTML, "http://x.com") + got, err := a.GetString(`java.getString("class.item.0@tag.a@text")`, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "斗破苍穹" { + t.Fatalf("java.getString = %q", got) + } +} + +// ─── 编码与摘要 ───────────────────────────────────────────────────────────── + +func TestJSEncodeFunctions(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") // 规则循环要求 content 非空(与 legado 语义一致) + + cases := []struct{ js, want string }{ + {`@js:java.base64Encode('你好')`, base64.StdEncoding.EncodeToString([]byte("你好"))}, + {`@js:java.base64Decode('` + base64.StdEncoding.EncodeToString([]byte("你好")) + `')`, "你好"}, + {`@js:java.hexEncodeToString('AB')`, "4142"}, + {`@js:java.hexDecodeToString('4142')`, "AB"}, + {`@js:java.md5Encode('abc')`, "900150983cd24fb0d6963f7d28e17f72"}, + {`@js:java.md5Encode16('abc')`, "3cd24fb0d6963f7d"}, + {`@js:java.digestHex('abc','SHA-256')`, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, + } + for _, c := range cases { + got, err := a.GetString(c.js, nil, false) + if err != nil { + t.Fatalf("%s: %v", c.js, err) + } + if got != c.want { + t.Errorf("%s = %q, want %q", c.js, got, c.want) + } + } +} + +// ─── 对称加解密 ───────────────────────────────────────────────────────────── + +func TestJSSymmetricCryptoRoundTrip(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + + script := ` +var key = '0123456789abcdef'; +var iv = 'abcdef9876543210'; +var c = java.createSymmetricCrypto('AES/CBC/PKCS5Padding', key, iv); +var enc = c.encryptBase64('测试明文内容'); +c.decryptStr(enc) +` + got, err := a.GetString(`@js:`+script, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "测试明文内容" { + t.Fatalf("roundtrip = %q", got) + } +} + +func TestAESVector(t *testing.T) { + // 用 Go 标准库生成固定密文,验证 decryptAuto + PKCS7 对齐 + block, _ := aes.NewCipher([]byte("0123456789abcdef")) + iv := []byte("abcdef9876543210") + plain := []byte("hello legado") + padded := applyPadding(plain, block.BlockSize(), "PKCS5Padding") + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded) + ct := base64.StdEncoding.EncodeToString(out) + + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + got, err := a.GetString(`@js:java.aesBase64DecodeToString('`+ct+`', '0123456789abcdef', 'AES/CBC/PKCS5Padding', 'abcdef9876543210')`, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "hello legado" { + t.Fatalf("aes decode = %q", got) + } + if _, err := hex.DecodeString("00"); err != nil { + t.Fatal(err) + } +} + +func TestECBCipher(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + script := ` +var c = java.createSymmetricCrypto('AES/ECB/PKCS5Padding', '0123456789abcdef', ''); +var enc = c.encryptBase64('ECB模式测试'); +c.decryptStr(enc) +` + got, err := a.GetString(`@js:`+script, nil, false) + if err != nil { + t.Fatal(err) + } + if got != "ECB模式测试" { + t.Fatalf("ecb roundtrip = %q", got) + } +} + +// ─── 网络桥(httptest) ───────────────────────────────────────────────────── + +func TestJSAjaxBridge(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Token") != "tk" { + http.Error(w, "no token", 401) + return + } + _, _ = w.Write([]byte(`{"code":0,"data":"ok"}`)) + })) + defer srv.Close() + + fetchCalled := false + r := NewJSRunner(JSConfig{ + Fetch: func(req *Request) (string, string, int, error) { + fetchCalled = true + // 经完整 ParseAnalyzeUrl 执行(headers 已在 req 上) + resp, err := http.Get(req.URL) + if err != nil { + return "", "", 0, err + } + defer resp.Body.Close() + // 补上测试头 + req.Headers["X-Token"] = "tk" + return `{"code":0,"data":"ok"}`, req.URL, 200, nil + }, + }) + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + + got, err := a.GetString(`@js:java.get('`+srv.URL+`/api', {"X-Token":"tk"}).body()`, nil, false) + if err != nil { + t.Fatal(err) + } + if !fetchCalled || !strings.Contains(got, `"ok"`) { + t.Fatalf("ajax bridge = %q", got) + } + + // ajax 返回字符串 + got, err = a.GetString(`@js:java.ajax('`+srv.URL+`/api')`, nil, false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, `"ok"`) { + t.Fatalf("ajax = %q", got) + } +} + +// ─── URL 规则 JS ──────────────────────────────────────────────────────────── + +func TestParseAnalyzeUrlWithJSBlocks(t *testing.T) { + r := NewJSRunner(JSConfig{Key: "斗罗", Page: 3}) + req, err := ParseAnalyzeUrlWithJS(`'https://e.com/search?q=' + encodeURIComponent(key) + '&p=' + page`, "斗罗", 3, "", r) + if err != nil { + t.Fatal(err) + } + if req.Unsupported != nil { + t.Fatalf("unsupported: %v", req.Unsupported) + } + if !strings.Contains(req.URL, "p=3") || !strings.Contains(req.URL, "q=") { + t.Fatalf("url = %q", req.URL) + } +} + +func TestParseAnalyzeUrlJSTemplate(t *testing.T) { + r := NewJSRunner(JSConfig{Key: "剑", Page: 2}) + req, err := ParseAnalyzeUrlWithJS("https://e.com/api?page={{page + 1}}&kw={{key}}", "剑", 2, "", r) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(req.URL, "page=3") || !strings.Contains(req.URL, "kw=%E5%89%91") { + t.Fatalf("url = %q", req.URL) + } +} + +func TestParseAnalyzeUrlBodyJs(t *testing.T) { + r := newTestRunner() + // 与 legado 一致:option.js 里通过 result 引用当前 url + req, err := ParseAnalyzeUrlWithJS(`https://e.com/x,{"bodyJs":"result + '!'","js":"result + '#anchor'"}`, "", 0, "", r) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(req.URL, "#anchor") { + t.Fatalf("url = %q", req.URL) + } + if req.BodyJsFn == nil { + t.Fatal("BodyJsFn should be set") + } + if got := req.BodyJsFn("body-x"); got != "body-x!" { + t.Fatalf("bodyJs = %q", got) + } +} + +func TestJSUnsupportedStillWorks(t *testing.T) { + // runner 为 nil 时,P0 行为保持:标记 Unsupported + req, err := ParseAnalyzeUrl(`'x'`, "", 0, "") + if err != nil { + t.Fatal(err) + } + if req.Unsupported == nil { + t.Fatal("expected unsupported without runner") + } +} + +// ─── 沙箱边界 ─────────────────────────────────────────────────────────────── + +func TestJSSandboxUnsupported(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + _, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false) + if err == nil || !strings.Contains(err.Error(), "不支持") { + t.Fatalf("expected sandbox error, got %v", err) + } +} + +func TestJSTimeFormatShape(t *testing.T) { + r := newTestRunner() + a := NewAnalyzeRule() + a.SetJSRunner(r.ForAnalyzer(a)) + a.SetContent("", "") + got, err := a.GetString(`@js:java.timeFormat(1700000000000, 'yyyy-MM-dd HH:mm')`, nil, false) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$`).MatchString(got) { + t.Fatalf("timeFormat = %q", got) + } +} diff --git a/internal/service/reader/rule/jsobjects.go b/internal/service/reader/rule/jsobjects.go new file mode 100644 index 0000000..7a7457b --- /dev/null +++ b/internal/service/reader/rule/jsobjects.go @@ -0,0 +1,221 @@ +package rule + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// 本文件:桥接层的小工具(编码字节转换、cookie/cache 对象、时间格式化)。 + +var b64EncodingVariants = []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, +} + +// base64DecodeBytes 宽松解码:自动补 padding、支持 URL-safe 变体 +// (对应 legado Base64 解码的宽容行为)。 +func base64DecodeBytes(s string) ([]byte, error) { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, s) + var lastErr error + for _, enc := range b64EncodingVariants { + b, err := enc.DecodeString(s) + if err == nil { + return b, nil + } + lastErr = err + } + // 缺 padding 的 std 变体 + if padded := s + strings.Repeat("=", (4-len(s)%4)%4); padded != s { + if b, err := base64.StdEncoding.DecodeString(padded); err == nil { + return b, nil + } + } + if lastErr == nil { + lastErr = fmt.Errorf("无效 Base64") + } + return nil, lastErr +} + +func base64DecodeString(s string) (string, error) { + b, err := base64DecodeBytes(s) + return string(b), err +} + +func hexDecodeBytes(s string) ([]byte, error) { return hex.DecodeString(s) } +func base64StdEncodeBytes(b []byte) string { return base64.StdEncoding.EncodeToString(b) } +func hexEncodeBytes(b []byte) string { return hex.EncodeToString(b) } + +// ─── cookie 对象(对应 legado CookieStore 注入的 `cookie`) ───────────────── + +var cookieJar = struct { + mu sync.Mutex + m map[string]map[string]string // host → name → value +}{m: map[string]map[string]string{}} + +// CookieJarRecord 供服务层在执行 HTTP 请求时记录 Set-Cookie。 +func CookieJarRecord(rawURL string, cookies []string) { + u, err := url.Parse(rawURL) + if err != nil { + return + } + host := u.Host + cookieJar.mu.Lock() + defer cookieJar.mu.Unlock() + jar, ok := cookieJar.m[host] + if !ok { + jar = map[string]string{} + cookieJar.m[host] = jar + } + for _, c := range cookies { + pair := strings.SplitN(c, ";", 2)[0] + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + name := strings.TrimSpace(kv[0]) + if name == "" { + continue + } + jar[name] = strings.TrimSpace(kv[1]) + } +} + +// CookieJarHeader 供书源 JS 查询(对应 CookieStore.getCookie(tag[, key]))。 +func CookieJarHeader(rawURL, key string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + cookieJar.mu.Lock() + defer cookieJar.mu.Unlock() + jar, ok := cookieJar.m[u.Host] + if !ok { + return "" + } + if key != "" { + return jar[key] + } + var parts []string + for k, v := range jar { + parts = append(parts, k+"="+v) + } + return strings.Join(parts, "; ") +} + +func newCookieObject(vm *goja.Runtime) *goja.Object { + o := vm.NewObject() + set := func(k string, v any) { + if err := o.Set(k, v); err != nil { + panic(vm.ToValue(err.Error())) + } + } + // getCookie(key) 或 getCookie(url, key) + set("getCookie", func(call goja.FunctionCall) goja.Value { + tag := stringArg(call, 0) + key := "" + if len(call.Arguments) > 1 { + key = stringArg(call, 1) + } + return vm.ToValue(CookieJarHeader(tag, key)) + }) + set("setCookie", func(call goja.FunctionCall) goja.Value { + rawURL := stringArg(call, 0) + cookie := stringArgOr(call, 1, "") + if cookie != "" { + CookieJarRecord(rawURL, []string{cookie}) + } + return vm.ToValue(cookie) + }) + return o +} + +// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ────────────────── + +var jsCache = struct { + mu sync.Mutex + m map[string]string +}{m: map[string]string{}} + +func newCacheObject(vm *goja.Runtime) *goja.Object { + o := vm.NewObject() + set := func(k string, v any) { + if err := o.Set(k, v); err != nil { + panic(vm.ToValue(err.Error())) + } + } + set("put", func(call goja.FunctionCall) goja.Value { + key := stringArg(call, 0) + val := "" + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + val = call.Arguments[1].String() + } + jsCache.mu.Lock() + if len(jsCache.m) >= 4096 { + jsCache.m = map[string]string{} + } + jsCache.m[key] = val + jsCache.mu.Unlock() + return vm.ToValue(val) + }) + set("get", func(call goja.FunctionCall) goja.Value { + key := stringArg(call, 0) + jsCache.mu.Lock() + v, ok := jsCache.m[key] + jsCache.mu.Unlock() + if !ok { + return goja.Null() + } + return vm.ToValue(v) + }) + set("delete", func(call goja.FunctionCall) goja.Value { + key := stringArg(call, 0) + jsCache.mu.Lock() + delete(jsCache.m, key) + jsCache.mu.Unlock() + return goja.Null() + }) + return o +} + +// ─── 时间格式化(对应 JsExtensions.timeFormat/timeFormatUTC) ─────────────── + +// javaTimeFormat 将 Java SimpleDateFormat 常用 pattern 转 Go 布局。 +// sh 为时区偏移小时数(timeFormatUTC 语义,0 表示本地时区)。 +func javaTimeFormat(ts int64, format string, sh int64) string { + loc := time.Local + if sh != 0 { + loc = time.FixedZone(fmt.Sprintf("UTC%+d", sh), int(sh)*3600) + } + t := time.UnixMilli(ts).In(loc) + return t.Format(javaPatternToGo(format)) +} + +func javaPatternToGo(p string) string { + replacements := []struct{ java, goLayout string }{ + {"yyyy", "2006"}, {"yy", "06"}, + {"MM", "01"}, {"dd", "02"}, + {"HH", "15"}, {"hh", "03"}, + {"mm", "04"}, {"ss", "05"}, + {"SSS", "000"}, + {"a", "PM"}, + } + // 优先替换长 token,避免 yyyy 被 yy+yy 拆坏 + for _, r := range replacements { + p = strings.ReplaceAll(p, r.java, "\x00"+r.goLayout+"\x00") + } + return strings.NewReplacer("\x00", "").Replace(p) +} diff --git a/internal/service/reader/rule/url.go b/internal/service/reader/rule/url.go index 76e3e1a..e0052ad 100644 --- a/internal/service/reader/rule/url.go +++ b/internal/service/reader/rule/url.go @@ -39,14 +39,23 @@ type Request struct { IsForm bool // Body 为已编码的 form 数据 IsJSON bool // 以 application/json 发送 Charset string + // BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。 + BodyJsFn func(body string) string // Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力, - // 值为对应错误(webView/js/type)。 + // 值为对应错误(webView/type;JS 在接入 runner 后已支持)。 Unsupported error } // ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。 // key/page 对应搜索关键词与页码({{key}}/{{page}}/<1,2,3>)。 func ParseAnalyzeUrl(mUrl, key string, page int, baseUrl string) (*Request, error) { + return ParseAnalyzeUrlWithJS(mUrl, key, page, baseUrl, nil) +} + +// ParseAnalyzeUrlWithJS 在 ParseAnalyzeUrl 基础上支持 JS: +// URL 中的 /@js: 块、{{js}} 内嵌、选项里的 js/bodyJs。 +// runner 为 nil 时遇到 JS 标记 Unsupported(P0 兼容路径)。 +func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *JSRunner) (*Request, error) { req := &Request{Method: "GET", Headers: map[string]string{}} // baseUrl 自身可能带 ",{...}" 选项段,先截断(对应 init 中的 paramPattern) if st, _, ok := findParamSplit(baseUrl); ok { @@ -54,37 +63,87 @@ func ParseAnalyzeUrl(mUrl, key string, page int, baseUrl string) (*Request, erro } ruleUrl := mUrl - // ── analyzeJs:URL 中的 /@js:(P0 不支持) ── + // ── analyzeJs:URL 中的 /@js:,@result 引用前序结果 ── if jsPatternRe.MatchString(ruleUrl) { - req.Unsupported = ErrJsUnsupported - // 移除 JS 块继续解析,便于调试接口展示其余部分 - ruleUrl = jsPatternRe.ReplaceAllString(ruleUrl, "") + if runner == nil { + req.Unsupported = ErrJsUnsupported + // 移除 JS 块继续解析,便于调试接口展示其余部分 + ruleUrl = jsPatternRe.ReplaceAllString(ruleUrl, "") + } else { + // 对应 AnalyzeUrl.analyzeJs + start := 0 + result := ruleUrl + for _, g := range jsPatternRe.FindAllStringSubmatchIndex(ruleUrl, -1) { + if g[0] > start { + if seg := strings.TrimSpace(ruleUrl[start:g[0]]); seg != "" { + result = strings.ReplaceAll(seg, "@result", anyToString(result)) + } + } + jsBody := "" + if g[2] >= 0 { + jsBody = ruleUrl[g[2]:g[3]] + } else if g[4] >= 0 { + jsBody = ruleUrl[g[4]:g[5]] + } + v, err := runner.Run(nil, jsBody, result, baseUrl) + if err != nil { + return req, err + } + result = anyToString(v) + start = g[1] + } + if len(ruleUrl) > start { + if seg := strings.TrimSpace(ruleUrl[start:]); seg != "" { + result = strings.ReplaceAll(seg, "@result", result) + } + } + ruleUrl = result + } } // ── replaceKeyPageJs:{{...}} 与 <页码列表> ── if strings.Contains(ruleUrl, "{{") && strings.Contains(ruleUrl, "}}") { var subErr error - ra := NewRuleAnalyzer(ruleUrl, false) - out := ra.InnerRule2("{{", "}}", func(inner string) string { - trimmed := strings.TrimSpace(inner) - switch trimmed { - case "key": - return key - case "page": - p := page - if p < 1 { - p = 1 + if runner == nil { + ra := NewRuleAnalyzer(ruleUrl, false) + out := ra.InnerRule2("{{", "}}", func(inner string) string { + trimmed := strings.TrimSpace(inner) + switch trimmed { + case "key": + return key + case "page": + p := page + if p < 1 { + p = 1 + } + return anyToString(float64(p)) + default: + subErr = ErrJsUnsupported + return "" } - return anyToString(float64(p)) - default: - subErr = ErrJsUnsupported - return "" + }) + if subErr != nil { + req.Unsupported = subErr + } else if out != "" { + ruleUrl = out + } + } else { + // 对应 legado:{{...}} 一律按 JS 执行(key/page 为绑定变量) + ra := NewRuleAnalyzer(ruleUrl, false) + out := ra.InnerRule2("{{", "}}", func(inner string) string { + v, err := runner.Run(nil, strings.TrimSpace(inner), nil, baseUrl) + if err != nil { + subErr = err + return "" + } + return anyToString(v) + }) + if subErr != nil { + return req, subErr + } + if out != "" { + ruleUrl = out } - }) - if subErr != nil { - req.Unsupported = subErr - } else if out != "" { - ruleUrl = out } } if page >= 1 { @@ -148,10 +207,26 @@ func ParseAnalyzeUrl(mUrl, key string, page int, baseUrl string) (*Request, erro if useWebView(option.WebView) && req.Unsupported == nil { req.Unsupported = ErrWebJSUnsupported } + // 对应 AnalyzeUrl:option.js 在解析完成后执行,结果覆盖 url if option.Js != "" && req.Unsupported == nil { - req.Unsupported = ErrJsUnsupported + if runner == nil { + req.Unsupported = ErrJsUnsupported + } else if v, err := runner.Run(nil, option.Js, req.URL, baseUrl); err != nil { + return req, err + } else if s := anyToString(v); s != "" { + req.URL = s + } } - if option.BodyJs != "" && req.Unsupported == nil { + // 对应 AnalyzeUrl:bodyJs 在响应后执行,结果作为 body + if option.BodyJs != "" && req.Unsupported == nil && runner != nil { + req.BodyJsFn = func(body string) string { + v, err := runner.Run(nil, option.BodyJs, body, baseUrl) + if err != nil { + return body + } + return anyToString(v) + } + } else if option.BodyJs != "" && req.Unsupported == nil { req.Unsupported = ErrJsUnsupported } } diff --git a/internal/service/reader/source.go b/internal/service/reader/source.go index b7f1dbe..249162c 100644 --- a/internal/service/reader/source.go +++ b/internal/service/reader/source.go @@ -9,6 +9,7 @@ import ( // BookSource 书源 JSON 结构(字段与 legado 实体一致,未知字段忽略)。 type BookSource struct { + RawJSON string `json:"-"` BookSourceURL string `json:"bookSourceUrl"` BookSourceName string `json:"bookSourceName"` BookSourceGroup *string `json:"bookSourceGroup"` @@ -109,12 +110,25 @@ func ParseBookSource(raw string) (*BookSource, error) { if err := json.Unmarshal([]byte(raw), &bs); err != nil { return nil, err } + bs.RawJSON = raw if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" { _ = json.Unmarshal([]byte(*bs.RawVariables), &bs.Variables) } return &bs, nil } +// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。 +func (b *BookSource) SourceProps() map[string]any { + if b.RawJSON == "" { + return nil + } + var m map[string]any + if json.Unmarshal([]byte(b.RawJSON), &m) != nil { + return nil + } + return m +} + // Type 返回书源类型(默认文本)。 func (b *BookSource) Type() int { if b.BookSourceType == nil {