优化阅读功能

This commit is contained in:
truewhile
2026-10-01 21:57:25 +08:00
parent c1ff55ffbf
commit ce4319ec25
56 changed files with 9511 additions and 354 deletions
+62 -2
View File
@@ -26,9 +26,18 @@ type AnalyzeRule struct {
bookVars map[string]string
vars map[string]string
chapterTitle string
chapterIndex int
bookName string
sourceGetter func(key string) string
sourcePutter func(key, value string)
// bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。
bookMeta map[string]any
// bookCustom 书籍自定义变量(对应 legado Book.variableMap),
// 由规则 JS 的 book.getVariable / book.putVariable 读写。
bookCustom map[string]string
// bookTypeOverride 书源在规则 JS 里给 book.type 赋的值
// (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。
bookTypeOverride *int
sourceGetter func(key string) string
sourcePutter func(key, value string)
ruleCache map[string][]*SourceRule
}
@@ -85,6 +94,57 @@ func (a *AnalyzeRule) SetChapterContext(title string, vars map[string]string) {
}
}
// SetChapterIndex 设置当前章节下标(规则 JS 的 chapter.index)。
func (a *AnalyzeRule) SetChapterIndex(i int) { a.chapterIndex = i }
// SetBookMeta 注入书籍元数据(legado 的 Book 实体字段),
// 供规则 JS 里的 `book` 对象读取(name/author/coverUrl/durChapterIndex…)。
func (a *AnalyzeRule) SetBookMeta(meta map[string]any) {
if len(meta) == 0 {
return
}
if a.bookMeta == nil {
a.bookMeta = make(map[string]any, len(meta))
}
for k, v := range meta {
a.bookMeta[k] = v
}
}
// SetBookCustomVars 注入书籍自定义变量(对应 legado Book.variableMap),
// 规则 JS 通过 book.getVariable / book.putVariable 读写。
func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) {
if vars != nil {
a.bookCustom = vars
}
}
// SetBookType 记录书源声明的书籍类型(legado Book.type)。
func (a *AnalyzeRule) SetBookType(t int) {
v := t
a.bookTypeOverride = &v
}
// BookTypeOverride 返回书源在规则 JS 里声明的书籍类型;
// ok 为 false 表示书源没有声明(应沿用书架记录里的类型)。
func (a *AnalyzeRule) BookTypeOverride() (int, bool) {
if a.bookTypeOverride == nil {
return 0, false
}
return *a.bookTypeOverride, true
}
// bookTypeValue 供 book.type 读取:优先书源本次声明的值,否则用元数据里的。
func (a *AnalyzeRule) bookTypeValue() any {
if a.bookTypeOverride != nil {
return *a.bookTypeOverride
}
if v, ok := a.bookMeta["type"]; ok {
return v
}
return 0
}
// SetBookContext 设置书籍上下文(name 与书籍级变量存储)。
func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
a.bookName = name
+63 -12
View File
@@ -410,7 +410,13 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
}
// ── 宿主交互:服务端无 UI,转为可回传前端的提示 / 待打开链接 ──
// ── 宿主交互:提示、浏览器页面、登录界面刷新 ──
//
// legado 用内置 WebView 承载页面:startBrowser / startBrowserAwait 打开一个
// 页面让用户完成防爬校验、登录或参数选择,Await 版本还会把用户操作后的
// 页面源码作为 StrResponse 返回。真实的聚合类书源(如光遇聚合)把「线路
// 切换」「用户后台」「书源设置」全部建在这两个函数上,因此这里必须真正
// 把页面交给前端,而不是记录个地址就算完。
if r.state != nil {
toast := func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -420,17 +426,59 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
}
set("toast", toast)
set("longToast", toast)
// startBrowser(url, title):记录待打开地址,前端可代开新标签。
// startBrowser(url, title[, html]):展示页面,不等待用户完成。
set("startBrowser", func(call goja.FunctionCall) goja.Value {
r.state.OpenBrowser(stringArg(call, 0), stringArgOr(call, 1, ""))
if err := r.openBrowser(parseBrowserArgs(call)); err != nil {
bridgeErr("startBrowser", err)
}
return goja.Null()
})
// startBrowserAwait:服务端无 WebView,无法等待人工校验。
// 记录地址后抛出明确错误,避免书源逻辑误把空 body 当成功。
// showBrowser(url, html, preloadJs, config):legado 中同样是「打开即返回」
// 的对话框,只是参数含义不同,这里统一按展示处理。
set("showBrowser", func(call goja.FunctionCall) goja.Value {
req := parseBrowserArgs(call)
if req.Title == "" {
req.Title = stringArgOr(call, 2, "")
}
if err := r.openBrowser(req); err != nil {
bridgeErr("showBrowser", err)
}
return goja.Null()
})
// startBrowserAwait(url, title[, refetchAfterSuccess][, html]):
// 展示页面并阻塞等待用户完成后回传页面内容(对应 legado StrResponse)。
set("startBrowserAwait", func(call goja.FunctionCall) goja.Value {
url := stringArg(call, 0)
r.state.OpenBrowser(url, stringArgOr(call, 1, ""))
panic(vm.ToValue("java.startBrowserAwait: 服务端无浏览器,需要人工操作的页面请手动打开:" + url))
req := parseBrowserArgs(call)
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if _, isStr := call.Arguments[2].Export().(string); !isStr {
req.Refetch = call.Arguments[2].ToBoolean()
}
}
res, err := r.awaitBrowser(req)
if err != nil {
bridgeErr("startBrowserAwait", err)
}
finalURL := res.URL
if finalURL == "" {
finalURL = req.URL
}
return newResponseObject(vm, res.Body, 200, finalURL, nil)
})
// reLoginView / refreshExplore:请求宿主重新渲染登录表单。
// MeBox 每次登录动作后都会重新拉取 loginUi 并重建表单,因此这是真实
// 生效的信号,而不是静默空实现。
for _, name := range []string{"reLoginView", "refreshExplore"} {
set(name, func(call goja.FunctionCall) goja.Value {
r.requestUIRefresh()
return goja.Null()
})
}
// upLoginData(data):书源把服务端返回的值回填进登录表单。
set("upLoginData", func(call goja.FunctionCall) goja.Value {
r.applyLoginData(call.Arguments)
return goja.Null()
})
}
// 设备标识:部分源用 deviceID/androidId 做"是否支持该环境"探测,
@@ -440,10 +488,13 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
panic(vm.ToValue("java." + name + ": 服务端无设备标识"))
})
}
// 刷新发现页 / 打开界面:纯 UI 动作,服务端空实现。
for _, name := range []string{"refreshExplore", "open", "showBrowser", "reLoginView", "qread"} {
set(name, func(call goja.FunctionCall) goja.Value { return goja.Null() })
}
// 说明:下面这些名字在服务端刻意不定义,调用即抛异常,以对齐 legado 的
// 真实可用面,避免「静默空实现」让书源误判运行环境:
// qread / showReadingBrowser / startBrowserDp —— legado 中并不存在
// (例如 checkEnv 用 java.qread() 探测「轻阅」,旧实现返回成功会让书源
// 误认为运行在轻阅里,从而跳过它自己的降级分支)
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
// 服务端没有对应页面,谎报成功会让书源走错分支
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
+227
View File
@@ -0,0 +1,227 @@
package rule
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/dop251/goja"
)
// 本文件:书源 JS 的宿主浏览器能力契约。
//
// legado 用内置 WebView 实现 java.startBrowser / java.startBrowserAwait:
// 打开一个页面让用户完成防爬校验、登录或参数选择,并(Await 版本)把用户
// 操作后的页面源码作为 StrResponse 返回给书源解析。真实书源把整套「线路
// 切换」「用户后台」「书源设置」都建在这两个函数上,因此服务端必须提供
// 等价能力,否则这些按钮只会走到书源自己的 catch 分支。
//
// MeBox 没有 WebView,改由服务层把页面交给前端网页承载:
// - AwaitBrowser:阻塞等待前端回传 DOM(对应 startBrowserAwait)
// - OpenBrowser: 只展示不等待(对应 startBrowser / showBrowser)
// BrowserTask 一个需要用户在前端页面中完成的请求。
type BrowserTask struct {
// URL 书源传入的原始地址,可能是 http(s),也可能是 data:text/html;base64,…。
URL string
// Title 页面标题(对应 legado 对话框标题)。
Title string
// HTML URL 为 data: 系地址时解析出的页面源码,空表示由服务层按 URL 抓取。
HTML string
// Refetch 对应 legado startBrowserAwait 的 refetchAfterSuccess:
// 用户确认后重新抓取该 URL 作为返回内容,而不是回传页面 DOM。
Refetch bool
}
// BrowserResult 用户在页面上完成操作后的回传。
type BrowserResult struct {
// URL 最终地址(对应 StrResponse.url())。
URL string
// Body 页面内容:默认是用户操作后的 DOM 序列化,
// Refetch 为真时是服务端重新抓取的响应体。
Body string
}
// BrowserHost 由服务层注入的宿主浏览器实现。
type BrowserHost interface {
// AwaitBrowser 展示页面并阻塞等待用户回传(java.startBrowserAwait)。
AwaitBrowser(ctx context.Context, req BrowserTask) (BrowserResult, error)
// OpenBrowser 展示页面但不等待(java.startBrowser / java.showBrowser)。
OpenBrowser(ctx context.Context, req BrowserTask) error
}
// ErrBrowserCancelled 用户在页面里主动取消。
var ErrBrowserCancelled = errBrowserCancelled{}
type errBrowserCancelled struct{}
func (errBrowserCancelled) Error() string { return "用户取消了页面操作" }
// ─── 运行时侧:把页面交给宿主浏览器 ────────────────────────────────────────
// parseBrowserArgs 解析 startBrowser / startBrowserAwait 的位置参数。
//
// startBrowser(url, title[, html])
// startBrowserAwait(url, title[, refetchAfterSuccess][, html])
//
// 两者第 3 参类型不同(html 字符串 vs boolean),按实际类型判定。
func parseBrowserArgs(call goja.FunctionCall) BrowserTask {
req := BrowserTask{
URL: stringArg(call, 0),
Title: stringArgOr(call, 1, ""),
}
req.HTML = ParseDataHTML(req.URL)
// 第 3 / 第 4 参里的字符串按 html 处理(data: URL 之外显式传入的页面源码)。
for _, i := range []int{2, 3} {
if len(call.Arguments) <= i {
continue
}
arg := call.Arguments[i]
if goja.IsUndefined(arg) || goja.IsNull(arg) {
continue
}
if s, ok := arg.Export().(string); ok && strings.TrimSpace(s) != "" {
req.HTML = s
}
}
return req
}
// openBrowser 展示页面但不等待。
//
// 注入了宿主浏览器时交给宿主(前端可交互的页面面板);未注入时退化为
// 记录地址供前端代开,不中断书源逻辑。
func (r *JSRunner) openBrowser(req BrowserTask) error {
if req.URL == "" && req.HTML == "" {
return nil
}
if r.cfg.Browser != nil {
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
}
if r.state != nil {
r.state.OpenBrowser(req.URL, req.Title)
}
return nil
}
// awaitBrowser 展示页面并阻塞等待用户完成。
//
// 等待期间暂停 JS 超时计时:用户可能要看很久,默认 10s 的执行预算不能
// 用在「等人」上。
func (r *JSRunner) awaitBrowser(req BrowserTask) (BrowserResult, error) {
if req.URL == "" && req.HTML == "" {
return BrowserResult{}, fmt.Errorf("startBrowserAwait: 缺少页面地址")
}
if r.cfg.Browser == nil {
if r.state != nil {
r.state.OpenBrowser(req.URL, req.Title)
}
return BrowserResult{}, fmt.Errorf("服务端未提供浏览器能力,需要人工操作的页面请手动打开")
}
resume := r.pauseTimeout()
defer resume()
return r.cfg.Browser.AwaitBrowser(r.ctx(), req)
}
// ctx 返回本次执行的上下文(未注入时用 Background)。
func (r *JSRunner) ctx() context.Context {
if r.cfg.Ctx != nil {
return r.cfg.Ctx
}
return context.Background()
}
// requestUIRefresh 请求宿主重新渲染登录表单(java.reLoginView / refreshExplore)。
func (r *JSRunner) requestUIRefresh() {
if ui, ok := r.state.(UIState); ok {
ui.RequestUIRefresh()
}
}
// applyLoginData 把 java.upLoginData 传入的值合并进登录表单。
func (r *JSRunner) applyLoginData(args []goja.Value) {
if len(args) == 0 || goja.IsUndefined(args[0]) || goja.IsNull(args[0]) {
return
}
ui, ok := r.state.(UIState)
if !ok {
return
}
exported, ok := args[0].Export().(map[string]any)
if !ok {
return
}
data := make(map[string]string, len(exported))
for k, v := range exported {
data[k] = anyToStringish(v)
}
ui.ApplyLoginData(data)
}
func anyToStringish(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", v)
}
}
// ParseDataHTML 解析 data:text/html 系地址,返回内嵌的页面源码。
// 支持 base64 与百分号编码两种形式;非 data 地址或解码失败返回空串。
func ParseDataHTML(rawURL string) string {
s := strings.TrimSpace(rawURL)
if !strings.HasPrefix(s, "data:") {
return ""
}
comma := strings.Index(s, ",")
if comma < 0 {
return ""
}
meta := s[len("data:"):comma]
payload := s[comma+1:]
// MIME 限定在 text/*(书源也可能用 text/plain 塞 HTML)
mime := meta
if i := strings.Index(mime, ";"); i >= 0 {
mime = mime[:i]
}
if mime != "" && !strings.HasPrefix(mime, "text/") {
return ""
}
if strings.Contains(strings.ToLower(meta), ";base64") {
// base64 可能带换行;同时兼容 URL-safe 变体
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, payload)
if b, err := base64.StdEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.URLEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.RawURLEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
return ""
}
if dec, err := url.PathUnescape(payload); err == nil {
return dec
}
return ""
}
@@ -0,0 +1,305 @@
package rule
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"sync"
"testing"
"time"
)
// 本文件:书源宿主浏览器(java.startBrowser / startBrowserAwait)的运行时测试。
//
// 这些用例对应真实聚合类书源的「切换线路」「用户后台」路径:
// 书源把页面交给宿主,宿主回传用户操作后的页面源码,书源再从 DOM 里解析结果。
// fakeBrowserHost 记录宿主调用并按用例给定的函数回传结果。
type fakeBrowserHost struct {
mu sync.Mutex
awaitCalls []BrowserTask
openCalls []BrowserTask
respond func(req BrowserTask) (BrowserResult, error)
openErr error
}
func (f *fakeBrowserHost) AwaitBrowser(_ context.Context, req BrowserTask) (BrowserResult, error) {
f.mu.Lock()
f.awaitCalls = append(f.awaitCalls, req)
respond := f.respond
f.mu.Unlock()
if respond == nil {
return BrowserResult{URL: req.URL, Body: ""}, nil
}
return respond(req)
}
func (f *fakeBrowserHost) OpenBrowser(_ context.Context, req BrowserTask) error {
f.mu.Lock()
defer f.mu.Unlock()
f.openCalls = append(f.openCalls, req)
return f.openErr
}
// newBrowserRunner 用聚合源固件构建运行时,并注入宿主浏览器。
func newBrowserRunner(t *testing.T, state SourceState, host BrowserHost, timeout time.Duration) *JSRunner {
t.Helper()
props := sampleSourceProps(t)
r := NewJSRunner(JSConfig{
SourceProps: props,
JSLib: props["jsLib"].(string),
State: state,
BaseURL: "https://v1.example-aggregate.com",
Browser: host,
Timeout: timeout,
Fetch: func(req *Request) (string, string, int, error) {
return `{}`, req.URL, 200, nil
},
})
if err := r.JSLibErr(); err != nil {
t.Fatalf("jsLib 执行失败: %v", err)
}
return r
}
// TestParseDataHTML 内嵌页面地址应被解码(base64 与百分号编码两种形式)。
func TestParseDataHTML(t *testing.T) {
page := "<html><body>线路</body></html>"
cases := map[string]string{
"base64": "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(page)),
"charset+base64": "data:text/html;charset=utf-8;base64," +
base64.StdEncoding.EncodeToString([]byte(page)),
"urlencoded": "data:text/html," + "%3Chtml%3E%3Cbody%3E%23%3C%2Fbody%3E%3C%2Fhtml%3E",
}
for name, raw := range cases {
got := ParseDataHTML(raw)
if got == "" {
t.Fatalf("%s: 未解码出页面内容", name)
}
}
if got := ParseDataHTML("https://example.com/a"); got != "" {
t.Fatalf("非 data 地址不应解码,实际 %q", got)
}
if got := ParseDataHTML("data:image/png;base64,AAAA"); got != "" {
t.Fatalf("非文本 MIME 不应解码,实际 %q", got)
}
// base64 带换行(书源拼接长 HTML 时常见)
wrapped := "data:text/html;base64," + wrapBase64(base64.StdEncoding.EncodeToString([]byte(page)))
if got := ParseDataHTML(wrapped); got != page {
t.Fatalf("带换行的 base64 解码失败: %q", got)
}
}
func wrapBase64(s string) string {
var b strings.Builder
for i, r := range s {
if i > 0 && i%40 == 0 {
b.WriteByte('\n')
}
b.WriteRune(r)
}
return b.String()
}
// TestSwitchLineViaStartBrowserAwait 核心回归:书源用 startBrowserAwait 打开
// 内嵌页面,宿主回传用户操作后的 DOM,书源据此切换线路。
func TestSwitchLineViaStartBrowserAwait(t *testing.T) {
state := NewMemoryState()
host := &fakeBrowserHost{}
host.respond = func(req BrowserTask) (BrowserResult, error) {
// 宿主应当已经把 data: 地址解码成可承载的页面源码
if !strings.Contains(req.HTML, `id="serverValue"`) {
t.Errorf("宿主未收到解码后的页面: %q", req.HTML)
}
if req.Title != "线路设置" {
t.Errorf("标题 = %q", req.Title)
}
if req.Refetch {
t.Error("第 3 个参数为 false 时不应要求重新抓取")
}
// 模拟用户在页面里选了 v2 线路后回传的 DOM
return BrowserResult{
URL: req.URL,
Body: `<html><body><span id="serverValue">https://v2.example-aggregate.com</span></body></html>`,
}, nil
}
r := newBrowserRunner(t, state, host, 5*time.Second)
loginJS := loadTestdata(t, "sample_loginUrl.js")
v, err := r.EvalAction(loginJS+"\nswitchLine()", nil)
if err != nil {
t.Fatalf("switchLine 执行失败: %v", err)
}
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
t.Fatalf("switchLine 返回 %q", got)
}
// 书源应把解析出的线路写进源变量,并让 BaseUrl 跟随
if got := state.GetVariable(); !strings.Contains(got, "v2.example-aggregate.com") {
t.Fatalf("线路未写入源变量: %q", got)
}
base, err := r.EvalAction(`BaseUrl()`, nil)
if err != nil {
t.Fatal(err)
}
if got := anyToString(base); got != "https://v2.example-aggregate.com" {
t.Fatalf("BaseUrl 未跟随线路: %q", got)
}
if len(host.awaitCalls) != 1 {
t.Fatalf("宿主 await 调用次数 = %d", len(host.awaitCalls))
}
}
// TestStartBrowserAwaitPausesJSTimeout 等待人工操作期间不应被 JS 执行超时打断。
// 宿主故意比 Timeout 慢,若看门狗没有暂停,goja 会在恢复执行时抛出超时异常。
func TestStartBrowserAwaitPausesJSTimeout(t *testing.T) {
state := NewMemoryState()
host := &fakeBrowserHost{}
host.respond = func(req BrowserTask) (BrowserResult, error) {
// 模拟用户慢慢点选:远超 Timeout
time.Sleep(300 * time.Millisecond)
return BrowserResult{URL: req.URL, Body: "ok"}, nil
}
r := newBrowserRunner(t, state, host, 80*time.Millisecond)
loginJS := loadTestdata(t, "sample_loginUrl.js")
if _, err := r.EvalAction(loginJS+"\nswitchLine()", nil); err != nil {
t.Fatalf("等待人工操作期间被误判为 JS 超时: %v", err)
}
}
// TestStartBrowserAwaitCancel 用户取消页面时应把异常抛回书源,
// 书源走自己的降级分支(提示语),而不是拿到空 body 当成成功。
func TestStartBrowserAwaitCancel(t *testing.T) {
state := NewMemoryState()
host := &fakeBrowserHost{}
host.respond = func(BrowserTask) (BrowserResult, error) {
return BrowserResult{}, ErrBrowserCancelled
}
r := newBrowserRunner(t, state, host, 5*time.Second)
loginJS := loadTestdata(t, "sample_loginUrl.js")
_, err := r.EvalAction(loginJS+"\nswitchLine()", nil)
if err == nil {
t.Fatal("取消后应把异常抛回书源")
}
if !strings.Contains(err.Error(), "取消") {
t.Fatalf("错误信息未说明取消: %v", err)
}
}
// TestUserBackendUsesAwait 用户后台按钮同样走 startBrowserAwait(普通 http 地址)。
func TestUserBackendUsesAwait(t *testing.T) {
state := NewMemoryState()
state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn")
host := &fakeBrowserHost{}
host.respond = func(req BrowserTask) (BrowserResult, error) {
if !strings.HasSuffix(req.URL, "/user") {
t.Errorf("用户后台地址 = %q", req.URL)
}
return BrowserResult{URL: req.URL, Body: "<html>用户后台</html>"}, nil
}
r := newBrowserRunner(t, state, host, 5*time.Second)
loginJS := loadTestdata(t, "sample_loginUrl.js")
if _, err := r.EvalAction(loginJS+"\nuser()", nil); err != nil {
t.Fatalf("user() 执行失败: %v", err)
}
if len(host.awaitCalls) != 1 {
t.Fatalf("应为用户后台打开页面,await 调用 = %d", len(host.awaitCalls))
}
}
// TestStartBrowserDoesNotWait startBrowser 只展示,不阻塞也不回传。
func TestStartBrowserDoesNotWait(t *testing.T) {
state := NewMemoryState()
host := &fakeBrowserHost{}
r := newBrowserRunner(t, state, host, 5*time.Second)
if _, err := r.EvalAction(`java.startBrowser('https://vip.example.com', '光遇看书')`, nil); err != nil {
t.Fatalf("startBrowser 失败: %v", err)
}
if len(host.openCalls) != 1 || len(host.awaitCalls) != 0 {
t.Fatalf("startBrowser 应只展示不等待: open=%d await=%d", len(host.openCalls), len(host.awaitCalls))
}
if host.openCalls[0].URL != "https://vip.example.com" {
t.Fatalf("展示地址 = %q", host.openCalls[0].URL)
}
}
// TestShowBrowserOpensDialog showBrowser 与 startBrowser 一样是「打开即返回」。
func TestShowBrowserOpensDialog(t *testing.T) {
state := NewMemoryState()
host := &fakeBrowserHost{}
r := newBrowserRunner(t, state, host, 5*time.Second)
if _, err := r.EvalAction(`java.showBrowser('https://example.com/settings', '', '光遇书源设置', '')`, nil); err != nil {
t.Fatalf("showBrowser 失败: %v", err)
}
if len(host.openCalls) != 1 {
t.Fatalf("showBrowser 应展示页面: %d", len(host.openCalls))
}
}
// TestEnvProbeAPIsThrow 服务端不具备的 UI 函数必须抛异常,而不是静默成功。
//
// 这条对真实书源很关键:聚合源用 checkEnv() 探测运行环境,其中
// try { java.qread(); return "轻阅"; } catch (e) {}
// 旧实现把 qread 做成「返回 null 的空操作」,于是探测结果变成「轻阅」,
// 书源据此跳过自己的降级分支,表现为「按钮点了没反应」。
func TestEnvProbeAPIsThrow(t *testing.T) {
r := NewJSRunner(JSConfig{State: NewMemoryState()})
for _, name := range []string{"qread", "showReadingBrowser", "startBrowserDp", "open", "searchBook"} {
if _, err := r.EvalAction("java."+name+"()", nil); err == nil {
t.Fatalf("java.%s 应当抛异常(服务端无此能力)", name)
}
}
// checkEnv 结构:qread 抛异常后应落到最后的 "改版",而不是 "轻阅"
v, err := r.EvalAction(`(function () {
try { java.qread(); return '轻阅'; } catch (e) {}
try { java.deviceID(); return '苹果'; } catch (e) {}
return '改版';
})()`, nil)
if err != nil {
t.Fatalf("环境探测执行失败: %v", err)
}
if got := anyToString(v); got != "改版" {
t.Fatalf("环境探测 = %q,期望 改版", got)
}
}
// TestUpLoginDataAndReLoginView 表单回填与重画信号应被显式记录。
func TestUpLoginDataAndReLoginView(t *testing.T) {
state := NewMemoryState()
r := NewJSRunner(JSConfig{State: state})
if _, err := r.EvalAction(`java.upLoginData({邮箱:'user@example.com', 昵称:'tester'})`, nil); err != nil {
t.Fatalf("upLoginData 失败: %v", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(state.GetLoginInfo()), &m); err != nil {
t.Fatalf("登录信息不是 JSON: %q", state.GetLoginInfo())
}
if m["邮箱"] != "user@example.com" || m["昵称"] != "tester" {
t.Fatalf("表单值未回填: %v", m)
}
if !state.UIRefreshRequested() {
t.Fatal("upLoginData 应请求重画表单")
}
// 标记是「取走即清」
if state.UIRefreshRequested() {
t.Fatal("重画标记应被消费")
}
if _, err := r.EvalAction(`java.reLoginView()`, nil); err != nil {
t.Fatalf("reLoginView 失败: %v", err)
}
if !state.UIRefreshRequested() {
t.Fatal("reLoginView 应请求重画表单")
}
if _, err := r.EvalAction(`java.refreshExplore()`, nil); err != nil {
t.Fatalf("refreshExplore 失败: %v", err)
}
if !state.UIRefreshRequested() {
t.Fatal("refreshExplore 应请求重画表单")
}
}
+81
View File
@@ -0,0 +1,81 @@
package rule
import (
"encoding/base64"
"encoding/hex"
"regexp"
"strings"
)
// 本文件:data: 地址与「type 声明」——书源用它当参数信封。
//
// 聚合类书源(光遇聚合等)会把上一阶段的结果打包进一个 data: 地址当 URL 用:
//
// searchUrl → `data:;base64,<base64(参数JSON)>,{"type":"gysearch"}`
// bookUrl → `data:;base64,<base64({book_id,sources,…})>,{"type":"gydetail"}`
// tocUrl / chapterUrl 同理
//
// legado 对这类地址的处理(AnalyzeUrl):
// 1. getByteArrayIfDataUri():地址以 data: 开头时本地 base64 解码取字节,不发网络请求;
// 2. getStrResponseAwait():只要选项里声明了 type(值本身不参与判断),
// 就直接返回 `HexUtil.encodeHexStr(字节)`——即原始字节的十六进制串。
//
// 书源随后用 `java.hexDecodeToString(result)` 把 hex 还原成 JSON 取回参数,
// 再自行发出真正的请求。所以「type」不是内容类型,而是一个「请以 hex 返回
// 原始字节」的开关;MeBox 早期把它当成不支持的能力直接拒绝了,导致这类书源
// 在搜索第一步就报「书源 URL 声明了不支持的 type」。
// dataURIPayloadRe 对应 legado AppPattern.dataUriRegex:`^data:.*?;base64,(.*)`。
// 只认带 `;base64,` 的数据地址(legado 也只处理这一种)。
var dataURIPayloadRe = regexp.MustCompile(`(?s)^data:.*?;base64,(.*)$`)
// DecodeDataURI 解析 `data:…;base64,…` 地址,返回解码后的原始字节。
// ok 为 false 表示这不是一个可解析的 base64 数据地址。
// 对应 legado AnalyzeUrl.getByteArrayIfDataUri()。
func DecodeDataURI(raw string) ([]byte, bool) {
m := dataURIPayloadRe.FindStringSubmatch(strings.TrimSpace(raw))
if m == nil {
return nil, false
}
// base64 允许换行与空白(书源拼接长载荷时常见),逐种变体尝试。
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, m[1])
if cleaned == "" {
return []byte{}, true
}
for _, enc := range []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
} {
if b, err := enc.DecodeString(cleaned); err == nil {
return b, true
}
}
return nil, false
}
// IsDataURI 判断地址是否是 data: 地址。
func IsDataURI(raw string) bool {
return strings.HasPrefix(strings.TrimSpace(raw), "data:")
}
// EncodeRuleBody 按请求声明的响应形态把原始字节转成规则层看到的 body。
//
// HexBody(选项里声明了 type)→ 原始字节的十六进制串
// 其他 → 按 charset 解码的文本
//
// 对应 legado getStrResponseAwait 里的两个分支。
func EncodeRuleBody(req *Request, raw []byte, charset string) string {
if req != nil && req.HexBody {
return hex.EncodeToString(raw)
}
if body, err := DecodeBytes(raw, charset); err == nil {
return body
}
return string(raw)
}
@@ -0,0 +1,108 @@
package rule
import (
"encoding/base64"
"encoding/hex"
"strings"
"testing"
)
// 本文件:data: 参数信封 + URL 选项 type 的回归测试。
//
// 背景:聚合类书源把上一阶段的结果打包成
// `data:;base64,<base64(参数JSON)>,{"type":"gysearch"}`
// 当 URL 用。legado 对它的处理是:① 本地 base64 解码取字节;② 因为声明了 type,
// 把字节按 hex 返回。MeBox 早期把带 type 的 URL 直接判为「不支持的能力」,
// 导致这类书源第一步就报「书源 URL 声明了不支持的 type」。
func TestDecodeDataURI(t *testing.T) {
payload := `{"key":"宠魅","page":1}`
enc := base64.StdEncoding.EncodeToString([]byte(payload))
cases := map[string]string{
"标准 base64": "data:;base64," + enc,
"带 MIME": "data:application/json;base64," + enc,
"无 padding": "data:;base64," + base64.RawStdEncoding.EncodeToString([]byte(payload)),
"URL-safe": "data:;base64," + base64.URLEncoding.EncodeToString([]byte(payload)),
"含空白/换行": "data:;base64," + wrapBase64(enc),
}
for name, raw := range cases {
got, ok := DecodeDataURI(raw)
if !ok {
t.Fatalf("%s: 应能解码", name)
}
if string(got) != payload {
t.Fatalf("%s: 解码结果 %q", name, string(got))
}
}
for _, raw := range []string{
"https://example.com/a",
"data:text/plain,hello", // 非 base64 形式
"",
} {
if _, ok := DecodeDataURI(raw); ok {
t.Fatalf("不应识别为 base64 数据地址: %q", raw)
}
}
}
// TestParseAnalyzeUrlDataTypeDeclaration 带 type 的 data: 地址应被解析为
// 「hex 返回」的请求,而不是不支持的能力。
func TestParseAnalyzeUrlDataTypeDeclaration(t *testing.T) {
payload := base64.StdEncoding.EncodeToString([]byte(`{"key":"宠魅"}`))
raw := "data:;base64," + payload + `,{"type":"gysearch"}`
req, err := ParseAnalyzeUrlWithJS(raw, "", 0, "https://example.com", NewJSRunner(JSConfig{}))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if req.Unsupported != nil {
t.Fatalf("带 type 的地址不应被判为不支持: %v", req.Unsupported)
}
if !req.HexBody {
t.Fatal("声明了 type 时应按 hex 返回响应")
}
// 选项里的 JSON 不能被当成数据载荷的一部分
if strings.Contains(req.URL, `"type"`) {
t.Fatalf("选项未与地址分离: %q", req.URL)
}
if _, ok := DecodeDataURI(req.URL); !ok {
t.Fatalf("URL 不是可解码的数据地址: %q", req.URL)
}
}
// TestParseAnalyzeUrlWithoutType 没有 type 时不应进入 hex 模式。
func TestParseAnalyzeUrlWithoutType(t *testing.T) {
req, err := ParseAnalyzeUrlWithJS("https://example.com/a?x=1", "", 0, "https://example.com", NewJSRunner(JSConfig{}))
if err != nil {
t.Fatal(err)
}
if req.HexBody {
t.Fatal("未声明 type 时不应按 hex 返回")
}
if req.URL != "https://example.com/a?x=1" {
t.Fatalf("URL = %q", req.URL)
}
}
// TestEncodeRuleBody 声明 type 时返回原始字节的 hex,否则返回解码后的文本。
func TestEncodeRuleBody(t *testing.T) {
raw := []byte(`{"key":"宠魅"}`)
hexReq := &Request{HexBody: true}
got := EncodeRuleBody(hexReq, raw, "")
if got != hex.EncodeToString(raw) {
t.Fatalf("hex 模式 = %q", got)
}
// 书源会用它还原
back, err := hex.DecodeString(got)
if err != nil || string(back) != string(raw) {
t.Fatalf("hex 往返失败: %v %q", err, string(back))
}
plainReq := &Request{}
if got := EncodeRuleBody(plainReq, raw, ""); got != string(raw) {
t.Fatalf("非 hex 模式 = %q", got)
}
}
-2
View File
@@ -12,6 +12,4 @@ var (
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
// ErrTypeUnsupported 书源 URL 声明了 type(zip/file 等),暂不支持。
ErrTypeUnsupported = errors.New("书源 URL 声明了不支持的 type")
)
+159 -14
View File
@@ -1,6 +1,7 @@
package rule
import (
"context"
"fmt"
"strings"
"sync"
@@ -68,6 +69,80 @@ type JSConfig struct {
// StateOnly 只构建会话状态与 jsLib 环境(登录交互用),
// 不注入 book/result 等规则上下文。
StateOnly bool
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
// nil 时这两个函数抛出不支持错误。
Browser BrowserHost
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
Ctx context.Context
}
// interruptGuard 是 JS 执行超时的看门狗:到期后中断虚拟机。
//
// 单独抽出来的原因是 java.startBrowserAwait 会阻塞等待用户在网页上操作
// (可达数分钟),这段时间必须暂停计时,否则默认 10s 的超时会在用户还没
// 点完 √ 时就把脚本打断。Pause/Resume 之间不计时,Resume 后重新起算完整
// 的一段预算——语义即「每一段自动执行各有一次预算,等人不算」。
type interruptGuard struct {
vm *goja.Runtime
timeout time.Duration
reason string
mu sync.Mutex
paused int
stopped bool
timer *time.Timer
}
func newInterruptGuard(vm *goja.Runtime, timeout time.Duration, reason string) *interruptGuard {
g := &interruptGuard{vm: vm, timeout: timeout, reason: reason}
g.start()
return g
}
// start 起一个新的超时计时(调用方需持锁或处于初始化阶段)。
func (g *interruptGuard) start() {
g.timer = time.AfterFunc(g.timeout, func() {
g.mu.Lock()
skip := g.stopped || g.paused > 0
g.mu.Unlock()
if !skip {
g.vm.Interrupt(g.reason)
}
})
}
// Pause 暂停计时(等待人工操作),返回恢复函数。
func (g *interruptGuard) Pause() func() {
g.mu.Lock()
g.paused++
if g.timer != nil {
g.timer.Stop()
g.timer = nil
}
g.mu.Unlock()
return g.Resume
}
// Resume 恢复计时。
func (g *interruptGuard) Resume() {
g.mu.Lock()
defer g.mu.Unlock()
if g.paused > 0 {
g.paused--
}
if g.paused == 0 && !g.stopped && g.timer == nil {
g.start()
}
}
// Stop 永久停止计时(本次 JS 执行结束)。
func (g *interruptGuard) Stop() {
g.mu.Lock()
defer g.mu.Unlock()
g.stopped = true
if g.timer != nil {
g.timer.Stop()
g.timer = nil
}
}
// JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。
@@ -78,6 +153,28 @@ type JSRunner struct {
state SourceState
// jsLibErr 记录 jsLib 执行失败原因(登录接口需要如实回报)。
jsLibErr error
// guard 当前执行的超时看门狗;java.startBrowserAwait 阻塞期间置为 nil。
guardMu sync.Mutex
guard *interruptGuard
}
// setGuard 记录/清除当前执行的看门狗。
func (r *JSRunner) setGuard(g *interruptGuard) {
r.guardMu.Lock()
r.guard = g
r.guardMu.Unlock()
}
// pauseTimeout 暂停当前 JS 执行的超时计时,返回恢复函数。
// 供 java.startBrowserAwait 在等待人工操作期间调用。
func (r *JSRunner) pauseTimeout() func() {
r.guardMu.Lock()
g := r.guard
r.guardMu.Unlock()
if g == nil {
return func() {}
}
return g.Pause()
}
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
@@ -119,8 +216,12 @@ func (r *JSRunner) loadJSLib() {
r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err)
return
}
timer := time.AfterFunc(r.cfg.Timeout, func() { r.vm.Interrupt("jsLib 执行超时") })
defer timer.Stop()
g := newInterruptGuard(r.vm, r.cfg.Timeout, "jsLib 执行超时")
r.setGuard(g)
defer func() {
g.Stop()
r.setGuard(nil)
}()
if _, err := r.vm.RunProgram(prog); err != nil {
r.jsLibErr = fmt.Errorf("jsLib 执行失败: %v", err)
}
@@ -160,8 +261,12 @@ func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) {
if err != nil {
return nil, fmt.Errorf("JS 编译失败: %w", err)
}
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") })
defer timer.Stop()
g := newInterruptGuard(vm, r.cfg.Timeout, "JS 执行超时")
r.setGuard(g)
defer func() {
g.Stop()
r.setGuard(nil)
}()
v, err := vm.RunProgram(prog)
if err != nil {
return nil, fmt.Errorf("JS 执行失败: %v", err)
@@ -187,8 +292,12 @@ func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (s
if err != nil {
return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err)
}
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("loginCheckJs 执行超时") })
defer timer.Stop()
g := newInterruptGuard(vm, r.cfg.Timeout, "loginCheckJs 执行超时")
r.setGuard(g)
defer func() {
g.Stop()
r.setGuard(nil)
}()
v, err := vm.RunProgram(prog)
if err != nil {
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
@@ -232,8 +341,8 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
r.installJava(a)
// 上下文绑定
if a != nil {
vm.Set("book", map[string]any{"name": a.bookName})
vm.Set("chapter", map[string]any{"title": a.chapterTitle})
vm.Set("book", newBookObject(vm, a))
vm.Set("chapter", newChapterObject(vm, a))
vm.Set("title", a.chapterTitle)
if a.content != nil {
vm.Set("src", resultString(a.content))
@@ -247,11 +356,18 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
vm.Set("src", nil)
}
base := baseURL
// baseUrl 绑定优先用「解析器当前处理的页面地址」再退回书源地址。
//
// 对应 legado:evalJS 里 bindings["baseUrl"] = analyzeRule.baseUrl,
// 而 baseUrl 由各阶段 setBaseUrl(bookUrl / tocUrl / chapterUrl) 设定。
// 这一点很关键:聚合类书源会用 String(baseUrl).startsWith("data:")
// 判断「当前这一层是不是书源自搭的参数信封」,若把 baseUrl 固定成书源地址,
// 书源会走 else 分支直接把 hex 原文当结果返回,详情/目录随之全空。
if base == "" && a != nil {
base = a.baseUrl
}
if base == "" {
base = r.cfg.BaseURL
if a != nil && base == "" {
base = a.baseUrl
}
}
vm.Set("baseUrl", base)
vm.Set("result", toJSValue(vm, result))
@@ -267,12 +383,16 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
}
vm.Set("nextChapterUrl", nil)
prog, err := compileCached(js)
prog, err := compileCached(scopedRuleJS(js))
if err != nil {
return nil, fmt.Errorf("JS 编译失败: %w", err)
}
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") })
defer timer.Stop()
g := newInterruptGuard(vm, r.cfg.Timeout, "JS 执行超时")
r.setGuard(g)
defer func() {
g.Stop()
r.setGuard(nil)
}()
v, err := vm.RunProgram(prog)
if err != nil {
return nil, fmt.Errorf("JS 执行失败: %v", err)
@@ -280,6 +400,31 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
return exportValue(v), nil
}
// scopedRuleJS 把一段规则 JS 包进块作用域后编译。
//
// 同一个 goja Runtime 会被一个书源的所有规则 JS 复用,而顶层 let/const 会留在
// 全局词法环境里,于是「前一个脚本声明过的名字,后一个脚本再声明」会直接报
// `SyntaxError: Identifier 'x' has already been declared`。
//
// 典型触发(光遇聚合):搜索列表规则是
//
// <js>const { key, tab, sourcesKey, page, ... } = res; ...</js>$.data
//
// 而单本书的 bookUrl 规则是
//
// <js>let book_id = ...; let tab = result.tab || '小说'; ...</js>
//
// 两者在同一轮解析里先后执行,第二个必然编译失败;因为失败发生在「逐条取字段」
// 阶段且被 continue 跳过,表现出来就是「搜索有结果但一条都读不出来」。
// legado 用的 Rhino 对顶层 let 更宽松,所以同一书源在阅读 App 里是正常的。
//
// 包一层块即可隔离词法声明,同时保留块最后表达式的值(JS 规范中块的完成值就是
// 最后一条语句的值),也不改变 this(仍是全局对象,书源的 this.getVariable /
// this.BaseUrl 照常可用)。
func scopedRuleJS(js string) string {
return "{\n" + js + "\n}"
}
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
func exportValue(v goja.Value) any {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
@@ -0,0 +1,98 @@
package rule
import (
"strings"
"testing"
)
// 本文件:规则 JS 作用域隔离的回归测试。
//
// 同一个 goja Runtime 会被一个书源的所有规则 JS 复用,而顶层 let/const 会留在
// 全局词法环境里。若不做隔离,「前一个脚本声明过的名字,后一个脚本再声明」就会
// 报 `SyntaxError: Identifier 'x' has already been declared`。
//
// 光遇聚合正好踩中:搜索列表规则是 `const { key, tab, ... } = res`,
// 单本书的 bookUrl 规则是 `let tab = result.tab || '小说'`。失败发生在「逐条取
// 字段」阶段且被 continue 跳过,于是表现为「搜索有结果但一条都读不出来」。
// legado 用的 Rhino 对顶层 let 更宽松,所以同一书源在阅读 App 里正常。
// TestRuleJSScopeIsIsolated 先后执行的规则 JS 不应因顶层 let/const 重名而失败。
func TestRuleJSScopeIsIsolated(t *testing.T) {
r := NewJSRunner(JSConfig{})
// 第一个脚本:解构声明(搜索列表规则的写法)
v, err := r.Run(nil, `const { tab, key } = {tab:'A', key:'K'}; tab + key`, nil, "")
if err != nil {
t.Fatalf("第一个脚本失败: %v", err)
}
if got := anyToString(v); got != "AK" {
t.Fatalf("第一个脚本返回 %q", got)
}
// 第二个脚本重名声明(bookUrl 规则的写法):不得报「已声明」
for i := 0; i < 3; i++ {
v, err = r.Run(nil, `let tab = 'B'; let book_id = 'x' + tab; book_id`, nil, "")
if err != nil {
t.Fatalf("第 %d 次重名声明冲突: %v", i+2, err)
}
if got := anyToString(v); got != "xB" {
t.Fatalf("第 %d 次返回 %q", i+2, got)
}
}
}
// TestRuleJSKeepsCompletionValue 包块不能吃掉「最后一条语句的值」——
// 书源的 URL 规则几乎都靠这个完成值返回结果。
func TestRuleJSKeepsCompletionValue(t *testing.T) {
r := NewJSRunner(JSConfig{})
v, err := r.Run(nil, "`data:;base64,AAAA,{\"type\":\"gysearch\"}`", nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); !strings.HasPrefix(got, "data:;base64,") {
t.Fatalf("模板字面量的完成值丢了: %q", got)
}
// 末尾带行注释也不能把收尾的块注释掉
v, err = r.Run(nil, "let a = 'ok'; a // 末尾注释", nil, "")
if err != nil {
t.Fatalf("带末尾注释的脚本失败: %v", err)
}
if got := anyToString(v); got != "ok" {
t.Fatalf("返回 %q", got)
}
}
// TestRuleJSThisStaysGlobal 包块后 this 仍是全局对象,
// 书源的 this.getVariable / this.BaseUrl / this.request 才照常可用。
func TestRuleJSThisStaysGlobal(t *testing.T) {
r := NewJSRunner(JSConfig{})
v, err := r.Run(nil, `String(this === globalThis) + '|' + typeof java.ajax`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "true|function" {
t.Fatalf("this 语义被破坏: %q", got)
}
}
// TestRuleJSCanReadGlobalsFromJSLib 规则 JS 仍能读到 jsLib 定义的全局函数
// 与顶层 lexical 绑定(hosts 这类)。
func TestRuleJSCanReadGlobalsFromJSLib(t *testing.T) {
r := NewJSRunner(JSConfig{
JSLib: `let hosts = ['https://v1.example.com'];
function BaseUrl(){ return hosts[0] }
function pick(k){ return k + '@' + BaseUrl() }`,
})
if err := r.JSLibErr(); err != nil {
t.Fatalf("jsLib 失败: %v", err)
}
v, err := r.Run(nil, `{ let x = 'A'; pick(x) }`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "A@https://v1.example.com" {
t.Fatalf("规则读不到 jsLib 的全局: %q", got)
}
}
+79
View File
@@ -107,6 +107,85 @@ func newCookieObject(vm *goja.Runtime, state SourceState) *goja.Object {
return o
}
// ─── 书籍 / 章节对象 ───────────────────────────────────────────────────────
// newBookObject 构造规则 JS 里的 `book`(对应 legado 的 Book 实体)。
//
// 书源会读它的元数据(name / author / coverUrl / durChapterIndex / order / type…)、
// 给它赋值(book.type = …、book.imageStyle = …)、调用 setUseReplaceRule(),
// 以及用 getVariable / putVariable 读写书籍自定义变量。
//
// 早期这里只绑了 {"name": ...},书源一碰 `book.setUseReplaceRule(false)`
// 就 TypeError,整段详情/目录规则 JS 直接失败(表现为「详情空白、目录 0 章」)。
func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
o := vm.NewObject()
set := func(k string, v any) {
_ = o.Set(k, v)
}
for k, v := range a.bookMeta {
if k == "type" {
continue // type 用访问器,见下
}
set(k, v)
}
// name 以 SetBookContext 的值为准(legado 里 book.name 就是这个)
set("name", a.bookName)
set("bookName", a.bookName)
// book.type:书源会赋值来声明书籍类型(听书=1 / 漫画=2 …),
// legado 会把它写回 Book.type,服务层据此决定正文按文本/音频/图片返回。
// 用访问器把写入记下来,否则赋值只活在本次 JS 里,读完仍是文本。
_ = o.DefineAccessorProperty("type",
vm.ToValue(func(call goja.FunctionCall) goja.Value { return vm.ToValue(a.bookTypeValue()) }),
vm.ToValue(func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) && !goja.IsNull(call.Arguments[0]) {
a.SetBookType(int(call.Arguments[0].ToInteger()))
}
return goja.Undefined()
}),
goja.FLAG_FALSE, goja.FLAG_TRUE)
// readConfig:书源读 book.readConfig.useReplaceRule,并可能回写
rc := vm.NewObject()
_ = rc.Set("useReplaceRule", false)
set("readConfig", rc)
set("setUseReplaceRule", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
_ = rc.Set("useReplaceRule", call.Arguments[0].ToBoolean())
}
return goja.Null()
})
// 书籍自定义变量(legado Book.variableMap)。
//
// 注意「缺省返回空串」:legado 的 RuleDataInterface.getVariable 是
// variableMap[key] ?: getBigVariable(key) ?: ""
// 返回 "" 而不是 null。书源会直接写 `String(book.getVariable('custom')) || ''`,
// 若这里返回 null,String(null) 得到字符串 "null"(真值),会被当成
// tone_id 发给站点,站点直接返回空正文(表现为「正文 0 字」)。
set("getVariable", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(a.bookCustom[stringArg(call, 0)])
})
set("putVariable", func(call goja.FunctionCall) goja.Value {
if a.bookCustom == nil {
a.bookCustom = map[string]string{}
}
a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "")
return goja.Null()
})
return o
}
// newChapterObject 构造规则 JS 里的 `chapter`(对应 legado 的 BookChapter)。
func newChapterObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
o := vm.NewObject()
_ = o.Set("title", a.chapterTitle)
_ = o.Set("index", a.chapterIndex)
_ = o.Set("isVip", false)
_ = o.Set("isPay", false)
return o
}
// newSourceObject 构造 JS 的 `source` 对象。
// 对应 legado BaseSource 的变量与登录信息读写。
func newSourceObject(vm *goja.Runtime, state SourceState, props map[string]any) *goja.Object {
@@ -136,9 +136,9 @@ func TestLoginMissingCredentials(t *testing.T) {
}
}
// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错,
// 避免书源逻辑把空 body 当成校验成功。
func TestStartBrowserAwaitRecordsURL(t *testing.T) {
// TestStartBrowserAwaitWithoutHost startBrowserAwait 在未注入宿主浏览器时应
// 明确报错,并记录待打开地址,避免书源逻辑把空 body 当成校验成功。
func TestStartBrowserAwaitWithoutHost(t *testing.T) {
state := NewMemoryState()
r := newSampleRunner(t, state)
loginJS := loadTestdata(t, "sample_loginUrl.js")
@@ -146,7 +146,7 @@ func TestStartBrowserAwaitRecordsURL(t *testing.T) {
// 先造出登录态,让 user() 走到 startBrowserAwait
state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn")
_, err := r.EvalAction(loginJS+"\nuser()", nil)
if err == nil || !strings.Contains(err.Error(), "无浏览器") {
if err == nil || !strings.Contains(err.Error(), "浏览器") {
t.Fatalf("应明确报不支持,实际: %v", err)
}
browsers := state.Browsers()
+52
View File
@@ -1,6 +1,7 @@
package rule
import (
"encoding/json"
"net"
"net/url"
"strings"
@@ -43,6 +44,20 @@ type SourceState interface {
OpenBrowser(url, title string)
}
// UIState 是可选实现的登录界面信号接口。
//
// java.reLoginView / java.refreshExplore / java.upLoginData 在 legado 中直接
// 操作登录对话框的控件。服务端不能碰 DOM,因此把意图显式交给宿主处理,
// 而不是静默丢弃——静默丢弃会让书源以为表单已经按它的预期更新了。
type UIState interface {
// RequestUIRefresh 请求宿主重新渲染登录表单
// (对应 legado SourceLoginJsExtensions.reLoginView / refreshExplore)。
RequestUIRefresh()
// ApplyLoginData 把书源给出的值合并进登录表单
// (对应 legado SourceLoginJsExtensions.upLoginData)。
ApplyLoginData(data map[string]string)
}
// CookieDomain 取 URL 的有效顶级域 +1(对应 legado NetworkUtils.getSubDomain)。
// 裸域名(无 scheme)按 http 处理;IP 原样返回;解析失败回退 host 本身。
func CookieDomain(rawURL string) string {
@@ -114,6 +129,7 @@ type MemoryState struct {
cookies map[string]map[string]string // domain → name → value
toasts []string
browsers []BrowserRequest
uiRefresh bool
}
// BrowserRequest 前端可代为打开的浏览器地址(java.startBrowser 收集)。
@@ -208,6 +224,42 @@ func (m *MemoryState) OpenBrowser(url, title string) {
m.browsers = append(m.browsers, BrowserRequest{URL: url, Title: title})
}
// RequestUIRefresh 实现 UIState(进程内实现,供单测)。
func (m *MemoryState) RequestUIRefresh() {
m.mu.Lock()
defer m.mu.Unlock()
m.uiRefresh = true
}
// ApplyLoginData 实现 UIState:合并进 loginInfo。
func (m *MemoryState) ApplyLoginData(data map[string]string) {
if len(data) == 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
cur := map[string]string{}
if m.loginInfo != "" {
_ = json.Unmarshal([]byte(m.loginInfo), &cur)
}
for k, v := range data {
cur[k] = v
}
if b, err := json.Marshal(cur); err == nil {
m.loginInfo = string(b)
}
m.uiRefresh = true
}
// UIRefreshRequested 返回并清空「重画登录表单」标记。
func (m *MemoryState) UIRefreshRequested() bool {
m.mu.Lock()
defer m.mu.Unlock()
out := m.uiRefresh
m.uiRefresh = false
return out
}
// Toasts 返回并清空已收集的宿主提示。
func (m *MemoryState) Toasts() []string {
m.mu.Lock()
@@ -50,6 +50,28 @@ function user() {
java.startBrowserAwait(BaseUrl() + '/user', '用户后台');
}
// 切换线路:与真实聚合源同构——把一段内嵌 HTML 交给宿主浏览器,
// 用户在页面里点选,宿主回传「操作后的页面源码」,书源再从 DOM 里
// 解析出所选线路写进源变量。(光遇聚合的 getServerSettings 即此结构)
function switchLine() {
let hostsbk = (getVariable('云端配置') || {})['hosts'] || hosts;
let html = '<!DOCTYPE html><html><body>'
+ '<span id="serverValue">' + BaseUrl() + '</span>'
+ '<span id="autoSwitchValue">true</span>'
+ '</body></html>';
let body = java.startBrowserAwait(
'data:text/html;base64,' + java.base64Encode(html), '线路设置', false
).body();
let match = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
if (!match) {
java.longToast('解析线路失败');
return;
}
setVariable('线路', match[1], false);
java.longToast('已切换到 ' + match[1]);
return match[1];
}
// 查看信息
function checkStatus() {
if (String(getToken()).length < 10) {
+37 -15
View File
@@ -39,10 +39,14 @@ type Request struct {
IsForm bool // Body 为已编码的 form 数据
IsJSON bool // 以 application/json 发送
Charset string
// HexBody 对应 URL 选项里的 type:声明了 type 时,响应按「原始字节的 hex」
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
HexBody bool
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
BodyJsFn func(body string) string
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
// 值为对应错误(webView/type;JS 在接入 runner 后已支持)。
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
Unsupported error
}
@@ -198,8 +202,10 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
}
}
req.Charset = option.Charset
if option.Type != "" && req.Unsupported == nil {
req.Unsupported = ErrTypeUnsupported
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
if option.Type != "" {
req.HexBody = true
}
if option.WebJs != "" && req.Unsupported == nil {
req.Unsupported = ErrWebJSUnsupported
@@ -232,6 +238,13 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
}
// ── query / body 编码(对应 analyzeUrl 尾部) ──
//
// query 一律先做一次百分号编码规范化:legado 底层的 OkHttp 会把非 ASCII 与
// 非法字符编码掉,而 Go 的 http 客户端会把 RawQuery 原样写进请求行 —— 原生
// 中文、花括号、引号会直接上线,服务端多半回 400/空响应(书源侧表现为
// request() 判定「线路报错」,把全部线路试一遍后返回空串)。
// POST 同样要编码:query 并不会挪进 body。
req.URL = normalizeQuery(req.URL, req.Charset)
if req.Method == "POST" {
req.URLNoQuery = req.URL
body := req.Body
@@ -241,23 +254,32 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
} else if isJSONStr(body) && req.Headers["Content-Type"] == "" {
req.IsJSON = true
}
} else if pos := strings.Index(req.URL, "?"); pos != -1 {
req.URLNoQuery = req.URL[:pos]
} else {
pos := strings.Index(req.URL, "?")
if pos != -1 {
query := encodeParams(req.URL[pos+1:], req.Charset, true)
req.URLNoQuery = req.URL[:pos]
if query != "" {
req.URL = req.URLNoQuery + "?" + query
} else {
req.URL = req.URLNoQuery
}
} else {
req.URLNoQuery = req.URL
}
req.URLNoQuery = req.URL
}
return req, nil
}
// normalizeQuery 把 URL 的 query 规范化成百分号编码形式。
// 已经编码好的 query 原样保留(对应 NetworkUtils.encodedQuery 的短路),data: 地址不动。
func normalizeQuery(rawURL, charset string) string {
if IsDataURI(rawURL) {
return rawURL
}
pos := strings.Index(rawURL, "?")
if pos < 0 {
return rawURL
}
base := rawURL[:pos]
query := encodeParams(rawURL[pos+1:], charset, true)
if query == "" {
return base
}
return base + "?" + query
}
var pagePatternRe = regexp.MustCompile(`<([^>]*)>`)
func useWebView(raw json.RawMessage) bool {
@@ -0,0 +1,149 @@
package rule
import (
"strings"
"testing"
)
// 本文件:URL query 规范化 与 规则 JS 上下文绑定的回归测试。
//
// 背景(光遇聚合):
// - 书源把参数拼进 query 后交给 java.ajax,且 POST 也把参数放在 query 里;
// MeBox 早期只对 GET 重编码 query,POST 的原生中文/引号会直接写进请求行,
// 服务端回 400/空响应,书源判定「线路报错」并把全部线路试一遍。
// - 书源用 String(baseUrl).startsWith("data:") 判断当前层是不是自搭的参数信封,
// 所以规则 JS 的 baseUrl 必须是「当前处理的页面地址」,不能固定成书源地址。
// TestNormalizeQueryEncodesNonASCII 非 ASCII 与非法字符必须被百分号编码。
func TestNormalizeQueryEncodesNonASCII(t *testing.T) {
raw := `https://example.com/detail?id=abc&source=番茄&variable={"custom":""}`
got := normalizeQuery(raw, "")
if strings.Contains(got, "番茄") {
t.Fatalf("中文未被编码: %s", got)
}
if !strings.Contains(got, "source=%E7%95%AA%E8%8C%84") {
t.Fatalf("中文编码结果异常: %s", got)
}
if strings.Contains(got, `{"custom":""}`) {
t.Fatalf("引号未被编码: %s", got)
}
// 分隔符与等号必须保留,否则参数结构会散掉
if !strings.Contains(got, "&") || !strings.Contains(got, "=") {
t.Fatalf("query 结构被破坏: %s", got)
}
}
// TestNormalizeQueryKeepsEncoded 已编码好的 query 原样保留,不二次编码。
func TestNormalizeQueryKeepsEncoded(t *testing.T) {
raw := "https://example.com/s?q=%E5%AE%A0%E9%AD%85&page=1"
if got := normalizeQuery(raw, ""); got != raw {
t.Fatalf("已编码 query 被改动:\n got=%s\nwant=%s", got, raw)
}
}
// TestNormalizeQueryLeavesDataURI data: 地址的载荷不是 query,不能动。
func TestNormalizeQueryLeavesDataURI(t *testing.T) {
raw := `data:;base64,eyJhIjoxfQ==`
if got := normalizeQuery(raw, ""); got != raw {
t.Fatalf("data 地址被改动: %s", got)
}
}
// TestParseAnalyzeUrlEncodesPOSTQuery POST 的 query 也要编码。
// 回归:之前只有 GET 分支做重编码,POST 的原生中文直接上线。
func TestParseAnalyzeUrlEncodesPOSTQuery(t *testing.T) {
raw := `https://example.com/detail?source=番茄,{"method":"POST","headers":{"Content-Type":"application/json"},"body":"{\"html\":\"\"}"}`
req, err := ParseAnalyzeUrlWithJS(raw, "", 0, "https://example.com", NewJSRunner(JSConfig{}))
if err != nil {
t.Fatal(err)
}
if req.Method != "POST" {
t.Fatalf("method = %q", req.Method)
}
if strings.Contains(req.URL, "番茄") {
t.Fatalf("POST query 未编码: %s", req.URL)
}
if !strings.Contains(req.URL, "source=%E7%95%AA%E8%8C%84") {
t.Fatalf("POST query 编码异常: %s", req.URL)
}
if req.Body != `{"html":""}` {
t.Fatalf("body 被改动: %q", req.Body)
}
}
// TestRuleJSBaseUrlPrefersPageURL 规则 JS 的 baseUrl 应是「当前页面地址」。
//
// 书源用 String(baseUrl).startsWith("data:") 判断当前层是不是自搭的参数信封;
// 若把 baseUrl 固定成书源地址,书源会走 else 分支把 hex 原文当结果返回。
func TestRuleJSBaseUrlPrefersPageURL(t *testing.T) {
const pageURL = `data:;base64,eyJhIjoxfQ==`
r := NewJSRunner(JSConfig{BaseURL: "https://source.example.com"})
ar := NewAnalyzeRule()
ar.SetContent("body", pageURL)
v, err := r.Run(ar, `baseUrl`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != pageURL {
t.Fatalf("baseUrl = %q,期望页面地址 %q", got, pageURL)
}
if !strings.HasPrefix(anyToString(v), "data:") {
t.Fatal("书源的 data: 分支判断会失效")
}
}
// TestRuleJSBaseUrlFallsBackToSource 页面地址为空时回退书源地址。
func TestRuleJSBaseUrlFallsBackToSource(t *testing.T) {
r := NewJSRunner(JSConfig{BaseURL: "https://source.example.com"})
v, err := r.Run(NewAnalyzeRule(), `baseUrl`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "https://source.example.com" {
t.Fatalf("baseUrl = %q", got)
}
}
// TestRuleJSBookObject 规则 JS 的 book 对象要具备书源依赖的成员。
//
// 回归:早期只绑了 {"name": ...},书源一碰 book.setUseReplaceRule(false)
// 就 TypeError,整段详情/目录规则 JS 直接失败。
func TestRuleJSBookObject(t *testing.T) {
state := NewMemoryState()
r := NewJSRunner(JSConfig{State: state})
ar := NewAnalyzeRule()
ar.SetBookContext("宠魅", nil)
ar.SetChapterContext("第1集", nil)
ar.SetChapterIndex(0)
js := `(function(){
book.setUseReplaceRule(false); // 早期会 TypeError
var v = String(book.getVariable('custom')); // 缺省必须是 ""(不是 "null")
book.type = 32; // 听书源这样声明类型
book.imageStyle = 'TEXT';
return book.name + '|' + v + '|' + book.type + '|' + chapter.title;
})()`
v, err := r.Run(ar, js, nil, "")
if err != nil {
t.Fatalf("book 对象成员缺失: %v", err)
}
if got := anyToString(v); got != "宠魅||32|第1集" {
t.Fatalf("book 行为异常: %q", got)
}
// 书源声明的类型要被服务层读回
if bt, ok := ar.BookTypeOverride(); !ok || bt != 32 {
t.Fatalf("BookTypeOverride = %v/%v,期望 32", bt, ok)
}
// putVariable 写回后 getVariable 能读到
if _, err := r.Run(ar, `book.putVariable('custom','v1')`, nil, ""); err != nil {
t.Fatal(err)
}
v, err = r.Run(ar, `String(book.getVariable('custom'))`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "v1" {
t.Fatalf("putVariable 后 getVariable = %q", got)
}
}