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:
truewhile
2026-09-30 16:36:32 +08:00
parent d0b4255aa8
commit 5976a8d310
10 changed files with 1720 additions and 69 deletions
+63 -43
View File
@@ -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 {
+467
View File
@@ -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)
}
}
+285
View File
@@ -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
}
+235
View File
@@ -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()
}
+324
View File
@@ -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)
}
}
+221
View File
@@ -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)
}
+101 -26
View File
@@ -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
}
}
+14
View File
@@ -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 {