mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
feat(reader): P2——goja JS 引擎接入 + Go 原生 java.* 桥接
- 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 期望值
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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), "<?xml") {
|
||||
body = "<?xml version=\"1.0\"?>" + 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("<js:%d>", 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()
|
||||
}
|
||||
@@ -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: / <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)
|
||||
}
|
||||
// <js></js> 块
|
||||
got, err = a.GetString(`<js>"结果:" + result</js>`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "结果:正文内容" {
|
||||
t.Fatalf("<js> = %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(`<js>java.getString("class.item.0@tag.a@text")</js>`, 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(`<js>'https://e.com/search?q=' + encodeURIComponent(key) + '&p=' + page</js>`, "斗罗", 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(`<js>'x'</js>`, "", 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}} 内嵌、选项里的 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>/@js:(P0 不支持) ──
|
||||
// ── analyzeJs:URL 中的 <js>/@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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user