diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index f5ac771..4b2e0f2 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -375,6 +375,12 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st if err != nil { return "", "", fmt.Errorf("打开页面失败: %w", err) } + // 空响应没有可展示的内容:以前照样包一层空
交给 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 接口):包一层 ,至少让用户看到内容
diff --git a/internal/service/reader/rule/analyzer.go b/internal/service/reader/rule/analyzer.go
index 2b25483..647e03a 100644
--- a/internal/service/reader/rule/analyzer.go
+++ b/internal/service/reader/rule/analyzer.go
@@ -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 字符引用:命名实体(&、 )、十进制(')、
+// 十六进制(')。分号可选——旧式简写( 、&)在 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 实体(`&` → `&`、`'` → `'`)。
+//
+// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
+// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
+// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
+// `…?item_id=X¶=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) {
diff --git a/internal/service/reader/rule/analyzer_entity_test.go b/internal/service/reader/rule/analyzer_entity_test.go
new file mode 100644
index 0000000..03d0df5
--- /dev/null
+++ b/internal/service/reader/rule/analyzer_entity_test.go
@@ -0,0 +1,80 @@
+package rule
+
+import (
+ "strings"
+ "testing"
+)
+
+// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 ¶ 吃成
+// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
+// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
+func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "段评地址里的 ¶ 不能被当成实体",
+ in: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
+ want: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
+ },
+ {
+ name: "¬ 开头的参数名同样不能被吃掉",
+ in: "https://example.com/api?book=1¬ify=1",
+ want: "https://example.com/api?book=1¬ify=1",
+ },
+ {
+ name: "带分号的实体照旧还原",
+ in: "url?a=1&b=2&c=3",
+ want: "url?a=1&b=2&c=3",
+ },
+ {
+ name: "数字实体照旧还原",
+ in: "'引号' 'x' ©",
+ want: "'引号' 'x' ©",
+ },
+ {
+ name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
+ in: "第一段 第二段© 2026×",
+ 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 的规则结果同样不能把段评地址里的 ¶ 吃掉
+// (光遇聚合的正文规则是 … $.content 形态,最终取 $.content)。
+func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
+ const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ¶=11&source=QQ%E9%98%85%E8%AF%BB"
+ payload := `{"content":"正文
"}`
+
+ 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, "¶=11") {
+ t.Fatalf("段评地址里的 ¶=11 被改写了: %q", got)
+ }
+ if strings.Contains(got, "\u00b6") {
+ t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
+ }
+}
diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx
index 7661b75..9bb64e8 100644
--- a/web/src/pages/reader/ReaderViewPage.tsx
+++ b/web/src/pages/reader/ReaderViewPage.tsx
@@ -271,6 +271,10 @@ export default function ReaderViewPage() {
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
const ac = new AbortController()
;(async () => {
+ // 每次重新取正文都先清掉上一次的报错。换源时的典型情形:服务端章节缓存刚被清空、
+ // 而本地 chapters 还是旧源的,这一次请求必然失败(「章节缓存为空」);等新源目录
+ // 落地后正文 effect 会再来一次并且成功,但不清这里的话,旧错误会一直压在正文上。
+ setError('')
setContent(null)
setLoadingStage('content')
try {
@@ -556,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 ?? '换源失败')