Compare commits

..

7 Commits

Author SHA1 Message Date
truewhile 0be7f8b97e 优化 2026-10-04 17:50:26 +08:00
truewhile 6984158f24 修复段评往下滑一直闪:评论接口分页改游标,页面还在按 page 翻页
上游评论页按 page=N 翻页,但列表接口 /para_review 早已改成游标分页(响应给
next_cursor,请求要带 cursor=)。接口不认识 page,于是每次「加载更多」都原样
返回第一页:一条新评论都加不进去,has_more 又始终是 true,评论页便一遍遍触发
加载更多、反复闪骨架屏——用户看到的就是段评往下滑一闪一闪,评论一条没多。

评论页是上游 HTML 改不了,于是在承载页面的接口代理里做最小适配:
  - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
  - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
    避免页面在「加载更多 → 没有新内容」之间空转。

只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求,其余原样透传。
2026-10-04 16:28:13 +08:00
truewhile d545ff1a7b 修复段评打开后没有评论:承载页面丢失原地址参数
段评气泡上的数字来自正文里的 <comment count>,但点开后的评论页跑在
「/api/reader/browser/page?id=..&s=..」这个承载地址上,页面看到的
location.search 只剩我们自己的 id/s,原地址的 item_id/para/source 全丢了。
评论页正是靠这几个参数去请求 /para_review,于是按缺参数请求,列表永远为空:
外面气泡显示 99,点开一条段评都没有。

承载地址改为把原地址的 query 原样拼在签名参数之后(页面看到的 search 与原站
一致);id/s 放最前,同名的原参数顶不掉我们的签名参数。
2026-10-04 13:53:44 +08:00
truewhile 227e0a9197 优化 2026-10-04 13:29:39 +08:00
truewhile e85df5fb6b 优化 2026-10-04 12:35:33 +08:00
truewhile 6656f9c398 优化 2026-10-04 11:46:17 +08:00
truewhile dacfec7492 优化,bug处理 2026-10-04 01:21:07 +08:00
30 changed files with 1927 additions and 131 deletions
+1
View File
@@ -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/字段一一对应。
+5 -3
View File
@@ -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
+15 -2
View File
@@ -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
}
+79
View File
@@ -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)
@@ -0,0 +1,239 @@
package reader
import (
"encoding/json"
"net/url"
"sort"
"strconv"
"strings"
"sync"
)
// 本文件:段落评论接口(/para_review)的分页适配。
//
// 上游聚合站自带的评论页按 page=N 翻页(滑到底请求 page=2、page=3…),但它的列表
// 接口早就改成了游标分页:响应里给 next_cursor,请求时要带 cursor=。接口不认识
// page,于是每次「加载更多」都原样返回第一页——一条新评论都加不进去,has_more 又
// 始终是 true,评论页就一遍遍触发加载更多、反复闪骨架屏。用户看到的就是「段评往
// 下滑一闪一闪」,其实评论一条没多。
//
// 评论页是上游 HTML,改不了,于是在代理层做最小适配:
// - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
// - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
// 免得页面在「加载更多 → 没有新内容」之间空转。
//
// 只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求;对不上就原样透传,
// 不影响别的书源。
// commentPageState 一个评论列表(同一段落 + 同一排序)的翻页状态。
type commentPageState struct {
// cursor 上一页响应给的游标:下一页请求用它代替 page。
cursor string
// seen 已经下发给页面的 comment_id:整页重复时据此收尾。
seen map[string]struct{}
}
// commentPager 承载页面里评论接口的翻页适配器(每个承载页面一份)。
type commentPager struct {
mu sync.Mutex
states map[string]*commentPageState
}
func newCommentPager() *commentPager {
return &commentPager{states: map[string]*commentPageState{}}
}
// rewriteRequest 按需把 page=N 改写成 cursor=<上一页游标>。
//
// 返回改写后的地址与状态键(空串表示这个请求不需要适配,调用方也不必观察响应)。
func (p *commentPager) rewriteRequest(target string) (string, string) {
if p == nil {
return target, ""
}
parsed, err := url.Parse(target)
if err != nil || !isCommentListPath(parsed.Path) {
return target, ""
}
query := parsed.Query()
pageRaw := strings.TrimSpace(query.Get("page"))
if pageRaw == "" || strings.TrimSpace(query.Get("cursor")) != "" {
return target, ""
}
page, err := strconv.Atoi(pageRaw)
if err != nil || page < 1 {
return target, ""
}
key := commentPageKey(parsed.Path, query)
if page == 1 {
// 重新加载 / 换了排序:游标与去重表都从头来。
p.mu.Lock()
delete(p.states, key)
p.mu.Unlock()
return target, key
}
p.mu.Lock()
cursor := ""
if st := p.states[key]; st != nil {
cursor = st.cursor
}
p.mu.Unlock()
if cursor == "" {
// 不是从第一页翻过来的(没有游标可用):原样透传,仍然观察响应,
// 靠下面的「整页重复」判断让页面停下来。
return target, key
}
query.Set("cursor", cursor)
query.Del("page")
parsed.RawQuery = query.Encode()
return parsed.String(), key
}
// observeResponse 观察评论列表响应:记下游标,并在整页重复时把 has_more 改成 false。
func (p *commentPager) observeResponse(key, body string) string {
if p == nil || key == "" {
return body
}
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "{") {
return body
}
var env commentListEnvelope
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
return body
}
if env.Data.Comments == nil && env.Data.HasMore == nil {
return body
}
ids := make([]string, 0, len(env.Data.Comments))
for _, c := range env.Data.Comments {
if id := strings.TrimSpace(c.CommentID); id != "" {
ids = append(ids, id)
}
}
p.mu.Lock()
defer p.mu.Unlock()
if p.states == nil {
p.states = map[string]*commentPageState{}
}
st := p.states[key]
if st == nil {
st = &commentPageState{seen: map[string]struct{}{}}
p.states[key] = st
}
repeated := len(ids) > 0
for _, id := range ids {
if _, ok := st.seen[id]; !ok {
repeated = false
break
}
}
if repeated && env.Data.HasMore != nil && *env.Data.HasMore {
// 整页都是老评论:接口不认 page,再翻下去也只会拿到同一页。
// 改掉 has_more,让页面显示「没有更多了」,别再反复拉取闪骨架屏。
if replaced, ok := setFirstJSONBoolFalse(trimmed, "has_more"); ok {
return replaced
}
return body
}
for _, id := range ids {
st.seen[id] = struct{}{}
}
if env.Data.HasMore != nil && !*env.Data.HasMore {
// 已经是最后一页:游标链到头,清掉它,页面万一再要一页也别拿旧游标
// 去请求(那种请求会落在下面的「整页重复」判断上收尾)。
st.cursor = ""
return body
}
// 游标只在拿到新内容时前进:请求失败/重复时页面会重试同一页,
// 游标也跟着重试同一个。
if cursor := rawJSONScalar(env.Data.Cursor); cursor != "" {
st.cursor = cursor
}
return body
}
// commentListEnvelope 评论列表响应里我们关心的字段。
type commentListEnvelope struct {
Data struct {
Comments []struct {
CommentID string `json:"comment_id"`
} `json:"comments"`
HasMore *bool `json:"has_more"`
Cursor json.RawMessage `json:"next_cursor"`
} `json:"data"`
}
// isCommentListPath 判断是否是段落评论列表接口(/para_review)。
func isCommentListPath(path string) bool {
p := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(path)), "/")
return strings.HasSuffix(p, "/para_review")
}
// commentPageKey 生成翻页状态键:接口路径 + 除 page/cursor 之外的查询参数。
// 同一段落的不同排序(sort_by)各自一套状态,互不干扰。
func commentPageKey(path string, query url.Values) string {
rest := make(url.Values, len(query))
for k, v := range query {
if k == "page" || k == "cursor" {
continue
}
rest[k] = v
}
keys := make([]string, 0, len(rest))
for k := range rest {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(path)
for _, k := range keys {
for _, v := range rest[k] {
b.WriteString("|" + k + "=" + v)
}
}
return b.String()
}
// rawJSONScalar 取一个 JSON 标量的字面量(游标是毫秒时间戳,可能被上游写成数字)。
func rawJSONScalar(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
if strings.HasPrefix(s, `"`) {
var out string
if json.Unmarshal(raw, &out) == nil {
return strings.TrimSpace(out)
}
return ""
}
return s
}
// setFirstJSONBoolFalse 把 JSON 里第一个 `"key":true` 改成 `"key":false`。
//
// 只做这一处等长替换,不重新序列化整份响应:评论里的 user_id、时间戳动辄十几位,
// 走一遍 float64 会变成科学计数法,页面再解析就废了。
func setFirstJSONBoolFalse(body, key string) (string, bool) {
token := `"` + key + `"`
idx := strings.Index(body, token)
if idx < 0 {
return body, false
}
i := idx + len(token)
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
i++
}
if i >= len(body) || body[i] != ':' {
return body, false
}
i++
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
i++
}
if !strings.HasPrefix(body[i:], "true") {
return body, false
}
return body[:i] + "false" + body[i+len("true"):], true
}
@@ -0,0 +1,187 @@
package reader
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:段落评论翻页适配(browser_comment_page.go)的测试。
//
// 覆盖两件事:
// 1. page=N(N>1)被换成上一页响应给的 cursor;
// 2. 整页重复(接口不认 page 时的表现)时把 has_more 改成 false,页面才会收尾。
const commentParaBase = "https://cmt.example.com/para_review?item_id=X&para=1&source=QQ"
func TestCommentPagerRewritesPageToCursor(t *testing.T) {
p := newCommentPager()
// 第一页:原样请求,响应里的游标被记下
target, key := p.rewriteRequest(commentParaBase + "&page=1")
if key == "" {
t.Fatal("段落评论接口应被识别")
}
if target != commentParaBase+"&page=1" {
t.Fatalf("第一页不该改写: %q", target)
}
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
// 第二页:改成带 cursor 的请求,page 不再出现
target, key2 := p.rewriteRequest(commentParaBase + "&page=2")
if key2 != key {
t.Fatalf("同一段落的状态键应一致: %q vs %q", key2, key)
}
if !strings.Contains(target, "cursor=1500000000000") || strings.Contains(target, "page=") {
t.Fatalf("第二页应换成 cursor 请求: %q", target)
}
// 拿到新评论:游标前进到下一页
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
target, _ = p.rewriteRequest(commentParaBase + "&page=3")
if !strings.Contains(target, "cursor=1400000000000") {
t.Fatalf("第三页应使用新的游标: %q", target)
}
// 重新加载(page=1)会把整轮状态清掉:没有游标就原样透传
p.rewriteRequest(commentParaBase + "&page=1")
target, _ = p.rewriteRequest(commentParaBase + "&page=2")
if strings.Contains(target, "cursor=") {
t.Fatalf("重置后不该还带着旧游标: %q", target)
}
}
func TestCommentPagerStopsOnRepeatedPage(t *testing.T) {
p := newCommentPager()
_, key := p.rewriteRequest(commentParaBase + "&page=1")
first := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
if got := p.observeResponse(key, first); got != first {
t.Fatalf("第一页不该被改写: %q", got)
}
// 同一页又被返回一次(接口忽略 page):改 has_more,让页面显示「没有更多了」
repeat := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
got := p.observeResponse(key, repeat)
if !strings.Contains(got, `"has_more":false`) {
t.Fatalf("重复页应把 has_more 改成 false: %q", got)
}
// 只动 has_more 一处,其它字段(尤其是大整数)保持原样
if strings.Replace(got, `"has_more":false`, `"has_more":true`, 1) != first {
t.Fatalf("除 has_more 外不应改动响应: %q", got)
}
// 重复页不再推进游标
target, _ := p.rewriteRequest(commentParaBase + "&page=2")
if !strings.Contains(target, "cursor=1500000000000") {
t.Fatalf("游标不该被重复页顶掉: %q", target)
}
}
func TestCommentPagerIgnoresOtherRequests(t *testing.T) {
p := newCommentPager()
cases := []string{
"https://cmt.example.com/book/detail?page=2", // 不是评论接口
"https://cmt.example.com/para_review?item_id=X", // 没有 page
"https://cmt.example.com/para_review?item_id=X&cursor=1", // 已经在用游标
"https://cmt.example.com/para_review?item_id=X&page=0", // page 非法
"https://cmt.example.com/para_review?item_id=X&sort_by=1&", // 没有 page
}
for _, target := range cases {
got, key := p.rewriteRequest(target)
if got != target || key != "" {
t.Fatalf("不该改写的请求被处理了: %q -> %q (key=%q)", target, got, key)
}
}
// 非 JSON 响应原样返回
if got := p.observeResponse("k", "<html></html>"); got != "<html></html>" {
t.Fatalf("非 JSON 响应被改动: %q", got)
}
}
// TestBrowserXHRCommentPagerFollowsCursor 端到端:页面按 page 翻页,
// 代理层换成 cursor,接口给出新评论,最后一页重复时收尾。
func TestBrowserXHRCommentPagerFollowsCursor(t *testing.T) {
svc, _ := newLoginTestService(t)
var mu sync.Mutex
seenCursors := []string{}
seenPages := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/page":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<html><body>comment page</body></html>`)
case "/para_review":
q := r.URL.Query()
mu.Lock()
seenCursors = append(seenCursors, q.Get("cursor"))
seenPages = append(seenPages, q.Get("page"))
mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 上游只认 cursor:不认识 page,靠 next_cursor 一页页往前。
switch q.Get("cursor") {
case "":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
case "1500000000000":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
case "1400000000000":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":false,"next_cursor":""}}`)
default:
// 兜底分支:上游把同一页又给了一遍(页面的 page 循环就会走到这里)
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":true,"next_cursor":"1300000000000"}}`)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
browserCookieTarget{}, rule.BrowserTask{URL: srv.URL + "/page", Title: "段评"}, browserModeOpen)
if err != nil {
t.Fatalf("登记承载页面失败: %v", err)
}
call := func(page string) string {
t.Helper()
res, err := svc.ProxyBrowserXHR(t.Context(), entry.id, http.MethodGet,
srv.URL+"/para_review?item_id=X&para=1&source=QQ&page="+page, nil, "")
if err != nil {
t.Fatalf("代理请求失败: %v", err)
}
return res.Body
}
if body := call("1"); !strings.Contains(body, `"has_more":true`) || !strings.Contains(body, "c1") {
t.Fatalf("第一页响应异常: %q", body)
}
if body := call("2"); !strings.Contains(body, "c2") {
t.Fatalf("第二页应拿到下一页评论: %q", body)
}
if body := call("3"); !strings.Contains(body, "c3") || !strings.Contains(body, `"has_more":false`) {
t.Fatalf("第三页应是真正的最后一页: %q", body)
}
// 页面不知道已经到底,还可能再要一页:上游又把最后一页给了一遍,
// 适配层要把 has_more 改成 false,页面才会显示「没有更多了」并停下。
last := call("4")
if !strings.Contains(last, `"has_more":false`) {
t.Fatalf("整页重复时应收尾(has_more=false): %q", last)
}
mu.Lock()
defer mu.Unlock()
if strings.Join(seenPages, ",") != "1,,,4" {
t.Fatalf("page 参数只应出现在没有游标可用的请求上: %v", seenPages)
}
if strings.Join(seenCursors, ",") != ",1500000000000,1400000000000," {
t.Fatalf("游标传递不符预期: %v", seenCursors)
}
var env map[string]any
if err := json.Unmarshal([]byte(last), &env); err != nil {
t.Fatalf("收尾后的响应不是合法 JSON: %v", err)
}
}
+123 -19
View File
@@ -94,11 +94,18 @@ type pendingBrowser struct {
sourceURL string
userID string
request rule.BrowserTask
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
// 书源会话就能读到登录凭证。
cookies browserCookieTarget
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
html string
finalURL string
mode string
expires time.Time
// comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor,
// 见 browser_comment_page.go)。
comments *commentPager
done chan struct{}
mu sync.Mutex
@@ -113,21 +120,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 +193,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,11 +215,13 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
sourceURL: sourceURL,
userID: userID,
request: req,
cookies: cookies,
html: html,
finalURL: finalURL,
mode: mode,
expires: time.Now().Add(browserPageTTL),
done: make(chan struct{}),
comments: newCommentPager(),
}
s.browserMu.Lock()
@@ -319,7 +363,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,10 +375,16 @@ 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)
}
// 空响应没有可展示的内容:以前照样包一层空 <pre> 交给 iframe,面板就是一片白,
// 用户看不出是地址有问题还是自己没登录。这里直接报错(如段评地址参数被改写、
// 上游按缺参数返回空 body 的情形)。
if strings.TrimSpace(body) == "" {
return "", "", fmt.Errorf("目标地址没有返回任何内容: %s", truncateForLog(req.URL, 120))
}
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
if !strings.Contains(strings.ToLower(contentType), "html") {
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
@@ -355,8 +405,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 +414,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 +478,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"
@@ -454,8 +515,20 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
if method == "" {
method = http.MethodGet
}
// 段落评论接口:评论页按 page=N 翻页,而接口只认 cursor(见
// browser_comment_page.go)。改写发生在补书源凭据之前,Cookie/Referer
// 仍然按目标站点选取。
pagerKey := ""
if entry.comments != nil {
target, pagerKey = entry.comments.rewriteRequest(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 +579,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"
@@ -513,6 +588,9 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
if isTextualContent(contentType) {
out.Body = string(data)
if entry.comments != nil && pagerKey != "" {
out.Body = entry.comments.observeResponse(pagerKey, out.Body)
}
} else {
out.Body = base64.StdEncoding.EncodeToString(data)
out.Base64 = true
@@ -665,8 +743,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string {
return s.browserSign("asset|" + id + "|" + target)
}
func (s *ReaderService) browserPageURL(id string) string {
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
// browserPageURL 生成 iframe 承载地址。
//
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id&para&source
// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的
// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去
// 请求评论接口,气泡数照样显示,点开却一条段评都没有。
//
// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。
// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。
func (s *ReaderService) browserPageURL(id, target string) string {
u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
if q := browserTargetQuery(target); q != "" {
u += "&" + q
}
return u
}
// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。
func browserTargetQuery(target string) string {
target = strings.TrimSpace(target)
if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") {
return ""
}
parsed, err := url.Parse(target)
if err != nil {
return ""
}
return parsed.RawQuery
}
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
@@ -824,7 +928,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
Title: e.request.Title,
Mode: e.mode,
Seq: e.seq,
PageURL: s.browserPageURL(e.id),
PageURL: s.browserPageURL(e.id, e.request.URL),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
TargetURL: target,
+50 -5
View File
@@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
}
}
// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。
//
// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。
// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有,
// 点开却一条评论都没有。
func TestBrowserPageURLKeepsTargetQuery(t *testing.T) {
svc, _ := newLoginTestService(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>comment page</body></html>`))
}))
defer srv.Close()
// 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。
target := srv.URL + "/get_para_review?item_id=abc&para=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy"
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen)
if err != nil {
t.Fatalf("登记承载页面失败: %v", err)
}
page := svc.browserPageOf(entry)
u, err := url.Parse(page.PageURL)
if err != nil {
t.Fatalf("承载地址不合法: %q", page.PageURL)
}
q := u.Query()
if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" {
t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL)
}
// 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办
if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) {
t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL)
}
if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil {
t.Fatalf("带业务参数后签名校验失败: %v", err)
}
// 没有 query 的地址(含 data: 页面)不该多出参数
for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} {
if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 {
t.Fatalf("无 query 的地址被拼进了多余参数: %q", got)
}
}
}
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
@@ -201,7 +246,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 +311,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 +358,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 +448,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 +466,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)
}
+2
View File
@@ -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)
}
}
+148
View File
@@ -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
}
+5 -5
View File
@@ -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") {
+37 -15
View File
@@ -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 {
+45 -4
View File
@@ -2,6 +2,7 @@ package rule
import (
"net/url"
"regexp"
"strings"
"github.com/PaesslerAG/jsonpath"
@@ -450,10 +451,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
if result == nil {
result = ""
}
str := resultString(result)
if strings.Contains(str, "&") {
str = html.UnescapeString(str)
}
str := unescapeRuleEntities(resultString(result))
if isUrl {
if strings.TrimSpace(str) == "" {
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
@@ -597,6 +595,49 @@ func applyReplaceRegex(result string, r ResolvedSourceRule) string {
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
}
// entityRefRe 匹配一个 HTML 字符引用:命名实体(&amp;、&nbsp;)、十进制(&#39;)、
// 十六进制(&#x27;)。分号可选——旧式简写(&nbsp、&amp)在 HTML 文本里同样成立。
var entityRefRe = regexp.MustCompile(`&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?`)
// unescapeRuleEntities 还原规则结果里的 HTML 实体(`&amp;` → `&`、`&#39;` → `'`)。
//
// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
// `…?item_id=X&para=1&source=QQ阅读` 会变成 `…?item_id=X¶=1&source=QQ阅读`,
// 上游按缺参数处理返回空页面,段评面板就只剩一片空白(见 reader/comment.go)。
func unescapeRuleEntities(s string) string {
if !strings.Contains(s, "&") {
return s
}
var b strings.Builder
last := 0
for _, loc := range entityRefRe.FindAllStringIndex(s, -1) {
match := s[loc[0]:loc[1]]
if !strings.HasSuffix(match, ";") && loc[1] < len(s) && isEntityStopByte(s[loc[1]]) {
continue
}
decoded := html.UnescapeString(match)
if decoded == match {
continue // 不是已知实体,原样保留
}
b.WriteString(s[last:loc[0]])
b.WriteString(decoded)
last = loc[1]
}
if last == 0 {
return s
}
b.WriteString(s[last:])
return b.String()
}
// isEntityStopByte 判断旧式简写实体后面这个字符是否让它失去实体语义
// (HTML5 规定不带分号的引用后跟 = 或字母数字时按普通文本处理)。
func isEntityStopByte(c byte) bool {
return c == '=' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
// resultString 对应 Kotlin 的 result.toString()。
func resultString(result any) string {
switch t := result.(type) {
@@ -0,0 +1,80 @@
package rule
import (
"strings"
"testing"
)
// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 &para 吃成
// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "段评地址里的 &para 不能被当成实体",
in: "https://v1.qingtian618.com/get_para_review?item_id=X&para=1&source=QQ%E9%98%85%E8%AF%BB",
want: "https://v1.qingtian618.com/get_para_review?item_id=X&para=1&source=QQ%E9%98%85%E8%AF%BB",
},
{
name: "&not 开头的参数名同样不能被吃掉",
in: "https://example.com/api?book=1&notify=1",
want: "https://example.com/api?book=1&notify=1",
},
{
name: "带分号的实体照旧还原",
in: "url?a=1&amp;b=2&amp;c=3",
want: "url?a=1&b=2&c=3",
},
{
name: "数字实体照旧还原",
in: "&#39;引号&#39; &#x27;x&#x27; &#169;",
want: "'引号' 'x' ©",
},
{
name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
in: "第一段&nbsp第二段&copy 2026&times",
want: "第一段\u00a0第二段© 2026×",
},
{
name: "不是实体的 & 原样保留",
in: "a & b &foo; c",
want: "a & b &foo; c",
},
{
name: "不含 & 的字符串直接放行",
in: "魔法大陆。",
want: "魔法大陆。",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := unescapeRuleEntities(c.in); got != c.want {
t.Fatalf("unescapeRuleEntities(%q) = %q,期望 %q", c.in, got, c.want)
}
})
}
}
// 端到端:走 GetString 的规则结果同样不能把段评地址里的 &para 吃掉
// (光遇聚合的正文规则是 <js>…</js>$.content 形态,最终取 $.content)。
func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ&para=11&source=QQ%E9%98%85%E8%AF%BB"
payload := `{"content":"<p>正文<comment ident=\"` + url + `\" count=\"39\" /></p>"}`
a := NewAnalyzeRule()
a.SetContent(payload, "https://v1.qingtian618.com")
got, err := a.GetString("$.content", nil, false)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if !strings.Contains(got, "&para=11") {
t.Fatalf("段评地址里的 &para=11 被改写了: %q", got)
}
if strings.Contains(got, "\u00b6") {
t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
}
}
@@ -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)
}
}
+3 -3
View File
@@ -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 != "宠魅" {
+3 -3
View File
@@ -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)
}
+24 -2
View File
@@ -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
}
+3 -2
View File
@@ -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),
+67
View File
@@ -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>
)
}
+3 -48
View File
@@ -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({
+26 -4
View File
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, R
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { pickChapterIndex } from './bookDetailModel'
import { SourcePickerDialog } from './SourcePickerDialog'
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
@@ -86,7 +87,16 @@ export default function ReaderBookPage() {
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
const startReading = async () => {
/**
* 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。
*/
const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise<number> => {
const cached = await readerAPI.listChapters(bookID).catch(() => [])
return pickChapterIndex(cached, chapter)
}
// chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。
const startReading = async (chapter?: ReaderTocChapter) => {
setBusy(true)
try {
let book = shelfBook
@@ -105,7 +115,9 @@ export default function ReaderBookPage() {
cover_url: cover,
})
}
navigate(`/reader/view/${book.id}`)
// 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。
const index = chapter ? await resolveChapterIndex(book.id, chapter) : null
navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
} finally {
@@ -273,11 +285,11 @@ export default function ReaderBookPage() {
<Trash2 size={13} className="mr-1 inline" /> 移出书架
</button>
) : (
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<BookOpen size={13} className="mr-1 inline" /> 加入书架
</button>
)}
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
</button>
@@ -318,6 +330,16 @@ export default function ReaderBookPage() {
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
{c.is_volume ? (
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
) : c.url ? (
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
<button
type="button"
disabled={busy}
onClick={() => startReading(c)}
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
>
{c.title}
</button>
) : (
c.title
)}
+86 -13
View File
@@ -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
+14 -1
View File
@@ -220,7 +220,10 @@ export default function ReaderViewPage() {
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
idx = b.dur_chapter_index
}
pendingPosRef.current = b.dur_chapter_pos ?? 0
// 位置只在「接着上次那一章读」时恢复:URL 显式跳到别的章时,b.dur_chapter_pos
// 属于另一章,套到新章上会从中间开始(音频表现为一进去就从错的时间点续播)。
const resumingSavedChapter = !explicitChapter || idx === b.dur_chapter_index
pendingPosRef.current = resumingSavedChapter ? (b.dur_chapter_pos ?? 0) : 0
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
@@ -268,6 +271,10 @@ export default function ReaderViewPage() {
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
const ac = new AbortController()
;(async () => {
// 每次重新取正文都先清掉上一次的报错。换源时的典型情形:服务端章节缓存刚被清空、
// 而本地 chapters 还是旧源的,这一次请求必然失败(「章节缓存为空」);等新源目录
// 落地后正文 effect 会再来一次并且成功,但不清这里的话,旧错误会一直压在正文上。
setError('')
setContent(null)
setLoadingStage('content')
try {
@@ -553,6 +560,12 @@ export default function ReaderViewPage() {
await readerAPI.switchOrigin(book.id, origin)
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
contentCache.current.clear()
// 换源后服务端已清空章节缓存,而本地的 chapters 还是旧源的目录:不清理的话,
// 重新加载书籍期间(listBooks 已返回、新目录还没抓回来)正文 effect 会拿旧目录
// 去打新源,撞上「章节缓存为空」。这里先把正文/目录清空,等新源目录到位再取。
setError('')
setContent(null)
setChapters([])
setReloadKey((v) => v + 1)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
@@ -0,0 +1,46 @@
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader.ts'
import { pickChapterIndex } from './bookDetailModel.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`bookDetailModel: ${name}`)
}
function cached(index: number, url: string): ReaderChapter {
return { index, title: `第${index}章`, url, is_volume: false }
}
function toc(index: number, url: string, over: Partial<ReaderTocChapter> = {}): ReaderTocChapter {
return { index, title: `第${index}章`, url, is_volume: false, update_time: '', ...over }
}
// 目录一致(正常情况):按 url 定位到的序号与现抓序号相同
{
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
check('same url keeps the same index', pickChapterIndex(list, toc(2, 'https://s/3')) === 2)
}
// 站点在开头插了新章:同一章在现抓目录里是 2,在缓存目录里是 1——必须用缓存里的 1,
// 照搬现抓的 2 会跳到下一章。
{
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
check('inserted chapter maps back to the cached index', pickChapterIndex(list, toc(2, 'https://s/2')) === 1)
}
// 缓存目录里没有这一章(站点改版、或两边取的不是同一份目录):退回现抓的序号
{
const list = [cached(0, 'https://s/1')]
check('unknown url falls back to the fetched index', pickChapterIndex(list, toc(7, 'https://s/7')) === 7)
}
// 缓存为空(第一次打开这本书):退回现抓序号,阅读器随后会把这份目录存下来
{
check('empty cache falls back to the fetched index', pickChapterIndex([], toc(5, 'https://s/5')) === 5)
}
// 章节没有 url(拿不到地址):退回现抓序号,不做 url 匹配
{
const list = [cached(0, ''), cached(1, '')]
check('chapter without url falls back to the fetched index', pickChapterIndex(list, toc(1, '')) === 1)
}
console.log('bookDetailModel.test.ts ok')
+20
View File
@@ -0,0 +1,20 @@
// 书籍详情页点目录跳读的纯逻辑(不依赖 React,便于单测)。
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader'
/**
* 把详情页点中的章节换算成阅读器要的序号。
*
* 阅读器打开一本书时优先用库里缓存的目录(`/books/:id/chapters`),而详情页显示的
* 目录是现抓的:站点更新过目录(插入或调整过章节)时两者会错位,直接照搬现抓目录
* 里的序号会跳到别的章。url 是章节的稳定标识,所以先按 url 在缓存目录里定位;
* 找不到(缓存为空,或两边取的根本不是同一份目录)才退回现抓的序号——缓存为空时
* 阅读器本来就会把这次现抓的目录存下来,序号自然对得上。
*/
export function pickChapterIndex(cached: ReaderChapter[], chapter: ReaderTocChapter): number {
if (chapter.url) {
const hit = cached.findIndex((c) => c.url === chapter.url)
if (hit >= 0) return hit
}
return chapter.index
}
+144 -2
View File
@@ -64,6 +64,14 @@ class ReaderAudioEngine {
private detaching = false
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
/** 预取在途的章序号,避免同一章重复请求。 */
private prefetching = new Set<number>()
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
private warmer: HTMLAudioElement | null = null
/** warmer 当前装载的音轨,避免重复赋值。 */
private warmedTrack = ''
constructor() {
const audio = new Audio()
@@ -93,7 +101,13 @@ class ReaderAudioEngine {
}
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
this.commitProgress()
// 换书:预取的音轨属于上一本,全部作废
if (s.bookId !== input.bookId) this.clearPrefetch()
this.applyChapter(input)
}
/** 真正把一章装上播放器(同步部分):预取命中的续播也走这里。 */
private applyChapter(input: LoadChapterInput): void {
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
useReaderAudioStore.setState({
bookId: input.bookId,
@@ -128,6 +142,9 @@ class ReaderAudioEngine {
}
void this.attachSource(input.track)
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex !== null) this.prefetchChapter(nextIndex)
}
play(): void {
@@ -222,6 +239,7 @@ class ReaderAudioEngine {
stop(): void {
this.commitProgress()
this.detachSource()
this.clearPrefetch()
this.timerDeadline = 0
this.pausedAt = 0
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
@@ -242,12 +260,22 @@ class ReaderAudioEngine {
private async attachSource(src: string): Promise<void> {
const token = ++this.loadToken
this.detachSource()
this.setSessionMetadata(src)
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
// 「无用户手势」拦下——表现正是切章卡住、解锁后必须手动点播放。直接覆盖 src,
// 元素始终持有媒体,会话才接得上。
this.hls?.destroy()
this.hls = null
if (!src) {
this.audio.removeAttribute('src')
this.audio.load()
this.setSessionMetadata(null)
this.fail('本章没有可播放的音频')
return
}
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
if (!this.audio.paused) this.audio.pause()
this.setSessionMetadata(src)
if (src.includes('.m3u8')) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
@@ -270,6 +298,7 @@ class ReaderAudioEngine {
this.audio.src = src
}
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
private detachSource(): void {
this.detaching = true
this.hls?.destroy()
@@ -279,9 +308,117 @@ class ReaderAudioEngine {
this.detaching = false
}
// ─── 预取与续播 ───────────────────────────────────────────────────────
/**
* 提前解析某章的音轨并预热字节。息屏时 ended 与 play() 之间不能有网络空档,
* 否则系统收走音频会话、标签页被冻结,浏览器就拒绝自动播放了。
*/
private prefetchChapter(index: number): void {
const { bookId, chapters } = useReaderAudioStore.getState()
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
if (this.prefetched.has(index) || this.prefetching.has(index)) return
// 反复跳章时旧的预取会变成垃圾,做个上限兜底
if (this.prefetched.size > 32) this.prefetched.clear()
this.prefetching.add(index)
const forBook = bookId
readerAPI
.bookContent(bookId, index)
.then((ct) => {
// 期间换了书:结果作废
if (useReaderAudioStore.getState().bookId !== forBook) return
const track = ct.tracks?.[0]
if (ct.type !== 'audio' || !track) return
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false })
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
// 避免用户中途停下时白下整章的流量。
if (ct.transcoding) this.warmNextBytes(track)
})
.catch(() => undefined)
.finally(() => this.prefetching.delete(index))
}
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
private warmNextBytes(track: string): void {
// HLS 交给 hls.js 按需拉流,二次下载没有意义
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
this.warmedTrack = track
if (!this.warmer) {
const a = new Audio()
a.preload = 'auto'
this.warmer = a
}
this.warmer.src = track
}
/** 本章快播完时把下一章的字节预热起来(30 秒足够开头缓冲)。 */
private maybeWarmNext(): void {
const audio = this.audio
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return
if (audio.duration - audio.currentTime > 30) return
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex === null) return
const hit = this.prefetched.get(nextIndex)
if (hit) this.warmNextBytes(hit.track)
}
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
private playPrefetched(index: number): boolean {
const hit = this.prefetched.get(index)
if (!hit) return false
this.prefetched.delete(index)
const s = useReaderAudioStore.getState()
this.applyChapter({
bookId: s.bookId,
bookName: s.bookName,
cover: s.cover,
chapters: s.chapters,
chapterIndex: index,
track: hit.track,
transcoding: hit.transcoding,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
initialPos: 0,
})
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
if (!hit.track.includes('.m3u8')) this.play()
return true
}
private clearPrefetch(): void {
this.prefetched.clear()
this.prefetching.clear()
this.warmedTrack = ''
if (this.warmer) {
this.warmer.removeAttribute('src')
this.warmer.load()
}
}
private async goToChapter(index: number, initialPos: number): Promise<void> {
const { bookId, chapters } = useReaderAudioStore.getState()
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
// 命中预取:直接装,不等网络(片尾自动续播大多走这里)
const hit = this.prefetched.get(index)
if (hit) {
useReaderAudioStore.setState({ status: 'loading', error: '' })
const s = useReaderAudioStore.getState()
this.loadChapter({
bookId,
bookName: s.bookName,
cover: s.cover,
chapters,
chapterIndex: index,
track: hit.track,
transcoding: hit.transcoding,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
initialPos,
})
return
}
useReaderAudioStore.setState({ status: 'loading', error: '' })
try {
const ct = await readerAPI.bookContent(bookId, index)
@@ -344,6 +481,10 @@ class ReaderAudioEngine {
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
this.commitProgress()
if (useReaderAudioStore.getState().hasNext) {
// 预取命中:在 ended 的同一次事件里同步接上下一章。息屏时这段「零空档」
// 很关键——空太久系统会收走音频会话、冻结标签页,浏览器随后拒绝自动播放。
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex !== null && this.playPrefetched(nextIndex)) return
this.next()
return
}
@@ -389,6 +530,7 @@ class ReaderAudioEngine {
this.checkCreditsEnd()
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
this.checkTimer()
this.maybeWarmNext()
})
audio.addEventListener('error', () => {
+109
View File
@@ -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')
+84
View File
@@ -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
}