mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6656f9c398 | |||
| dacfec7492 |
@@ -249,4 +249,5 @@
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
|
||||
@@ -295,12 +295,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
@@ -362,13 +362,15 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
|
||||
Page int `json:"page"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -56,7 +56,12 @@ func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) 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 {
|
||||
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
|
||||
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
|
||||
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此再也存不进去。这里必须硬删。
|
||||
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
|
||||
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
|
||||
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
@@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
if existing.DeletedAt.Valid {
|
||||
// 历史行被软删过:连 deleted_at 一起写回,把它复活
|
||||
st.DeletedAt = gorm.DeletedAt{}
|
||||
return r.db.WithContext(ctx).Unscoped().Save(st).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
|
||||
//
|
||||
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
|
||||
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
|
||||
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://d.example.com"
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 重新导入同 URL 的书源,再存一次状态
|
||||
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
|
||||
t.Fatalf("状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
|
||||
//
|
||||
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
|
||||
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
|
||||
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://e.example.com"
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
|
||||
if err := repo.db.Exec(
|
||||
"update reader_source_states set deleted_at = ? where source_url = ?",
|
||||
time.Now(), url).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
|
||||
t.Fatalf("复活后状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
|
||||
@@ -94,6 +94,10 @@ type pendingBrowser struct {
|
||||
sourceURL string
|
||||
userID string
|
||||
request rule.BrowserTask
|
||||
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
|
||||
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
|
||||
// 书源会话就能读到登录凭证。
|
||||
cookies browserCookieTarget
|
||||
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||
html string
|
||||
finalURL string
|
||||
@@ -113,21 +117,56 @@ type browserHost struct {
|
||||
sourceURL string
|
||||
sourceID string
|
||||
userID string
|
||||
// state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。
|
||||
// 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把
|
||||
// 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。
|
||||
state *sourceState
|
||||
capture bool
|
||||
}
|
||||
|
||||
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。
|
||||
func (h *browserHost) cookieTarget() browserCookieTarget {
|
||||
if !h.capture {
|
||||
return browserCookieTarget{}
|
||||
}
|
||||
return browserCookieTarget{state: h.state}
|
||||
}
|
||||
|
||||
// browserCookieTarget 面板链路要回写 Cookie 的目标会话。
|
||||
type browserCookieTarget struct {
|
||||
state *sourceState
|
||||
}
|
||||
|
||||
// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。
|
||||
// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。
|
||||
// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。
|
||||
func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) {
|
||||
if t.state == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
sink := cookieSinkFrom(ctx)
|
||||
if sink == nil {
|
||||
sink = &cookieSink{}
|
||||
}
|
||||
if resp.Request != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
}
|
||||
sink.apply(t.state)
|
||||
}
|
||||
|
||||
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait)
|
||||
if err != nil {
|
||||
return rule.BrowserResult{}, err
|
||||
}
|
||||
@@ -151,16 +190,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u
|
||||
}
|
||||
|
||||
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerBrowser 准备页面内容并登记待办。
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||
id := newBrowserID()
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,6 +212,7 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
cookies: cookies,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
@@ -319,7 +359,7 @@ func jsStringEscape(s string) string {
|
||||
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) {
|
||||
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
@@ -331,7 +371,7 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||
}
|
||||
@@ -355,8 +395,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ
|
||||
}
|
||||
|
||||
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -364,16 +404,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target
|
||||
}
|
||||
|
||||
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
|
||||
// 返回 (contentType, status, body, error)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应
|
||||
// Set-Cookie 写回(页面可能靠资源响应续期会话)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return "", 0, nil, errors.New("页面已过期,请重新打开")
|
||||
}
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target)
|
||||
return contentType, status, data, err
|
||||
}
|
||||
|
||||
// requestBrowserResource 带书源凭据请求一个外部地址。
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。
|
||||
if cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
state := s.newSourceState(reqCtx, sourceURL)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
@@ -419,6 +468,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
// 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。
|
||||
cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
@@ -456,6 +507,11 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的
|
||||
// Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。
|
||||
if entry.cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -506,6 +562,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
return nil, err
|
||||
}
|
||||
data = helper.DecompressBody(resp, data)
|
||||
// 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。
|
||||
entry.cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
st.flush()
|
||||
|
||||
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -266,7 +266,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
if gotSourceURL != srv.URL {
|
||||
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
|
||||
}
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
|
||||
if err != nil {
|
||||
t.Fatalf("拉取资源失败: %v", err)
|
||||
}
|
||||
@@ -313,7 +313,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) {
|
||||
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||
st.flush()
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -403,7 +403,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||
|
||||
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
|
||||
t.Fatalf("登记 open 页面失败: %v", err)
|
||||
}
|
||||
@@ -421,7 +421,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// 再点别的按钮:待办表里只应剩新页面,且序号更大
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
|
||||
t.Fatalf("登记新页面失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -451,6 +451,8 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
|
||||
title = "段评"
|
||||
}
|
||||
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
|
||||
// 段评是「只展示」的页面,没有登录会话可回写 Cookie。
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。
|
||||
//
|
||||
// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里
|
||||
// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的
|
||||
// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档,
|
||||
// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。
|
||||
|
||||
// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302,
|
||||
// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」
|
||||
// 能被分开断言。
|
||||
func cookieCaptureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, "/done", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>面板</body></html>`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie
|
||||
// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。
|
||||
func TestExecuteCapturesRedirectHopCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got)
|
||||
}
|
||||
// 最终响应的 Cookie 按该跳地址归档。
|
||||
if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") {
|
||||
t.Fatalf("最终响应的 Cookie 没有被收下: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时
|
||||
// 主链路不应自动累积 Cookie。
|
||||
func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
if got := state.GetCookie(srv.URL); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经
|
||||
// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。
|
||||
// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。
|
||||
func TestProxyBrowserXHRCapturesCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceURL = "https://api.example.com"
|
||||
const sourceID = "panel-source-1"
|
||||
state := svc.newSourceState(ctx, sourceURL)
|
||||
|
||||
// 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。
|
||||
if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{state: state},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
|
||||
// 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。
|
||||
if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板请求的凭证没有写回会话: %q", got)
|
||||
}
|
||||
|
||||
// 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。
|
||||
state.flush()
|
||||
reloaded := svc.newSourceState(ctx, sourceURL)
|
||||
if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板拿到的凭证没有落库: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板)
|
||||
// 代理请求照常工作,只是不做 Cookie 累积。
|
||||
func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceID = "panel-source-2"
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求应照常成功: %v", err)
|
||||
}
|
||||
if res.Status != http.StatusOK {
|
||||
t.Fatalf("代理状态 = %d", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。
|
||||
func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil {
|
||||
t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie")
|
||||
}
|
||||
if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st {
|
||||
t.Fatal("开启时应把会话交给面板回写 Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的,
|
||||
// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。
|
||||
func TestSourceStateCookieConcurrency(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
url := "https://example.com/"
|
||||
st.SetCookie(url, "k=1")
|
||||
_ = st.GetCookie(url)
|
||||
_ = st.snapshotCookies()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") {
|
||||
t.Fatalf("并发写入后应能读到 Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:把响应下发的 Set-Cookie 收进书源会话。
|
||||
//
|
||||
// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。
|
||||
// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带
|
||||
// Set-Cookie,再跳到业务域名)这三个做法都会漏:
|
||||
//
|
||||
// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到;
|
||||
// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域
|
||||
// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶,
|
||||
// 之后 api.bilibili.com 的请求取不到这份 Cookie;
|
||||
// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库,
|
||||
// 页面在面板里「登录成功」但书源会话始终是空的。
|
||||
//
|
||||
// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie,
|
||||
// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。
|
||||
|
||||
// capturedCookie 一条待写入会话的 Cookie 及其归属地址。
|
||||
type capturedCookie struct {
|
||||
// url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点),
|
||||
// 否则用下发它的那一跳的请求地址。
|
||||
url string
|
||||
cookie string
|
||||
}
|
||||
|
||||
// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。
|
||||
// 并发安全:面板里的多个资源/接口请求会同时走这里。
|
||||
type cookieSink struct {
|
||||
mu sync.Mutex
|
||||
items []capturedCookie
|
||||
}
|
||||
|
||||
// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。
|
||||
func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) {
|
||||
if s == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
items := make([]capturedCookie, 0, len(resp.Cookies()))
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck == nil || ck.Name == "" {
|
||||
continue
|
||||
}
|
||||
target := ""
|
||||
if d := strings.TrimSpace(ck.Domain); d != "" {
|
||||
target = strings.TrimPrefix(d, ".")
|
||||
} else if reqURL != nil {
|
||||
target = reqURL.String()
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items = append(s.items, items...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain 取出并清空已收集的 Cookie。
|
||||
func (s *cookieSink) drain() []capturedCookie {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.items
|
||||
s.items = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。
|
||||
func (s *cookieSink) apply(state *sourceState) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
items := s.drain()
|
||||
if state == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
state.SetCookie(it.url, it.cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ──────────────
|
||||
|
||||
type cookieSinkKey struct{}
|
||||
|
||||
// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。
|
||||
func withCookieSink(ctx context.Context, sink *cookieSink) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, cookieSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。
|
||||
func cookieSinkFrom(ctx context.Context) *cookieSink {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink)
|
||||
return sink
|
||||
}
|
||||
|
||||
// captureRedirectCookies 作为 http.Client.CheckRedirect:
|
||||
// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。
|
||||
//
|
||||
// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致
|
||||
// (10 次),所以装上它不改变既有链路的行为。
|
||||
func captureRedirectCookies(req *http.Request, via []*http.Request) error {
|
||||
if req != nil {
|
||||
if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil {
|
||||
// via 的最后一跳才是产生 req.Response 的那个请求。
|
||||
prev := req
|
||||
if n := len(via); n > 0 {
|
||||
prev = via[n-1]
|
||||
}
|
||||
sink.add(prev.URL, req.Response)
|
||||
}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。
|
||||
// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。
|
||||
func enableCookieCapture(c *http.Client) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.CheckRedirect = captureRedirectCookies
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil)
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -63,11 +63,14 @@ type ReaderService struct {
|
||||
|
||||
// NewReaderService 创建服务。
|
||||
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
||||
client := helper.NewSiteHTTPClient(30, true)
|
||||
// 逐跳收集 Set-Cookie:登录源常把凭证放在跳转链中间那一跳(见 cookies.go)。
|
||||
enableCookieCapture(client)
|
||||
return &ReaderService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
http: client,
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
limiter: newSourceRateLimiter(),
|
||||
}
|
||||
@@ -319,12 +322,13 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
}
|
||||
}
|
||||
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
|
||||
buildRequest := func() (*http.Request, error) {
|
||||
// reqCtx 每次尝试单独传入,好让 Set-Cookie 只从「最终那次尝试」收集。
|
||||
buildRequest := func(reqCtx context.Context) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = strings.NewReader(req.Body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, req.Method, target, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -361,9 +365,18 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
var (
|
||||
resp *http.Response
|
||||
data []byte
|
||||
// sink 收集这次请求(含重定向各跳)下发的 Set-Cookie,
|
||||
// 只在需要回写会话且书源允许自动累积时才挂。
|
||||
sink *cookieSink
|
||||
)
|
||||
for attempt := 1; ; attempt++ {
|
||||
httpReq, err := buildRequest()
|
||||
sink = nil
|
||||
reqCtx := ctx
|
||||
if state != nil && captureCookies {
|
||||
sink = &cookieSink{}
|
||||
reqCtx = withCookieSink(ctx, sink)
|
||||
}
|
||||
httpReq, err := buildRequest(reqCtx)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
@@ -405,15 +418,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
body = string(data)
|
||||
}
|
||||
finalURL := resp.Request.URL.String()
|
||||
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)
|
||||
var cookieStrs []string
|
||||
for _, ck := range resp.Cookies() {
|
||||
cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value)
|
||||
}
|
||||
if len(cookieStrs) > 0 && state != nil && captureCookies {
|
||||
for _, ck := range cookieStrs {
|
||||
state.SetCookie(finalURL, ck)
|
||||
}
|
||||
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)。
|
||||
// sink 里已含重定向各跳的 Cookie,这里再把最终响应补进去,
|
||||
// 归属域按 Cookie 自己的 Domain 优先、否则按该跳地址(见 cookies.go)。
|
||||
if sink != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
sink.apply(state)
|
||||
}
|
||||
// bodyJs 二次处理
|
||||
if req.BodyJsFn != nil {
|
||||
@@ -606,6 +616,11 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
|
||||
sourceURL: sess.srcURL(),
|
||||
sourceID: sess.srcID(),
|
||||
userID: sess.userID,
|
||||
// state 交出去,让面板里页面自己发起的请求也能把 Set-Cookie
|
||||
// 写回这次会话(见 browser_panel.go / cookies.go)。
|
||||
state: sess.state,
|
||||
// capture 对应 enabledCookieJar:关掉时面板同样不自动累积 Cookie。
|
||||
capture: sess.captureCookies(),
|
||||
}
|
||||
}
|
||||
return rule.NewJSRunner(rule.JSConfig{
|
||||
@@ -751,7 +766,14 @@ type searchHit struct {
|
||||
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
|
||||
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
|
||||
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
|
||||
//
|
||||
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
|
||||
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
|
||||
// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -775,7 +797,7 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri
|
||||
g.Go(func() error {
|
||||
gctxSrc, cancel := context.WithTimeout(gctx, perSourceTimeout)
|
||||
defer cancel()
|
||||
books, err := s.searchInSource(gctxSrc, &src, nil, key, 1)
|
||||
books, err := s.searchInSource(gctxSrc, &src, nil, key, page)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索分页。
|
||||
//
|
||||
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
|
||||
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
|
||||
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
|
||||
|
||||
func pagedSearchSourceJSON(serverURL string) string {
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": "分页源",
|
||||
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestSearchUsesRequestedPage(t *testing.T) {
|
||||
var gotPage string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPage = r.URL.Query().Get("page")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{"data": []map[string]string{{
|
||||
"book_name": fmt.Sprintf("第%s页的书", gotPage),
|
||||
"author": "作者",
|
||||
"book_url": "/book/" + gotPage,
|
||||
}}}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
|
||||
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{2, "2"},
|
||||
{5, "5"},
|
||||
{0, "1"}, // 非法页码退回第 1 页
|
||||
{-3, "1"}, // 负数同理
|
||||
} {
|
||||
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
|
||||
}
|
||||
if gotPage != tc.want {
|
||||
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
|
||||
}
|
||||
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
|
||||
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
|
||||
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
|
||||
func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
|
||||
|
||||
books, _, err := svc.Search(t.Context(), "书", nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "单页书" {
|
||||
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||
out, _ := json.Marshal(src)
|
||||
_ = prepareLoginSource(t, svc, string(out))
|
||||
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil)
|
||||
books, _, err = svc.Search(t.Context(), "书", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
@@ -23,8 +24,12 @@ type sourceState struct {
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]string // domain → "k=v; k=v"
|
||||
loaded bool
|
||||
// cookies / cookieMu:domain → "k=v; k=v"。
|
||||
// cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写
|
||||
// Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。
|
||||
cookies map[string]string
|
||||
cookieMu sync.Mutex
|
||||
loaded bool
|
||||
|
||||
toasts []string
|
||||
browsers []rule.BrowserRequest
|
||||
@@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) {
|
||||
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||
|
||||
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
return st.getCookieKeyLocked(rawURL, key)
|
||||
}
|
||||
|
||||
// getCookieKeyLocked 的调用方需持有 cookieMu。
|
||||
func (st *sourceState) getCookieKeyLocked(rawURL, key string) string {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
@@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||
if merged == st.cookies[domain] {
|
||||
return
|
||||
@@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if _, ok := st.cookies[domain]; !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,12 +213,14 @@ func (st *sourceState) flush() {
|
||||
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
cookiesJSON := ""
|
||||
if len(st.cookies) > 0 {
|
||||
if b, err := json.Marshal(st.cookies); err == nil {
|
||||
cookiesJSON = string(b)
|
||||
}
|
||||
}
|
||||
st.cookieMu.Unlock()
|
||||
rec := &model.ReaderSourceState{
|
||||
SourceURL: st.sourceURL,
|
||||
Variable: st.variable,
|
||||
@@ -221,6 +239,8 @@ func (st *sourceState) flush() {
|
||||
|
||||
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||
func (st *sourceState) snapshotCookies() map[string]string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
out := make(map[string]string, len(st.cookies))
|
||||
for d, c := range st.cookies {
|
||||
out[d] = c
|
||||
@@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string {
|
||||
|
||||
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||
func (st *sourceState) clearCookies() {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if len(st.cookies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -268,11 +268,12 @@ export const readerAPI = {
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
search: (key: string, sourceIds: string[] = []) =>
|
||||
// page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。
|
||||
search: (key: string, sourceIds: string[] = [], page = 1) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key, source_ids: sourceIds },
|
||||
{ key, source_ids: sourceIds, page },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type LoadMoreSentinelProps = {
|
||||
/** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */
|
||||
error?: string
|
||||
onRetry?: () => void
|
||||
label?: string
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调
|
||||
* onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。
|
||||
*
|
||||
* 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会
|
||||
* 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空
|
||||
* 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。
|
||||
*/
|
||||
export function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
error = '',
|
||||
onRetry,
|
||||
label = '加载更多',
|
||||
loadingLabel = '加载中…',
|
||||
}: LoadMoreSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore || error) return
|
||||
// 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore, error])
|
||||
|
||||
if (!hasMore) return null
|
||||
|
||||
const showRetry = !!error && !!onRetry
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
|
||||
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={showRetry ? onRetry : onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? loadingLabel : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { LoadMoreSentinel } from '../components/LoadMoreSentinel'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid'
|
||||
import type { Media } from '../types'
|
||||
@@ -67,57 +68,11 @@ export function LibraryMediaSections({
|
||||
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
|
||||
)}
|
||||
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
<LoadMoreSentinel hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore) return
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore])
|
||||
|
||||
if (!hasMore) return null
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
|
||||
@@ -4,26 +4,26 @@ import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||
import { LoadMoreSentinel } from '../../components/LoadMoreSentinel'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from '../../utils/searchBooks'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
// 结果按页取:滚到底自动请求下一页并增量合并,直到某一页不再带来新书。
|
||||
|
||||
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
|
||||
function shelfBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
/** 单次搜索最多翻到第几页。书源分页规则千奇百怪,个别源会一直返回新条目,
|
||||
* 不设上限时用户一直往下滚就会一直发请求(每次都要把所有源再搜一遍)。 */
|
||||
const MAX_SEARCH_PAGES = 20
|
||||
|
||||
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
/** 这本搜索结果是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(shelfBookKey(book.name, book.author))) return true
|
||||
if (shelf.has(searchBookKey(book.name, book.author))) return true
|
||||
if (book.book_url && shelf.has(book.book_url)) return true
|
||||
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
|
||||
}
|
||||
@@ -39,6 +39,16 @@ export default function ReaderSearchPage() {
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
const [showSkipped, setShowSkipped] = useState(false)
|
||||
const [adding, setAdding] = useState('')
|
||||
// 滚动加载:page 是已加载到的页码;hasMore 为 false 表示到底了(或还没搜过)。
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [moreError, setMoreError] = useState('')
|
||||
// loadMore 的在途标记(见 loadMore 里的说明),比 loadingMore 状态更早生效。
|
||||
const loadingMoreRef = useRef(false)
|
||||
// 每次新搜索 +1:在飞的 loadMore 回来时若已经换了关键词(searchSeq 已变),
|
||||
// 就把那一页结果丢掉,避免旧关键词的第 N 页被并进新列表。
|
||||
const searchSeq = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||
@@ -69,7 +79,7 @@ export default function ReaderSearchPage() {
|
||||
.then((books) => {
|
||||
const keys = new Set<string>()
|
||||
for (const b of books) {
|
||||
keys.add(shelfBookKey(b.name, b.author))
|
||||
keys.add(searchBookKey(b.name, b.author))
|
||||
if (b.book_url) keys.add(b.book_url)
|
||||
}
|
||||
setShelfKeys(keys)
|
||||
@@ -89,17 +99,26 @@ export default function ReaderSearchPage() {
|
||||
const doSearch = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || searching) return
|
||||
searchSeq.current++
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
setLoadingMore(false)
|
||||
setMoreError('')
|
||||
try {
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds)
|
||||
setBooks(res.books ?? [])
|
||||
const res = await readerAPI.search(kw, scopeIds, 1)
|
||||
const list = res.books ?? []
|
||||
setBooks(list)
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
setSkipped(res.skipped ?? [])
|
||||
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
|
||||
setPage(1)
|
||||
// 首页一条都没有 → 后面也不会有;有结果就先假定还有下一页。
|
||||
// 书源是否支持分页只有它自己知道({{page}} 也可能藏在 URL 的 <js> 里),
|
||||
// 所以这里不预判:多花一次「第 2 页」请求,由 loadMore 发现这一页没带来新书时收口。
|
||||
setHasMore(list.length > 0)
|
||||
if (list.length === 0) toast.error('所有书源都没有找到结果')
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
|
||||
toast.error(msg)
|
||||
@@ -108,6 +127,47 @@ export default function ReaderSearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底加载下一页。每页都会把范围内的书源重新搜一遍(后端按页下发),
|
||||
// 因此这里要注意两件事:
|
||||
// 1. 结果必须增量合并而不是替换——不支持 {{page}} 的源会在每一页重复返回首页结果;
|
||||
// 2. 合并后没有新书(重复内容或该页为空)就说明到底了,否则用户会一直滚一直转圈。
|
||||
const loadMore = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || !hasMore || loadingMore || searching || books === null) return
|
||||
// loadingMore 是渲染后才生效的状态:同一帧里观察器可能回调两次,用 ref 兜住,
|
||||
// 避免把同一页请求两次(每次翻页都要把所有源再搜一遍,重复请求不便宜)。
|
||||
if (loadingMoreRef.current) return
|
||||
loadingMoreRef.current = true
|
||||
const next = page + 1
|
||||
if (next > MAX_SEARCH_PAGES) {
|
||||
setHasMore(false)
|
||||
loadingMoreRef.current = false
|
||||
return
|
||||
}
|
||||
setLoadingMore(true)
|
||||
setMoreError('')
|
||||
const seq = searchSeq.current
|
||||
try {
|
||||
const res = await readerAPI.search(kw, scopeIds, next)
|
||||
// 期间用户又发起了一次搜索:这一页属于旧关键词,直接丢弃。
|
||||
if (searchSeq.current !== seq) return
|
||||
const incoming = res.books ?? []
|
||||
const merged = mergeSearchBooks(books, incoming)
|
||||
setBooks(merged)
|
||||
setSkipped((prev) => mergeSearchSkipped(prev, res.skipped ?? []))
|
||||
setPage(next)
|
||||
if (merged.length === books.length) setHasMore(false)
|
||||
} catch (e) {
|
||||
if (searchSeq.current !== seq) return
|
||||
// 不弹 toast 打断浏览:把失败原因挂在哨兵上,改由用户点重试。
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载下一页失败'
|
||||
setMoreError(msg)
|
||||
} finally {
|
||||
loadingMoreRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
|
||||
const autoSearchedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -127,7 +187,7 @@ export default function ReaderSearchPage() {
|
||||
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||
setShelfKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(shelfBookKey(book.name, book.author))
|
||||
next.add(searchBookKey(book.name, book.author))
|
||||
if (origin.book_url) next.add(origin.book_url)
|
||||
return next
|
||||
})
|
||||
@@ -277,6 +337,19 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */}
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMore}
|
||||
error={moreError}
|
||||
onRetry={loadMore}
|
||||
loadingLabel={`正在搜索第 ${page + 1} 页…`}
|
||||
/>
|
||||
{!hasMore && !moreError && visibleBooks.length > 0 && (
|
||||
<p className="py-2 text-center text-2xs text-[var(--app-subtle)]">已经到底了</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReaderSearchBook, ReaderSearchOrigin } from '../api/reader.ts'
|
||||
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from './searchBooks.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`searchBooks: ${name}`)
|
||||
}
|
||||
|
||||
function origin(sourceId: string, bookUrl: string, originName = sourceId): ReaderSearchOrigin {
|
||||
return { source_id: sourceId, origin: `https://${sourceId}.example.com`, origin_name: originName, origin_type: 0, book_url: bookUrl, latest_chapter: '' }
|
||||
}
|
||||
|
||||
function book(over: Partial<ReaderSearchBook> & { name: string; author: string }): ReaderSearchBook {
|
||||
return {
|
||||
kind: '',
|
||||
word_count: '',
|
||||
latest_chapter: '',
|
||||
intro: '',
|
||||
cover_url: '',
|
||||
book_url: '',
|
||||
origins: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
// 聚合键:作者缺省时只用书名(和后端 name|author 的拼法一致)
|
||||
check('key uses name and author', searchBookKey('全球高武', '懒人') === '全球高武|懒人')
|
||||
check('key falls back to name without author', searchBookKey('全球高武', '') === '全球高武')
|
||||
check('key trims blanks', searchBookKey(' 全球高武 ', ' 懒人 ') === '全球高武|懒人')
|
||||
check('key treats blank author as missing', searchBookKey('全球高武', ' ') === '全球高武')
|
||||
|
||||
// 跨页去重:不支持分页的源在每一页都返回首页结果,不能重复追加一条书
|
||||
{
|
||||
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: 'http://c/1.jpg' })]
|
||||
const page2 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('duplicate across pages is merged', merged.length === 1)
|
||||
check('duplicate origin is not re-added', merged[0].origins.length === 1)
|
||||
check('existing fields survive the merge', merged[0].cover_url === 'http://c/1.jpg')
|
||||
}
|
||||
|
||||
// 新一页的书接在已有书后面,顺序不倒
|
||||
{
|
||||
const page1 = [book({ name: '甲', author: '作者', origins: [origin('a', '/a')] })]
|
||||
const page2 = [book({ name: '乙', author: '作者', origins: [origin('a', '/b')] })]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('new page appends', merged.length === 2)
|
||||
check('first page keeps its position', merged[0].name === '甲')
|
||||
check('second page follows', merged[1].name === '乙')
|
||||
}
|
||||
|
||||
// 同书多源:书源并进同一项(决定「N 源可换」),且不会补齐覆盖已有的有效值
|
||||
{
|
||||
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: '', intro: '第一页的简介' })]
|
||||
const page2 = [
|
||||
book({
|
||||
name: '全球高武',
|
||||
author: '懒人',
|
||||
origins: [origin('b', '/2')],
|
||||
cover_url: 'http://c/2.jpg',
|
||||
intro: '第二页的简介',
|
||||
kind: '玄幻',
|
||||
}),
|
||||
]
|
||||
const merged = mergeSearchBooks(page1, page2)
|
||||
check('same book merges origins', merged[0].origins.length === 2)
|
||||
check('missing cover is filled', merged[0].cover_url === 'http://c/2.jpg')
|
||||
check('missing kind is filled', merged[0].kind === '玄幻')
|
||||
check('existing intro is not overwritten', merged[0].intro === '第一页的简介')
|
||||
}
|
||||
|
||||
// 不同作者的同名书不能被合并成一条
|
||||
{
|
||||
const merged = mergeSearchBooks(
|
||||
[book({ name: '同名书', author: '甲', origins: [origin('a', '/1')] })],
|
||||
[book({ name: '同名书', author: '乙', origins: [origin('a', '/2')] })],
|
||||
)
|
||||
check('same name different author stays separate', merged.length === 2)
|
||||
}
|
||||
|
||||
// 无名条目没有聚合键,直接丢弃(后端也会跳过 Name 为空的项)
|
||||
{
|
||||
const merged = mergeSearchBooks([], [book({ name: ' ', author: '作者' })])
|
||||
check('nameless item is dropped', merged.length === 0)
|
||||
}
|
||||
|
||||
// 不原地改动上一份状态:prev 的数组与对象都不能被写坏
|
||||
{
|
||||
const prev = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
|
||||
const snapshot = JSON.stringify(prev)
|
||||
mergeSearchBooks(prev, [book({ name: '全球高武', author: '懒人', origins: [origin('b', '/2')] })])
|
||||
check('prev state is not mutated', JSON.stringify(prev) === snapshot)
|
||||
}
|
||||
|
||||
// 失败书源按 source_id 去重,原因取最新一页的
|
||||
{
|
||||
const merged = mergeSearchSkipped(
|
||||
[{ source_id: 'a', origin_name: '源A', reason: '超时' }],
|
||||
[
|
||||
{ source_id: 'a', origin_name: '源A', reason: '限流' },
|
||||
{ source_id: 'b', origin_name: '源B', reason: '解析失败' },
|
||||
],
|
||||
)
|
||||
check('skipped is deduped by source', merged.length === 2)
|
||||
check('skipped reason comes from the newest page', merged[0].reason === '限流')
|
||||
check('new failed source is appended', merged[1].source_id === 'b')
|
||||
check('empty incoming keeps the same array', mergeSearchSkipped(merged, []) === merged)
|
||||
}
|
||||
|
||||
console.log('searchBooks.test.ts ok')
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReaderSearchBook, ReaderSearchOrigin, ReaderSearchSkipped } from '../api/reader'
|
||||
|
||||
/**
|
||||
* 同名同作者的聚合键——与后端 mergeSearchResults 的 `name|author` 一致:
|
||||
* 同一本书多源命中时合并成一行;作者缺省时只用书名(否则作者为空的源会被拆成两条)。
|
||||
*/
|
||||
export function searchBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
|
||||
/** 两条命中书源是否指向同一个源的同一个地址(跨页重复返回时用于去重)。 */
|
||||
function sameOrigin(a: ReaderSearchOrigin, b: ReaderSearchOrigin): boolean {
|
||||
return a.source_id === b.source_id && a.book_url === b.book_url
|
||||
}
|
||||
|
||||
/** 补齐缺失字段(对应后端 fillMissingSearchBookFields):先到的空值不能挡掉后面源的有效值。 */
|
||||
function fillMissing(dst: ReaderSearchBook, src: ReaderSearchBook): void {
|
||||
if (!dst.cover_url) dst.cover_url = src.cover_url
|
||||
if (!dst.intro) dst.intro = src.intro
|
||||
if (!dst.kind) dst.kind = src.kind
|
||||
if (!dst.word_count) dst.word_count = src.word_count
|
||||
if (!dst.latest_chapter) dst.latest_chapter = src.latest_chapter
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新一页的搜索结果并入已加载的列表(滚动加载用)。
|
||||
*
|
||||
* 每一页都是后端独立聚合出来的,跨页去重只能在前端做:书源 searchUrl 里没有
|
||||
* {{page}} 时它会重复返回首页结果,同一个源的同一本书就可能在每一页都出现,
|
||||
* 直接 append 会让列表堆满重复行。这里按 searchBookKey 合并成一项:
|
||||
* 并书源(决定「N 源可换」)、补缺失字段;顺序保持「先到的在前」,
|
||||
* 于是第 2 页的新书接在第 1 页后面。
|
||||
*
|
||||
* 返回新数组与新对象,调用方可以直接交给 setState,不必担心原地改动上一份状态。
|
||||
*/
|
||||
export function mergeSearchBooks(prev: ReaderSearchBook[], incoming: ReaderSearchBook[]): ReaderSearchBook[] {
|
||||
const out = prev.map((b) => ({ ...b, origins: [...b.origins] }))
|
||||
const byKey = new Map<string, ReaderSearchBook>()
|
||||
for (const b of out) byKey.set(searchBookKey(b.name, b.author), b)
|
||||
|
||||
for (const b of incoming) {
|
||||
// 无名条目没有可用的聚合键(后端也会跳过 Name 为空的项),直接丢弃。
|
||||
if (!b.name.trim()) continue
|
||||
const key = searchBookKey(b.name, b.author)
|
||||
const existing = byKey.get(key)
|
||||
if (!existing) {
|
||||
const copy: ReaderSearchBook = { ...b, origins: [...b.origins] }
|
||||
out.push(copy)
|
||||
byKey.set(key, copy)
|
||||
continue
|
||||
}
|
||||
fillMissing(existing, b)
|
||||
for (const origin of b.origins) {
|
||||
if (!existing.origins.some((o) => sameOrigin(o, origin))) existing.origins.push(origin)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并搜索失败的书源列表(滚动加载时每页失败的源可能不同)。
|
||||
* 按 source_id 去重,原因是「任何一页失败过就算失败」——排查页面关心的是哪些源
|
||||
* 有问题,而不是它这一页恰好成功;同一个源在多页里原因不同时取最新一页的。
|
||||
*/
|
||||
export function mergeSearchSkipped(
|
||||
prev: ReaderSearchSkipped[],
|
||||
incoming: ReaderSearchSkipped[],
|
||||
): ReaderSearchSkipped[] {
|
||||
if (incoming.length === 0) return prev
|
||||
const out = [...prev]
|
||||
const index = new Map(out.map((s, i) => [s.source_id, i]))
|
||||
for (const s of incoming) {
|
||||
const at = index.get(s.source_id)
|
||||
if (at === undefined) {
|
||||
index.set(s.source_id, out.length)
|
||||
out.push(s)
|
||||
} else {
|
||||
out[at] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user