mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
优化阅读功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 应请求重画表单")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user