优化阅读功能

This commit is contained in:
truewhile
2026-10-01 21:57:25 +08:00
parent c1ff55ffbf
commit ce4319ec25
56 changed files with 9511 additions and 354 deletions
+227
View File
@@ -0,0 +1,227 @@
package rule
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/dop251/goja"
)
// 本文件:书源 JS 的宿主浏览器能力契约。
//
// legado 用内置 WebView 实现 java.startBrowser / java.startBrowserAwait:
// 打开一个页面让用户完成防爬校验、登录或参数选择,并(Await 版本)把用户
// 操作后的页面源码作为 StrResponse 返回给书源解析。真实书源把整套「线路
// 切换」「用户后台」「书源设置」都建在这两个函数上,因此服务端必须提供
// 等价能力,否则这些按钮只会走到书源自己的 catch 分支。
//
// MeBox 没有 WebView,改由服务层把页面交给前端网页承载:
// - AwaitBrowser:阻塞等待前端回传 DOM(对应 startBrowserAwait)
// - OpenBrowser: 只展示不等待(对应 startBrowser / showBrowser)
// BrowserTask 一个需要用户在前端页面中完成的请求。
type BrowserTask struct {
// URL 书源传入的原始地址,可能是 http(s),也可能是 data:text/html;base64,…。
URL string
// Title 页面标题(对应 legado 对话框标题)。
Title string
// HTML URL 为 data: 系地址时解析出的页面源码,空表示由服务层按 URL 抓取。
HTML string
// Refetch 对应 legado startBrowserAwait 的 refetchAfterSuccess:
// 用户确认后重新抓取该 URL 作为返回内容,而不是回传页面 DOM。
Refetch bool
}
// BrowserResult 用户在页面上完成操作后的回传。
type BrowserResult struct {
// URL 最终地址(对应 StrResponse.url())。
URL string
// Body 页面内容:默认是用户操作后的 DOM 序列化,
// Refetch 为真时是服务端重新抓取的响应体。
Body string
}
// BrowserHost 由服务层注入的宿主浏览器实现。
type BrowserHost interface {
// AwaitBrowser 展示页面并阻塞等待用户回传(java.startBrowserAwait)。
AwaitBrowser(ctx context.Context, req BrowserTask) (BrowserResult, error)
// OpenBrowser 展示页面但不等待(java.startBrowser / java.showBrowser)。
OpenBrowser(ctx context.Context, req BrowserTask) error
}
// ErrBrowserCancelled 用户在页面里主动取消。
var ErrBrowserCancelled = errBrowserCancelled{}
type errBrowserCancelled struct{}
func (errBrowserCancelled) Error() string { return "用户取消了页面操作" }
// ─── 运行时侧:把页面交给宿主浏览器 ────────────────────────────────────────
// parseBrowserArgs 解析 startBrowser / startBrowserAwait 的位置参数。
//
// startBrowser(url, title[, html])
// startBrowserAwait(url, title[, refetchAfterSuccess][, html])
//
// 两者第 3 参类型不同(html 字符串 vs boolean),按实际类型判定。
func parseBrowserArgs(call goja.FunctionCall) BrowserTask {
req := BrowserTask{
URL: stringArg(call, 0),
Title: stringArgOr(call, 1, ""),
}
req.HTML = ParseDataHTML(req.URL)
// 第 3 / 第 4 参里的字符串按 html 处理(data: URL 之外显式传入的页面源码)。
for _, i := range []int{2, 3} {
if len(call.Arguments) <= i {
continue
}
arg := call.Arguments[i]
if goja.IsUndefined(arg) || goja.IsNull(arg) {
continue
}
if s, ok := arg.Export().(string); ok && strings.TrimSpace(s) != "" {
req.HTML = s
}
}
return req
}
// openBrowser 展示页面但不等待。
//
// 注入了宿主浏览器时交给宿主(前端可交互的页面面板);未注入时退化为
// 记录地址供前端代开,不中断书源逻辑。
func (r *JSRunner) openBrowser(req BrowserTask) error {
if req.URL == "" && req.HTML == "" {
return nil
}
if r.cfg.Browser != nil {
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
}
if r.state != nil {
r.state.OpenBrowser(req.URL, req.Title)
}
return nil
}
// awaitBrowser 展示页面并阻塞等待用户完成。
//
// 等待期间暂停 JS 超时计时:用户可能要看很久,默认 10s 的执行预算不能
// 用在「等人」上。
func (r *JSRunner) awaitBrowser(req BrowserTask) (BrowserResult, error) {
if req.URL == "" && req.HTML == "" {
return BrowserResult{}, fmt.Errorf("startBrowserAwait: 缺少页面地址")
}
if r.cfg.Browser == nil {
if r.state != nil {
r.state.OpenBrowser(req.URL, req.Title)
}
return BrowserResult{}, fmt.Errorf("服务端未提供浏览器能力,需要人工操作的页面请手动打开")
}
resume := r.pauseTimeout()
defer resume()
return r.cfg.Browser.AwaitBrowser(r.ctx(), req)
}
// ctx 返回本次执行的上下文(未注入时用 Background)。
func (r *JSRunner) ctx() context.Context {
if r.cfg.Ctx != nil {
return r.cfg.Ctx
}
return context.Background()
}
// requestUIRefresh 请求宿主重新渲染登录表单(java.reLoginView / refreshExplore)。
func (r *JSRunner) requestUIRefresh() {
if ui, ok := r.state.(UIState); ok {
ui.RequestUIRefresh()
}
}
// applyLoginData 把 java.upLoginData 传入的值合并进登录表单。
func (r *JSRunner) applyLoginData(args []goja.Value) {
if len(args) == 0 || goja.IsUndefined(args[0]) || goja.IsNull(args[0]) {
return
}
ui, ok := r.state.(UIState)
if !ok {
return
}
exported, ok := args[0].Export().(map[string]any)
if !ok {
return
}
data := make(map[string]string, len(exported))
for k, v := range exported {
data[k] = anyToStringish(v)
}
ui.ApplyLoginData(data)
}
func anyToStringish(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", v)
}
}
// ParseDataHTML 解析 data:text/html 系地址,返回内嵌的页面源码。
// 支持 base64 与百分号编码两种形式;非 data 地址或解码失败返回空串。
func ParseDataHTML(rawURL string) string {
s := strings.TrimSpace(rawURL)
if !strings.HasPrefix(s, "data:") {
return ""
}
comma := strings.Index(s, ",")
if comma < 0 {
return ""
}
meta := s[len("data:"):comma]
payload := s[comma+1:]
// MIME 限定在 text/*(书源也可能用 text/plain 塞 HTML)
mime := meta
if i := strings.Index(mime, ";"); i >= 0 {
mime = mime[:i]
}
if mime != "" && !strings.HasPrefix(mime, "text/") {
return ""
}
if strings.Contains(strings.ToLower(meta), ";base64") {
// base64 可能带换行;同时兼容 URL-safe 变体
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, payload)
if b, err := base64.StdEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.RawStdEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.URLEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
if b, err := base64.RawURLEncoding.DecodeString(cleaned); err == nil {
return string(b)
}
return ""
}
if dec, err := url.PathUnescape(payload); err == nil {
return dec
}
return ""
}