mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 06:16:37 +08:00
优化
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
|
||||||
|
//
|
||||||
|
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
|
||||||
|
// 这里只钉住契约与安全边界,避免把测试绑到网络。
|
||||||
|
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
|
||||||
|
container := newReaderHandlerContainer(t)
|
||||||
|
router := registerReaderRoutesForTest(container)
|
||||||
|
|
||||||
|
post := func(body string) int {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"缺少 url", `{"book_id":"x"}`},
|
||||||
|
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
|
||||||
|
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
|
||||||
|
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if code := post(c.body); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s:应 400,得到 %d", c.name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
|||||||
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
||||||
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
||||||
|
|
||||||
|
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
|
||||||
|
g.POST("/comments/open", readerOpenCommentHandler(svc))
|
||||||
|
|
||||||
// 搜索(多源聚合)
|
// 搜索(多源聚合)
|
||||||
g.POST("/search", readerSearchHandler(svc))
|
g.POST("/search", readerSearchHandler(svc))
|
||||||
|
|
||||||
@@ -249,6 +252,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
|
||||||
|
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
|
||||||
|
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
|
||||||
|
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
var body struct {
|
||||||
|
BookID string `json:"book_id" binding:"required"`
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := c.GetString(middleware.CtxUserID)
|
||||||
|
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// readerBrowserPageHandler 承载待办页面本体。//
|
// readerBrowserPageHandler 承载待办页面本体。//
|
||||||
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
||||||
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ type BrowserPage struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
|
// Seq 登记序号(单调递增)。待办表可能同时存多个页面,前端按它取最新的
|
||||||
|
// 那个展示——不能依赖返回顺序,map 遍历是随机的。
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
||||||
PageURL string `json:"page_url"`
|
PageURL string `json:"page_url"`
|
||||||
// Refetch 见 rule.BrowserTask.Refetch。
|
// Refetch 见 rule.BrowserTask.Refetch。
|
||||||
@@ -83,8 +86,10 @@ type BrowserPageSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pendingBrowser 一个待用户完成的页面。
|
// pendingBrowser 一个待用户完成的页面。
|
||||||
|
// seq 为登记序号(越大越新,见 ReaderService.browserSeq),供前端挑选最新页面。
|
||||||
type pendingBrowser struct {
|
type pendingBrowser struct {
|
||||||
id string
|
id string
|
||||||
|
seq int64
|
||||||
sourceID string
|
sourceID string
|
||||||
sourceURL string
|
sourceURL string
|
||||||
userID string
|
userID string
|
||||||
@@ -180,6 +185,8 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
|||||||
s.browserPending = map[string]*pendingBrowser{}
|
s.browserPending = map[string]*pendingBrowser{}
|
||||||
}
|
}
|
||||||
s.pruneBrowsersLocked()
|
s.pruneBrowsersLocked()
|
||||||
|
s.browserSeq++
|
||||||
|
entry.seq = s.browserSeq
|
||||||
s.browserPending[id] = entry
|
s.browserPending[id] = entry
|
||||||
s.browserMu.Unlock()
|
s.browserMu.Unlock()
|
||||||
|
|
||||||
@@ -716,6 +723,10 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
|||||||
if e.sourceID != sourceID {
|
if e.sourceID != sourceID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
|
||||||
|
if e.finished {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
|
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
|
||||||
if e.userID != "" && userID != "" && e.userID != userID {
|
if e.userID != "" && userID != "" && e.userID != userID {
|
||||||
continue
|
continue
|
||||||
@@ -726,14 +737,20 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
|
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
|
||||||
|
//
|
||||||
|
// 交付后立刻把条目移出待办表:open 模式(java.startBrowser)没有别的清理
|
||||||
|
// 路径,留着会一直躺到 TTL 到期——光遇聚合的「❇️ 更新书源」就是这么在
|
||||||
|
// 15 分钟内反复串到其它按钮上的。
|
||||||
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
|
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
|
||||||
entry := s.lookupBrowser(id)
|
entry := s.lookupBrowser(id)
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
return errors.New("页面已过期或已完成")
|
return errors.New("页面已过期或已完成")
|
||||||
}
|
}
|
||||||
|
// 注意锁序:browserMu 必须在 entry.mu 之外获取(pruneBrowsersLocked 是
|
||||||
|
// browserMu → entry.mu),这里不能持着 entry.mu 去等 browserMu。
|
||||||
entry.mu.Lock()
|
entry.mu.Lock()
|
||||||
defer entry.mu.Unlock()
|
|
||||||
if entry.finished {
|
if entry.finished {
|
||||||
|
entry.mu.Unlock()
|
||||||
return errors.New("该页面已完成")
|
return errors.New("该页面已完成")
|
||||||
}
|
}
|
||||||
if cancelled {
|
if cancelled {
|
||||||
@@ -749,6 +766,9 @@ func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, c
|
|||||||
if entry.done != nil {
|
if entry.done != nil {
|
||||||
close(entry.done)
|
close(entry.done)
|
||||||
}
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
s.dropBrowser(id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,6 +805,14 @@ func (s *ReaderService) pruneBrowsersLocked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// browserPageOf 生成前端展示用的描述(自行加锁)。
|
||||||
|
// registerBrowser 之后需要把页面直接回给调用方(如打开一条段评)时用它。
|
||||||
|
func (s *ReaderService) browserPageOf(e *pendingBrowser) BrowserPage {
|
||||||
|
s.browserMu.Lock()
|
||||||
|
defer s.browserMu.Unlock()
|
||||||
|
return s.pageOfLocked(e)
|
||||||
|
}
|
||||||
|
|
||||||
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
|
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
|
||||||
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||||
target := e.request.URL
|
target := e.request.URL
|
||||||
@@ -795,6 +823,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
|||||||
ID: e.id,
|
ID: e.id,
|
||||||
Title: e.request.Title,
|
Title: e.request.Title,
|
||||||
Mode: e.mode,
|
Mode: e.mode,
|
||||||
|
Seq: e.seq,
|
||||||
PageURL: s.browserPageURL(e.id),
|
PageURL: s.browserPageURL(e.id),
|
||||||
Refetch: e.request.Refetch,
|
Refetch: e.request.Refetch,
|
||||||
SourceID: e.sourceID,
|
SourceID: e.sourceID,
|
||||||
|
|||||||
@@ -390,3 +390,46 @@ func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
|
|||||||
t.Fatal("未解除阻塞")
|
t.Fatal("未解除阻塞")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBrowserPanelOpenPageDroppedAfterResolve open 模式(java.startBrowser)
|
||||||
|
// 的页面在用户关闭后必须立刻离开待办表。
|
||||||
|
//
|
||||||
|
// 回归:这条日志只删除 wait 模式(awaitBrowser 的 defer),open 模式没有清理
|
||||||
|
// 路径,会一直躺到 15 分钟 TTL 到期。前端轮询到它就又把旧页面弹出来——光遇
|
||||||
|
// 聚合点过「❇️ 更新书源」后,接下来点「书源设置」「段评设置」都会跳到书源更新页。
|
||||||
|
func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
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,
|
||||||
|
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
|
||||||
|
t.Fatalf("登记 open 页面失败: %v", err)
|
||||||
|
}
|
||||||
|
old := waitPending(t, svc, sourceID)
|
||||||
|
if old.Mode != browserModeOpen {
|
||||||
|
t.Fatalf("模式 = %q,期望 open", old.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户关掉页面
|
||||||
|
if err := svc.ResolveBrowser(old.ID, readerTestUserID, "", "", true); err != nil {
|
||||||
|
t.Fatalf("关闭页面失败: %v", err)
|
||||||
|
}
|
||||||
|
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
|
||||||
|
t.Fatalf("关闭后待办未清理: %+v", pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再点别的按钮:待办表里只应剩新页面,且序号更大
|
||||||
|
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
|
||||||
|
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
|
||||||
|
t.Fatalf("登记新页面失败: %v", err)
|
||||||
|
}
|
||||||
|
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||||
|
if len(pages) != 1 || pages[0].Title != "光遇书源设置" {
|
||||||
|
t.Fatalf("待办表应只剩新页面: %+v", pages)
|
||||||
|
}
|
||||||
|
if pages[0].Seq <= old.Seq {
|
||||||
|
t.Fatalf("新页面 seq 应更大: %d <= %d", pages[0].Seq, old.Seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件:正文里的「段评」锚点解析(对齐 legado 的段评机制)。
|
||||||
|
//
|
||||||
|
// legado 侧段评有两种落在正文里的形态:
|
||||||
|
//
|
||||||
|
// 1. 段落里的 <comment ident="评论地址" count="评论数" />:宿主(苹果/源阅分支)
|
||||||
|
// 直接把它解析成段落旁的评论气泡;
|
||||||
|
// 2. 「改版」宿主不认 <comment>,改由书源在正文规则里把它换成一张内嵌 SVG 图片,
|
||||||
|
// 图片地址后面再跟一段 JSON 配置:
|
||||||
|
// <img src="data:image/svg+xml;base64,…{"click":"showCmt('评论地址','平台','段评')","style":"text"}">
|
||||||
|
// legado 识别的是地址后面的这份配置(ReadBookActivity.oldClickImg),点击时把
|
||||||
|
// 配置里的 click/js 当 JS 执行(showCmt 内部再调 java.showBrowser 打开评论页)。
|
||||||
|
//
|
||||||
|
// MeBox 没有 Android 的 Canvas 排版,正文按行渲染,因此这里把两种形态都归一成
|
||||||
|
// 「某一行的段评气泡」:只摘掉标记本身,正文文字一个字都不动,并把评论地址 / 评论数
|
||||||
|
// 结构化下发给前端;点击气泡时前端复用宿主浏览器(带书源 Cookie)打开评论地址。
|
||||||
|
//
|
||||||
|
// 约定:但凡无法确定评论地址的图片,一律原样保留交给既有链路,绝不猜一个地址出来。
|
||||||
|
|
||||||
|
// ContentComment 正文里的一条段评锚点(随正文一起下发给前端)。
|
||||||
|
type ContentComment struct {
|
||||||
|
// Line 段落行号:Content 按 "\n" 拆分后的 0-based 下标。
|
||||||
|
Line int `json:"line"`
|
||||||
|
// Count 评论数(0 表示未知:前端只画一个不带数字的气泡)。
|
||||||
|
Count int `json:"count"`
|
||||||
|
// URL 评论地址(书源 showCmt 的第一个参数),前端点击时交给宿主浏览器打开。
|
||||||
|
URL string `json:"url"`
|
||||||
|
// Label 评论类型:段评 / 本章说,用作气泡提示。
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
// Block 独占一行的整块评论(章末「本章说」),前端按居中整块渲染。
|
||||||
|
Block bool `json:"block,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentTagRe 匹配正文里的 <comment …> 标签。属性顺序不定、可能自闭合,
|
||||||
|
// 也可能带一个配对结束标签(`<comment …>…</comment>`),一并吃掉。
|
||||||
|
var commentTagRe = regexp.MustCompile(`(?is)<comment\b[^>]*?/?>(?:</comment>)?`)
|
||||||
|
|
||||||
|
// 下面三条属性正则都允许单/双引号两种写法;捕获组 1 是双引号内容,2 是单引号内容。
|
||||||
|
var (
|
||||||
|
commentIdentRe = regexp.MustCompile(`(?is)\bident\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||||
|
commentCountRe = regexp.MustCompile(`(?is)\bcount\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||||
|
commentOnPressRe = regexp.MustCompile(`(?is)\bonPress\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// commentJSURLRe 从 showCmt / showReadingBrowser / startBrowserDp 的调用里取第一个字符串参数。
|
||||||
|
// 苹果分支的 <comment onPress="java.showReadingBrowser('地址',…)" /> 就靠它取地址。
|
||||||
|
// RE2 不支持反向引用,双/单引号两种写法拆成两个捕获组。
|
||||||
|
var commentJSURLRe = regexp.MustCompile(`(?is)(?:showCmt|showReadingBrowser|startBrowserDp)\s*\(\s*(?:"([^"]*)"|'([^']*)')`)
|
||||||
|
|
||||||
|
// imgParamRe 宽容地提取图片地址后面 JSON 配置里的键值对。
|
||||||
|
//
|
||||||
|
// 不用 encoding/json 解析:光遇聚合的「本章说」把配置拼成 {'type':'qtbzs',"click":"…",'style':'FULL'}
|
||||||
|
// (单双引号混用),严格 JSON 解析会失败,而它又是合法 JS 对象字面量。
|
||||||
|
var imgParamRe = regexp.MustCompile(`(?is)["']?(click|js|style|type|width)["']?\s*:\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')`)
|
||||||
|
|
||||||
|
// svgCountRe 取内嵌段评 SVG 里画在气泡上的数字(书源把评论数直接画进了 SVG 文本)。
|
||||||
|
var svgCountRe = regexp.MustCompile(`(?is)<text[^>]*>\s*([0-9]+)\s*\+?\s*</text>`)
|
||||||
|
|
||||||
|
// wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。
|
||||||
|
var wrapMarkupRe = regexp.MustCompile(`(?is)</?(?:p|div|br|hr|h[1-6]|article|dd|dl)\b[^>]*>`)
|
||||||
|
|
||||||
|
// extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。
|
||||||
|
//
|
||||||
|
// 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。
|
||||||
|
func extractContentComments(content string) (string, []ContentComment) {
|
||||||
|
if content == "" || (!strings.Contains(content, "<comment") && !strings.Contains(content, "<img")) {
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
out := make([]string, len(lines))
|
||||||
|
comments := make([]ContentComment, 0, 4)
|
||||||
|
changed := false
|
||||||
|
for i, line := range lines {
|
||||||
|
text, cs := extractLineComments(line, i)
|
||||||
|
out[i] = text
|
||||||
|
if len(cs) > 0 {
|
||||||
|
changed = true
|
||||||
|
comments = append(comments, cs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n"), comments
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractLineComments 处理一行:返回去掉段评标记后的文字与该行上的段评。
|
||||||
|
func extractLineComments(line string, lineNo int) (string, []ContentComment) {
|
||||||
|
type marker struct {
|
||||||
|
start, end int
|
||||||
|
comment ContentComment
|
||||||
|
}
|
||||||
|
var markers []marker
|
||||||
|
|
||||||
|
for _, m := range commentTagRe.FindAllStringIndex(line, -1) {
|
||||||
|
if c, ok := parseCommentTag(line[m[0]:m[1]], lineNo); ok {
|
||||||
|
markers = append(markers, marker{m[0], m[1], c})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range scanImgTags(line) {
|
||||||
|
if c, ok := parseCommentImage(tag.src, lineNo); ok {
|
||||||
|
markers = append(markers, marker{tag.start, tag.end, c})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(markers) == 0 {
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(markers, func(i, j int) bool { return markers[i].start < markers[j].start })
|
||||||
|
comments := make([]ContentComment, 0, len(markers))
|
||||||
|
var b strings.Builder
|
||||||
|
prev := 0
|
||||||
|
for _, mk := range markers {
|
||||||
|
if mk.start < prev { // 与前一个标记重叠(理论上不会出现),跳过避免重复删除
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(line[prev:mk.start])
|
||||||
|
prev = mk.end
|
||||||
|
c := mk.comment
|
||||||
|
// 摘掉标记后整行只剩标签/空白 → 说明评论独占一行(章末「本章说」)
|
||||||
|
c.Block = isBlankMarkup(line[:mk.start] + line[mk.end:])
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
b.WriteString(line[prev:])
|
||||||
|
return b.String(), comments
|
||||||
|
}
|
||||||
|
|
||||||
|
// imgTagMatch 一行里一个 <img> 标签的位置与 src 值。
|
||||||
|
type imgTagMatch struct {
|
||||||
|
start, end int
|
||||||
|
src string
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanImgTags 扫描一行里的所有 <img> 标签,返回标签位置与 src 值。
|
||||||
|
//
|
||||||
|
// 不能用 <img[^>]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成
|
||||||
|
// <img src="data:…,{"click":"showCmt('…','…')"}"> —— 属性值里的 JSON 又用了双引号,
|
||||||
|
// 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号,
|
||||||
|
// 只把「花括号之外遇到的引号」当作属性值的结束。
|
||||||
|
func scanImgTags(line string) []imgTagMatch {
|
||||||
|
var out []imgTagMatch
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
for i := 0; i < len(line); {
|
||||||
|
idx := strings.Index(lower[i:], "<img")
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start := i + idx
|
||||||
|
src, end, ok := scanImgTagSrc(line, start)
|
||||||
|
if !ok {
|
||||||
|
i = start + 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, imgTagMatch{start: start, end: end, src: src})
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanImgTagSrc 从 <img 起始位置扫描出 src 属性值,并返回标签结束下标(含 '>')。
|
||||||
|
func scanImgTagSrc(line string, start int) (string, int, bool) {
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
i := start + 4 // 跳过 "<img"
|
||||||
|
for {
|
||||||
|
si := strings.Index(lower[i:], "src")
|
||||||
|
if si < 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
si += i
|
||||||
|
q := si + 3
|
||||||
|
for q < len(line) && isHTMLSpace(line[q]) {
|
||||||
|
q++
|
||||||
|
}
|
||||||
|
// 必须是 src= 而不是 srcset= 之类的近似字段
|
||||||
|
if q >= len(line) || line[q] != '=' {
|
||||||
|
i = si + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q++
|
||||||
|
for q < len(line) && isHTMLSpace(line[q]) {
|
||||||
|
q++
|
||||||
|
}
|
||||||
|
if q >= len(line) || (line[q] != '"' && line[q] != '\'') {
|
||||||
|
i = si + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quote := line[q]
|
||||||
|
valStart := q + 1
|
||||||
|
depth := 0
|
||||||
|
j := valStart
|
||||||
|
for ; j < len(line); j++ {
|
||||||
|
c := line[j]
|
||||||
|
switch {
|
||||||
|
case c == '{':
|
||||||
|
depth++
|
||||||
|
case c == '}' && depth > 0:
|
||||||
|
depth--
|
||||||
|
case c == quote && depth == 0:
|
||||||
|
// 花括号之外的引号才是属性值的结束
|
||||||
|
gt := strings.IndexByte(line[j:], '>')
|
||||||
|
if gt < 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
return line[valStart:j], j + gt + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTMLSpace 判断 HTML 属性之间的空白(含换行/制表)。
|
||||||
|
func isHTMLSpace(c byte) bool {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommentTag 解析宿主侧的 <comment ident count /> 形式。
|
||||||
|
func parseCommentTag(tag string, lineNo int) (ContentComment, bool) {
|
||||||
|
rawURL := attrOf(commentIdentRe, tag)
|
||||||
|
if rawURL == "" {
|
||||||
|
rawURL = attrOf(commentOnPressRe, tag)
|
||||||
|
}
|
||||||
|
rawURL = extractCommentURL(rawURL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
count, _ := strconv.Atoi(strings.TrimSpace(attrOf(commentCountRe, tag)))
|
||||||
|
return ContentComment{Line: lineNo, Count: count, URL: rawURL, Label: "段评"}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommentImage 解析「图片地址 + JSON 配置」形式的段评。
|
||||||
|
//
|
||||||
|
// 只把真正指向评论的气泡当段评:地址要么来自 showCmt/showReadingBrowser,
|
||||||
|
// 要么图片本身就是内嵌的段评 SVG。其它带 click 配置的图片保持原样,避免误伤。
|
||||||
|
func parseCommentImage(src string, lineNo int) (ContentComment, bool) {
|
||||||
|
src = strings.TrimSpace(src)
|
||||||
|
if src == "" {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
base, params := splitImgSrcParams(src)
|
||||||
|
if len(params) == 0 {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
action := params["click"]
|
||||||
|
if action == "" {
|
||||||
|
action = params["js"]
|
||||||
|
}
|
||||||
|
if action == "" {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
isCommentCall := strings.Contains(action, "showCmt") || strings.Contains(action, "showReadingBrowser")
|
||||||
|
if !isCommentCall && !strings.HasPrefix(base, "data:image/svg+xml") {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
rawURL := extractCommentURL(action)
|
||||||
|
if rawURL == "" {
|
||||||
|
return ContentComment{}, false
|
||||||
|
}
|
||||||
|
label := "段评"
|
||||||
|
if strings.Contains(action, "本章说") {
|
||||||
|
label = "本章说"
|
||||||
|
}
|
||||||
|
return ContentComment{
|
||||||
|
Line: lineNo,
|
||||||
|
Count: commentCountFromImage(base),
|
||||||
|
URL: rawURL,
|
||||||
|
Label: label,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitImgSrcParams 把图片地址拆成(地址, JSON 配置),没有配置时返回 (src, nil)。
|
||||||
|
//
|
||||||
|
// 对应 legado 的 AnalyzeUrl.paramPattern `\s*,\s*(?=\{)`:地址与配置之间用「逗号 + 花括号」分隔。
|
||||||
|
func splitImgSrcParams(src string) (string, map[string]string) {
|
||||||
|
for i := 0; i < len(src); i++ {
|
||||||
|
if src[i] != ',' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(src) && (src[j] == ' ' || src[j] == '\t' || src[j] == '\n' || src[j] == '\r') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j >= len(src) || src[j] != '{' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := strings.LastIndexByte(src, '}')
|
||||||
|
if end <= j {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if params := parseImgParams(src[j : end+1]); len(params) > 0 {
|
||||||
|
return strings.TrimSpace(src[:i]), params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImgParams 宽容解析图片配置里的键值对(见 imgParamRe 的说明)。
|
||||||
|
func parseImgParams(raw string) map[string]string {
|
||||||
|
ms := imgParamRe.FindAllStringSubmatch(raw, -1)
|
||||||
|
if len(ms) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
val := m[2]
|
||||||
|
if val == "" {
|
||||||
|
val = m[3]
|
||||||
|
}
|
||||||
|
val = strings.ReplaceAll(val, `\"`, `"`)
|
||||||
|
val = strings.ReplaceAll(val, `\'`, `'`)
|
||||||
|
out[strings.ToLower(m[1])] = val
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentCountFromImage 从内嵌段评 SVG 里取评论数(书源把数字画进了 SVG 文本)。
|
||||||
|
// 取不到时返回 0,前端只画一个不带数字的气泡。
|
||||||
|
func commentCountFromImage(src string) int {
|
||||||
|
decoded, ok := decodeCommentDataURI(src)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m := svgCountRe.FindStringSubmatch(decoded)
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCommentDataURI 解出 data: URI 的文本内容(支持 base64 与明文两种)。
|
||||||
|
func decodeCommentDataURI(s string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(s, "data:") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
comma := strings.IndexByte(s, ',')
|
||||||
|
if comma < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
meta, payload := s[:comma], s[comma+1:]
|
||||||
|
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
return payload, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCommentURL 从 showCmt/showReadingBrowser 调用或裸地址里取评论地址。
|
||||||
|
func extractCommentURL(js string) string {
|
||||||
|
js = strings.TrimSpace(js)
|
||||||
|
if js == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if m := commentJSURLRe.FindStringSubmatch(js); m != nil {
|
||||||
|
return trimCommentURL(firstSubmatch(m))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(js, "http://") || strings.HasPrefix(js, "https://") {
|
||||||
|
return trimCommentURL(js)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrOf 取属性值,双引号优先、单引号兜底。
|
||||||
|
func attrOf(re *regexp.Regexp, s string) string {
|
||||||
|
m := re.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return firstSubmatch(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstSubmatch 返回「双引号 / 单引号」两个捕获组里非空的那个。
|
||||||
|
func firstSubmatch(m []string) string {
|
||||||
|
if len(m) > 1 && m[1] != "" {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
if len(m) > 2 {
|
||||||
|
return m[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimCommentURL 去空白并还原 HTML 实体。
|
||||||
|
// 苹果/源阅分支会把地址里的 & 转成 &(paraForiOS),直接拿去请求会 404。
|
||||||
|
func trimCommentURL(raw string) string {
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
""", `"`, """, `"`,
|
||||||
|
"'", "'", "'", "'",
|
||||||
|
"<", "<", ">", ">",
|
||||||
|
"&", "&",
|
||||||
|
)
|
||||||
|
return strings.TrimSpace(r.Replace(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
|
||||||
|
func isBlankMarkup(s string) bool {
|
||||||
|
s = wrapMarkupRe.ReplaceAllString(s, "")
|
||||||
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\u3000", " ")
|
||||||
|
return strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 打开段评(宿主浏览器承载) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// OpenContentComment 在宿主浏览器里打开一条段评。
|
||||||
|
//
|
||||||
|
// 书源点击段评气泡时执行的是它自己的 showCmt(...):先 java.ajax 取评论页,
|
||||||
|
// 再 java.showBrowser 展示。MeBox 不跑书源 JS,而是把 showCmt 的第一个参数
|
||||||
|
// (评论地址)解析出来(见本文件),复用承载登录页的那套宿主浏览器打开:
|
||||||
|
// 服务端带书源 Cookie 抓取评论页,前端 iframe 展示。这样评论接口需要的登录态
|
||||||
|
// 仍然生效——前端直接 window.open 会以未登录身份访问。
|
||||||
|
//
|
||||||
|
// 返回的 BrowserPage 直接给前端渲染,不必走轮询(段评只需展示、无需回传 DOM)。
|
||||||
|
func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, rawURL, title string) (*BrowserPage, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, fmt.Errorf("阅读服务未就绪")
|
||||||
|
}
|
||||||
|
target, err := validateCommentURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
book, err := s.repo.GetBook(ctx, bookID)
|
||||||
|
if err != nil || book == nil {
|
||||||
|
return nil, fmt.Errorf("书籍不存在或已移出书架")
|
||||||
|
}
|
||||||
|
sourceID := ""
|
||||||
|
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
|
||||||
|
sourceID = src.ID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(title) == "" {
|
||||||
|
title = "段评"
|
||||||
|
}
|
||||||
|
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
|
||||||
|
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page := s.browserPageOf(entry)
|
||||||
|
return &page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCommentURL 校验评论地址:只允许 http(s),并挡住内网地址。
|
||||||
|
//
|
||||||
|
// 段评地址来自书源正文,会带着书源登录态由服务端去抓;不做限制就等于给了一个
|
||||||
|
// 「以书源身份请求任意地址」的口子(SSRF)。
|
||||||
|
func validateCommentURL(raw string) (string, error) {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
if u == "" {
|
||||||
|
return "", fmt.Errorf("评论地址为空")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
return "", fmt.Errorf("评论地址无效:%s", truncateForLog(u, 80))
|
||||||
|
}
|
||||||
|
if isPrivateHost(parsed.Hostname()) {
|
||||||
|
return "", fmt.Errorf("评论地址指向内网,已拒绝")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateHost 判断主机名是否是回环/内网/链路本地地址。
|
||||||
|
func isPrivateHost(host string) bool {
|
||||||
|
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||||
|
if h == "" || h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(h)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 段评解析单测:覆盖 legado 的两种正文形态(<comment /> 与「图片地址 + JSON 配置」),
|
||||||
|
// 以及「绝不能吞掉或破坏正文文字」这条硬约束。
|
||||||
|
|
||||||
|
// svgCommentSrc 按光遇聚合 createSvg 的写法拼一张带评论数气泡图:
|
||||||
|
// data:image/svg+xml;base64,<svg>,{"click":"showCmt('地址','平台','段评')","style":"text"}
|
||||||
|
//
|
||||||
|
// 注意第二个逗号后面直接跟 JSON,且 JSON 自身用双引号——它嵌在双引号属性里,
|
||||||
|
// 正是会打断普通正则的「非法 HTML」,解析必须扛得住。
|
||||||
|
func svgCommentSrc(t *testing.T, count int, click string, style string) string {
|
||||||
|
t.Helper()
|
||||||
|
svg := fmt.Sprintf(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg'><text x='1' y='2'>%d</text></svg>`, count)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString([]byte(svg))
|
||||||
|
return fmt.Sprintf(`data:image/svg+xml;base64,%s,{"click":%q,"style":%q}`, b64, click, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsLegacyTag(t *testing.T) {
|
||||||
|
content := `<p>第一段<comment ident="https://cdn.example.com/c/1" count="12" /></p>
|
||||||
|
<p>第二段</p>`
|
||||||
|
text, comments := extractContentComments(content)
|
||||||
|
if text != "<p>第一段</p>\n<p>第二段</p>" {
|
||||||
|
t.Fatalf("正文被破坏:%q", text)
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||||
|
}
|
||||||
|
c := comments[0]
|
||||||
|
if c.Line != 0 || c.Count != 12 || c.URL != "https://cdn.example.com/c/1" || c.Label != "段评" {
|
||||||
|
t.Fatalf("解析结果不符:%+v", c)
|
||||||
|
}
|
||||||
|
if c.Block {
|
||||||
|
t.Fatalf("段落内的段评不应标记为整块:%+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsIOSOnPress(t *testing.T) {
|
||||||
|
content := `<p>正文<comment count="7" onPress="java.showReadingBrowser('https://cdn.example.com/c/2?a=1&b=2','番茄段评')"></comment></p>`
|
||||||
|
text, comments := extractContentComments(content)
|
||||||
|
if text != "<p>正文</p>" {
|
||||||
|
t.Fatalf("正文被破坏:%q", text)
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||||
|
}
|
||||||
|
// & 必须还原成 &,否则请求评论接口会 404
|
||||||
|
if comments[0].URL != "https://cdn.example.com/c/2?a=1&b=2" || comments[0].Count != 7 {
|
||||||
|
t.Fatalf("解析结果不符:%+v", comments[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsInlineImage(t *testing.T) {
|
||||||
|
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1&item_id=2','番茄','段评')", "text")
|
||||||
|
content := `<p>正文内容<img src="` + src + `"></p>
|
||||||
|
<p>下一段</p>`
|
||||||
|
text, comments := extractContentComments(content)
|
||||||
|
if text != "<p>正文内容</p>\n<p>下一段</p>" {
|
||||||
|
t.Fatalf("正文被破坏:%q", text)
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||||
|
}
|
||||||
|
c := comments[0]
|
||||||
|
if c.Line != 0 || c.Count != 3 || c.Label != "段评" {
|
||||||
|
t.Fatalf("解析结果不符:%+v", c)
|
||||||
|
}
|
||||||
|
// 地址里的 & 原样保留(JSON 里的 & 不是实体)
|
||||||
|
if c.URL != "https://v1.example.com/get_review?book_id=1&item_id=2" {
|
||||||
|
t.Fatalf("评论地址 = %q", c.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsChapterEndBlock(t *testing.T) {
|
||||||
|
// 章末「本章说」:单独一行,配置用单引号键、双引号值(合法 JS 对象字面量,非法 JSON)
|
||||||
|
line := `<img src="https://v1.example.com/chapter_review_svg?book_id=1&img=1,` +
|
||||||
|
`{'type':'qtbzs',` +
|
||||||
|
`"click":"showCmt('https://v1.example.com/get_review?book_id=1','番茄','本章说' )",` +
|
||||||
|
`'style':'FULL'}">`
|
||||||
|
content := "<p>正文最后一段</p>\n" + line
|
||||||
|
text, comments := extractContentComments(content)
|
||||||
|
if text != "<p>正文最后一段</p>\n" {
|
||||||
|
t.Fatalf("正文被破坏:%q", text)
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||||
|
}
|
||||||
|
c := comments[0]
|
||||||
|
if c.Line != 1 || c.Label != "本章说" || !c.Block {
|
||||||
|
t.Fatalf("解析结果不符:%+v", c)
|
||||||
|
}
|
||||||
|
if c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||||
|
t.Fatalf("评论地址 = %q", c.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsCountOver99(t *testing.T) {
|
||||||
|
// 书源把 >99 的评论数显示成 "99+",解析要取到 99
|
||||||
|
src := svgCommentSrc(t, 99, "showCmt('https://x.example.com/c','番茄','段评')", "text")
|
||||||
|
// 手工把文本改成 99+
|
||||||
|
src = strings.Replace(src, ">99<", ">99+<", 1)
|
||||||
|
_, comments := extractContentComments(`<p>x<img src="` + src + `"></p>`)
|
||||||
|
if len(comments) != 1 || comments[0].Count != 99 {
|
||||||
|
t.Fatalf("期望解析出 99,得到 %+v", comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有任何段评标记时,正文必须逐字节不变(不能因为「看起来像 HTML」就动手)。
|
||||||
|
func TestExtractContentCommentsPreservesPlainContent(t *testing.T) {
|
||||||
|
content := `<p>普通段落</p>
|
||||||
|
<p>带插图的段落<img src="https://cdn.example.com/a.jpg"></p>
|
||||||
|
<p>带 onclick 图但非段评<img src="https://cdn.example.com/b.jpg,{'click':'openPhoto(1)'}"></p>`
|
||||||
|
text, comments := extractContentComments(content)
|
||||||
|
if text != content {
|
||||||
|
t.Fatalf("正文被改动:\n got %q\nwant %q", text, content)
|
||||||
|
}
|
||||||
|
if len(comments) != 0 {
|
||||||
|
t.Fatalf("不应解析出段评:%+v", comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentCommentsEmpty(t *testing.T) {
|
||||||
|
if text, comments := extractContentComments(""); text != "" || comments != nil {
|
||||||
|
t.Fatalf("空正文应原样返回,得到 %q / %+v", text, comments)
|
||||||
|
}
|
||||||
|
if text, comments := extractContentComments("<p>只有普通正文</p>"); text != "<p>只有普通正文</p>" || comments != nil {
|
||||||
|
t.Fatalf("无标记正文应原样返回,得到 %q / %+v", text, comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitImgSrcParams(t *testing.T) {
|
||||||
|
base, params := splitImgSrcParams(`data:image/svg+xml;base64,QUJD,{"click":"showCmt('u','x')","style":"text"}`)
|
||||||
|
if base != "data:image/svg+xml;base64,QUJD" {
|
||||||
|
t.Fatalf("base = %q", base)
|
||||||
|
}
|
||||||
|
if params["click"] != "showCmt('u','x')" || params["style"] != "text" {
|
||||||
|
t.Fatalf("params = %+v", params)
|
||||||
|
}
|
||||||
|
if base, params := splitImgSrcParams("https://x.example.com/a.jpg"); params != nil || base != "https://x.example.com/a.jpg" {
|
||||||
|
t.Fatalf("无配置时不应拆出参数:%q / %+v", base, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCommentURL(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
"", "not-a-url", "file:///etc/passwd", "javascript:alert(1)",
|
||||||
|
"http://localhost/c", "http://127.0.0.1/c", "http://192.168.1.1/c", "http://[::1]/c",
|
||||||
|
}
|
||||||
|
for _, u := range bad {
|
||||||
|
if _, err := validateCommentURL(u); err == nil {
|
||||||
|
t.Fatalf("%q 应被拒绝", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
good := "https://v1.qingtian618.com/get_review?book_id=1&item_id=2"
|
||||||
|
if got, err := validateCommentURL(good); err != nil || got != good {
|
||||||
|
t.Fatalf("合法地址被拒:%q / %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:书源正文里带段评标记时,GetContentForBook 应清掉标记、
|
||||||
|
// 下发结构化 Comments,且正文文字完好。
|
||||||
|
func TestGetContentForBookExtractsComments(t *testing.T) {
|
||||||
|
src := svgCommentSrc(t, 5, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
|
||||||
|
// 服务端按 hex 还原规则返回值里的正文;这里让正文规则直接返回一段放进 data 信封的 JSON。
|
||||||
|
pageJSON := fmt.Sprintf(`{"content":%q}`, "<p>第一段<img src=\""+src+"\"></p>\n<p>第二段</p>")
|
||||||
|
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(pageJSON))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc, _ := newLoginTestService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
srcJSON := fmt.Sprintf(`{
|
||||||
|
"bookSourceUrl": %q,
|
||||||
|
"bookSourceName": "段评测试源",
|
||||||
|
"bookSourceType": 0,
|
||||||
|
"ruleContent": { "content": "$.content" }
|
||||||
|
}`, srv.URL)
|
||||||
|
importTestSource(t, svc, srcJSON, srv.URL)
|
||||||
|
|
||||||
|
book := &model.ReaderBook{
|
||||||
|
UserID: "u1",
|
||||||
|
Origin: srv.URL,
|
||||||
|
OriginName: "段评测试源",
|
||||||
|
BookURL: srv.URL + "/book/1",
|
||||||
|
Name: "测试书",
|
||||||
|
Type: 0,
|
||||||
|
}
|
||||||
|
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||||
|
t.Fatalf("创建书籍失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||||
|
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("写入章节失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("取正文失败: %v", err)
|
||||||
|
}
|
||||||
|
if out.Type != "text" {
|
||||||
|
t.Fatalf("类型 = %q,期望 text", out.Type)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.Content, "<img") || strings.Contains(out.Content, "showCmt") {
|
||||||
|
t.Fatalf("段评标记没有清干净:%q", out.Content)
|
||||||
|
}
|
||||||
|
if out.Content != "<p>第一段</p>\n<p>第二段</p>" {
|
||||||
|
t.Fatalf("正文被破坏:%q", out.Content)
|
||||||
|
}
|
||||||
|
if len(out.Comments) != 1 {
|
||||||
|
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
|
||||||
|
}
|
||||||
|
c := out.Comments[0]
|
||||||
|
if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||||
|
t.Fatalf("段评内容不符:%+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,8 +45,10 @@ type ReaderService struct {
|
|||||||
|
|
||||||
// browserMu / browserPending 保护「待用户完成的页面」表
|
// browserMu / browserPending 保护「待用户完成的页面」表
|
||||||
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
|
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
|
||||||
|
// browserSeq 是页面登记的单调递增序号,供前端判断哪个页面最新。
|
||||||
browserMu sync.Mutex
|
browserMu sync.Mutex
|
||||||
browserPending map[string]*pendingBrowser
|
browserPending map[string]*pendingBrowser
|
||||||
|
browserSeq int64
|
||||||
|
|
||||||
// limiter 单源限速(书源 concurrentRate)。
|
// limiter 单源限速(书源 concurrentRate)。
|
||||||
limiter *sourceRateLimiter
|
limiter *sourceRateLimiter
|
||||||
@@ -1401,6 +1403,9 @@ type ChapterContent struct {
|
|||||||
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
|
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
|
||||||
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
|
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
|
||||||
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
|
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
|
||||||
|
// Comments 正文里的段评锚点(<comment> 标记与带 click 配置的内嵌评论图,见 comment.go)。
|
||||||
|
// 只在文本类型下有意义;前端按 Line 把气泡挂到对应段落旁。
|
||||||
|
Comments []ContentComment `json:"comments,omitempty"`
|
||||||
// Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了),
|
// Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了),
|
||||||
// 首次播放需要等转码完成,之后命中缓存秒开。
|
// 首次播放需要等转码完成,之后命中缓存秒开。
|
||||||
Transcoding bool `json:"transcoding,omitempty"`
|
Transcoding bool `json:"transcoding,omitempty"`
|
||||||
@@ -1702,6 +1707,9 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
|||||||
out.ImageStyle = SPtr(cr.ImageStyle)
|
out.ImageStyle = SPtr(cr.ImageStyle)
|
||||||
default:
|
default:
|
||||||
out.Type = "text"
|
out.Type = "text"
|
||||||
|
// 正文里的段评标记(<comment …/> 或「图片地址 + click 配置」的内嵌评论图)
|
||||||
|
// 归一成结构化锚点,正文文字原样保留(见 comment.go 的说明)。
|
||||||
|
out.Content, out.Comments = extractContentComments(content)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,26 @@ export interface ReaderChapterContent {
|
|||||||
tracks?: string[]
|
tracks?: string[]
|
||||||
images?: string[]
|
images?: string[]
|
||||||
image_style?: string
|
image_style?: string
|
||||||
|
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
|
||||||
|
comments?: ReaderContentComment[]
|
||||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||||
transcoding?: boolean
|
transcoding?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
|
||||||
|
export interface ReaderContentComment {
|
||||||
|
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
|
||||||
|
line: number
|
||||||
|
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
|
||||||
|
count: number
|
||||||
|
/** 评论地址(书源 showCmt 的第一个参数)。 */
|
||||||
|
url: string
|
||||||
|
/** 段评 / 本章说。 */
|
||||||
|
label?: string
|
||||||
|
/** 独占一行的整块评论(章末「本章说」)。 */
|
||||||
|
block?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReaderReplaceRule {
|
export interface ReaderReplaceRule {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -174,6 +190,8 @@ export interface ReaderBrowserPage {
|
|||||||
title: string
|
title: string
|
||||||
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||||
mode: 'wait' | 'open'
|
mode: 'wait' | 'open'
|
||||||
|
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
|
||||||
|
seq: number
|
||||||
/** iframe 承载地址(同源,带签名)。 */
|
/** iframe 承载地址(同源,带签名)。 */
|
||||||
page_url: string
|
page_url: string
|
||||||
refetch: boolean
|
refetch: boolean
|
||||||
@@ -223,6 +241,9 @@ export const readerAPI = {
|
|||||||
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
||||||
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
||||||
api.post('/reader/browser/result', body).then((r) => r.data),
|
api.post('/reader/browser/result', body).then((r) => r.data),
|
||||||
|
// 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
|
||||||
|
openContentComment: (body: { book_id: string; url: string; title?: string }) =>
|
||||||
|
api.post<ReaderBrowserPage>('/reader/comments/open', body).then((r) => r.data),
|
||||||
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
||||||
browserXHR: (body: {
|
browserXHR: (body: {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
ListEnd,
|
ListEnd,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
Minus,
|
Minus,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -17,12 +18,21 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
|
||||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
import {
|
||||||
|
readerAPI,
|
||||||
|
type ReaderBook,
|
||||||
|
type ReaderBrowserPage,
|
||||||
|
type ReaderChapter,
|
||||||
|
type ReaderChapterContent,
|
||||||
|
type ReaderContentComment,
|
||||||
|
type ReaderSearchOrigin,
|
||||||
|
} from '../../api/reader'
|
||||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||||
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||||
|
import BrowserPanel from './BrowserPanel'
|
||||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||||
import { ReaderComic } from './ReaderComic'
|
import { ReaderComic } from './ReaderComic'
|
||||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||||
@@ -63,6 +73,12 @@ const COMIC_SPREAD_MIN_WIDTH = 900
|
|||||||
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
|
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
|
||||||
const NO_IMAGES: string[] = []
|
const NO_IMAGES: string[] = []
|
||||||
|
|
||||||
|
/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
|
||||||
|
interface ReaderParagraphBlock {
|
||||||
|
text: string
|
||||||
|
comments: ReaderContentComment[]
|
||||||
|
}
|
||||||
|
|
||||||
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||||
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||||
if (i !== -1) return i
|
if (i !== -1) return i
|
||||||
@@ -93,6 +109,8 @@ export default function ReaderViewPage() {
|
|||||||
const [switchLoading, setSwitchLoading] = useState(false)
|
const [switchLoading, setSwitchLoading] = useState(false)
|
||||||
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
|
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
|
||||||
|
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||||
const topBarRef = useRef<HTMLDivElement>(null)
|
const topBarRef = useRef<HTMLDivElement>(null)
|
||||||
const [menuInset, setMenuInset] = useState(0)
|
const [menuInset, setMenuInset] = useState(0)
|
||||||
@@ -715,15 +733,59 @@ export default function ReaderViewPage() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
|
// 正文按行组织成「段落块」:保留原始行号(段评锚点按行号挂靠),
|
||||||
|
// 只丢掉既无文字又无段评的空行。
|
||||||
|
const blocks = useMemo<ReaderParagraphBlock[]>(() => {
|
||||||
|
const lines = (content ?? '').split('\n')
|
||||||
|
const byLine = new Map<number, ReaderContentComment[]>()
|
||||||
|
for (const c of media?.type === 'text' ? (media.comments ?? []) : []) {
|
||||||
|
const list = byLine.get(c.line)
|
||||||
|
if (list) list.push(c)
|
||||||
|
else byLine.set(c.line, [c])
|
||||||
|
}
|
||||||
|
const out: ReaderParagraphBlock[] = []
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const text = lines[i].trim()
|
||||||
|
const comments = byLine.get(i) ?? []
|
||||||
|
if (text === '' && comments.length === 0) continue
|
||||||
|
out.push({ text, comments })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [content, media])
|
||||||
|
|
||||||
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
|
// 打开段评:服务端带书源 Cookie 抓取评论页,这里用 iframe 承载。
|
||||||
const renderParagraph = (line: string, key: number) => {
|
// 前端直接 window.open 会以未登录身份访问,评论接口拿不到数据。
|
||||||
if (line.startsWith(IMG_MARK)) {
|
const openComment = useCallback(
|
||||||
|
async (comment: ReaderContentComment) => {
|
||||||
|
if (!book) return
|
||||||
|
if (!comment.url) {
|
||||||
|
toast('这条评论没有可用地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const page = await readerAPI.openContentComment({
|
||||||
|
book_id: book.id,
|
||||||
|
url: comment.url,
|
||||||
|
title: comment.label || '段评',
|
||||||
|
})
|
||||||
|
setBrowserPage(page)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '打开段评失败',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[book],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
|
||||||
|
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
|
||||||
|
const { text, comments } = block
|
||||||
|
if (text.startsWith(IMG_MARK)) {
|
||||||
return (
|
return (
|
||||||
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
|
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
|
||||||
<img
|
<img
|
||||||
src={line.slice(IMG_MARK.length)}
|
src={text.slice(IMG_MARK.length)}
|
||||||
alt=""
|
alt=""
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
|
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
|
||||||
@@ -731,9 +793,44 @@ export default function ReaderViewPage() {
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const standalone = text === '' && comments.length > 0
|
||||||
return (
|
return (
|
||||||
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
<p
|
||||||
{line}
|
key={key}
|
||||||
|
style={{
|
||||||
|
textIndent: text ? '2em' : undefined,
|
||||||
|
marginBottom: settings.paragraphSpacing,
|
||||||
|
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
{comments.map((c, ci) => (
|
||||||
|
<button
|
||||||
|
key={`${c.line}-${ci}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
|
||||||
|
// 点气泡会顺带翻页或呼出菜单。
|
||||||
|
e.stopPropagation()
|
||||||
|
void openComment(c)
|
||||||
|
}}
|
||||||
|
title={c.label || '段评'}
|
||||||
|
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
|
||||||
|
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
|
||||||
|
style={{
|
||||||
|
borderColor: theme.accent,
|
||||||
|
color: theme.accent,
|
||||||
|
fontSize: Math.max(10, settings.fontSize - 4),
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '2px 6px',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare size={11} />
|
||||||
|
{c.count > 0 && <span>{c.count}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -785,8 +882,16 @@ export default function ReaderViewPage() {
|
|||||||
) : settings.pageMode === 'page' ? (
|
) : settings.pageMode === 'page' ? (
|
||||||
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
|
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
|
||||||
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
|
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
|
||||||
不改高度也就不触发重新分页) */
|
不改高度也就不触发重新分页)。
|
||||||
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
|
relative + z-index + pointer-events-none:正文容器带 transform,
|
||||||
|
本身就形成一个层叠上下文,段评气泡的 z-index 只在容器内部生效,
|
||||||
|
会被后面的点击覆盖层压住。把整层抬到覆盖层之上并关闭指针事件,
|
||||||
|
只让气泡自己 pointer-events:auto,气泡可点、其余位置照样透给
|
||||||
|
覆盖层翻页/呼出菜单。 */
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none relative z-[5] h-full px-4 py-2 ${MENU_SHIFT}`}
|
||||||
|
style={menuShiftStyle}
|
||||||
|
>
|
||||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
@@ -801,7 +906,7 @@ export default function ReaderViewPage() {
|
|||||||
lineHeight: settings.lineHeight,
|
lineHeight: settings.lineHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -825,13 +930,13 @@ export default function ReaderViewPage() {
|
|||||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||||
>
|
>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载 / 错误 / 空内容态 */}
|
{/* 加载 / 错误 / 空内容态 */}
|
||||||
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
|
{(loadingStage !== null || (content !== null && blocks.length === 0)) && contentType === 'text' && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
|
||||||
{loadingStage !== null ? (
|
{loadingStage !== null ? (
|
||||||
<Loader2 className="animate-spin opacity-60" size={24} />
|
<Loader2 className="animate-spin opacity-60" size={24} />
|
||||||
@@ -865,7 +970,7 @@ export default function ReaderViewPage() {
|
|||||||
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
|
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
|
||||||
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
|
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
|
||||||
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
|
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
|
||||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
|
||||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1172,6 +1277,9 @@ export default function ReaderViewPage() {
|
|||||||
onClose={() => setSwitchOpen(false)}
|
onClose={() => setSwitchOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 段评评论页:复用书源页面的承载面板(带书源 Cookie 由服务端抓取) */}
|
||||||
|
{browserPage && <BrowserPanel page={browserPage} onClose={() => setBrowserPage(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,16 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
|||||||
const pollBrowserPages = useCallback(async () => {
|
const pollBrowserPages = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const pages = await readerAPI.browserPending(sourceId)
|
const pages = await readerAPI.browserPending(sourceId)
|
||||||
const next = pages.find((p) => !handledPages.current.has(p.id))
|
// 只取本次还没处理过的页面,并按 seq 取最新登记的那个。
|
||||||
if (next) setBrowserPage(next)
|
// 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序
|
||||||
|
// 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上
|
||||||
|
// (光遇聚合「更新书源」残留后串页就是这么来的)。
|
||||||
|
const next = pages
|
||||||
|
.filter((p) => !handledPages.current.has(p.id))
|
||||||
|
.reduce<ReaderBrowserPage | null>((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null)
|
||||||
|
if (!next) return
|
||||||
|
// 已经在展示的页面不要被更旧的页面替换掉
|
||||||
|
setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next))
|
||||||
} catch {
|
} catch {
|
||||||
// 轮询失败不打断正在执行的动作
|
// 轮询失败不打断正在执行的动作
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user