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. 段落里的 :宿主(苹果/源阅分支) // 直接把它解析成段落旁的评论气泡; // 2. 「改版」宿主不认 ,改由书源在正文规则里把它换成一张内嵌 SVG 图片, // 图片地址后面再跟一段 JSON 配置: // // 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 匹配正文里的 标签。属性顺序不定、可能自闭合, // 也可能带一个配对结束标签(`…`),一并吃掉。 var commentTagRe = regexp.MustCompile(`(?is)]*?/?>(?:)?`) // 下面三条属性正则都允许单/双引号两种写法;捕获组 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 的调用里取第一个字符串参数。 // 苹果分支的 就靠它取地址。 // 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)]*>\s*([0-9]+)\s*\+?\s*`) // wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。 var wrapMarkupRe = regexp.MustCompile(`(?is)]*>`) // extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。 // // 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。 func extractContentComments(content string) (string, []ContentComment) { if content == "" || (!strings.Contains(content, " 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 一行里一个 标签的位置与 src 值。 type imgTagMatch struct { start, end int src string } // scanImgTags 扫描一行里的所有 标签,返回标签位置与 src 值。 // // 不能用 ]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成 // —— 属性值里的 JSON 又用了双引号, // 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号, // 只把「花括号之外遇到的引号」当作属性值的结束。 func scanImgTags(line string) []imgTagMatch { var out []imgTagMatch lower := strings.ToLower(line) for i := 0; i < len(line); { idx := strings.Index(lower[i:], "')。 func scanImgTagSrc(line string, start int) (string, int, bool) { lower := strings.ToLower(line) i := start + 4 // 跳过 "= 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 解析宿主侧的 形式。 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() }