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 ?? '换源失败')