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
+15 -59
View File
@@ -12,96 +12,52 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
// encPrefix tags ciphertext rows so we can tell them apart from legacy
// plaintext values.
const encPrefix = "enc:v1:"
// plaintext values. Kept as an alias of the shared helper's prefix so both
// implementations stay wire-compatible.
const encPrefix = helper.EncPrefix
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
// secret (the JWT secret).
//
// The cipher itself lives in helper.SecretCipher so lower layers (e.g. the
// reader subsystem, which cannot import this package) can share one
// implementation; this type keeps the service-layer logging and API.
type CryptoService struct {
log *zap.Logger
aead cipher.AEAD
log *zap.Logger
cipher *helper.SecretCipher
}
// NewCryptoService derives a 256-bit key from the given secret via
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
c := &CryptoService{log: log}
if strings.TrimSpace(secret) == "" {
return c
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
log.Error("crypto: aes.NewCipher", zap.Error(err))
return c
}
aead, err := cipher.NewGCM(block)
if err != nil {
log.Error("crypto: cipher.NewGCM", zap.Error(err))
return c
}
c.aead = aead
return c
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
}
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
// Empty inputs round-trip unchanged.
func (c *CryptoService) Encrypt(plain string) string {
if plain == "" || c.aead == nil {
return plain
}
if strings.HasPrefix(plain, encPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
return c.cipher.Encrypt(plain)
}
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if c == nil || value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
return value
}
raw := strings.TrimPrefix(value, encPrefix)
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return value
}
if len(data) < c.aead.NonceSize() {
return value
}
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
if err != nil {
return value
}
return string(plain)
return c.cipher.Decrypt(value)
}
// IsEncrypted returns true if value carries the encrypted prefix.
func (c *CryptoService) IsEncrypted(value string) bool {
return strings.HasPrefix(value, encPrefix)
return c.cipher.IsEncrypted(value)
}
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
+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)
}
+404
View File
@@ -0,0 +1,404 @@
package reader
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/repository"
)
// 本文件:登录类书源的服务层链路测试。
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
// loginTestServer 模拟一个需要登录的书源站点:
// - POST /login_api 校验账号密码并下发会话 Cookie
// - GET /search 读取 Cookie,无 Cookie 返回 401(模拟登录后才能搜索)
func loginTestServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/login_api":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["register_email"] != "user@example.com" || body["password"] != "pw123456" {
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
return
}
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "SESSION_abcdef123456", Path: "/"})
_, _ = w.Write([]byte(`{"code":0,"key":"SESSION_abcdef123456"}`))
case r.URL.Path == "/search":
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=SESSION_abcdef123456") {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<html><body>未登录</body></html>`))
return
}
_, _ = w.Write([]byte(`<html><body>
<div class="item"><h3><a href="/book/9">会员专享书</a></h3><span class="author">作者</span></div>
</body></html>`))
default:
http.NotFound(w, r)
}
}))
}
func loginTestSourceJSON(t *testing.T, server string) string {
t.Helper()
// loginUrl 是登录逻辑:读 result 里的表单值 → 调登录接口 → 写入 Cookie。
// BaseUrl() 在真实书源里由 jsLib 提供,这里一并定义。
loginJS := `function BaseUrl() { return baseUrl; }
function login(flag) {
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
var res = java.ajax(BaseUrl() + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
var data = JSON.parse(res);
if (data.code == 0) {
setAllCookies('qttoken=' + data.key);
java.toast('登录成功');
return true;
}
java.toast(data.msg || '登录失败');
return false;
}
function setAllCookies(ck) { cookie.setCookie(BaseUrl(), ck); }`
loginUI := []map[string]any{
{"name": "邮箱", "type": "text"},
{"name": "密码", "type": "password"},
{"name": "登录", "type": "button", "action": "login(true)"},
}
uiJSON, err := json.Marshal(loginUI)
if err != nil {
t.Fatal(err)
}
src := map[string]any{
"bookSourceUrl": server,
"bookSourceName": "登录源",
"bookSourceType": 0,
"enabledCookieJar": true,
"loginUrl": loginJS,
"loginUi": string(uiJSON),
"searchUrl": server + "/search",
"ruleSearch": map[string]any{
"bookList": "class.item",
"name": "tag.h3@tag.a@text",
"bookUrl": "tag.h3@tag.a@href",
"author": "class.author@text",
},
}
out, err := json.Marshal(src)
if err != nil {
t.Fatal(err)
}
return string(out)
}
func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
t.Helper()
// 唯一库名避免同包测试互相污染;cache=shared 让连接池共享同一份内存库
// (多源搜索会并发写会话状态,而 :memory: 下每个连接各自一个库,
// 并发写入对后续读取不可见,会造成测试假失败)。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
svc := NewReaderService(cfg, zap.NewNop(), repos)
return svc, repos
}
// prepareLoginSource 导入测试书源并返回其 ID。
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
t.Helper()
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil {
t.Fatal(err)
}
srcs, err := svc.ListSources(t.Context())
if err != nil || len(srcs) == 0 {
t.Fatalf("导入后应能读到书源: %v", err)
}
return srcs[0].ID
}
// TestSourceLoginEndToEnd 登录成功后:
// - Cookie 落库(可再次读出)
// - 搜索请求自动携带 Cookie 并通过鉴权
// - 登出后 Cookie 清除,搜索重新变成未登录
func TestSourceLoginEndToEnd(t *testing.T) {
srv := loginTestServer(t)
defer srv.Close()
svc, repos := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员"); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
}
// ── 登录 ──
res, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "pw123456",
})
if err != nil {
t.Fatalf("登录失败: %v", err)
}
if !res.OK {
t.Fatalf("登录未成功: %+v", res)
}
if !res.LoggedIn {
t.Fatalf("登录后应处于已登录态: %+v", res)
}
if len(res.Cookies) == 0 {
t.Fatalf("登录后应有 Cookie 落库: %+v", res)
}
// ── 登录态应落库(换一个 service 实例仍可读到)──
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
info, err := svc2.GetSourceLogin(ctx, sourceID)
if err != nil {
t.Fatal(err)
}
if !info.LoggedIn || len(info.Cookies) == 0 {
t.Fatalf("新实例未能读到已持久化的登录态: %+v", info)
}
if info.Values["邮箱"] != "user@example.com" {
t.Fatalf("登录表单值未持久化: %+v", info.Values)
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员")
if err != nil {
t.Fatal(err)
}
if len(skipped) > 0 {
t.Fatalf("已登录后搜索不应失败: %+v", skipped)
}
if len(books) != 1 || books[0].Name != "会员专享书" {
t.Fatalf("搜索结果异常: %+v", books)
}
// ── 登出:Cookie 清除,搜索重新未登录 ──
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil {
t.Fatal(err)
}
info, err = svc.GetSourceLogin(ctx, sourceID)
if err != nil {
t.Fatal(err)
}
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员"); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
}
}
// TestSourceLoginWrongPassword 密码错误时登录动作应报失败并给出书源提示。
func TestSourceLoginWrongPassword(t *testing.T) {
srv := loginTestServer(t)
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
res, err := svc.RunLoginAction(t.Context(), sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "wrong",
})
if err != nil {
t.Fatal(err)
}
if res.LoggedIn {
t.Fatalf("密码错误不应处于已登录态: %+v", res)
}
if len(res.Toasts) == 0 {
t.Fatalf("应把书源的失败提示回传:%+v", res)
}
if !strings.Contains(strings.Join(res.Toasts, " "), "密码错误") {
t.Fatalf("提示语未透传: %v", res.Toasts)
}
}
// TestSourceLoginInfo_ExposesUIFields 登录界面描述应包含 loginUi 的控件定义。
func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
srv := loginTestServer(t)
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
info, err := svc.GetSourceLogin(t.Context(), sourceID)
if err != nil {
t.Fatal(err)
}
if !info.HasLoginJS {
t.Fatal("应识别到 loginUrl")
}
var names []string
for _, f := range info.Fields {
names = append(names, f.Name)
}
for _, want := range []string{"邮箱", "密码", "登录"} {
if !containsStr(names, want) {
t.Fatalf("loginUi 字段缺失 %q: %v", want, names)
}
}
// 密码字段类型应保留,前端据此用 password 输入框
for _, f := range info.Fields {
if f.Name == "密码" && f.Type != "password" {
t.Fatalf("密码字段类型 = %q", f.Type)
}
}
}
// TestSourceStateEncryptedAtRest 登录信息与 Cookie 应加密落库。
func TestSourceStateEncryptedAtRest(t *testing.T) {
srv := loginTestServer(t)
defer srv.Close()
svc, repos := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
if _, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "pw123456",
}); err != nil {
t.Fatal(err)
}
src, err := repos.Reader.GetSource(ctx, sourceID)
if err != nil {
t.Fatal(err)
}
st, err := repos.Reader.GetSourceState(ctx, src.SourceURL)
if err != nil || st == nil {
t.Fatalf("未找到会话状态: %v", err)
}
if !strings.HasPrefix(st.LoginInfo, "enc:v1:") {
t.Fatalf("登录信息应加密存储,实际: %q", st.LoginInfo)
}
if !strings.HasPrefix(st.Cookies, "enc:v1:") {
t.Fatalf("Cookie 应加密存储,实际: %q", st.Cookies)
}
if strings.Contains(st.LoginInfo, "pw123456") {
t.Fatal("明文密码出现在库中")
}
}
// TestSourceVariableRoundTrip 源变量可通过接口读写,并被书源 JS getVariable 读到。
func TestSourceVariableRoundTrip(t *testing.T) {
srv := loginTestServer(t)
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
t.Fatal(err)
}
info, err := svc.GetSourceLogin(ctx, sourceID)
if err != nil {
t.Fatal(err)
}
var m map[string]string
if err := json.Unmarshal([]byte(info.Variable), &m); err != nil {
t.Fatalf("变量回读失败: %q", info.Variable)
}
if m["线路"] != "https://v2.example.com" {
t.Fatalf("变量值不符: %+v", m)
}
// 非法 JSON 应被拒绝
if err := svc.SetSourceVariable(ctx, sourceID, "not-json"); err == nil {
t.Fatal("非法 JSON 变量应被拒绝")
}
}
func containsStr(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// TestEnabledCookieJarGating enabledCookieJar=false 时不再自动保存响应 Set-Cookie,
// 但书源 JS 主动 cookie.setCookie 写入的仍应保留(对应 legado 语义)。
func TestEnabledCookieJarGating(t *testing.T) {
// 站点在响应里下发 Set-Cookie
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
// 造一个 searchUrl 指向该站点、enabledCookieJar=false 的书源
srcJSON := `{
"bookSourceUrl": "` + srv.URL + `",
"bookSourceName": "无 CookieJar 源",
"bookSourceType": 0,
"enabledCookieJar": false,
"searchUrl": "` + srv.URL + `/search",
"ruleSearch": {"bookList":"class.item","name":"tag.h3@tag.a@text","bookUrl":"tag.h3@tag.a@href"}
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意"); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
src, _ := reposReaderURL(ctx, svc, sourceID)
st := svc.newSourceState(ctx, src)
if got := st.GetCookie(srv.URL); strings.Contains(got, "auto=from-response") {
t.Fatalf("enabledCookieJar=false 时不应自动保存 Set-Cookie: %q", got)
}
// 对照:开启时应当捕获
srcJSONOn := strings.Replace(srcJSON, `"enabledCookieJar": false`, `"enabledCookieJar": true`, 1)
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
}))
defer srv2.Close()
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意"); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
t.Fatalf("enabledCookieJar 默认开启时应捕获 Set-Cookie: %q", got)
}
}
// reposReaderURL 取书源的 SourceURL(会话状态按它索引)。
func reposReaderURL(ctx context.Context, svc *ReaderService, sourceID string) (string, error) {
src, err := svc.repo.GetSource(ctx, sourceID)
if err != nil {
return "", err
}
return src.SourceURL, nil
}
+253 -55
View File
@@ -37,19 +37,21 @@ const (
// ReaderService 阅读服务。
type ReaderService struct {
cfg *config.Config
log *zap.Logger
repo *repository.ReaderRepository
http *http.Client
cfg *config.Config
log *zap.Logger
repo *repository.ReaderRepository
http *http.Client
crypto *helper.SecretCipher
}
// NewReaderService 创建服务。
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
return &ReaderService{
cfg: cfg,
log: log,
repo: repos.Reader,
http: helper.NewSiteHTTPClient(30, true),
cfg: cfg,
log: log,
repo: repos.Reader,
http: helper.NewSiteHTTPClient(30, true),
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
}
}
@@ -59,6 +61,8 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con
// 返回导入数量。
func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, error) {
text = strings.TrimSpace(text)
// 去 UTF-8 BOM(Windows 记事本导出的书源文件常见),否则 URL 检测和 JSON 解析都会失败
text = strings.TrimPrefix(text, "\uFEFF")
if text == "" {
return 0, fmt.Errorf("导入内容为空")
}
@@ -136,7 +140,7 @@ func int64Now(p *int64) int64 {
// ParseSourcePayload 识别 JSON 数组 / 单对象 / Base64 / 每行一个对象,
// 返回书源 JSON 字符串列表(冒烟 CLI 复用)。
func ParseSourcePayload(text string) []string {
text = strings.TrimSpace(text)
text = strings.TrimPrefix(strings.TrimSpace(text), "\uFEFF")
tryDecode := func(s string) []string {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(s), &arr); err == nil {
@@ -190,9 +194,29 @@ func ParseSourcePayload(text string) []string {
return out
}
// ListSources 书源列表。
// ListSources 书源列表(标注是否支持登录,供前端决定是否显示登录入口)。
func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
return s.repo.ListSources(ctx)
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, err
}
for i := range sources {
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
}
return sources, nil
}
// rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
func rawSourceHasLogin(rawJSON string) bool {
var probe struct {
LoginURL *string `json:"loginUrl"`
LoginUI *string `json:"loginUi"`
}
if json.Unmarshal([]byte(rawJSON), &probe) != nil {
return false
}
return strings.TrimSpace(SPtr(probe.LoginURL)) != "" || strings.TrimSpace(SPtr(probe.LoginUI)) != ""
}
// UpdateSourceEnabled 启停书源。
@@ -214,6 +238,12 @@ func (s *ReaderService) DeleteSource(ctx context.Context, id string) error {
// execute 执行 rule.Request,返回(解码后 body, 最终 URL, HTTP 状态码)。
func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, string, int, error) {
return s.executeWithState(ctx, req, nil, false)
}
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) {
var bodyReader io.Reader
if req.Body != "" {
bodyReader = strings.NewReader(req.Body)
@@ -222,6 +252,23 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
if target == "" {
target = req.URL
}
if state != nil {
// 登录请求头(除 Cookie 外)优先级低于书源显式配置,高于预设。
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
if _, ok := req.Headers[k]; !ok {
req.Headers[k] = v
}
}
// Cookie 仅在调用方未显式指定时附加,避免覆盖书源自带的鉴权 Cookie。
if hasHeaderFold(req.Headers, "cookie") == "" {
if ck := state.CookieForRequest(target); ck != "" {
req.Headers["Cookie"] = ck
}
}
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
if err != nil {
return "", "", 0, err
@@ -263,8 +310,10 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
for _, ck := range resp.Cookies() {
cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value)
}
if len(cookieStrs) > 0 {
rule.CookieJarRecord(finalURL, cookieStrs)
if len(cookieStrs) > 0 && state != nil && captureCookies {
for _, ck := range cookieStrs {
state.SetCookie(finalURL, ck)
}
}
// bodyJs 二次处理
if req.BodyJsFn != nil {
@@ -277,6 +326,16 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
return body, finalURL, resp.StatusCode, nil
}
// hasHeaderFold 大小写不敏感地取请求头值。
func hasHeaderFold(headers map[string]string, name string) string {
for k, v := range headers {
if strings.EqualFold(k, name) {
return v
}
}
return ""
}
func charsetFromContentType(ct string) string {
if ct == "" {
return ""
@@ -294,34 +353,160 @@ func charsetFromContentType(ct string) string {
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
// newRuleAnalyzer 为指定书源构建规则解析器(注入书源变量与 JS 运行时)。
func (s *ReaderService) newRuleAnalyzer(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int, body, finalURL string) *rule.AnalyzeRule {
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
// 会话状态(Cookie/变量/登录信息)绑在一起,供各阶段复用并在结束时落库。
type sourceSession struct {
svc *ReaderService
ctx context.Context
src *model.ReaderBookSource
bs *BookSource
state *sourceState
}
// newSession 为指定书源建立执行上下文。
func (s *ReaderService) newSession(ctx context.Context, src *model.ReaderBookSource, bs *BookSource) *sourceSession {
url := ""
if src != nil {
url = src.SourceURL
} else if bs != nil {
url = bs.BookSourceURL
}
sess := &sourceSession{svc: s, ctx: ctx, src: src, bs: bs, state: s.newSourceState(ctx, url)}
sess.seedVariable()
return sess
}
// seedVariable 用书源 JSON 的 variables 字段初始化源变量(仅当尚未保存过)。
// 对应「导入书源后源变量为作者设定的默认值」的行为。
func (sess *sourceSession) seedVariable() {
if sess.bs == nil || sess.state == nil {
return
}
if strings.TrimSpace(sess.state.GetVariable()) != "" {
return
}
if raw := strings.TrimSpace(SPtr(sess.bs.RawVariables)); raw != "" && json.Valid([]byte(raw)) {
sess.state.SetVariable(raw)
}
}
// close 落库会话状态(Cookie/变量可能在执行中被书源 JS 改写)。
func (sess *sourceSession) close() {
if sess != nil && sess.state != nil {
sess.state.flush()
}
}
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) {
body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies())
if err != nil {
return body, finalURL, code, err
}
// loginCheckJs:书源借此检测会话失效并自行重登/重取(对应 legado WebBook.checkJs)。
// 返回新 body 时替换原响应,使上层规则直接拿到修复后的内容。
if check := SPtr(sess.bs.LoginCheckJS); strings.TrimSpace(check) != "" {
body = sess.applyLoginCheck(check, body, code, finalURL)
}
return body, finalURL, code, nil
}
// captureCookies 是否自动保存响应里的 Set-Cookie。
// 对应 legado 的 enabledCookieJar(默认 true):关掉后不再自动累积 Cookie,
// 但书源 JS 主动 cookie.setCookie 写入的仍会保存(那是明确意图)。
func (sess *sourceSession) captureCookies() bool {
return sess.bs == nil || sess.bs.EnabledCookieJarOrDefault()
}
// applyLoginCheck 执行 loginCheckJs;失败时保留原 body,避免因检查脚本本身出错而中断阅读。
func (sess *sourceSession) applyLoginCheck(check, body string, code int, finalURL string) string {
runner := sess.runner("", 0)
newBody, changed, err := runner.EvalLoginCheck(stripJSWrapper(check), body, code, finalURL)
if err != nil {
if sess.svc.log != nil {
sess.svc.log.Warn("reader:loginCheckJs 执行失败",
zap.String("source", sess.srcName()), zap.Error(err))
}
return body
}
if changed && newBody != "" {
return newBody
}
return body
}
// newAnalyzer 构建规则解析器(注入书源变量与 JS 运行时)。
func (sess *sourceSession) newAnalyzer(key string, page int, body, finalURL string) *rule.AnalyzeRule {
ar := rule.NewAnalyzeRule()
ar.SetContent(body, finalURL)
applySourceVariables(ar, bs)
ar.SetJSRunner(s.jsRunnerFor(ctx, src, bs, key, page).ForAnalyzer(ar))
applySourceVariables(ar, sess.bs)
// 内嵌 JS 里的 java.put/java.get 读写书源级变量(对应 source.variableMap)
ar.SetSourceVariables(sess.state.GetVariableKey, sess.state.SetVariableKey)
ar.SetJSRunner(sess.runner(key, page).ForAnalyzer(ar))
return ar
}
// jsRunnerFor 为本次请求构建 JS 运行时(网络桥回 execute,携带书源上下文)。
func (s *ReaderService) jsRunnerFor(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) *rule.JSRunner {
// runner 构建本次请求的 JS 运行时(网络桥回 execute,携带书源上下文与会话状态)。
func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
return rule.NewJSRunner(rule.JSConfig{
Fetch: func(req *rule.Request) (string, string, int, error) {
return s.execute(ctx, req)
return sess.fetch(req)
},
SourceProps: bs.SourceProps(),
SourceProps: sess.bs.SourceProps(),
Log: func(msg string) {
if s.log != nil {
s.log.Info("reader:source-js",
zap.String("source", srcNameOf(src, bs)), zap.String("log", msg))
if sess.svc.log != nil {
sess.svc.log.Info("reader:source-js",
zap.String("source", srcNameOf(sess.src, sess.bs)), zap.String("log", msg))
}
},
BaseURL: src.SourceURL,
BaseURL: sess.srcURL(),
Key: key,
Page: page,
State: sess.state,
JSLib: SPtr(sess.bs.JSLib),
})
}
// srcURL 书源标识 URL(会话状态与 baseUrl 的键)。
func (sess *sourceSession) srcURL() string {
if sess.src != nil && sess.src.SourceURL != "" {
return sess.src.SourceURL
}
if sess.bs != nil {
return sess.bs.BookSourceURL
}
return ""
}
// srcName 书源显示名。
func (sess *sourceSession) srcName() string {
return srcNameOf(sess.src, sess.bs)
}
// headerJSON 书源级请求头 JSON。
func (sess *sourceSession) headerJSON() string {
if sess.src != nil && sess.src.Header != "" {
return sess.src.Header
}
return SPtr(sess.bs.Header)
}
// applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。
func (sess *sourceSession) applyHeaders(req *rule.Request) {
raw := sess.headerJSON()
if raw == "" {
return
}
var headers map[string]any
if json.Unmarshal([]byte(raw), &headers) == nil {
for k, v := range headers {
if _, ok := req.Headers[k]; !ok {
req.Headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
func srcNameOf(src *model.ReaderBookSource, bs *BookSource) string {
if src != nil {
return src.Name
@@ -480,30 +665,22 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
if sr == nil || SPtr(sr.BookList) == "" {
return nil, fmt.Errorf("书源未配置搜索列表规则")
}
runner := s.jsRunnerFor(ctx, src, bs, key, page)
req, err := rule.ParseAnalyzeUrlWithJS(searchURL, key, page, src.SourceURL, runner)
sess := s.newSession(ctx, src, bs)
defer sess.close()
runner := sess.runner(key, page)
req, err := rule.ParseAnalyzeUrlWithJS(searchURL, key, page, sess.srcURL(), runner)
if err != nil {
return nil, err
}
if req.Unsupported != nil {
return nil, req.Unsupported
}
// 书源级请求头
if src.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(src.Header), &headers) == nil {
for k, v := range headers {
if _, ok := req.Headers[k]; !ok {
req.Headers[k] = fmt.Sprintf("%v", v)
}
}
}
}
body, finalURL, _, err := s.execute(ctx, req)
sess.applyHeaders(req)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
}
ar := s.newRuleAnalyzer(ctx, src, bs, key, page, body, finalURL)
ar := sess.newAnalyzer(key, page, body, finalURL)
elements, err := ar.GetElements(SPtr(sr.BookList))
if err != nil {
@@ -596,20 +773,22 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
if bir == nil {
return nil, fmt.Errorf("书源未配置详情规则")
}
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, src.SourceURL, runner)
sess := s.newSession(ctx, src, bs)
defer sess.close()
runner := sess.runner("", 0)
req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, sess.srcURL(), runner)
if err != nil {
return nil, err
}
if req.Unsupported != nil {
return nil, req.Unsupported
}
applySourceHeaders(req, src)
body, finalURL, _, err := s.execute(ctx, req)
sess.applyHeaders(req)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
}
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
ar := sess.newAnalyzer("", 0, body, finalURL)
info := &BookInfo{BookURL: bookURL, TocURL: bookURL}
if initRule := SPtr(bir.Init); initRule != "" {
@@ -687,20 +866,22 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
if tocURL == "" {
tocURL = bookURL
}
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, src.SourceURL, runner)
sess := s.newSession(ctx, src, bs)
defer sess.close()
runner := sess.runner("", 0)
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
if err != nil {
return nil, err
}
if req.Unsupported != nil {
return nil, req.Unsupported
}
applySourceHeaders(req, src)
body, finalURL, _, err := s.execute(ctx, req)
sess.applyHeaders(req)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
}
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
ar := sess.newAnalyzer("", 0, body, finalURL)
elements, err := ar.GetElements(SPtr(tr.ChapterList))
if err != nil {
@@ -790,6 +971,20 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook,
}
}
}
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
if s.repo != nil {
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(rawURL); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
}
}
if httpReq.Header.Get("Referer") == "" && book.Origin != "" {
httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/")
}
@@ -854,22 +1049,25 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
var parts []string
url := chapterURL
lastFinalURL := ""
// 整章(含翻页)共用一个会话,翻页期间 Cookie/变量变更保持一致。
sess := s.newSession(ctx, src, bs)
defer sess.close()
for i := 0; i < maxContentNextPage; i++ {
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, src.SourceURL, runner)
runner := sess.runner("", 0)
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, sess.srcURL(), runner)
if err != nil {
return nil, err
}
if req.Unsupported != nil {
return nil, req.Unsupported
}
applySourceHeaders(req, src)
body, finalURL, _, err := s.execute(ctx, req)
sess.applyHeaders(req)
body, finalURL, _, err := sess.fetch(req)
if err != nil {
return nil, err
}
lastFinalURL = finalURL
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
ar := sess.newAnalyzer("", 0, body, finalURL)
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
if err != nil {
+4
View File
@@ -231,6 +231,10 @@ func TestParseSourcePayload(t *testing.T) {
if got := ParseSourcePayload(b64); len(got) != 1 {
t.Fatalf("base64 payload = %d", len(got))
}
// UTF-8 BOM(Windows 导出文件常见)
if got := ParseSourcePayload("\uFEFF" + arr); len(got) != 2 {
t.Fatalf("bom array payload = %d", len(got))
}
}
func TestMergeSearchResults(t *testing.T) {
+43 -1
View File
@@ -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",
+145 -13
View File
@@ -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 {
+213 -61
View File
@@ -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)
}
}
+240
View File
@@ -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)
+117
View File
@@ -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 || '未设置'));
}
+35
View File
@@ -22,6 +22,9 @@ type BookSource struct {
ConcurrentRate *string `json:"concurrentRate"`
Header *string `json:"header"`
LoginURL *string `json:"loginUrl"`
LoginUI *string `json:"loginUi"`
LoginCheckJS *string `json:"loginCheckJs"`
JSLib *string `json:"jsLib"`
BookSourceComment *string `json:"bookSourceComment"`
LastUpdateTime *int64 `json:"lastUpdateTime"`
RespondTime *int64 `json:"respondTime"`
@@ -38,6 +41,38 @@ type BookSource struct {
RawVariables *string `json:"variables"`
}
// EnabledCookieJarOrDefault 是否启用 Cookie 自动携带(legado 默认 true)。
func (b *BookSource) EnabledCookieJarOrDefault() bool {
return b.EnabledCookieJar == nil || *b.EnabledCookieJar
}
// HasLogin 是否声明了登录能力(登录需要 loginUrl 的 JS 或 loginUi 表单)。
func (b *BookSource) HasLogin() bool {
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
}
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
// 因此 loginUrl 同时充当登录交互的函数库。
func (b *BookSource) LoginJS() string {
return stripJSWrapper(SPtr(b.LoginURL))
}
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
func stripJSWrapper(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "@js:") {
return s[len("@js:"):]
}
if strings.HasPrefix(s, "<js>") {
s = s[len("<js>"):]
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
return s
}
return s
}
// SearchRule 搜索规则。
type SearchRule struct {
CheckKeyWord *string `json:"checkKeyWord"`
+274
View File
@@ -0,0 +1,274 @@
package reader
import (
"context"
"encoding/json"
"strings"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:把书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)落到 DB。
// 对应 legado 的 CacheManager + CookieStore,按书源 URL 隔离,
// 使登录态在服务端重启后依然有效。
// sourceState 实现 rule.SourceState,按书源 URL 读写 ReaderSourceState。
// 写入是"读-改-写":一次性把四个字段整体落库,保证并发下不丢字段。
type sourceState struct {
svc *ReaderService
ctx context.Context
sourceURL string
// 进程内累积区:一次规则执行内可能多次读写,先落内存再统一 flush。
variable string
loginInfo string
loginHeader string
cookies map[string]string // domain → "k=v; k=v"
loaded bool
toasts []string
browsers []rule.BrowserRequest
dirty bool
}
// newSourceState 载入指定书源的会话状态(含解密)。
func (s *ReaderService) newSourceState(ctx context.Context, sourceURL string) *sourceState {
st := &sourceState{svc: s, ctx: ctx, sourceURL: sourceURL, cookies: map[string]string{}}
if s.repo == nil || sourceURL == "" {
st.loaded = true
return st
}
rec, err := s.repo.GetSourceState(ctx, sourceURL)
if err != nil || rec == nil {
st.loaded = true
return st
}
st.variable = s.decrypt(rec.Variable)
st.loginInfo = s.decrypt(rec.LoginInfo)
st.loginHeader = s.decrypt(rec.LoginHeader)
if ck := s.decrypt(rec.Cookies); ck != "" {
_ = json.Unmarshal([]byte(ck), &st.cookies)
}
st.loaded = true
return st
}
func (st *sourceState) GetVariable() string { return st.variable }
func (st *sourceState) SetVariable(v string) {
if st.variable == v {
return
}
st.variable = v
st.dirty = true
}
// GetVariableKey / SetVariableKey 以键为单位读写源变量 map,
// 对应 legado source.variableMap(规则里的 @get:{} / @put:{} 与 java.get/put 走这里)。
func (st *sourceState) GetVariableKey(key string) string {
if key == "" {
return ""
}
var m map[string]string
if json.Unmarshal([]byte(st.variable), &m) != nil {
return ""
}
return m[key]
}
func (st *sourceState) SetVariableKey(key, value string) {
if key == "" {
return
}
m := map[string]string{}
_ = json.Unmarshal([]byte(st.variable), &m)
if m == nil {
m = map[string]string{}
}
m[key] = value
b, err := json.Marshal(m)
if err != nil {
return
}
st.SetVariable(string(b))
}
func (st *sourceState) GetLoginInfo() string { return st.loginInfo }
func (st *sourceState) SetLoginInfo(v string) {
if st.loginInfo == v {
return
}
st.loginInfo = v
st.dirty = true
}
func (st *sourceState) GetLoginHeader() string { return st.loginHeader }
func (st *sourceState) SetLoginHeader(v string) {
if st.loginHeader == v {
return
}
st.loginHeader = v
st.dirty = true
}
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
func (st *sourceState) GetCookieKey(rawURL, key string) string {
domain := rule.CookieDomain(rawURL)
if domain == "" {
return ""
}
raw, ok := st.cookies[domain]
if !ok {
return ""
}
if key == "" {
return raw
}
return rule.ParseCookie(raw)[key]
}
func (st *sourceState) SetCookie(rawURL, cookie string) {
domain := rule.CookieDomain(rawURL)
if domain == "" || strings.TrimSpace(cookie) == "" {
return
}
merged := rule.MergeCookie(st.cookies[domain], cookie)
if merged == st.cookies[domain] {
return
}
st.cookies[domain] = merged
st.dirty = true
}
func (st *sourceState) RemoveCookie(rawURL string) {
domain := rule.CookieDomain(rawURL)
if domain == "" {
return
}
if _, ok := st.cookies[domain]; !ok {
return
}
delete(st.cookies, domain)
st.dirty = true
}
func (st *sourceState) Toast(msg string) { st.toasts = append(st.toasts, msg) }
func (st *sourceState) OpenBrowser(url, title string) {
st.browsers = append(st.browsers, rule.BrowserRequest{URL: url, Title: title})
}
// flush 把累积状态落库(登录信息与 Cookie 加密存储)。
func (st *sourceState) flush() {
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
return
}
cookiesJSON := ""
if len(st.cookies) > 0 {
if b, err := json.Marshal(st.cookies); err == nil {
cookiesJSON = string(b)
}
}
rec := &model.ReaderSourceState{
SourceURL: st.sourceURL,
Variable: st.variable,
LoginInfo: st.svc.encrypt(st.loginInfo),
LoginHeader: st.svc.encrypt(st.loginHeader),
Cookies: st.svc.encrypt(cookiesJSON),
}
if err := st.svc.repo.SaveSourceState(st.ctx, rec); err != nil {
if st.svc.log != nil {
st.svc.log.Warn("reader: 保存书源会话状态失败: " + err.Error())
}
return
}
st.dirty = false
}
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
func (st *sourceState) snapshotCookies() map[string]string {
out := make(map[string]string, len(st.cookies))
for d, c := range st.cookies {
out[d] = c
}
return out
}
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
func (st *sourceState) clearCookies() {
if len(st.cookies) == 0 {
return
}
st.cookies = map[string]string{}
st.dirty = true
}
// CookieForRequest 返回应附加到该请求的 Cookie 串:
// 会话 Cookie 优先,其次是 loginHeader 中显式声明的 Cookie。
func (st *sourceState) CookieForRequest(rawURL string) string {
if st == nil {
return ""
}
cookie := st.GetCookie(rawURL)
if h := strings.TrimSpace(st.loginHeader); h != "" {
var m map[string]any
if json.Unmarshal([]byte(h), &m) == nil {
for k, v := range m {
if strings.EqualFold(k, "cookie") {
extra := strings.TrimSpace(toStringVal(v))
if extra != "" {
cookie = rule.MergeCookie(cookie, extra)
}
}
}
}
}
return cookie
}
// LoginHeaderMap 返回 loginHeader 的解析结果(除 Cookie 外的头)。
func (st *sourceState) LoginHeaderMap() map[string]string {
out := map[string]string{}
if st == nil || strings.TrimSpace(st.loginHeader) == "" {
return out
}
var m map[string]any
if json.Unmarshal([]byte(st.loginHeader), &m) != nil {
return out
}
for k, v := range m {
out[k] = toStringVal(v)
}
return out
}
func toStringVal(v any) string {
if s, ok := v.(string); ok {
return s
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
// encrypt / decrypt 复用全局 CryptoService(密钥来自 JWTSecret)。
func (s *ReaderService) encrypt(plain string) string {
if s.crypto == nil {
return plain
}
return s.crypto.Encrypt(plain)
}
func (s *ReaderService) decrypt(value string) string {
if s.crypto == nil {
return value
}
return s.crypto.Decrypt(value)
}
var _ rule.SourceState = (*sourceState)(nil)