mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
6984158f24
上游评论页按 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 的请求,其余原样透传。
240 lines
7.3 KiB
Go
240 lines
7.3 KiB
Go
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
|
|
}
|