优化登录,优化阅读

This commit is contained in:
truewhile
2026-10-03 21:00:16 +08:00
parent 5242d54a06
commit b0f1ab39b9
22 changed files with 1513 additions and 155 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
req.Headers[k] = fmt.Sprintf("%v", v)
}
}
body, _, _, err := r.cfg.Fetch(req)
body, _, _, err := r.fetch(req)
if err != nil {
bridgeErr(name, err)
}
@@ -93,7 +93,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
if req.Unsupported != nil {
bridgeErr(name, req.Unsupported)
}
respBody, finalURL, code, err := r.cfg.Fetch(req)
respBody, finalURL, code, err := r.fetch(req)
if err != nil {
bridgeErr(name, err)
}
+4
View File
@@ -98,6 +98,10 @@ func (r *JSRunner) openBrowser(req BrowserTask) error {
return nil
}
if r.cfg.Browser != nil {
// 宿主抓取待展示的页面同样是网络等待,不能吃掉 JS 执行预算
// (与 awaitBrowser 同一套暂停机制,见 pauseTimeout 的说明)。
resume := r.pauseTimeout()
defer resume()
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
}
if r.state != nil {
+16
View File
@@ -177,6 +177,22 @@ func (r *JSRunner) pauseTimeout() func() {
return g.Pause()
}
// fetch 执行一次桥接网络请求,等待期间暂停 JS 超时看门狗。
//
// 书源会把「线路重试」写进规则 JS:光遇聚合的 request() 会串行试 7 条线路,单条
// 线路最长可能等到客户端的 30s 超时。不暂停的话整条规则会被 10s 的 JS 超时打断,
// 而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是「线路
// 还在重试,接口已经 400」。java.startBrowserAwait 等待人工操作时用的是同一套暂停
// 机制。暂停只覆盖网络等待,纯 CPU 死循环仍然受 Timeout 约束。
func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
if r.cfg.Fetch == nil {
return "", "", 0, ErrJsUnsupported
}
resume := r.pauseTimeout()
defer resume()
return r.cfg.Fetch(req)
}
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
func NewJSRunner(cfg JSConfig) *JSRunner {
vm := goja.New()
+34
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
@@ -46,6 +47,39 @@ func TestJSTimeoutInterrupt(t *testing.T) {
}
}
// TestJSTimeoutPausedDuringFetch 网络等待期间不能被 JS 超时打断。
//
// 回归:书源把「线路重试」写在规则 JS 里(光遇聚合的 request() 串行试 7 条线路,
// 单条最长等到 HTTP 客户端超时)。看门狗不暂停的话,整条规则会被 10s 的 JS 超时
// 中断,而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是
// 「线路还在重试,接口已经 400」。
func TestJSTimeoutPausedDuringFetch(t *testing.T) {
r := NewJSRunner(JSConfig{
Timeout: 100 * time.Millisecond,
Fetch: func(req *Request) (string, string, int, error) {
time.Sleep(400 * time.Millisecond) // 远超过 JS 超时:模拟慢线路
return `{"content":"正文"}`, req.URL, 200, nil
},
})
ar := NewAnalyzeRule()
v, err := r.Run(ar, `(function(){
try { return java.ajax('https://slow.example.com/content'); }
catch (e) { return 'caught:' + e; }
})()`, nil, "")
if err != nil {
t.Fatalf("网络等待期间不应触发 JS 超时: %v", err)
}
if got := anyToString(v); !strings.Contains(got, "正文") {
t.Fatalf("ajax 返回值 = %q,期望上游正文", got)
}
// 暂停只覆盖网络等待:回到 JS 里的纯 CPU 死循环仍然要被超时打断。
_, err = r.Run(ar, `java.ajax('https://slow.example.com/content'); while(true){}`, nil, "")
if err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("网络等待之后的 CPU 死循环应当仍然超时,实际: %v", err)
}
}
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
func TestAnalyzeRuleJSEval(t *testing.T) {
+55 -31
View File
@@ -375,11 +375,20 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
}
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
//
// legado 的 cache 有两套存储:put/get/delete 落持久缓存(ACache),
// putMemory/getFromMemory 落进程内内存缓存。书源靠后者记录「这条段评点过几次」
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
var jsCache = struct {
mu sync.Mutex
m map[string]string
}{m: map[string]string{}}
mu sync.Mutex
m map[string]string
mem map[string]string
}{m: map[string]string{}, mem: map[string]string{}}
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
const jsCacheMaxEntries = 4096
func newCacheObject(vm *goja.Runtime) *goja.Object {
o := vm.NewObject()
@@ -388,37 +397,52 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
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()
// 两套存储共用同一份读写实现,只有落点不同。
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return 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(*store) >= jsCacheMaxEntries {
*store = map[string]string{}
}
(*store)[key] = val
jsCache.mu.Unlock()
return vm.ToValue(val)
}
jsCache.mu.Lock()
if len(jsCache.m) >= 4096 {
jsCache.m = map[string]string{}
}
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
v, ok := (*store)[key]
jsCache.mu.Unlock()
if !ok {
return goja.Null()
}
return vm.ToValue(v)
}
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 {
}
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
delete(*store, key)
jsCache.mu.Unlock()
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()
})
}
set("put", putTo(&jsCache.m))
set("get", getFrom(&jsCache.m))
set("delete", deleteFrom(&jsCache.m))
// 内存缓存(legado Cache.getFromMemory / putMemory)
set("putMemory", putTo(&jsCache.mem))
set("getFromMemory", getFrom(&jsCache.mem))
return o
}
@@ -0,0 +1,53 @@
package rule
import "testing"
// TestCacheMemoryRoundTrip 内存缓存(cache.putMemory / cache.getFromMemory)要能读写。
//
// 回归:「cache 对象只有 put/get/delete」曾让光遇聚合的正文规则整条失败——
// paraForAndroid 每一段带段评的文字都会调 cache.putMemory(url, 0),缺了它就抛
// TypeError: Object has no member 'putMemory',正文接口直接 400。
func TestCacheMemoryRoundTrip(t *testing.T) {
r := NewJSRunner(JSConfig{})
ar := NewAnalyzeRule()
if _, err := r.Run(ar, `cache.putMemory('__test_mem', 3)`, nil, ""); err != nil {
t.Fatalf("cache.putMemory 失败: %v", err)
}
v, err := r.Run(ar, `String(cache.getFromMemory('__test_mem'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "3" {
t.Fatalf("getFromMemory = %q,期望 3", got)
}
// 缺省返回 null(书源会写 `cache.getFromMemory(k) || ''` 这类兜底)。
v, err = r.Run(ar, `String(cache.getFromMemory('__test_mem_missing'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("缺失键 = %q,期望 null", got)
}
// 持久缓存与内存缓存是两套存储(legado 的 put/get 走 ACache,putMemory 走内存),
// 互不串门,否则书源会读到本该过期的值。
if _, err := r.Run(ar, `cache.put('__test_persist', 'p')`, nil, ""); err != nil {
t.Fatalf("cache.put 失败: %v", err)
}
v, err = r.Run(ar, `String(cache.getFromMemory('__test_persist'))`, nil, "")
if err != nil {
t.Fatalf("cache.getFromMemory 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("getFromMemory 读到了持久缓存的值: %q", got)
}
v, err = r.Run(ar, `String(cache.get('__test_mem'))`, nil, "")
if err != nil {
t.Fatalf("cache.get 失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
}
}