This commit is contained in:
truewhile
2026-09-30 19:22:24 +08:00
parent fa41936095
commit c1ff55ffbf
28 changed files with 3793 additions and 201 deletions
+291
View File
@@ -0,0 +1,291 @@
package reader
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:书源登录(对应 legado SourceLoginDialog / SourceLoginViewModel)。
//
// legado 的登录模型:
// - loginUrl 是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
// - loginUi 是一份表单描述(RowUi 数组:text / password / button / toggle / select);
// - 点按钮时执行 "loginUrl + '\n' + action",作用域里 result 是"表单当前值",
// 返回值被丢弃,只有抛异常才算失败;
// - 点"登录"确认按钮时执行 loginUrl 里的 login() 函数(不存在则报错)。
//
// 服务端无法弹窗,因此把 java.toast / java.startBrowser 的调用收集起来回传前端。
// LoginField 登录表单的一个控件(对应 legado RowUi)。
type LoginField struct {
Name string `json:"name"`
Type string `json:"type"` // text / password / button / toggle / select
Action string `json:"action,omitempty"`
Chars []string `json:"chars,omitempty"`
Default string `json:"default,omitempty"`
// ViewName 按钮/标签文案;legado 中它本身可以是 JS 表达式。
ViewName string `json:"viewName,omitempty"`
Style map[string]any `json:"style,omitempty"`
}
// SourceLoginInfo 书源登录界面描述与当前状态。
type SourceLoginInfo struct {
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
// HasLoginJS loginUrl 提供了登录逻辑。
HasLoginJS bool `json:"has_login_js"`
// LoginJS 登录逻辑 JS 原文(前端只作展示/调试,不执行)。
LoginJS string `json:"login_js,omitempty"`
// Fields 登录表单控件。
Fields []LoginField `json:"fields"`
// Values 已保存的登录信息(表单回填)。
Values map[string]string `json:"values"`
// Cookies 当前已保存的 Cookie(domain → cookie 串)。
Cookies map[string]string `json:"cookies"`
// Variable 源变量 JSON 原文(供变量编辑器)。
Variable string `json:"variable"`
// VariableComment 源变量说明(书源作者写的填写指引)。
VariableComment string `json:"variable_comment,omitempty"`
// LoggedIn 是否已具备登录态(存了登录信息或 Cookie)。
LoggedIn bool `json:"logged_in"`
// LoginFieldsHint loginUi 解析或执行的失败原因(不阻断展示)。
Error string `json:"error,omitempty"`
}
// LoginResult 登录动作执行结果。
type LoginResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Toasts []string `json:"toasts,omitempty"`
Browsers []rule.BrowserRequest `json:"browsers,omitempty"`
// Values 执行后的登录信息(可能与执行前不同,如 checkStatus 回填邮箱)。
Values map[string]string `json:"values"`
Cookies map[string]string `json:"cookies"`
LoggedIn bool `json:"logged_in"`
}
// GetSourceLogin 返回书源登录界面描述与当前登录状态。
func (s *ReaderService) GetSourceLogin(ctx context.Context, sourceID string) (*SourceLoginInfo, error) {
src, bs, err := s.loadSource(ctx, sourceID)
if err != nil {
return nil, err
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
state := sess.state
info := &SourceLoginInfo{
SourceID: src.ID,
SourceName: firstNonEmpty(src.Name, bs.BookSourceName),
HasLoginJS: strings.TrimSpace(SPtr(bs.LoginURL)) != "",
LoginJS: bs.LoginJS(),
Fields: []LoginField{},
Values: map[string]string{},
Cookies: state.snapshotCookies(),
Variable: state.GetVariable(),
VariableComment: SPtr(bs.VariableComment),
}
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
info.Values = m
}
// 登录态以「是否拿到凭证(Cookie)」为准:
// 只填过表单并不等于已登录,否则失败的登录也会显示为已登录。
info.LoggedIn = len(info.Cookies) > 0
fields, err := s.resolveLoginFields(sess, bs, info.Values)
if err != nil {
info.Error = err.Error()
// loginUi 解析失败时若只有 loginUrl,仍可用「打开登录页」方式登录。
if SPtr(bs.LoginUI) == "" {
info.Error = ""
}
} else {
info.Fields = fields
}
return info, nil
}
// resolveLoginFields 解析 loginUi:直接是 JSON 数组时直接解析;
// 是 @js:/<js> 时先执行得到 JSON(对应 legado evalUiJs)。
// values 为已保存的登录信息,作为 result 注入(legado 的 loginUi JS 会读它)。
func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource, values map[string]string) ([]LoginField, error) {
raw := strings.TrimSpace(SPtr(bs.LoginUI))
if raw == "" {
return nil, fmt.Errorf("书源未配置登录界面(loginUi)")
}
if isJSWrapped(raw) {
runner := sess.runner("", 0)
v, err := runner.EvalAction(bs.LoginJS()+"\n"+stripJSWrapper(raw), loginBindings(sess, values))
if err != nil {
return nil, fmt.Errorf("loginUi JS 执行失败: %w", err)
}
raw = strings.TrimSpace(anyToStr(v))
}
var fields []LoginField
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
return nil, fmt.Errorf("loginUi 不是合法的 JSON 表单: %w", err)
}
return fields, nil
}
// RunLoginAction 执行一个登录动作。
//
// action 为 loginUi 里某个控件的 action(如 "login(true)" / "checkStatus()");
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
func (s *ReaderService) RunLoginAction(ctx context.Context, sourceID, action string, fields map[string]string) (*LoginResult, error) {
src, bs, err := s.loadSource(ctx, sourceID)
if err != nil {
return nil, err
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
state := sess.state
loginJS := bs.LoginJS()
if loginJS == "" {
return nil, fmt.Errorf("书源未配置登录逻辑(loginUrl)")
}
// 合并登录信息:已保存值 + 本次提交值(对应 legado getLoginData)。
values := map[string]string{}
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
values = m
}
for k, v := range fields {
values[k] = v
}
// 若提供了新的表单值,先持久化(legado 在调用登录函数前先存 loginInfo)。
if len(fields) > 0 {
if b, err := json.Marshal(values); err == nil {
state.SetLoginInfo(string(b))
}
}
body := loginJS + "\n"
if strings.TrimSpace(action) == "" {
// 对应 legado:login() 必须由书源实现
body += "if (typeof login=='function'){ login.apply(this); } else { throw('Function login not implements!!!'); }"
} else {
body += action
}
runner := sess.runner("", 0)
_, runErr := runner.EvalAction(body, loginBindings(sess, values))
// 无论成功失败都要落库:Cookie/变量可能已被部分改写(如已拿到 token 但后续步骤报错)。
state.flush()
res := &LoginResult{
OK: runErr == nil,
Toasts: state.toasts,
Browsers: state.browsers,
Values: values,
Cookies: state.snapshotCookies(),
}
if runErr != nil {
res.Error = runErr.Error()
}
// 登录态以是否拿到凭证(Cookie)为准,避免"只存了表单"被显示成已登录。
res.LoggedIn = len(res.Cookies) > 0
return res, nil
}
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
// 保存后书源 JS 的 getVariable() 即可读到。
func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variable string) error {
if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
return fmt.Errorf("变量必须是合法 JSON")
}
src, bs, err := s.loadSource(ctx, sourceID)
if err != nil {
return err
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
sess.state.SetVariable(variable)
sess.state.flush()
return nil
}
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error {
src, bs, err := s.loadSource(ctx, sourceID)
if err != nil {
return err
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
b, err := json.Marshal(fields)
if err != nil {
return err
}
sess.state.SetLoginInfo(string(b))
sess.state.flush()
return nil
}
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error {
src, bs, err := s.loadSource(ctx, sourceID)
if err != nil {
return err
}
sess := s.newSession(ctx, src, bs)
defer sess.close()
sess.state.SetLoginInfo("")
sess.state.SetLoginHeader("")
sess.state.clearCookies()
sess.state.flush()
return nil
}
// loginBindings 构造登录动作的 JS 作用域绑定。
// 对应 legado:result 为表单数据;book/chapter/isLongClick 一并提供。
func loginBindings(sess *sourceSession, values map[string]string) map[string]any {
if values == nil {
values = map[string]string{}
}
return map[string]any{
"result": values,
"book": nil,
"chapter": nil,
"isLongClick": false,
"sourceUrl": sess.srcURL(),
}
}
func parseLoginInfoValues(raw string) (map[string]string, error) {
out := map[string]string{}
if strings.TrimSpace(raw) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, err
}
return out, nil
}
// isJSWrapped 判断规则串是否带 @js: / <js> 包裹。
func isJSWrapped(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "@js:") || strings.HasPrefix(s, "<js>")
}
func anyToStr(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}