mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
开发
This commit is contained in:
@@ -404,6 +404,48 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
|
||||
}
|
||||
|
||||
// ── 宿主交互:服务端无 UI,转为可回传前端的提示 / 待打开链接 ──
|
||||
if r.state != nil {
|
||||
toast := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
r.state.Toast(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Null()
|
||||
}
|
||||
set("toast", toast)
|
||||
set("longToast", toast)
|
||||
// startBrowser(url, title):记录待打开地址,前端可代开新标签。
|
||||
set("startBrowser", func(call goja.FunctionCall) goja.Value {
|
||||
r.state.OpenBrowser(stringArg(call, 0), stringArgOr(call, 1, ""))
|
||||
return goja.Null()
|
||||
})
|
||||
// startBrowserAwait:服务端无 WebView,无法等待人工校验。
|
||||
// 记录地址后抛出明确错误,避免书源逻辑误把空 body 当成功。
|
||||
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))
|
||||
})
|
||||
}
|
||||
// 设备标识:部分源用 deviceID/androidId 做"是否支持该环境"探测,
|
||||
// 成功返回会让源走安卓分支,这里统一以异常告知不支持并回退到通用分支。
|
||||
for _, name := range []string{"deviceID", "androidId"} {
|
||||
set(name, func(call goja.FunctionCall) goja.Value {
|
||||
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() })
|
||||
}
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
@@ -411,7 +453,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"startBrowser", "startBrowserAwait", "openVideoPlayer", "getVerificationCode",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
|
||||
@@ -48,7 +48,8 @@ type JSFetcher func(req *Request) (body string, finalURL string, code int, err e
|
||||
// JSConfig 构造 JS 运行时的配置。
|
||||
type JSConfig struct {
|
||||
Fetch JSFetcher
|
||||
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样)。
|
||||
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样);
|
||||
// 同时供 source.getLoginInfoMap 从 loginUi 的 default 初始化登录信息。
|
||||
SourceProps map[string]any
|
||||
// Log 对应 java.log。
|
||||
Log func(msg string)
|
||||
@@ -59,32 +60,156 @@ type JSConfig struct {
|
||||
// Key / Page 搜索上下文绑定({{key}}/{{page}} 在 JS 里的取值)。
|
||||
Key string
|
||||
Page int
|
||||
// State 书源会话状态(变量/登录信息/Cookie)。nil 时用进程内 MemoryState。
|
||||
State SourceState
|
||||
// JSLib 书源 jsLib:在运行时创建后立即执行一次,
|
||||
// 其顶层函数与 lexical 绑定对该源后续所有 JS 可见(对应 legado SharedJsScope)。
|
||||
JSLib string
|
||||
// StateOnly 只构建会话状态与 jsLib 环境(登录交互用),
|
||||
// 不注入 book/result 等规则上下文。
|
||||
StateOnly bool
|
||||
}
|
||||
|
||||
// JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。
|
||||
type JSRunner struct {
|
||||
vm *goja.Runtime
|
||||
cfg JSConfig
|
||||
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
|
||||
vm *goja.Runtime
|
||||
cfg JSConfig
|
||||
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
|
||||
state SourceState
|
||||
// jsLibErr 记录 jsLib 执行失败原因(登录接口需要如实回报)。
|
||||
jsLibErr error
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时并注入全局对象:cookie / cache / source。
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = defaultJSTimeout
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}}
|
||||
vm.Set("cookie", newCookieObject(vm))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
if cfg.SourceProps != nil {
|
||||
vm.Set("source", cfg.SourceProps)
|
||||
} else {
|
||||
vm.Set("source", vm.NewObject())
|
||||
state := cfg.State
|
||||
if state == nil {
|
||||
state = NewMemoryState()
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
}
|
||||
|
||||
// loadJSLib 执行书源 jsLib(一次),使其中定义的函数与 lexical 绑定
|
||||
// 对后续 Run 可见(goja 的顶层 let/const 与函数声明在同一 Runtime 内保持)。
|
||||
func (r *JSRunner) loadJSLib() {
|
||||
if strings.TrimSpace(r.cfg.JSLib) == "" {
|
||||
return
|
||||
}
|
||||
prog, err := compileCached(r.cfg.JSLib)
|
||||
if err != nil {
|
||||
r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err)
|
||||
return
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { r.vm.Interrupt("jsLib 执行超时") })
|
||||
defer timer.Stop()
|
||||
if _, err := r.vm.RunProgram(prog); err != nil {
|
||||
r.jsLibErr = fmt.Errorf("jsLib 执行失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// JSLibErr 返回 jsLib 的执行错误(nil 表示正常)。
|
||||
func (r *JSRunner) JSLibErr() error { return r.jsLibErr }
|
||||
|
||||
// State 返回运行时使用的会话状态。
|
||||
func (r *JSRunner) State() SourceState { return r.state }
|
||||
|
||||
// EvalAction 在已装载 jsLib 的环境中执行一段交互 JS(登录按钮 action 等)。
|
||||
// bindings 为附加的 JS 全局绑定(如 result)。返回脚本的返回值。
|
||||
//
|
||||
// 绑定集合与 Run 对齐(baseUrl/key/page),因为登录交互 JS 同样会用到它们。
|
||||
func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return nil, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
if r.cfg.Key != "" {
|
||||
vm.Set("key", r.cfg.Key)
|
||||
} else {
|
||||
vm.Set("key", nil)
|
||||
}
|
||||
if r.cfg.Page > 0 {
|
||||
vm.Set("page", r.cfg.Page)
|
||||
} else {
|
||||
vm.Set("page", nil)
|
||||
}
|
||||
for k, v := range bindings {
|
||||
vm.Set(k, toJSValue(vm, v))
|
||||
}
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") })
|
||||
defer timer.Stop()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 执行失败: %v", err)
|
||||
}
|
||||
return exportValue(v), nil
|
||||
}
|
||||
|
||||
// EvalLoginCheck 执行书源的 loginCheckJs(对应 legado WebBook 的 checkJs 钩子)。
|
||||
//
|
||||
// 语义对齐 legado:作用域里的 `result` 是一个响应对象(body()/code()/url()),
|
||||
// 脚本须返回响应对象或字符串;返回新 body 时调用方以之替换原响应体,
|
||||
// 从而支持「检测到会话失效 → 重新登录 → 重取页面」。
|
||||
func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (string, bool, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return "", false, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", newResponseObject(vm, body, code, finalURL, nil))
|
||||
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err)
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("loginCheckJs 执行超时") })
|
||||
defer timer.Stop()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
|
||||
}
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", false, nil
|
||||
}
|
||||
// 返回响应对象时取 body()(对应 legado 的 StrResponse)
|
||||
if obj, ok := v.(*goja.Object); ok {
|
||||
if fn, ok := goja.AssertFunction(obj.Get("body")); ok {
|
||||
res, err := fn(obj)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
|
||||
}
|
||||
return res.String(), true, nil
|
||||
}
|
||||
}
|
||||
return v.String(), true, nil
|
||||
}
|
||||
|
||||
|
||||
// ForAnalyzer 返回绑定到指定解析器的执行函数(java.getString 等规则回调
|
||||
// 会桥回该解析器,对应 legado 中 AnalyzeRule 自身实现 JsExtensions)。
|
||||
func (r *JSRunner) ForAnalyzer(a *AnalyzeRule) func(js string, result any) (any, error) {
|
||||
@@ -104,7 +229,7 @@ func (r *JSRunner) RunWithBaseURL(a *AnalyzeRule, js string, result any, baseURL
|
||||
func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (any, error) {
|
||||
vm := r.vm
|
||||
// java 对象:每次执行重建(桥回当前解析器)
|
||||
vm.Set("java", newJavaObject(vm, r, a))
|
||||
r.installJava(a)
|
||||
// 上下文绑定
|
||||
if a != nil {
|
||||
vm.Set("book", map[string]any{"name": a.bookName})
|
||||
@@ -176,6 +301,13 @@ func toJSValue(vm *goja.Runtime, v any) any {
|
||||
return nil
|
||||
case string, bool, int, int32, int64, float64, map[string]any, []any:
|
||||
return t
|
||||
// 登录表单等以 map[string]string 传入,交给 goja 直接反射转换
|
||||
case map[string]string:
|
||||
m := make(map[string]any, len(t))
|
||||
for k, v := range t {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
case []string:
|
||||
arr := make([]any, len(t))
|
||||
for i, s := range t {
|
||||
|
||||
@@ -3,8 +3,8 @@ package rule
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,88 +61,240 @@ func hexEncodeBytes(b []byte) string { return hex.EncodeToString(b) }
|
||||
|
||||
// ─── cookie 对象(对应 legado CookieStore 注入的 `cookie`) ─────────────────
|
||||
|
||||
var cookieJar = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string // host → name → value
|
||||
}{m: map[string]map[string]string{}}
|
||||
// 会话状态由服务层注入(落库);未注入时用进程内 MemoryState。
|
||||
// 见 state.go —— 旧的全局 cookieJar 因无法按书源隔离且不落库已废弃。
|
||||
|
||||
// CookieJarRecord 供服务层在执行 HTTP 请求时记录 Set-Cookie。
|
||||
func CookieJarRecord(rawURL string, cookies []string) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
host := u.Host
|
||||
cookieJar.mu.Lock()
|
||||
defer cookieJar.mu.Unlock()
|
||||
jar, ok := cookieJar.m[host]
|
||||
if !ok {
|
||||
jar = map[string]string{}
|
||||
cookieJar.m[host] = jar
|
||||
}
|
||||
for _, c := range cookies {
|
||||
pair := strings.SplitN(c, ";", 2)[0]
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(kv[0])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
jar[name] = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
|
||||
// CookieJarHeader 供书源 JS 查询(对应 CookieStore.getCookie(tag[, key]))。
|
||||
func CookieJarHeader(rawURL, key string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cookieJar.mu.Lock()
|
||||
defer cookieJar.mu.Unlock()
|
||||
jar, ok := cookieJar.m[u.Host]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return jar[key]
|
||||
}
|
||||
var parts []string
|
||||
for k, v := range jar {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func newCookieObject(vm *goja.Runtime) *goja.Object {
|
||||
// newCookieObject 构造 JS 的 `cookie` 对象:
|
||||
// getCookie(url[,key]) / setCookie(url,cookie) / replaceCookie / removeCookie / getKey。
|
||||
func newCookieObject(vm *goja.Runtime, state SourceState) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
// getCookie(key) 或 getCookie(url, key)
|
||||
set("getCookie", func(call goja.FunctionCall) goja.Value {
|
||||
tag := stringArg(call, 0)
|
||||
key := ""
|
||||
if len(call.Arguments) > 1 {
|
||||
key = stringArg(call, 1)
|
||||
return vm.ToValue(state.GetCookieKey(tag, stringArg(call, 1)))
|
||||
}
|
||||
return vm.ToValue(CookieJarHeader(tag, key))
|
||||
return vm.ToValue(state.GetCookie(tag))
|
||||
})
|
||||
set("setCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
CookieJarRecord(rawURL, []string{cookie})
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
// replaceCookie 与 setCookie 在服务端实现中同为覆盖式合并(对应 legado 的语义)
|
||||
set("replaceCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
set("removeCookie", func(call goja.FunctionCall) goja.Value {
|
||||
state.RemoveCookie(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetCookieKey(stringArg(call, 0), stringArg(call, 1)))
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
// newSourceObject 构造 JS 的 `source` 对象。
|
||||
// 对应 legado BaseSource 的变量与登录信息读写。
|
||||
func newSourceObject(vm *goja.Runtime, state SourceState, props map[string]any) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
bindSourceState(vm, set, state, props)
|
||||
// source.get/put:书源级键值缓存(legado 中即 source 的方法)。
|
||||
// 注意 java.get/put 另有语义(网络 / 解析器变量),在 bridge.go 中定义,
|
||||
// 不在这里覆盖。
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(sourceKeyGet(state, stringArg(call, 0)))
|
||||
})
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
val := stringArgOr(call, 1, "")
|
||||
sourceKeyPut(state, stringArg(call, 0), val)
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
// 书源自身属性(bookSourceUrl 等)原样可读,存量 JS 会读 source.loginUi 等做能力探测。
|
||||
for k, v := range props {
|
||||
if o.Get(k) != nil {
|
||||
continue
|
||||
}
|
||||
_ = o.Set(k, v)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// bindSourceState 把书源会话状态方法绑定到目标 JS 对象。
|
||||
// legado 中 book source 的 evalJS 把 `java` 绑成书源对象自身,因此 `java` 与
|
||||
// `source` 都能读到 getVariable/getLoginInfo 等方法——这里保持一致。
|
||||
func bindSourceState(vm *goja.Runtime, set func(k string, v any), state SourceState, props map[string]any) {
|
||||
set("getVariable", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetVariable())
|
||||
})
|
||||
setVariable := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
state.SetVariable("")
|
||||
return goja.Null()
|
||||
}
|
||||
state.SetVariable(call.Arguments[0].String())
|
||||
return goja.Null()
|
||||
}
|
||||
set("setVariable", setVariable)
|
||||
set("putVariable", setVariable)
|
||||
|
||||
set("getLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginInfo(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
v := ""
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) && !goja.IsNull(call.Arguments[0]) {
|
||||
v = call.Arguments[0].String()
|
||||
}
|
||||
state.SetLoginInfo(v)
|
||||
return vm.ToValue(true)
|
||||
})
|
||||
set("removeLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginInfo("")
|
||||
return goja.Null()
|
||||
})
|
||||
// getLoginInfoMap 对应 Kotlin:解析登录信息 JSON;
|
||||
// 未设置且 loginUi 非空时,用 loginUi 里各字段的 default 初始化。
|
||||
set("getLoginInfoMap", func(call goja.FunctionCall) goja.Value {
|
||||
if m := parseLoginInfoMap(state.GetLoginInfo()); m != nil {
|
||||
return vm.ToValue(m)
|
||||
}
|
||||
return vm.ToValue(initLoginInfoFromUI(props))
|
||||
})
|
||||
|
||||
set("getLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginHeader(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
header := stringArgOr(call, 0, "")
|
||||
state.SetLoginHeader(header)
|
||||
// 请求头里的 Cookie 同步进 cookie 存储(对应 legado putLoginHeader)
|
||||
if header != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(header), &m) == nil {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
state.SetCookie(headerCookieURL(props), fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("removeLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginHeader("")
|
||||
state.RemoveCookie(headerCookieURL(props))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(headerCookieURL(props))
|
||||
})
|
||||
}
|
||||
|
||||
// sourceKeyGet / sourceKeyPut:source.get/put 的独立键值槽,
|
||||
// 以 "__kv__" 前缀编码进同一份变量 JSON,从而与 getVariable 一起持久化。
|
||||
const sourceKVVar = "__kv__"
|
||||
|
||||
func sourceKeyGet(state SourceState, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]string
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
return m[sourceKVVar+key]
|
||||
}
|
||||
|
||||
func sourceKeyPut(state SourceState, key, val string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
m := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
m[sourceKVVar+key] = val
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state.SetVariable(string(b))
|
||||
}
|
||||
|
||||
// headerCookieURL 取 loginHeader 里 Cookie 归属的 URL。
|
||||
func headerCookieURL(props map[string]any) string {
|
||||
if props != nil {
|
||||
if u, ok := props["bookSourceUrl"].(string); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseLoginInfoMap 解析登录信息 JSON;空串或非法 JSON 返回 nil。
|
||||
func parseLoginInfoMap(s string) map[string]string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(s), &raw) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
out[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initLoginInfoFromUI 用 loginUi 各字段 default 生成初始登录信息
|
||||
// (对应 legado getLoginInfoMap:跳过 button 类型)。
|
||||
func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw, _ := props["loginUi"].(string)
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var rows []map[string]any
|
||||
if json.Unmarshal([]byte(raw), &rows) == nil {
|
||||
for _, row := range rows {
|
||||
if t, _ := row["type"].(string); t == "button" {
|
||||
continue
|
||||
}
|
||||
name, _ := row["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
def, _ := row["default"].(string)
|
||||
out[name] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
|
||||
|
||||
var jsCache = struct {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件用「聚合类登录书源」的典型结构验证服务端 JS 运行时:
|
||||
//
|
||||
// jsLib(公共函数库)+ loginUrl(登录逻辑)+ loginUi(表单)
|
||||
//
|
||||
// 与真实书源(如光遇聚合,jsLib 约 15 万字符)同构,但精简为可维护的固件。
|
||||
// 固件放在 testdata/,因此这些是常驻回归测试而非一次性 spike。
|
||||
|
||||
func loadTestdata(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("读取固件 %s 失败: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// sampleSourceProps 构造注入了 jsLib/loginUrl/loginUi 的书源属性。
|
||||
func sampleSourceProps(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"bookSourceUrl": "https://v1.example-aggregate.com",
|
||||
"bookSourceName": "样例聚合源",
|
||||
"jsLib": loadTestdata(t, "sample_jsLib.js"),
|
||||
"loginUrl": loadTestdata(t, "sample_loginUrl.js"),
|
||||
"loginUi": loadTestdata(t, "sample_loginUi.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// newSampleRunner 用聚合源固件构建运行时,网络由测试桩接管。
|
||||
func newSampleRunner(t *testing.T, state SourceState) *JSRunner {
|
||||
t.Helper()
|
||||
props := sampleSourceProps(t)
|
||||
r := NewJSRunner(JSConfig{
|
||||
SourceProps: props,
|
||||
JSLib: props["jsLib"].(string),
|
||||
State: state,
|
||||
BaseURL: "https://v1.example-aggregate.com",
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if strings.Contains(req.URL, "/login_api") {
|
||||
return `{"code":0,"key":"TOKEN_abcdefghijklmn"}`, req.URL, 200, nil
|
||||
}
|
||||
if strings.Contains(req.URL, "/user_api") {
|
||||
return `{"id":1,"email":"user@example.com","nickname":"tester"}`, req.URL, 200, nil
|
||||
}
|
||||
return `{}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("jsLib 执行失败: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestJSLibProvidesHelpers jsLib 里的函数与 lexical 绑定(let hosts)应跨执行可见。
|
||||
func TestJSLibProvidesHelpers(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`typeof getVariable + '|' + typeof request + '|' + typeof hosts`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalAction 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "function|function|object" {
|
||||
t.Fatalf("jsLib 符号不可见: %q", got)
|
||||
}
|
||||
// BaseUrl() 依赖 lexical hosts 与 source.getVariable
|
||||
v, err = r.EvalAction(`BaseUrl()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BaseUrl 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v1.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl() = %q", got)
|
||||
}
|
||||
// login 由 loginUrl 提供,未拼 loginUrl 时不应存在
|
||||
v, _ = r.EvalAction(`typeof login`, nil)
|
||||
if anyToString(v) != "undefined" {
|
||||
t.Fatalf("login 应仅由 loginUrl 定义,当前 %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginFlowWritesCookies login() 应请求登录接口、写入 Cookie,且 getToken 能读回。
|
||||
func TestLoginFlowWritesCookies(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
_, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "user@example.com", "密码": "pw123456"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("login(true) 执行失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getToken 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("getToken() = %q,期望登录后能读到 token", got)
|
||||
}
|
||||
// setAllCookies 遍历 hosts;两条线路同属 example-aggregate.com,
|
||||
// 按 eTLD+1 归并成一条 Cookie,两个线路地址都应能读到。
|
||||
if len(state.Snapshot()) != 1 {
|
||||
t.Fatalf("同站线路应归并为一条 Cookie,实际 %v", state.Snapshot())
|
||||
}
|
||||
for _, host := range []string{"https://v1.example-aggregate.com", "https://v2.example-aggregate.com"} {
|
||||
if !strings.Contains(state.GetCookie(host), "qttoken=TOKEN_abcdefghijklmn") {
|
||||
t.Fatalf("%s 未读到 token: %q", host, state.GetCookie(host))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "登录成功") {
|
||||
t.Fatalf("未收到登录成功提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginMissingCredentials 缺少账号密码时应给出提示且不产生 Cookie。
|
||||
func TestLoginMissingCredentials(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
if _, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{"result": map[string]string{}}); err != nil {
|
||||
t.Fatalf("执行失败: %v", err)
|
||||
}
|
||||
if len(state.Snapshot()) != 0 {
|
||||
t.Fatalf("未填表单不应写入 Cookie: %v", state.Snapshot())
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "请先输入账号密码") {
|
||||
t.Fatalf("缺少提示语: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错,
|
||||
// 避免书源逻辑把空 body 当成校验成功。
|
||||
func TestStartBrowserAwaitRecordsURL(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
// 先造出登录态,让 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(), "无浏览器") {
|
||||
t.Fatalf("应明确报不支持,实际: %v", err)
|
||||
}
|
||||
browsers := state.Browsers()
|
||||
if len(browsers) != 1 || !strings.HasSuffix(browsers[0].URL, "/user") {
|
||||
t.Fatalf("未记录待打开地址: %+v", browsers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginInfoMapInit 未保存登录信息时,getLoginInfoMap 用 loginUi 的
|
||||
// 非按钮字段初始化(对应 legado)。
|
||||
func TestSourceLoginInfoMapInit(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`JSON.stringify(source.getLoginInfoMap())`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getLoginInfoMap 失败: %v", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(anyToString(v)), &m); err != nil {
|
||||
t.Fatalf("返回不是 JSON 对象: %q", anyToString(v))
|
||||
}
|
||||
if _, ok := m["邮箱"]; !ok {
|
||||
t.Fatalf("loginUi 的「邮箱」未出现在登录信息: %v", m)
|
||||
}
|
||||
if _, ok := m["密码"]; !ok {
|
||||
t.Fatalf("loginUi 的「密码」未出现在登录信息: %v", m)
|
||||
}
|
||||
for k := range m {
|
||||
if strings.Contains(k, "登录") || strings.Contains(k, "注册") || strings.Contains(k, "后台") {
|
||||
t.Fatalf("button 字段不应出现在登录信息: %v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceVariablesRoundTrip 变量经 source.setVariable 写入后可由 getVariable 读出。
|
||||
func TestSourceVariablesRoundTrip(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
|
||||
if _, err := r.EvalAction(`setVariable('线路','https://v2.example-aggregate.com',false)`, nil); err != nil {
|
||||
t.Fatalf("setVariable 失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getVariable('线路')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("getVariable('线路') = %q", got)
|
||||
}
|
||||
// 变量变更应反映到状态存储(持久化的前提)
|
||||
if !strings.Contains(state.GetVariable(), "v2.example-aggregate.com") {
|
||||
t.Fatalf("变量未写回状态存储: %q", state.GetVariable())
|
||||
}
|
||||
// BaseUrl 也应跟随变量变化
|
||||
v, _ = r.EvalAction(`BaseUrl()`, nil)
|
||||
if anyToString(v) != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl 未跟随变量: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieObjectIsolation Cookie 按站点隔离,同站子域共享,removeCookie 可清除。
|
||||
func TestCookieObjectIsolation(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := NewJSRunner(JSConfig{State: state})
|
||||
|
||||
if _, err := r.EvalAction(`cookie.setCookie('https://a.example.com/x','t=1; u=2')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := r.EvalAction(`cookie.getCookie('https://a.example.com/y','t')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "1" {
|
||||
t.Fatalf("getCookie key = %q", anyToString(v))
|
||||
}
|
||||
// 同站子域共享 Cookie(对应 legado getSubDomain 取 eTLD+1)
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://www.example.com/')`, nil)
|
||||
if anyToString(v) == "" {
|
||||
t.Fatal("同站子域应共享 Cookie")
|
||||
}
|
||||
// 不同站点互不污染
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://other-site.net/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("跨站点读到了 Cookie: %q", anyToString(v))
|
||||
}
|
||||
// removeCookie 后读不到
|
||||
if _, err := r.EvalAction(`cookie.removeCookie('https://a.example.com')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://a.example.com/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("removeCookie 后仍读到: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatePersistsAcrossRunners 状态存储是跨运行时共享的:
|
||||
// 模拟服务端重启(新建 runner)后登录态仍在。
|
||||
func TestSourceStatePersistsAcrossRunners(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
r1 := newSampleRunner(t, state)
|
||||
if _, err := r1.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 新运行时复用同一 state(对应从 DB 重新载入)
|
||||
r2 := newSampleRunner(t, state)
|
||||
v, err := r2.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("新运行时未读到登录态: %q", anyToString(v))
|
||||
}
|
||||
// 已登录时再次 login 应提示已登录(走 getToken 短路分支)
|
||||
if _, err := r2.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "已登录") {
|
||||
t.Fatalf("已登录时应有提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReplacesBody loginCheckJs 返回新响应体时应替换原 body
|
||||
// (书源借此检测会话失效并重取页面)。
|
||||
func TestLoginCheckJsReplacesBody(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
// 返回响应对象(对应 legado 要求 loginCheckJs 返回 StrResponse)
|
||||
js := `if (result.body().indexOf('未登录') >= 0) { java.toast('会话失效,重新登录'); }
|
||||
var ok = JSON.stringify({code: 0, body: result.body()});
|
||||
result.body()`
|
||||
body, changed, err := r.EvalLoginCheck(js, "未登录", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatalf("EvalLoginCheck 失败: %v", err)
|
||||
}
|
||||
if !changed || body != "未登录" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReturnsString loginCheckJs 直接返回字符串时也应生效。
|
||||
func TestLoginCheckJsReturnsString(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(`'已修复内容'`, "原始", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "已修复内容" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsResponseAccessors 验证 result 暴露 code()/url()/header()。
|
||||
func TestLoginCheckJsResponseAccessors(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(
|
||||
`result.code() + '|' + result.url()`, "x", 403, "https://x.com/y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "403|https://x.com/y" {
|
||||
t.Fatalf("body=%q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// 本文件:书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)的抽象。
|
||||
// 对应 legado BaseSource 的 getVariable/putVariable/getLoginInfo/putLoginInfo/
|
||||
// getLoginHeader/putLoginHeader 与 CookieStore。实现由服务层落库提供;
|
||||
// 未注入时退回进程内 MemoryState(单测、冒烟 CLI 用)。
|
||||
|
||||
// SourceState 书源会话状态读写接口。
|
||||
type SourceState interface {
|
||||
// GetVariable / SetVariable 对应 source.getVariable / source.setVariable。
|
||||
GetVariable() string
|
||||
SetVariable(v string)
|
||||
|
||||
// GetLoginInfo / SetLoginInfo 对应 source.getLoginInfo / source.putLoginInfo。
|
||||
// 内容为登录表单的 JSON(键为字段名)。
|
||||
GetLoginInfo() string
|
||||
SetLoginInfo(v string)
|
||||
|
||||
// GetLoginHeader / SetLoginHeader 对应 source 的登录请求头(JSON),
|
||||
// 其中 Cookie 键会在请求时合并进 Cookie 头。
|
||||
GetLoginHeader() string
|
||||
SetLoginHeader(v string)
|
||||
|
||||
// Cookie 读写:rawURL 可为完整 URL 或裸域名,按有效顶级域+1 归并。
|
||||
GetCookie(rawURL string) string
|
||||
GetCookieKey(rawURL, key string) string
|
||||
SetCookie(rawURL, cookie string)
|
||||
RemoveCookie(rawURL string)
|
||||
|
||||
// Toast 收集宿主提示(java.toast / java.longToast),登录反馈靠它回传前端。
|
||||
Toast(msg string)
|
||||
// OpenBrowser 记录需要浏览器完成的地址(java.startBrowser)——
|
||||
// 服务端无法弹窗,前端据此提供「在新标签打开」。
|
||||
OpenBrowser(url, title string)
|
||||
}
|
||||
|
||||
// CookieDomain 取 URL 的有效顶级域 +1(对应 legado NetworkUtils.getSubDomain)。
|
||||
// 裸域名(无 scheme)按 http 处理;IP 原样返回;解析失败回退 host 本身。
|
||||
func CookieDomain(rawURL string) string {
|
||||
s := strings.TrimSpace(rawURL)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(s, "://") {
|
||||
if strings.HasPrefix(s, "//") {
|
||||
s = "http:" + s
|
||||
} else {
|
||||
s = "http://" + s
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
}
|
||||
etld1, err := publicsuffix.EffectiveTLDPlusOne(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return etld1
|
||||
}
|
||||
|
||||
// ParseCookie 把 "a=1; b=2" 拆为键值对(值不 Trim 内部空白,仅去首尾)。
|
||||
func ParseCookie(cookie string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 || kv[0] == "" {
|
||||
continue
|
||||
}
|
||||
out[kv[0]] = kv[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeCookie 将新 cookie 覆盖式合并进旧 cookie(对应 CookieStore.replaceCookie)。
|
||||
func MergeCookie(old, newCookie string) string {
|
||||
merged := ParseCookie(old)
|
||||
for k, v := range ParseCookie(newCookie) {
|
||||
merged[k] = v
|
||||
}
|
||||
return formatCookie(merged)
|
||||
}
|
||||
|
||||
func formatCookie(kv map[string]string) string {
|
||||
parts := make([]string, 0, len(kv))
|
||||
for k, v := range kv {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// MemoryState 进程内会话状态(未注入持久化实现时的回退)。
|
||||
type MemoryState struct {
|
||||
mu sync.Mutex
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]map[string]string // domain → name → value
|
||||
toasts []string
|
||||
browsers []BrowserRequest
|
||||
}
|
||||
|
||||
// BrowserRequest 前端可代为打开的浏览器地址(java.startBrowser 收集)。
|
||||
type BrowserRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// NewMemoryState 创建进程内状态。
|
||||
func NewMemoryState() *MemoryState {
|
||||
return &MemoryState{cookies: map[string]map[string]string{}}
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetVariable() string { m.mu.Lock(); defer m.mu.Unlock(); return m.variable }
|
||||
func (m *MemoryState) SetVariable(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.variable = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginInfo() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginInfo }
|
||||
func (m *MemoryState) SetLoginInfo(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginInfo = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginHeader() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginHeader }
|
||||
func (m *MemoryState) SetLoginHeader(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginHeader = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookie(rawURL string) string {
|
||||
return m.GetCookieKey(rawURL, "")
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookieKey(rawURL, key string) string {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return jar[key]
|
||||
}
|
||||
return formatCookie(jar)
|
||||
}
|
||||
|
||||
func (m *MemoryState) SetCookie(rawURL, cookie string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
jar = map[string]string{}
|
||||
m.cookies[domain] = jar
|
||||
}
|
||||
for k, v := range ParseCookie(cookie) {
|
||||
jar[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryState) RemoveCookie(rawURL string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.cookies, domain)
|
||||
}
|
||||
|
||||
func (m *MemoryState) Toast(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.toasts = append(m.toasts, msg)
|
||||
}
|
||||
|
||||
func (m *MemoryState) OpenBrowser(url, title string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.browsers = append(m.browsers, BrowserRequest{URL: url, Title: title})
|
||||
}
|
||||
|
||||
// Toasts 返回并清空已收集的宿主提示。
|
||||
func (m *MemoryState) Toasts() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := m.toasts
|
||||
m.toasts = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Browsers 返回并清空已收集的浏览器地址。
|
||||
func (m *MemoryState) Browsers() []BrowserRequest {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := m.browsers
|
||||
m.browsers = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot 返回当前 Cookie(domain → cookie 串)副本。
|
||||
func (m *MemoryState) Snapshot() map[string]string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[string]string, len(m.cookies))
|
||||
for d, jar := range m.cookies {
|
||||
out[d] = formatCookie(jar)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ SourceState = (*MemoryState)(nil)
|
||||
@@ -0,0 +1,117 @@
|
||||
// 测试用 jsLib:模拟聚合类书源的公共函数库结构。
|
||||
//
|
||||
// 真实书源(如光遇聚合)的 jsLib 有十几万字符,但关键结构就是这些:
|
||||
// 用 source.getVariable 做设置读写、用 cookie 存取凭证、用 BaseUrl() 拼接口地址。
|
||||
// 该文件由 JSRunner 在创建时执行一次,其函数对后续所有规则 JS 可见。
|
||||
// 同时保留了源码里常见的 lexical 声明(let hosts),验证跨执行可见性。
|
||||
|
||||
// 当前书源版本号
|
||||
let localVersion = '26.9.29.1';
|
||||
|
||||
// 初始服务器列表
|
||||
let hosts = [
|
||||
'https://v1.example-aggregate.com',
|
||||
'https://v2.example-aggregate.com'
|
||||
];
|
||||
|
||||
// 源变量默认值
|
||||
const defaultConfig = {
|
||||
线路: hosts[0],
|
||||
发现页来源: '番茄'
|
||||
};
|
||||
|
||||
// 获取源变量
|
||||
function getVariable(k) {
|
||||
if (k == undefined) k = "";
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(source.getVariable());
|
||||
} catch (e) {}
|
||||
if (k == "") {
|
||||
return parsed;
|
||||
}
|
||||
let value = parsed[k];
|
||||
if (value == undefined) {
|
||||
value = defaultConfig[k];
|
||||
}
|
||||
return value != undefined ? value : "";
|
||||
}
|
||||
|
||||
// 设置源变量
|
||||
function setVariable(k, v, t) {
|
||||
if (t == undefined) t = true;
|
||||
const vs = getVariable();
|
||||
vs[k] = v;
|
||||
source.setVariable(JSON.stringify(vs, null, 4));
|
||||
if (k != '云端配置' && t) {
|
||||
java.toast('设置 ' + k + ' 为 ' + v);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取正在使用的线路
|
||||
function BaseUrl() {
|
||||
let h = getVariable("线路");
|
||||
if (!h || String(h) == "undefined") {
|
||||
h = hosts[0];
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// 获取登陆 token
|
||||
function getToken() {
|
||||
let hostsbk = getVariable('云端配置') && getVariable('云端配置')['hosts'] || hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
let cookieValue = String(cookie.getCookie(hostsbk[i]));
|
||||
let parts = cookieValue.split(";");
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
if (parts[j].indexOf("qttoken") != -1) {
|
||||
return parts[j].split("=")[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 设置 ck
|
||||
function setAllCookies(ck) {
|
||||
let hostsbk = hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
cookie.setCookie(hostsbk[i], ck);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 ck
|
||||
function removeAllCookies() {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
cookie.removeCookie(hosts[i]);
|
||||
}
|
||||
java.toast('已退出登陆');
|
||||
}
|
||||
|
||||
// 请求封装(自动带 token)
|
||||
function request(url, method, body) {
|
||||
if (method == undefined) method = 'GET';
|
||||
if (body == undefined) body = {};
|
||||
let urla = url;
|
||||
if (url.indexOf('http') != 0) {
|
||||
urla = BaseUrl() + url;
|
||||
}
|
||||
let qttoken = getToken();
|
||||
let options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'cookie': 'qttoken=' + qttoken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
return java.ajax(urla + ',' + JSON.stringify(options));
|
||||
}
|
||||
|
||||
function parseJsonSafely(str) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"name": "线路设置",
|
||||
"type": "button",
|
||||
"action": "switchLine()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "邮箱",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "密码",
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"name": "🔅登录账号",
|
||||
"type": "button",
|
||||
"action": "login(true)",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🔐注册账号",
|
||||
"type": "button",
|
||||
"action": "register()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": " 🔚 退出登录",
|
||||
"type": "button",
|
||||
"action": "logout()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🪪 查看信息",
|
||||
"type": "button",
|
||||
"action": "checkStatus()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🏝用户后台",
|
||||
"type": "button",
|
||||
"action": "user()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
// 测试用 loginUrl:模拟聚合类书源的登录逻辑。
|
||||
//
|
||||
// 约定与 legado 一致:
|
||||
// - loginUrl 本身是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
|
||||
// - 表单值通过作用域里的 result 对象读取;
|
||||
// - 成功与否靠抛异常区分(返回值被忽略)。
|
||||
|
||||
function login(flag) {
|
||||
let token = getToken();
|
||||
if (String(token).length > 10) {
|
||||
java.longToast('当前已登录,请退出登录后重新登录');
|
||||
return true;
|
||||
}
|
||||
let email = result.邮箱;
|
||||
let pwd = result.密码;
|
||||
if (!email || !pwd) {
|
||||
java.longToast('请先输入账号密码!');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let data = request('/login_api', 'POST', {
|
||||
register_email: email,
|
||||
password: pwd
|
||||
});
|
||||
let response = parseJsonSafely(data);
|
||||
if (response && response.code == 0) {
|
||||
setAllCookies('qttoken=' + response.key);
|
||||
java.longToast('✅️登录成功');
|
||||
return true;
|
||||
}
|
||||
java.longToast('❌登录失败:' + ((response && response.msg) || '未知错误'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
java.longToast('❌登录失败,服务器错误');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登陆
|
||||
function logout() {
|
||||
removeAllCookies();
|
||||
}
|
||||
|
||||
// 用户后台(需要浏览器打开)
|
||||
function user() {
|
||||
if (String(getToken()).length < 10) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
java.startBrowserAwait(BaseUrl() + '/user', '用户后台');
|
||||
}
|
||||
|
||||
// 查看信息
|
||||
function checkStatus() {
|
||||
if (String(getToken()).length < 10) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
let res = parseJsonSafely(request('/user_api', 'POST'));
|
||||
if (!res || res.id == undefined) {
|
||||
java.toast('获取用户信息失败');
|
||||
return;
|
||||
}
|
||||
result.邮箱 = res.email;
|
||||
source.putLoginInfo(JSON.stringify(result));
|
||||
java.longToast('昵称:' + (res.nickname || '未设置'));
|
||||
}
|
||||
Reference in New Issue
Block a user