This commit is contained in:
truewhile
2026-09-30 19:22:24 +08:00
parent fa41936095
commit c1ff55ffbf
28 changed files with 3793 additions and 201 deletions
+145 -13
View File
@@ -48,7 +48,8 @@ type JSFetcher func(req *Request) (body string, finalURL string, code int, err e
// JSConfig 构造 JS 运行时的配置。
type JSConfig struct {
Fetch JSFetcher
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样)。
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样);
// 同时供 source.getLoginInfoMap 从 loginUi 的 default 初始化登录信息。
SourceProps map[string]any
// Log 对应 java.log。
Log func(msg string)
@@ -59,32 +60,156 @@ type JSConfig struct {
// Key / Page 搜索上下文绑定({{key}}/{{page}} 在 JS 里的取值)。
Key string
Page int
// State 书源会话状态(变量/登录信息/Cookie)。nil 时用进程内 MemoryState。
State SourceState
// JSLib 书源 jsLib:在运行时创建后立即执行一次,
// 其顶层函数与 lexical 绑定对该源后续所有 JS 可见(对应 legado SharedJsScope)。
JSLib string
// StateOnly 只构建会话状态与 jsLib 环境(登录交互用),
// 不注入 book/result 等规则上下文。
StateOnly bool
}
// JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。
type JSRunner struct {
vm *goja.Runtime
cfg JSConfig
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
vm *goja.Runtime
cfg JSConfig
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
state SourceState
// jsLibErr 记录 jsLib 执行失败原因(登录接口需要如实回报)。
jsLibErr error
}
// NewJSRunner 创建运行时并注入全局对象:cookie / cache / source。
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
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())
state := cfg.State
if state == nil {
state = NewMemoryState()
}
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
vm.Set("cookie", newCookieObject(vm, state))
vm.Set("cache", newCacheObject(vm))
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
srcObj := newSourceObject(vm, state, cfg.SourceProps)
vm.Set("source", srcObj)
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
r.installJava(nil)
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
r.loadJSLib()
return r
}
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
func (r *JSRunner) installJava(a *AnalyzeRule) {
r.vm.Set("java", newJavaObject(r.vm, r, a))
}
// loadJSLib 执行书源 jsLib(一次),使其中定义的函数与 lexical 绑定
// 对后续 Run 可见(goja 的顶层 let/const 与函数声明在同一 Runtime 内保持)。
func (r *JSRunner) loadJSLib() {
if strings.TrimSpace(r.cfg.JSLib) == "" {
return
}
prog, err := compileCached(r.cfg.JSLib)
if err != nil {
r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err)
return
}
timer := time.AfterFunc(r.cfg.Timeout, func() { r.vm.Interrupt("jsLib 执行超时") })
defer timer.Stop()
if _, err := r.vm.RunProgram(prog); err != nil {
r.jsLibErr = fmt.Errorf("jsLib 执行失败: %v", err)
}
}
// JSLibErr 返回 jsLib 的执行错误(nil 表示正常)。
func (r *JSRunner) JSLibErr() error { return r.jsLibErr }
// State 返回运行时使用的会话状态。
func (r *JSRunner) State() SourceState { return r.state }
// EvalAction 在已装载 jsLib 的环境中执行一段交互 JS(登录按钮 action 等)。
// bindings 为附加的 JS 全局绑定(如 result)。返回脚本的返回值。
//
// 绑定集合与 Run 对齐(baseUrl/key/page),因为登录交互 JS 同样会用到它们。
func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) {
if r.jsLibErr != nil {
return nil, r.jsLibErr
}
vm := r.vm
r.installJava(nil)
vm.Set("baseUrl", r.cfg.BaseURL)
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)
}
for k, v := range bindings {
vm.Set(k, toJSValue(vm, v))
}
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
}
// EvalLoginCheck 执行书源的 loginCheckJs(对应 legado WebBook 的 checkJs 钩子)。
//
// 语义对齐 legado:作用域里的 `result` 是一个响应对象(body()/code()/url()),
// 脚本须返回响应对象或字符串;返回新 body 时调用方以之替换原响应体,
// 从而支持「检测到会话失效 → 重新登录 → 重取页面」。
func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (string, bool, error) {
if r.jsLibErr != nil {
return "", false, r.jsLibErr
}
vm := r.vm
r.installJava(nil)
vm.Set("baseUrl", r.cfg.BaseURL)
vm.Set("result", newResponseObject(vm, body, code, finalURL, nil))
prog, err := compileCached(js)
if err != nil {
return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err)
}
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("loginCheckJs 执行超时") })
defer timer.Stop()
v, err := vm.RunProgram(prog)
if err != nil {
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
}
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return "", false, nil
}
// 返回响应对象时取 body()(对应 legado 的 StrResponse)
if obj, ok := v.(*goja.Object); ok {
if fn, ok := goja.AssertFunction(obj.Get("body")); ok {
res, err := fn(obj)
if err != nil {
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
}
return res.String(), true, nil
}
}
return v.String(), true, nil
}
// ForAnalyzer 返回绑定到指定解析器的执行函数(java.getString 等规则回调
// 会桥回该解析器,对应 legado 中 AnalyzeRule 自身实现 JsExtensions)。
func (r *JSRunner) ForAnalyzer(a *AnalyzeRule) func(js string, result any) (any, error) {
@@ -104,7 +229,7 @@ func (r *JSRunner) RunWithBaseURL(a *AnalyzeRule, js string, result any, baseURL
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))
r.installJava(a)
// 上下文绑定
if a != nil {
vm.Set("book", map[string]any{"name": a.bookName})
@@ -176,6 +301,13 @@ func toJSValue(vm *goja.Runtime, v any) any {
return nil
case string, bool, int, int32, int64, float64, map[string]any, []any:
return t
// 登录表单等以 map[string]string 传入,交给 goja 直接反射转换
case map[string]string:
m := make(map[string]any, len(t))
for k, v := range t {
m[k] = v
}
return m
case []string:
arr := make([]any, len(t))
for i, s := range t {