feedscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. feedscope/__init__.py +3 -0
  2. feedscope/__main__.py +4 -0
  3. feedscope/cli.py +154 -0
  4. feedscope/core/__init__.py +1 -0
  5. feedscope/core/browser.py +367 -0
  6. feedscope/core/config.py +367 -0
  7. feedscope/core/const.py +64 -0
  8. feedscope/core/display.py +62 -0
  9. feedscope/core/http.py +104 -0
  10. feedscope/core/i18n/__init__.py +202 -0
  11. feedscope/core/i18n/en.toml +447 -0
  12. feedscope/core/i18n/zh-CN.toml +444 -0
  13. feedscope/core/media.py +150 -0
  14. feedscope/core/models.py +86 -0
  15. feedscope/core/registry.py +53 -0
  16. feedscope/core/samples.py +68 -0
  17. feedscope/core/source.py +668 -0
  18. feedscope/core/text.py +54 -0
  19. feedscope/core/timefmt.py +53 -0
  20. feedscope/core/tree.py +134 -0
  21. feedscope/doctor.py +54 -0
  22. feedscope/login.py +168 -0
  23. feedscope/probe.py +131 -0
  24. feedscope/report.py +19 -0
  25. feedscope/sources/__init__.py +38 -0
  26. feedscope/sources/hn/__init__.py +66 -0
  27. feedscope/sources/hn/fetch.py +82 -0
  28. feedscope/sources/hn/parse.py +154 -0
  29. feedscope/sources/reddit/__init__.py +178 -0
  30. feedscope/sources/reddit/fetch.py +181 -0
  31. feedscope/sources/reddit/parse.py +318 -0
  32. feedscope/sources/rss/__init__.py +64 -0
  33. feedscope/sources/rss/fetch.py +94 -0
  34. feedscope/sources/rss/parse.py +116 -0
  35. feedscope/sources/telegram/__init__.py +61 -0
  36. feedscope/sources/telegram/fetch.py +79 -0
  37. feedscope/sources/telegram/parse.py +182 -0
  38. feedscope/sources/v2ex/__init__.py +64 -0
  39. feedscope/sources/v2ex/fetch.py +46 -0
  40. feedscope/sources/v2ex/parse.py +116 -0
  41. feedscope/sources/x/__init__.py +68 -0
  42. feedscope/sources/x/fetch.py +490 -0
  43. feedscope/sources/x/parse.py +535 -0
  44. feedscope/sources/zhihu/__init__.py +81 -0
  45. feedscope/sources/zhihu/fetch.py +402 -0
  46. feedscope/sources/zhihu/parse.py +235 -0
  47. feedscope/subscriptions.py +423 -0
  48. feedscope/ui/__init__.py +10 -0
  49. feedscope/ui/account.py +125 -0
  50. feedscope/ui/app.py +105 -0
  51. feedscope/ui/dialogs.py +127 -0
  52. feedscope/ui/feedback.py +64 -0
  53. feedscope/ui/focus_nav.py +113 -0
  54. feedscope/ui/labels.py +38 -0
  55. feedscope/ui/layout.py +66 -0
  56. feedscope/ui/list_render.py +42 -0
  57. feedscope/ui/screens/__init__.py +1 -0
  58. feedscope/ui/screens/answer.py +296 -0
  59. feedscope/ui/screens/detail.py +274 -0
  60. feedscope/ui/screens/language.py +59 -0
  61. feedscope/ui/screens/main.py +736 -0
  62. feedscope/ui/screens/main_setup.py +226 -0
  63. feedscope/ui/screens/reading.py +384 -0
  64. feedscope/ui/screens/source_setup.py +217 -0
  65. feedscope/ui/screens/subscriptions.py +196 -0
  66. feedscope/ui/theme.py +45 -0
  67. feedscope/ui/wrap.py +157 -0
  68. feedscope-0.1.0.dist-info/METADATA +302 -0
  69. feedscope-0.1.0.dist-info/RECORD +72 -0
  70. feedscope-0.1.0.dist-info/WHEEL +4 -0
  71. feedscope-0.1.0.dist-info/entry_points.txt +2 -0
  72. feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,402 @@
1
+ """知乎取数层:信息流走免签 API(常驻页签的页面 fetch,统一原语见
2
+ core/browser.ChannelTab.fetch),详情纯 API。
3
+
4
+ 热榜/推荐/关注 2026-11 实测都有免签接口(只认 cookie,推荐/关注就是页面
5
+ 自己在调的),请求由常驻 zhihu.com 通道页签的 fetch 发出——真 Chrome 的
6
+ TLS/自动头/cookie 与浏览器会话完全自洽(风控一致性优先,比 httpx 直调
7
+ 安全)。回答翻页走 feeds API、评论走 comment_v5(同通道 fetch)。浏览器
8
+ 页签约定:长活页签由 Loader 持有,close() 幂等关闭。raw dict / JSON ->
9
+ 模型在 parse.py,编排在包 __init__。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any
16
+
17
+ from feedscope.core.browser import ChannelTab
18
+ from feedscope.core.models import Answer, Comment, DetailDoc, FeedItem
19
+ from feedscope.core.i18n import raw, t
20
+ from feedscope.core.source import (
21
+ CommentLoader,
22
+ DetailLoader,
23
+ FeedLoader,
24
+ LoginRequired,
25
+ SourceError,
26
+ dedup_append,
27
+ dedup_by,
28
+ )
29
+ from feedscope.sources.zhihu.parse import (
30
+ _api_comments,
31
+ _api_question_doc,
32
+ _feed_api_item,
33
+ _feeds_answers,
34
+ _hot_api_items,
35
+ )
36
+
37
+ # ---- 页面上下文 fetch(通道页签里执行的 JS:API 请求由真 Chrome 发出)----
38
+ # 不模拟点按钮/开浮层/滚屏:DOM 交互无法完整触发分页(2026-11 实测触底
39
+ # 不发分页请求),API 有标准游标分页(paging.next),同源 fetch 带 cookie。
40
+ # x-zse-93 是客户端版本声明(静态常量、页面每个 API 请求都带),但按端点
41
+ # 区别对待(2026-11 实测矩阵):v4/questions 元信息端点必须带(否则 403
42
+ # 版本门禁 code 10003);其余端点带上无害;唯独 feeds 无 cursor 入口反而
43
+ # 拒绝它——故按调用方显式传入,不全局统一。x-zse-96 签名一律不需要。
44
+ _X_ZSE_93 = "101_3_3.0" # 知乎客户端版本声明(静态常量;三处使用:_headers 与两条复合 JS)
45
+
46
+ # 「N 条评论」= 根评论 + 子回复总数:root_comment 只带每根的热门子回复
47
+ # (1-2 条),其余靠 comment/{id}/child_comment 游标分页补全。
48
+ _CM_FIRST_URL = "https://www.zhihu.com/api/v4/comment_v5/answers/{aid}/root_comment?order_by=score&limit=20&offset="
49
+
50
+ # 子回复补全(一条根评论一串游标翻页,根与根之间 Promise.all 并发——
51
+ # 串行 fetch 时热门楼层几十条子回复能把首页拖到 20s+)。offset 为空 =
52
+ # 从头拉(与 root 首页同语义);seen 过滤已带出的热门子回复防重复。
53
+ _COMPLETE_CHILDREN_JS = """
54
+ async (roots) => {
55
+ const out = {};
56
+ await Promise.all(roots.map(async (r) => {
57
+ const seen = new Set(r.seen || []);
58
+ const kids = [];
59
+ let url = `https://www.zhihu.com/api/v4/comment_v5/comment/${r.id}/child_comment?limit=50&offset=${r.offset}`;
60
+ for (let i = 0; i < 6; i++) { // limit=50 一页基本到底;留翻页余地
61
+ try {
62
+ const resp = await fetch(url, {
63
+ headers: { "x-requested-with": "fetch", "x-zse-93": "__ZSE__" },
64
+ credentials: "include",
65
+ });
66
+ if (!resp.ok) break;
67
+ const d = await resp.json();
68
+ kids.push(...(d.data || []).filter((k) => !k.is_delete && !seen.has(String(k.id))));
69
+ url = d.paging && d.paging.next;
70
+ if (!url || d.paging.is_end) break;
71
+ } catch (e) { break; }
72
+ }
73
+ out[r.id] = kids;
74
+ }));
75
+ return out;
76
+ }
77
+ """.replace("__ZSE__", _X_ZSE_93)
78
+
79
+
80
+ def _raise_compound_error(data: Any) -> None:
81
+ """复合脚本(一次 evaluate 多请求)的返回自检:__status/__error 键转 SourceError。
82
+
83
+ 单请求已走 ChannelTab.fetch 的统一解包;这里只剩 _OPEN_QUESTION_JS 的
84
+ 嵌套形态({meta: {...}, feeds: {...}},各自的 __status 分开查)。
85
+ """
86
+ if not isinstance(data, dict):
87
+ raise SourceError(t("errors.zhihu_api_bad_data", kind=type(data).__name__))
88
+ if status := data.get("__status"):
89
+ raise SourceError(t("errors.zhihu_api_status", status=status))
90
+ if err := data.get("__error"):
91
+ raise SourceError(t("errors.zhihu_api_failed", error=err))
92
+
93
+
94
+ def _headers(zse93: bool) -> dict[str, str]:
95
+ """知乎 API 的手动头(浏览器不会自动带的):x-zse-93 按端点区别对待,见上注释。"""
96
+ h = {"x-requested-with": "fetch"}
97
+ if zse93:
98
+ h["x-zse-93"] = _X_ZSE_93
99
+ return h
100
+
101
+
102
+ async def _fetch_json(url: str, referrer: str | None = None, zse93: bool = False) -> dict:
103
+ """通道页签里 fetch JSON API -> JSON body(dict);网络/结构失败转 SourceError。
104
+
105
+ 取数原语与解包约定见 ChannelTab.fetch(知乎/Reddit/X 三源共用)。referrer
106
+ 不传 = 页签自然 referer(评论用:通道页 URL);信息流传对应页面 URL,
107
+ 与页面请求同形。401/403 的语义转译归 _api_json/调用方。
108
+ """
109
+ status, body = await _tab.fetch(
110
+ url, source=t("sources.zhihu.label"), headers=_headers(zse93), referrer=referrer
111
+ )
112
+ if status:
113
+ raise SourceError(t("errors.zhihu_api_status", status=status))
114
+ # 顶层不是 JSON 对象(登录墙 HTML 等)由 ChannelTab.fetch 统一报结构异常
115
+ return body or {}
116
+
117
+
118
+ async def _comment_page(url: str) -> tuple[list[Comment], str, bool]:
119
+ """拉一页根评论(先把带隐藏子回复的根并发补全);返回 (评论, next_url, is_end)。
120
+
121
+ 「还有没有子评论可拉」没有现成标识(2026-11 实测):can_unfold 在
122
+ root_comment 列表响应里恒 False(不是这个语义);child_comment_next_offset
123
+ 只在「已带出>=1 条且后面还有」时给值,覆盖不了「一条没带但 count>0」
124
+ 的形态——缺口只能用 child_comment_count - len(child_comments) 算,
125
+ 这两个字段是服务端维护的准确记录。
126
+
127
+ 无缺口(count<=带出数:无子评论/子评论已全带出)不发请求,有缺口
128
+ 就拉全(并发下一次请求,留着「另有 1 条回复」的尾巴比多花一次请求
129
+ 更伤阅读);补全失败(限流/风控)时静默保留接口已带出的热门子回复。
130
+ """
131
+ data = await _fetch_json(url)
132
+
133
+ def _gap(r: dict) -> int:
134
+ if r.get("is_delete"):
135
+ return 0
136
+ return (r.get("child_comment_count") or 0) - len(r.get("child_comments") or [])
137
+
138
+ pending = [
139
+ {
140
+ "id": r.get("id"),
141
+ "offset": r.get("child_comment_next_offset") or "", # 空 = 从头拉
142
+ "seen": [str(k.get("id")) for k in r.get("child_comments") or []],
143
+ }
144
+ for r in data.get("data") or []
145
+ if _gap(r) >= 1
146
+ ]
147
+ if pending:
148
+ try:
149
+ filled: dict = await _tab.eval(_COMPLETE_CHILDREN_JS, pending) or {}
150
+ for r in data.get("data") or []:
151
+ kids = filled.get(str(r.get("id"))) or filled.get(r.get("id"))
152
+ if kids:
153
+ r.setdefault("child_comments", []).extend(kids)
154
+ except Exception:
155
+ pass # 补全失败:热门子集兜底
156
+ paging = data.get("paging") or {}
157
+ return _api_comments(data), paging.get("next") or "", bool(paging.get("is_end"))
158
+
159
+
160
+ # ---- 信息流 API(热榜/推荐/关注:v3 免签接口,只认 cookie)----
161
+ # 2026-11 实测:这几个接口不查 x-zse-96 签名(那是 v4 的要求),页面自己
162
+ # 首请求打的正是推荐/关注这两个 URL(limit=10&desktop=true 逐字相同),
163
+ # 有 paging.next 游标。热榜页是 SSR 不发列表请求——全部流量统一走 www 网关
164
+ # (api.zhihu.com/topstory/hot-list 是同一份数据的 App 域孪生门,已弃),
165
+ # 30 条上限无翻页(limit 参数无效,www/api.zhihu.com 两门实测等价)。热榜
166
+ # 元信息列放 detail_text 热度——提问人拿不到(DOM 无作者节点,API 的
167
+ # author 也是 id=0 占位),维持「放热度不放人名」的形态。
168
+ HOT_API = "https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50"
169
+ RECOMMEND_API = "https://www.zhihu.com/api/v3/feed/topstory/recommend?limit=10&desktop=true"
170
+ FOLLOW_API = "https://www.zhihu.com/api/v3/moments?limit=10&desktop=true"
171
+
172
+ # 各流的 referer = 页面自己发该请求时所在页面(fetch 的 referrer 选项指定)。
173
+ _CHANNEL_URL = "https://www.zhihu.com/" # 通道页签停在 www 源上(cookie/CORS 的前提)
174
+
175
+
176
+ def _answer_count_pattern() -> str:
177
+ """meta 里回读回答总数的正则:由 meta.zhihu_n_answers 模板生成(占位符
178
+ 换捕获组),与 parse 层写入同键同语言,回读永远对得上。"""
179
+ return re.escape(raw("words.zhihu_n_answers")).replace(re.escape("{n}"), r"(\d+)")
180
+
181
+
182
+ async def _login_wall(page: Any) -> None:
183
+ """通道页签开页校验:goto 后确认没撞上登录墙(2026 起热榜也在墙后)。"""
184
+ if "signin" in page.url:
185
+ raise LoginRequired(t("errors.zhihu_login_wall", label=t("sources.zhihu.label")))
186
+
187
+
188
+ _tab = ChannelTab(
189
+ _CHANNEL_URL, check=_login_wall, name="zhihu"
190
+ ) # 单例/重建/在途跳转重试见 core.browser
191
+
192
+
193
+ async def _api_json(url: str, referer: str, zse93: bool = False) -> dict:
194
+ """API 一次通道页签 fetch -> JSON;401 = 会话过期转 LoginRequired(403 可能是风控,不并入)。"""
195
+ try:
196
+ return await _fetch_json(url, referrer=referer, zse93=zse93)
197
+ except SourceError as exc:
198
+ if "HTTP 401" in str(exc):
199
+ raise LoginRequired(
200
+ t("errors.zhihu_session_expired", label=t("sources.zhihu.label"))
201
+ ) from exc
202
+ raise
203
+
204
+
205
+ async def fetch_hot_list(referer: str) -> list[FeedItem]:
206
+ """热榜一次 API 调用拿全量(30 条上限,无翻页)。"""
207
+ return _hot_api_items(await _api_json(HOT_API, referer))
208
+
209
+
210
+ # ---- 问题详情(纯 API,全程不加载页面)----
211
+ # 元信息:v4/questions/{qid}(include 把 detail/author/answer_count 等全带出,
212
+ # 2026-11 实测)。该端点的 403 是客户端版本门禁(err code 10003「请升级
213
+ # 客户端」),不是签名门禁:带 x-zse-93(_X_ZSE_93 静态常量,页面每个
214
+ # API 请求都带,已在 _headers / 复合 JS 统一注入)即放行,x-zse-96 不需要。
215
+ # 回答:questions/{qid}/feeds 无 cursor 从头拉(与 SSR 首批一致,实测),
216
+ # 之后一路 paging.next;include 是页面请求原文(勿手改,改版失效时重新
217
+ # 抓包替换)。
218
+ QUESTION_API = (
219
+ "https://www.zhihu.com/api/v4/questions/{qid}"
220
+ "?include=detail%2Cexcerpt%2Canswer_count%2Cfollower_count%2Cvisit_count%2Cauthor%2Ccreated"
221
+ )
222
+ # 回答:feeds 无 cursor 从头拉(与 SSR 首批一致,实测)。注意无 cursor 入口
223
+ # 只吃这个精简 include 集——页面原文的完整 include 不带 cursor 会 403
224
+ # (2026-11 实测);翻页跟服务端 paging.next(自带全套参数),无需长串。
225
+ _ANSWERS_FEEDS_TOP = (
226
+ "https://www.zhihu.com/api/v4/questions/{qid}/feeds"
227
+ "?include=data%5B%2A%5D.is_normal%2Ccomment_count%2Ccan_comment%2Ccontent%2Cvoteup_count%2C"
228
+ "comment_permission%2Ccreated_time%2Cupdated_time%2Cquestion%2Cexcerpt%3B"
229
+ "data%5B%2A%5D.author.follower_count%2Cvip_info"
230
+ "&limit=5&order=default&platform=desktop"
231
+ )
232
+
233
+ # 开题一次 evaluate 并行拉元信息 + 首批回答(两请求独立,串行白等一个 RTT);
234
+ # 版本头按端点契约:元信息带 x-zse-93(否则 403),feeds 无 cursor 入口不带(带了反而 403)
235
+ _OPEN_QUESTION_JS = """
236
+ async ([metaUrl, feedsUrl, referer]) => {
237
+ const go = async (u, with93) => {
238
+ try {
239
+ const headers = { "x-requested-with": "fetch" };
240
+ if (with93) headers["x-zse-93"] = "__ZSE__";
241
+ const r = await fetch(u, { headers, credentials: "include", referrer: referer });
242
+ if (!r.ok) return { __status: r.status };
243
+ return await r.json();
244
+ } catch (e) { return { __error: String(e) }; }
245
+ };
246
+ const [meta, feeds] = await Promise.all([go(metaUrl, true), go(feedsUrl, false)]);
247
+ return { meta, feeds };
248
+ }
249
+ """.replace("__ZSE__", _X_ZSE_93)
250
+
251
+
252
+ # ---- 页面操作 ----
253
+
254
+
255
+ async def open_question(qid: str) -> DetailLoader:
256
+ """问题详情纯 API:元信息 questions API + 首批回答 feeds 无 cursor 从头拉。
257
+
258
+ 全程不加载问题页面——通道页签发两个请求即得(提问人/描述/计数 + 完整
259
+ 内容的前 5 条回答),之后 load_more 沿 paging.next 翻。
260
+ """
261
+ referer = f"https://www.zhihu.com/question/{qid}"
262
+ args = [QUESTION_API.format(qid=qid), _ANSWERS_FEEDS_TOP.format(qid=qid), referer]
263
+ r = await _tab.eval(_OPEN_QUESTION_JS, args) # 页签失效重建重试见 ChannelTab.eval
264
+ if not isinstance(r, dict) or "meta" not in r or "feeds" not in r:
265
+ raise SourceError(t("errors.zhihu_api_structure", label=t("sources.zhihu.label")))
266
+ try:
267
+ _raise_compound_error(r["meta"])
268
+ except SourceError as exc:
269
+ if "404" in str(exc): # 元信息端点 404 = 问题本身没了,别让用户看 HTTP 码猜
270
+ raise SourceError(
271
+ t("errors.zhihu_question_gone", label=t("sources.zhihu.label"))
272
+ ) from exc
273
+ raise
274
+ _raise_compound_error(r["feeds"])
275
+ doc = _api_question_doc(r["meta"], _feeds_answers(r["feeds"], qid))
276
+ paging = r["feeds"].get("paging") or {}
277
+ return ZhihuQuestionLoader(doc, paging.get("next") or None, referer, qid)
278
+
279
+
280
+ # ---- 增量 Loader(持活页签,close() 幂等)----
281
+
282
+
283
+ class ZhihuApiFeedLoader(FeedLoader):
284
+ """游标分页 API 的信息流 Loader(推荐/关注):请求走常驻通道页签。
285
+
286
+ 不持有页签(通道是进程级单例,随会话生灭);done 只在响应明示没有下一页
287
+ (is_end / next 空)时为真——拉空可能只是抖动,n 可再试;按 question id
288
+ 去重与首批共用 dedup_append。
289
+ """
290
+
291
+ def __init__(self, first_url: str, referer: str) -> None:
292
+ self.items: list[FeedItem] = []
293
+ self._seen: set[str] = set()
294
+ self._next = first_url
295
+ self._referer = referer # 与页面请求同形:按所在页面指定 referer
296
+ self._done = False
297
+
298
+ @property
299
+ def done(self) -> bool:
300
+ return self._done
301
+
302
+ async def load_more(self) -> list[FeedItem]:
303
+ if self._done or not self._next:
304
+ self._done = True
305
+ return []
306
+ data = await _api_json(self._next, self._referer)
307
+ paging = data.get("paging") or {}
308
+ self._next = paging.get("next") or ""
309
+ if not self._next or paging.get("is_end"):
310
+ self._done = True
311
+ return dedup_append(
312
+ self.items, self._seen, (_feed_api_item(r) for r in data.get("data") or [])
313
+ )
314
+
315
+
316
+ class ZhihuQuestionLoader(DetailLoader):
317
+ """问题详情:纯 API(元信息 + 回答 feeds 游标翻页),不持有任何页签。
318
+
319
+ 回答按 url 去重防排序抖动;done 由 is_end/无 next/总数(meta 里的
320
+ 「N 个回答」)判定——拉空可能只是抖动,n 可再试。评论层共享通道页签。
321
+ """
322
+
323
+ def __init__(self, doc: DetailDoc, next_url: str | None, referer: str, qid: str) -> None:
324
+ self.doc = doc
325
+ self._next = next_url
326
+ self._referer = referer
327
+ self._qid = qid # 翻页 URL 只带游标,回答里的 question 链接要本问题的 id
328
+ self._seen_urls = {a.url for a in doc.answers if a.url}
329
+ self._done = not next_url
330
+ m = re.search(_answer_count_pattern(), doc.meta or "")
331
+ self._total = int(m.group(1)) if m else None
332
+
333
+ @property
334
+ def done(self) -> bool:
335
+ return self._done
336
+
337
+ async def load_more(self) -> list[Answer]:
338
+ if self._done or self._next is None:
339
+ self._done = True
340
+ return []
341
+ data = await _api_json(self._next, self._referer)
342
+ # 回答按 url 去重防排序抖动(dedup_by:空 url 不去重直接放行)
343
+ new = dedup_by(_feeds_answers(data, self._qid), self._seen_urls, key=lambda a: a.url)
344
+ self.doc.answers.extend(new) # 维持「doc = 已加载全部」的不变量
345
+ paging = data.get("paging") or {}
346
+ self._next = paging.get("next") or None
347
+ if (
348
+ not self._next
349
+ or paging.get("is_end")
350
+ or self._total is not None
351
+ and len(self.doc.answers) >= self._total
352
+ ):
353
+ self._done = True
354
+ return new
355
+
356
+ async def open_comments(self, index: int) -> ZhihuCommentsLoader:
357
+ """按需抓第 index 个回答的评论首页(进评论层才调,走通道页签)。"""
358
+ if not 0 <= index < len(self.doc.answers):
359
+ raise SourceError(t("errors.zhihu_answer_out_of_range", label=t("sources.zhihu.label")))
360
+ m = re.search(r"/answer/(\d+)", self.doc.answers[index].url or "")
361
+ if not m:
362
+ raise SourceError(t("errors.zhihu_answer_no_id", label=t("sources.zhihu.label")))
363
+ try:
364
+ comments, next_url, _ = await _comment_page(_CM_FIRST_URL.format(aid=m.group(1)))
365
+ return ZhihuCommentsLoader(comments, next_url)
366
+ except SourceError:
367
+ raise
368
+ except Exception as exc: # 评论打不开不算灾难:给出口让 UI 提示
369
+ raise SourceError(
370
+ t("errors.zhihu_comments_failed", label=t("sources.zhihu.label"), error=exc)
371
+ ) from exc
372
+
373
+
374
+ class ZhihuCommentsLoader(CommentLoader):
375
+ """单条回答的评论加载器:纯 comment_v5 API 游标翻页(root_comment 每页
376
+ limit=20 条 + 子回复补全),n 一路按到 is_end。不持有页签(通道现取)。
377
+ """
378
+
379
+ def __init__(self, comments: list[Comment], next_url: str) -> None:
380
+ self.comments = comments
381
+ self._next = next_url
382
+ self._ids = {c.raw_id for c in comments}
383
+ self._done = not next_url # 没有下一页 = is_end
384
+
385
+ @property
386
+ def done(self) -> bool:
387
+ return self._done
388
+
389
+ async def load_more(self) -> list[Comment]:
390
+ if self._done or not self._next:
391
+ self._done = True
392
+ return []
393
+ try:
394
+ comments, next_url, is_end = await _comment_page(self._next)
395
+ except SourceError:
396
+ return [] # 单页失败不判死:可以再按 Ctrl+N 重试
397
+ new = dedup_by(comments, self._ids, key=lambda c: c.raw_id)
398
+ self.comments.extend(new)
399
+ self._next = next_url
400
+ if is_end or not next_url:
401
+ self._done = True
402
+ return new
@@ -0,0 +1,235 @@
1
+ """知乎解析层:DOM 提取结果 / API JSON / HTML -> 统一模型,纯函数,不碰网络与浏览器。
2
+
3
+ 本层只做「脏数据 -> FeedItem/Answer/markdown」的转换与清洗;取数在 fetch.py,
4
+ 编排在包 __init__。所有函数可直接用 fixture 测试。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from urllib.parse import parse_qs, urlparse
11
+
12
+ from feedscope.core.media import h2t as _h2t
13
+ from feedscope.core.display import fmt_count, view_count_text, votes_text
14
+ from feedscope.core.i18n import t
15
+ from feedscope.core.media import VIDEO_RE, Tokens, attrs, mark, pick_url, video_url
16
+ from feedscope.core.models import Answer, Comment, DetailDoc, FeedItem
17
+ from feedscope.core.text import strip_zwsp
18
+ from feedscope.core.timefmt import fmt_unix
19
+
20
+ # html2text 统一配置见 core/const.py(不硬换行;img/video 进它之前已换成记号)
21
+ # HTML2Text 实例经 media.h2t() 取(线程本地,见其 docstring),源内不自建
22
+
23
+ _NOSCRIPT_RE = re.compile(r"<noscript>.*?</noscript>", re.IGNORECASE | re.DOTALL)
24
+ # 表情贴纸(class="sticker")不是内容图:换成 alt 文本(如 "[大哭]"),
25
+ # 走 _IMG_RE 会变 [图片]+地址,那是看图不是看表情
26
+ _STICKER_RE = re.compile(r'<img[^>]*class="[^"]*\bsticker\b[^"]*"[^>]*>', re.IGNORECASE)
27
+ _ALT_RE = re.compile(r'alt="([^"]*)"')
28
+ _IMG_RE = re.compile(r"<img[^>]*>", re.IGNORECASE)
29
+ # img 地址按尺寸优先(2026-08 实测标记形态):data-original 原图 > data-actualsrc
30
+ # 显示图 > src(src 常是 data:image/svg+xml 懒加载占位,pick_url 会跳过 data:)。
31
+ # 2026-11 实测 data-original 整 URL 属性已没了(只剩 data-original-token),多数落到 src
32
+ _IMG_URL_ATTRS = ("data-original", "data-actualsrc", "src")
33
+ # 视频卡(2026-11 实测):content 里没有 <video> 标签,视频是 <a class="video-box">
34
+ # 卡片——href 是 link.zhihu.com 跳板,data-lens-id 是视频页 id,内部包封面缩略图
35
+ _VIDEO_BOX_RE = re.compile(r'<a\b[^>]*class="video-box"[^>]*>.*?</a>', re.IGNORECASE | re.DOTALL)
36
+ # zhimg 的 /50/ 是缩略代理路径(同 hash 内容更小),剥掉直达显示图
37
+ _ZHIMG_50_RE = re.compile(r"^(https://pic[\da-z]*\.zhimg\.com)/50/")
38
+
39
+
40
+ def _upgrade_img_url(url: str) -> str:
41
+ """图片地址微升级:剥 /50/ 缩略代理(实测同 hash 直连内容更大)。
42
+
43
+ 不猜原图尺寸:_r 原尺寸版 / 80/…_1440w 都只对部分 hash 存在(2026-11
44
+ 实测多数 405),盲改写会产出死链;src 的 _720w 本就是网页显示图。
45
+ """
46
+ return _ZHIMG_50_RE.sub(r"\1/", url)
47
+
48
+
49
+ def _video_box_md(block: str) -> str:
50
+ """video-box 卡片 -> [视频](视频页)。优先 data-lens-id 构造(跳板不参与,
51
+ 最稳);缺了才 decode href 跳板的 target 参数。封面缩略图随整卡吃掉。"""
52
+ m = re.match(r"<a\b[^>]*>", block)
53
+ a = attrs(m.group(0)) if m else {}
54
+ if lens := a.get("data-lens-id", ""):
55
+ return mark(t("media.video"), f"https://www.zhihu.com/video/{lens}")
56
+ href = a.get("href", "")
57
+ target = parse_qs(urlparse(href).query).get("target")
58
+ return mark(t("media.video"), target[0] if target else href)
59
+
60
+
61
+ def _md(html: str) -> str:
62
+ """HTML -> markdown:图片/视频换 [图片]/[视频] 链接(TUI 显示不了媒体,
63
+ 链接点击直达原图 / 视频页)、清零宽字符。"""
64
+ if not html:
65
+ return ""
66
+ html = _NOSCRIPT_RE.sub("", html) # noscript 里是 img 的重复副本
67
+
68
+ def _sticker_text(m: re.Match[str]) -> str:
69
+ a = _ALT_RE.search(m.group(0))
70
+ return a.group(1) if a else ""
71
+
72
+ html = _STICKER_RE.sub(_sticker_text, html) # 表情贴纸 -> "[大笑]" 文本
73
+ tok = Tokens()
74
+ html = _VIDEO_BOX_RE.sub( # 先于 img:整卡吃掉,封面缩略图不单独出 [图片]
75
+ lambda m: tok.stash(_video_box_md(m.group(0))), html
76
+ )
77
+ html = _IMG_RE.sub(
78
+ lambda m: tok.stash(
79
+ mark(t("media.image"), _upgrade_img_url(pick_url(m.group(0), _IMG_URL_ATTRS)))
80
+ ),
81
+ html,
82
+ )
83
+ html = VIDEO_RE.sub(lambda m: tok.stash(mark(t("media.video"), video_url(m.group(0)))), html)
84
+ text = strip_zwsp(tok.expand(_h2t().handle(html)))
85
+ # 相邻同图合并(懒加载双标记会把同一张图渲染两次);不同图各自保留。
86
+ # 链接形式 [图片](url) 整体比对;纯记号合并的 (?!\() 断言防止把带地址的
87
+ # [图片](…) 误吞成纯 [图片] 丢掉链接
88
+ img = re.escape(t("media.image")) # 记号随语言:正则由当前文案生成,回读永远对得上
89
+ text = re.sub(rf"({img}\([^)]*\))(?:[ \t]*\1)+", r"\1", text)
90
+ return re.sub(rf"({img}(?!\())(?:[ \t]*{img}(?!\())+", r"\1", text)
91
+
92
+
93
+ def _hot_api_items(data: dict) -> list[FeedItem]:
94
+ """热榜 API(www/api/v3/feed/topstory/hot-lists/total)响应 -> FeedItem 列表。
95
+
96
+ target.url 是 api.zhihu.com/questions/{id}(浏览器不可达的内部形态),
97
+ 统一重写为 www 问题页;元信息列放 detail_text(「2606 万热度」),与
98
+ 页面头部形态一致。接口上限 30 条且无翻页(limit 参数无效,2026-11 实测)。
99
+ 注意:本接口的 target.id 是未加引号的大整数(超 JS 安全整数 2^53),
100
+ 浏览器 r.json() 解析会丢精度(实测 …1699522 -> …1699500,进详情即 404);
101
+ target.url 是字符串、精确——id 一律从 url 提取,id 字段仅作兜底。
102
+ """
103
+ out: list[FeedItem] = []
104
+ for r in data.get("data") or []:
105
+ tgt = r.get("target") or {}
106
+ m = re.search(r"/questions?/(\d+)", tgt.get("url") or "")
107
+ qid = m.group(1) if m else str(tgt.get("id") or "")
108
+ title = strip_zwsp(tgt.get("title") or "")
109
+ if tgt.get("type") != "question" or not qid or not title:
110
+ continue
111
+ out.append(
112
+ FeedItem(
113
+ source="zhihu",
114
+ id=f"question/{qid}",
115
+ title=title,
116
+ url=f"https://www.zhihu.com/question/{qid}",
117
+ kind="question",
118
+ meta=strip_zwsp(r.get("detail_text") or ""),
119
+ )
120
+ )
121
+ return out
122
+
123
+
124
+ def _feed_api_item(r: dict) -> FeedItem | None:
125
+ """推荐/关注 feed API 条目 -> FeedItem;None = 丢弃。
126
+
127
+ 只留能进终端详情的问题卡(详情只支持问题页,文章/想法/视频等不展示):
128
+ - answer 卡(推荐 TOPIC_ACKNOWLEDGED_* / 关注 MEMBER_[VOTEUP_|ANSWER_]):
129
+ 标题取所属问题,作者取回答作者(与站方卡片展示的信息同源)
130
+ - question 卡(关注 MEMBER_ASK_QUESTION):标题直接在 target 上
131
+ - 其余(feed_group 分组卡、文章/想法/视频/广告)不进列表
132
+ """
133
+ t = r.get("target") or {}
134
+ if t.get("type") == "answer":
135
+ q = t.get("question") or {}
136
+ qid, title = str(q.get("id") or ""), strip_zwsp(q.get("title") or "")
137
+ author = strip_zwsp((t.get("author") or {}).get("name") or "")
138
+ elif t.get("type") == "question":
139
+ qid, title = str(t.get("id") or ""), strip_zwsp(t.get("title") or "")
140
+ author = strip_zwsp((t.get("author") or {}).get("name") or "")
141
+ else:
142
+ return None
143
+ if not qid or not title:
144
+ return None
145
+ return FeedItem(
146
+ source="zhihu",
147
+ id=f"question/{qid}",
148
+ title=title,
149
+ url=f"https://www.zhihu.com/question/{qid}",
150
+ kind="question",
151
+ meta=author, # 信息流元信息列放作者全名;列宽截断统一在 UI
152
+ )
153
+
154
+
155
+ def _api_question_doc(q: dict, answers: list[Answer]) -> DetailDoc:
156
+ """问题元信息 API(v4/questions/{qid}?include=…,2026-11 实测)-> DetailDoc。
157
+
158
+ meta 行「提问人 提问 · N 个回答 · N 万浏览」对齐页面头部形态;时间裸
159
+ 展示文本(与 X 详情同形,不带「提问于」前缀——各源元信息行/楼层头的
160
+ 时间统一裸时间,词汇归 core/timefmt 一处);描述 detail 是 HTML,走 _md。
161
+ """
162
+ asker = strip_zwsp((q.get("author") or {}).get("name") or t("words.anonymous"))
163
+ meta = t("words.zhihu_asked", asker=asker)
164
+ if n := q.get("answer_count"):
165
+ meta += t("words.zhihu_n_answers", n=n)
166
+ if visit := q.get("visit_count"):
167
+ meta += view_count_text(visit) # 万浏览/浏览的分支拼写收在 core.display
168
+ created = q.get("created") or 0
169
+ return DetailDoc(
170
+ title=strip_zwsp(q.get("title") or ""),
171
+ meta=meta,
172
+ time=fmt_unix(created),
173
+ description_md=_md(q.get("detail") or ""),
174
+ answers=answers,
175
+ )
176
+
177
+
178
+ def _api_comment(c: dict) -> Comment:
179
+ """comment_v5 JSON -> Comment(评论层数据全走 API,不碰 DOM)。"""
180
+ kids = c.get("child_comments") or []
181
+ likes = c.get("like_count") or 0
182
+ created = c.get("created_time") or 0
183
+ # root 接口 content 是纯文本,child 接口带 <p> 等 HTML:有标签才过 _md,
184
+ # 纯文本直用(_md 对纯文本可能做不必要的转义)
185
+ text = c.get("content") or ""
186
+ content_md = _md(text) if re.search(r"<[a-zA-Z]", text) else strip_zwsp(text)
187
+ return Comment(
188
+ author=strip_zwsp((c.get("author") or {}).get("name") or t("words.anonymous")),
189
+ votes=votes_text(likes),
190
+ content_md=content_md,
191
+ time=fmt_unix(created) if created else "",
192
+ replies=[_api_comment(r) for r in kids],
193
+ raw_id=str(c.get("id") or ""),
194
+ )
195
+
196
+
197
+ def _api_comments(data: dict) -> list[Comment]:
198
+ """root_comment 一页 -> 顶层评论列表(删掉的/仅自己可见的不算数)。"""
199
+ roots = [
200
+ c
201
+ for c in data.get("data") or []
202
+ if not c.get("is_delete") and not c.get("is_visible_only_to_myself")
203
+ ]
204
+ return [_api_comment(c) for c in roots]
205
+
206
+
207
+ def _feeds_answers(data: dict, qid: str) -> list[Answer]:
208
+ """回答 feeds API(questions/{qid}/feeds,2026-11 实测免签)一页 -> Answer 批次。
209
+
210
+ 展示词汇全 app 统一(core/display 唯一出处,与 X 回复楼层同形):
211
+ votes「赞 N」、time 裸北京时间、comment_count「N 条评论」;非 answer 卡
212
+ (罕见混入)跳过。
213
+ """
214
+ out: list[Answer] = []
215
+ for a in data.get("data") or []:
216
+ tgt = a.get("target") or {}
217
+ aid = str(tgt.get("id") or "")
218
+ if tgt.get("type") != "answer" or not aid:
219
+ continue
220
+ votes = tgt.get("voteup_count") or 0
221
+ cc = tgt.get("comment_count") or 0
222
+ created = tgt.get("created_time") or 0
223
+ out.append(
224
+ Answer(
225
+ author=strip_zwsp((tgt.get("author") or {}).get("name") or t("words.anonymous")),
226
+ votes=votes_text(votes),
227
+ content_md=_md(tgt.get("content") or ""),
228
+ time=fmt_unix(created),
229
+ url=f"https://www.zhihu.com/question/{qid}/answer/{aid}",
230
+ comment_count=t("display.comments_n", n=fmt_count(cc)) if cc else "",
231
+ )
232
+ )
233
+ return out
234
+
235
+