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
+2 -1
View File
@@ -80,4 +80,5 @@ media/
tools/
verify-cache/
verify-data/
.zcode/
.zcode/
.tmp-src
+114
View File
@@ -0,0 +1,114 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
dist-release/
# Data / runtime
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-smoke-data/
.tmp-deploy-smoke-cache/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mebox.pid
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
# WorkBuddy workspace (local AI assistant memory)
.workbuddy/
# Editor backups
*~
.tmp_*
# Runtime / local-only artifacts (清理补充)
.tmp/
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+3 -1
View File
@@ -18,7 +18,8 @@
**不做(本期明确排除):**
- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示)
- 登录类书源(loginUrl / loginUi 交互)
- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景;
纯 JS / 表单类登录已支持,见 P4.5)
- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3)
- 本地 TXT/EPUB 导入(列为后续可选)
@@ -122,6 +123,7 @@ web/src/
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 阅读器音频面板(hls.js 播 m3u8、直链 `<audio>`、上一章/播放暂停/下一章、倍速 0.75–2x、进度按秒记忆、播完自动下一章) | 音频源可听 |
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出;可选:本地 TXT/EPUB | 完整体验 |
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
+307
View File
@@ -0,0 +1,307 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
func newReaderHandlerContainer(t *testing.T) *service.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 := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
return &service.Container{
Repo: repos,
Cfg: cfg,
Log: zap.NewNop(),
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
}
}
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
func loginHandlerSource(server string) string {
loginJS := `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) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
java.toast(data.msg || '登录失败'); return false;
}`
ui, _ := json.Marshal([]map[string]any{
{"name": "邮箱", "type": "text"},
{"name": "密码", "type": "password"},
{"name": "登录", "type": "button", "action": "login(true)"},
})
src := map[string]any{
"bookSourceUrl": server,
"bookSourceName": "登录源",
"bookSourceType": 0,
"loginUrl": loginJS,
"loginUi": string(ui),
"searchUrl": server + "/search",
"ruleSearch": map[string]any{
"bookList": "class.item",
"name": "tag.h3@tag.a@text",
"bookUrl": "tag.h3@tag.a@href",
},
}
b, _ := json.Marshal(src)
return string(b)
}
// loginHandlerServer 模拟需要登录的站点。
func loginHandlerServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login_api":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
return
}
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
case "/search":
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<html>未登录</html>`))
return
}
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
default:
http.NotFound(w, r)
}
}))
}
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
srv := loginHandlerServer(t)
defer srv.Close()
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
// 导入书源
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
}
// 列表应标注 has_login
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
if w.Code != http.StatusOK {
t.Fatalf("书源列表 status=%d", w.Code)
}
var listResp struct {
Sources []struct {
ID string `json:"id"`
HasLogin bool `json:"has_login"`
} `json:"sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
t.Fatalf("书源列表解析失败: %s", w.Body.String())
}
if !listResp.Sources[0].HasLogin {
t.Fatal("has_login 应为 true")
}
sourceID := listResp.Sources[0].ID
// GET 登录信息:应返回表单字段与未登录状态
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
}
var info struct {
HasLoginJS bool `json:"has_login_js"`
LoggedIn bool `json:"logged_in"`
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"fields"`
}
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatalf("登录信息解析失败: %s", w.Body.String())
}
if !info.HasLoginJS || info.LoggedIn {
t.Fatalf("初始状态异常: %+v", info)
}
var names []string
for _, f := range info.Fields {
names = append(names, f.Name)
}
for _, want := range []string{"邮箱", "密码", "登录"} {
if !slices.Contains(names, want) {
t.Fatalf("缺少表单字段 %q: %v", want, names)
}
}
// 密码错误 → ok=false,提示透传
loginBody, _ := json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var failRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Toasts []string `json:"toasts"`
}
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
if failRes.LoggedIn {
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
}
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
t.Fatalf("失败提示未透传: %s", w.Body.String())
}
// 正确密码 → ok=true 且已登录
loginBody, _ = json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var okRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
t.Fatal(err)
}
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
}
// 搜索应携带 Cookie 并拿到结果
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var searchRes struct {
Books []struct{ Name string } `json:"books"`
Skipped []struct {
Reason string `json:"reason"`
} `json:"skipped"`
}
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
}
// 登出 → 登录态清空
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登出 status=%d", w.Code)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
var afterLogout struct {
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
t.Fatalf("登出后应无登录态: %s", w.Body.String())
}
}
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
func TestReaderSetSourceVariableRoute(t *testing.T) {
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
var list struct {
Sources []struct {
ID string `json:"id"`
} `json:"sources"`
}
_ = json.Unmarshal(w.Body.Bytes(), &list)
if len(list.Sources) != 1 {
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
}
id := list.Sources[0].ID
// 合法 JSON
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("保存变量 status=%d body=%s", w.Code, w.Body.String())
}
// 非法 JSON 应 400
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"not-json"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("非法变量应 400,实际 %d body=%s", w.Code, w.Body.String())
}
}
+82
View File
@@ -24,6 +24,13 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
// 书源登录与源变量(登录类书源必需)
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
@@ -122,6 +129,81 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
}
}
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
Action string `json:"action"`
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := svc.Reader.RunLoginAction(c.Request.Context(), c.Param("id"), body.Action, body.Fields)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Variable string `json:"variable"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
+87
View File
@@ -0,0 +1,87 @@
// Package helper — AES-256-GCM at-rest secret encryption.
//
// Sensitive values (third-party API keys, service passwords, book-source
// login credentials) are stored in the database. Encrypting them keyed off
// a per-install secret means a stolen DB file alone is not enough to
// recover the plaintext.
//
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag).
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
// install needs no migration step.
package helper
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// EncPrefix tags ciphertext rows so they can be told apart from plaintext.
const EncPrefix = "enc:v1:"
// SecretCipher encrypts/decrypts strings with AES-256-GCM derived from a
// stable per-install secret. A zero-value SecretCipher (empty key) is a
// pass-through, which keeps unit tests and keyless dev setups working.
type SecretCipher struct {
aead cipher.AEAD
}
// NewSecretCipher derives a 256-bit key from secret via SHA-256.
func NewSecretCipher(secret string) *SecretCipher {
if strings.TrimSpace(secret) == "" {
return &SecretCipher{}
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
return &SecretCipher{}
}
aead, err := cipher.NewGCM(block)
if err != nil {
return &SecretCipher{}
}
return &SecretCipher{aead: aead}
}
// Ready 表示是否真的能加解密(密钥有效)。
func (c *SecretCipher) Ready() bool { return c != nil && c.aead != nil }
// Encrypt returns the prefixed base64 ciphertext for plain.
// Empty inputs and already-encrypted values round-trip unchanged.
func (c *SecretCipher) Encrypt(plain string) string {
if plain == "" || !c.Ready() || strings.HasPrefix(plain, EncPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
sealed := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return EncPrefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns the plaintext for a stored value. Plaintext rows (no
// prefix) are returned unchanged, as are values that fail to authenticate.
func (c *SecretCipher) Decrypt(value string) string {
if value == "" || !c.Ready() || !strings.HasPrefix(value, EncPrefix) {
return value
}
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, EncPrefix))
if err != nil || len(data) < c.aead.NonceSize() {
return value
}
nonce, body := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, body, nil)
if err != nil {
return value
}
return string(plain)
}
// IsEncrypted reports whether value carries the encrypted prefix.
func (c *SecretCipher) IsEncrypted(value string) bool {
return strings.HasPrefix(value, EncPrefix)
}
+1
View File
@@ -62,6 +62,7 @@ func AllModels() []interface{} {
&ScrapeTask{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
&ReaderBook{},
&ReaderChapter{},
&ReaderReplaceRule{},
+14
View File
@@ -26,6 +26,20 @@ type ReaderBookSource struct {
LastUpdateTime int64 `json:"last_update_time"`
LastCheckAt *time.Time `json:"last_check_at"`
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
HasLogin bool `gorm:"-" json:"has_login"`
}
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
// sourceVariable / userInfo(登录信息)/ loginHeader 与 CookieStore。
// 与书源分表存放,避免每次导入更新书源时把用户登录态覆盖掉。
type ReaderSourceState struct {
Base
SourceURL string `gorm:"type:varchar(512);uniqueIndex" json:"source_url"`
Variable string `gorm:"type:text" json:"variable"` // source.getVariable/setVariable
LoginInfo string `gorm:"type:text" json:"login_info"` // source.getLoginInfo/putLoginInfo(登录表单 JSON)
LoginHeader string `gorm:"type:text" json:"login_header"` // source 登录请求头 JSON
Cookies string `gorm:"type:text" json:"cookies"` // JSON: domain → "k=v; k=v"
}
// ReaderBook 书架条目(含阅读进度,对应 legado Book)。
+39 -2
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"gorm.io/gorm"
@@ -50,9 +51,45 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
return r.db.WithContext(ctx).Save(src).Error
}
// DeleteSource 删除书源。
// DeleteSource 删除书源(连带清理其会话状态)。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderBookSource{}, "id = ?", id).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
})
}
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
var out model.ReaderSourceState
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
var existing model.ReaderSourceState
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(st).Error
}
if err != nil {
return err
}
st.ID = existing.ID
st.CreatedAt = existing.CreatedAt
return r.db.WithContext(ctx).Save(st).Error
}
// ListBooks 用户书架(按 order 排序)。
+128
View File
@@ -0,0 +1,128 @@
package repository
import (
"errors"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newReaderTestRepo(t *testing.T) *ReaderRepository {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(db).Reader
}
// TestSourceStateRoundTrip 验证会话状态按书源 URL 存取。
func TestSourceStateRoundTrip(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
// 不存在时返回 (nil, nil),调用方据此走默认值
got, err := repo.GetSourceState(ctx, "https://a.example.com")
if err != nil || got != nil {
t.Fatalf("首次读取应为空: got=%v err=%v", got, err)
}
st := &model.ReaderSourceState{
SourceURL: "https://a.example.com",
Variable: `{"线路":"v1"}`,
LoginInfo: `{"邮箱":"u@e.com"}`,
LoginHeader: `{"X-Token":"t"}`,
Cookies: `{"example.com":"qttoken=abc"}`,
}
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
got, err = repo.GetSourceState(ctx, "https://a.example.com")
if err != nil {
t.Fatal(err)
}
if got.LoginInfo != st.LoginInfo || got.Variable != st.Variable {
t.Fatalf("读回不一致: %+v", got)
}
// 再次保存应为更新而非插入(同 URL 唯一)
st.LoginInfo = `{"邮箱":"new@e.com"}`
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
var count int64
if err := repo.db.Model(&model.ReaderSourceState{}).
Where("source_url = ?", "https://a.example.com").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同一书源应只有一行状态,实际 %d", count)
}
got, _ = repo.GetSourceState(ctx, "https://a.example.com")
if got.LoginInfo != `{"邮箱":"new@e.com"}` {
t.Fatalf("更新未生效: %+v", got)
}
}
// TestDeleteSourceAlsoClearsState 删除书源应连带清理登录态。
func TestDeleteSourceAlsoClearsState(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://b.example.com", Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://b.example.com", LoginInfo: `{"a":"b"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://b.example.com")
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal(err)
}
if st != nil {
t.Fatalf("书源删除后登录态应一并清理,实际仍在: %+v", st)
}
}
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
func TestImportUpdateKeepsSourceState(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://c.example.com", RawJSON: `{"v":1}`}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://c.example.com", LoginInfo: `{"k":"keep"}`,
}); err != nil {
t.Fatal(err)
}
// 模拟导入更新:只改书源自身字段
src.RawJSON = `{"v":2}`
if err := repo.UpdateSource(ctx, src); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://c.example.com")
if err != nil {
t.Fatal(err)
}
if st == nil || st.LoginInfo != `{"k":"keep"}` {
t.Fatalf("更新书源后登录态丢失: %+v", st)
}
}
+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)
+55
View File
@@ -42,6 +42,8 @@ export interface ReaderSource {
comment: string
last_update_time: number
respond_time: number
// 书源声明了登录能力(loginUrl / loginUi)
has_login: boolean
}
export interface ReaderBook {
@@ -115,6 +117,47 @@ export interface ReaderReplaceRule {
order: number
}
// ── 书源登录 ──
export interface ReaderLoginField {
name: string
type: 'text' | 'password' | 'button' | 'toggle' | 'select'
action?: string
chars?: string[]
default?: string
viewName?: string
style?: Record<string, unknown>
}
export interface ReaderSourceLogin {
source_id: string
source_name: string
has_login_js: boolean
login_js?: string
fields: ReaderLoginField[]
values: Record<string, string>
cookies: Record<string, string>
variable: string
variable_comment?: string
logged_in: boolean
error?: string
}
export interface ReaderBrowserRequest {
url: string
title: string
}
export interface ReaderLoginResult {
ok: boolean
error?: string
toasts?: string[]
browsers?: ReaderBrowserRequest[]
values: Record<string, string>
cookies: Record<string, string>
logged_in: boolean
}
const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const
export const readerAPI = {
@@ -127,6 +170,18 @@ export const readerAPI = {
debugSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
// ── 书源登录与源变量 ──
// 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态)
sourceLogin: (id: string) => api.get<ReaderSourceLogin>(`/reader/sources/${id}/login`).then((r) => r.data),
// 执行登录动作:action 为空表示执行 loginUrl 的 login()(确认登录)
runSourceLogin: (id: string, body: { action?: string; fields?: Record<string, string> }) =>
api.post<ReaderLoginResult>(`/reader/sources/${id}/login`, body, longOpts).then((r) => r.data),
// 仅保存表单值,不触发登录
saveSourceLoginInfo: (id: string, fields: Record<string, string>) =>
api.put(`/reader/sources/${id}/login-info`, { fields }),
logoutSource: (id: string) => api.delete(`/reader/sources/${id}/login`),
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
// ── 搜索 / 详情 / 目录 / 正文 ──
search: (key: string) =>
api
+118 -8
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react'
import { ArrowLeft, Bug, Download, FileUp, KeyRound, Loader2, Play, Trash2, X } from 'lucide-react'
import { readerAPI, type ReaderSource } from '../../api/reader'
import { READER_SOURCE_TYPES } from './sourceTypes'
import SourceLoginDialog from './SourceLoginDialog'
// 书源管理页(仿 legado 书源列表:启停开关、快速调试、导入)。
// 导入书源文件大小上限(legado 全量导出通常 < 10MB)
const MAX_IMPORT_FILE_BYTES = 32 << 20
function hostOf(url: string): string {
try {
return new URL(url).host
@@ -16,16 +20,28 @@ function hostOf(url: string): string {
}
}
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`
return `${bytes}B`
}
export default function ReaderSourcesPage() {
const navigate = useNavigate()
const [sources, setSources] = useState<ReaderSource[] | null>(null)
const [importText, setImportText] = useState('')
const [importFile, setImportFile] = useState<{ name: string; text: string; size: number } | null>(null)
const [dragOver, setDragOver] = useState(false)
const [importing, setImporting] = useState(false)
const [showImport, setShowImport] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [debugId, setDebugId] = useState('')
const [debugKey, setDebugKey] = useState('')
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
const [debugging, setDebugging] = useState(false)
// 登录面板:记录正在登录的书源
const [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
const load = () => {
readerAPI
@@ -36,13 +52,29 @@ export default function ReaderSourcesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
const readFile = async (file: File) => {
if (file.size > MAX_IMPORT_FILE_BYTES) {
toast.error('文件过大(超过 32MB)')
return
}
try {
const text = (await file.text()).replace(/^\uFEFF/, '')
setImportFile({ name: file.name, text, size: file.size })
} catch {
toast.error('读取文件失败')
}
}
const doImport = async () => {
if (!importText.trim() || importing) return
const payload = importFile?.text ?? importText
if (!payload.trim() || importing) return
setImporting(true)
try {
const imported = await readerAPI.importSources(importText)
const imported = await readerAPI.importSources(payload)
toast.success(`成功导入 ${imported} 个书源`)
setImportText('')
setImportFile(null)
setShowImport(false)
load()
} catch (e) {
@@ -111,16 +143,63 @@ export default function ReaderSourcesPage() {
</div>
{showImport && (
<div className="mt-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
<div
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) readFile(file)
}}
className={`mt-4 rounded-2xl border bg-[var(--app-panel)] p-4 transition ${
dragOver ? 'border-brand-500 bg-brand-500/5' : 'border-[var(--app-border)]'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,application/json,text/plain"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) readFile(file)
e.target.value = ''
}}
/>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={6}
placeholder={'粘贴书源 JSON / Base64,或填一个书源链接(https://...)\n支持数组批量导入'}
placeholder={'粘贴书源 JSON / Base64,或填一个书源链接(https://...)\n也可选择 / 拖入书源文件(.json / .txt),支持数组批量导入'}
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
/>
<div className="mt-2 flex justify-end">
<button type="button" onClick={doImport} disabled={importing || !importText.trim()} className="btn-primary text-xs disabled:opacity-50">
{importFile && (
<div className="mt-2 flex items-center gap-2 rounded-xl border border-brand-500/30 bg-brand-500/10 px-3 py-1.5 text-xs font-bold text-brand-600">
<FileUp size={13} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">
{importFile.name}({fmtSize(importFile.size)})
</span>
<button type="button" title="移除文件" onClick={() => setImportFile(null)} className="shrink-0 hover:text-red-500">
<X size={13} />
</button>
</div>
)}
<div className="mt-2 flex items-center justify-between">
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-outline text-xs">
<FileUp size={13} className="mr-1 inline" /> 选择文件
</button>
<button
type="button"
onClick={doImport}
disabled={importing || !(importFile || importText.trim())}
className="btn-primary text-xs disabled:opacity-50"
>
{importing ? <Loader2 size={13} className="inline animate-spin" /> : '开始导入'}
</button>
</div>
@@ -151,6 +230,19 @@ export default function ReaderSourcesPage() {
</p>
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
</div>
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
{src.has_login && (
<button
type="button"
title={loggedInIds[src.id] ? '已登录,点击管理' : '登录'}
onClick={() => setLoginSource(src)}
className={`rounded-xl p-2 hover:bg-[var(--app-hover)] ${
loggedInIds[src.id] ? 'text-emerald-500' : 'text-[var(--app-muted)] hover:text-brand-600'
}`}
>
<KeyRound size={16} />
</button>
)}
<button
type="button"
title="快速调试"
@@ -213,6 +305,24 @@ export default function ReaderSourcesPage() {
</div>
))}
</div>
{loginSource && (
<SourceLoginDialog
sourceId={loginSource.id}
sourceName={loginSource.name}
onClose={() => {
setLoginSource(null)
// 关闭时回读登录态,更新列表上的登录标记
readerAPI
.sourceLogin(loginSource.id)
.then((info) => setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: info.logged_in })))
.catch(() => undefined)
}}
onLoggedInChange={(loggedIn) =>
setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: loggedIn }))
}
/>
)}
</div>
)
}
+379
View File
@@ -0,0 +1,379 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react'
import { readerAPI, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader'
// 书源登录面板(仿 legado SourceLoginDialog)。
//
// 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑,
// 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。
// 服务端没有弹窗,java.toast/longToast 的提示与 java.startBrowser 的地址
// 都通过返回值回传,这里负责展示并代为打开浏览器。
interface Props {
sourceId: string
sourceName: string
onClose: () => void
onLoggedInChange?: (loggedIn: boolean) => void
}
// formValues 取非按钮控件的初始值(已保存值优先,其次 default)。
function initialFormValues(info: ReaderSourceLogin): Record<string, string> {
const out: Record<string, string> = {}
for (const f of info.fields) {
if (f.type === 'button') continue
out[f.name] = info.values?.[f.name] ?? f.default ?? ''
}
return out
}
export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLoggedInChange }: Props) {
const [info, setInfo] = useState<ReaderSourceLogin | null>(null)
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState('')
const [form, setForm] = useState<Record<string, string>>({})
const [toasts, setToasts] = useState<string[]>([])
const [error, setError] = useState('')
const [variable, setVariable] = useState('')
const [showVariable, setShowVariable] = useState(false)
const [showLoginJS, setShowLoginJS] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await readerAPI.sourceLogin(sourceId)
setInfo(data)
setForm(initialFormValues(data))
setVariable(data.variable ?? '')
setError(data.error ?? '')
} catch (e) {
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '读取登录信息失败')
} finally {
setLoading(false)
}
}, [sourceId])
useEffect(() => {
void load()
}, [load])
// 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。
const buttons = useMemo(() => info?.fields.filter((f) => f.type === 'button') ?? [], [info])
const inputs = useMemo(() => info?.fields.filter((f) => f.type !== 'button') ?? [], [info])
const selects = useMemo(() => info?.fields.filter((f) => f.type === 'toggle' || f.type === 'select') ?? [], [info])
const runAction = async (action: string, opts: { saveFields?: boolean } = {}) => {
const key = action || '__login__'
setRunning(key)
setToasts([])
setError('')
try {
const res = await readerAPI.runSourceLogin(sourceId, { action, fields: form })
const messages = [...(res.toasts ?? [])]
if (res.error) messages.push(res.error)
setToasts(messages)
if (res.browsers?.length) {
// 服务端无法弹窗:把需要人工操作的页面提示给用户
for (const b of res.browsers) {
messages.push(`需要浏览器操作:${b.title || b.url}`)
}
setToasts([...messages])
}
// 同步回最新登录信息(书源可能在动作里回填字段)
if (res.values) setForm((prev) => ({ ...prev, ...res.values }))
if (opts.saveFields && !res.ok) {
// 动作失败也保留用户输入,便于改完重试
}
onLoggedInChange?.(res.logged_in)
const nextInfo = await readerAPI.sourceLogin(sourceId)
setInfo(nextInfo)
setVariable(nextInfo.variable ?? '')
if (res.logged_in) toast.success('登录成功')
else if (!res.ok) toast.error('登录失败')
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '登录执行失败'
setError(msg)
toast.error(msg)
} finally {
setRunning('')
}
}
const saveLoginInfo = async () => {
try {
await readerAPI.saveSourceLoginInfo(sourceId, form)
toast.success('已保存登录信息')
} catch {
toast.error('保存失败')
}
}
const doLogout = async () => {
if (!window.confirm('退出登录并清除该源已保存的 Cookie?')) return
try {
await readerAPI.logoutSource(sourceId)
toast.success('已退出登录')
onLoggedInChange?.(false)
await load()
} catch {
toast.error('退出失败')
}
}
const saveVariable = async () => {
try {
await readerAPI.setSourceVariable(sourceId, variable)
toast.success('源变量已保存')
const next = await readerAPI.sourceLogin(sourceId)
setInfo(next)
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
}
}
const openExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
// 已保存 Cookie 条目(domain → cookie),给用户一个登录态的直观依据
const cookieEntries = Object.entries(info?.cookies ?? {})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-[var(--app-border)] px-4 py-3">
<KeyRound size={16} className="shrink-0 text-brand-600" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-[var(--app-text)]">{sourceName}</p>
<p className="text-2xs text-[var(--app-muted)]">
{info?.logged_in ? '已登录' : '未登录'}
{info?.has_login_js ? ' · 含登录逻辑' : ' · 未配置登录逻辑'}
</p>
</div>
<button type="button" onClick={() => void load()} title="刷新" className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
<RefreshCw size={15} />
</button>
<button type="button" onClick={onClose} className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
<X size={16} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{loading && (
<div className="flex items-center justify-center py-16 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={20} />
</div>
)}
{!loading && (
<>
{error && (
<div className="mb-3 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-500">
{error}
</div>
)}
{/* 没有 loginUi 的表单时,只有 loginUrl:按 legado 语义当作登录页地址 */}
{!info?.fields.length && info?.has_login_js && (
<div className="mb-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-muted)]">
该书源没有表单(loginUi),登录需要打开网页完成。
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="btn-outline ml-2 text-2xs">
<ExternalLink size={12} className="mr-1 inline" />
打开登录页
</button>
</div>
)}
{/* 表单输入项 */}
{inputs.length > 0 && (
<div className="space-y-2">
{inputs.map((f) => (
<label key={f.name} className="block">
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{f.name}</span>
<input
type={f.type === 'password' ? 'password' : 'text'}
value={form[f.name] ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
/>
</label>
))}
</div>
)}
{/* 选择项(toggle / select):点击后按 action 交由书源处理 */}
{selects.length > 0 && (
<div className="mt-3 space-y-2">
{selects.map((f) => (
<SelectField
key={f.name}
field={f}
value={form[f.name] ?? f.default ?? ''}
disabled={!!running}
onPick={(v) => {
setForm((prev) => ({ ...prev, [f.name]: v }))
if (f.action) void runAction(f.action)
}}
/>
))}
</div>
)}
{/* 按钮区 */}
{buttons.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{buttons.map((f) => (
<button
key={`${f.name}-${f.action}`}
type="button"
disabled={!!running}
onClick={() => void runAction(f.action ?? '')}
className="btn-outline text-xs disabled:opacity-50"
>
{running === (f.action || '__login__') && <Loader2 size={12} className="mr-1 inline animate-spin" />}
{f.viewName || f.name}
</button>
))}
</div>
)}
{/* 提示与待打开地址 */}
{toasts.length > 0 && (
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
{toasts.filter(Boolean).join('\n')}
</pre>
)}
{/* 源变量编辑 */}
<div className="mt-4 border-t border-[var(--app-border)] pt-3">
<button
type="button"
onClick={() => setShowVariable((v) => !v)}
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
>
{showVariable ? '▾' : '▸'} 源变量(bookSourceUrl 变量的 JSON)
</button>
{showVariable && (
<div className="mt-2">
{info?.variable_comment && (
<p className="mb-2 whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-2 text-2xs text-[var(--app-muted)]">
{info.variable_comment}
</p>
)}
<textarea
value={variable}
onChange={(e) => setVariable(e.target.value)}
rows={6}
placeholder='{"线路":"https://v1.example.com"}'
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)] outline-none"
/>
<button type="button" onClick={() => void saveVariable()} className="btn-primary mt-2 text-2xs">
<Save size={12} className="mr-1 inline" /> 保存变量
</button>
</div>
)}
</div>
{/* 当前 Cookie:登录态的实际凭证 */}
{cookieEntries.length > 0 && (
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
<p className="text-2xs font-bold text-[var(--app-muted)]">已保存 Cookie</p>
<div className="mt-1 space-y-1">
{cookieEntries.map(([domain, cookie]) => (
<p key={domain} className="truncate font-mono text-2xs text-[var(--app-muted)]" title={cookie}>
{domain}: {cookie}
</p>
))}
</div>
</div>
)}
{/* loginUrl 原文(书源作者写的登录脚本) */}
{info?.login_js && (
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
<button
type="button"
onClick={() => setShowLoginJS((v) => !v)}
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
>
{showLoginJS ? '▾' : '▸'} 登录脚本(loginUrl)
</button>
{showLoginJS && (
<>
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="mt-2 mr-2 text-2xs text-brand-600">
<ExternalLink size={11} className="mr-1 inline" />
若为登录页地址可打开
</button>
<pre className="mt-2 max-h-64 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)]">
{info.login_js}
</pre>
</>
)}
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--app-border)] px-4 py-3">
<button type="button" onClick={() => void saveLoginInfo()} className="btn-outline text-xs" disabled={loading}>
<Save size={13} className="mr-1 inline" /> 仅保存信息
</button>
<button type="button" onClick={() => void doLogout()} className="btn-outline text-xs text-red-500" disabled={loading}>
<LogOut size={13} className="mr-1 inline" /> 退出登录
</button>
<button type="button" onClick={onClose} className="btn-primary text-xs">
关闭
</button>
</div>
</div>
</div>
)
}
// SelectField 渲染 toggle / select 控件。
function SelectField({
field,
value,
disabled,
onPick,
}: {
field: ReaderLoginField
value: string
disabled: boolean
onPick: (v: string) => void
}) {
const options = field.chars ?? []
if (field.type === 'toggle' || options.length === 0) {
return (
<div className="flex items-center gap-2">
<span className="text-2xs text-[var(--app-muted)]">{field.name}</span>
<button type="button" disabled={disabled} onClick={() => onPick(value ? '' : '1')} className="btn-outline text-2xs">
{value ? '已开启' : '未开启'}
</button>
</div>
)
}
return (
<label className="block">
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{field.name}</span>
<select
value={value}
disabled={disabled}
onChange={(e) => onPick(e.target.value)}
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
)
}