diff --git a/internal/service/reader/browser_comment_page.go b/internal/service/reader/browser_comment_page.go new file mode 100644 index 0000000..7583f5d --- /dev/null +++ b/internal/service/reader/browser_comment_page.go @@ -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 +} diff --git a/internal/service/reader/browser_comment_page_test.go b/internal/service/reader/browser_comment_page_test.go new file mode 100644 index 0000000..96192b7 --- /dev/null +++ b/internal/service/reader/browser_comment_page_test.go @@ -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¶=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", ""); got != "" { + 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, `comment page`) + 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¶=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) + } +} diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index 6981954..3d62a9e 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -103,6 +103,9 @@ type pendingBrowser struct { finalURL string mode string expires time.Time + // comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor, + // 见 browser_comment_page.go)。 + comments *commentPager done chan struct{} mu sync.Mutex @@ -218,6 +221,7 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID mode: mode, expires: time.Now().Add(browserPageTTL), done: make(chan struct{}), + comments: newCommentPager(), } s.browserMu.Lock() @@ -511,6 +515,13 @@ 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:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的 @@ -577,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