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
+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()
}