Files
MeBox/internal/service/reader/rule/goja.go
T
truewhile 5976a8d310 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 期望值
2026-09-30 16:36:32 +08:00

236 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}