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,668 @@
1
+ """Source 插件契约:一个网站一个子类,放在 feedscope.sources 下自行注册。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
9
+ from typing import Any, ClassVar, TypeVar
10
+
11
+ from feedscope.core.config import Subscription, feeds_for, hidden_feeds
12
+ from feedscope.core.i18n import t
13
+ from feedscope.core.models import Answer, Comment, DetailDoc, FeedItem
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _T = TypeVar("_T")
18
+
19
+
20
+ class SourceError(Exception):
21
+ """源侧失败的业务异常:消息为面向用户的当前语言文案(含源标签与原因,
22
+ 经 core.i18n.t 生成),UI 可直接展示。
23
+
24
+ 源在取数/解析边界把「自己认识的失败」(HTTP 状态、服务端错误体、超时)
25
+ 包成它;UI 出口对它直接展示消息,对其他技术异常才补「源标签 + 失败」前缀。
26
+ """
27
+
28
+
29
+ class LoginRequired(SourceError):
30
+ """源要求登录但当前 profile 未登录(如知乎重定向到 /signin)。"""
31
+
32
+
33
+ class FeedLoader(ABC):
34
+ """信息流加载器:可能持有浏览器页签等资源,切换/退出前必须 close()。"""
35
+
36
+ items: list[FeedItem]
37
+
38
+ @property
39
+ @abstractmethod
40
+ def done(self) -> bool:
41
+ """真的没有更多了(只读派生态:拉空不等于 done,可能只是网慢)。"""
42
+
43
+ @abstractmethod
44
+ async def load_more(self) -> list[FeedItem]:
45
+ """加载下一批条目;空列表 = 没有更多(或暂时没拉到,看 done)。"""
46
+
47
+ async def close(self) -> None:
48
+ """释放资源,幂等,出错自己吞。
49
+
50
+ 默认 no-op:不持资源的源(通道页签/连接池进程级共享)不覆写,
51
+ 持浏览器页签等资源的子类才需要。"""
52
+
53
+
54
+ class StaticFeedLoader(FeedLoader):
55
+ """一次性加载器的默认实现:没有更多。"""
56
+
57
+ def __init__(self, items: list[FeedItem]) -> None:
58
+ self.items = items
59
+
60
+ @property
61
+ def done(self) -> bool:
62
+ return True
63
+
64
+ async def load_more(self) -> list[FeedItem]:
65
+ return []
66
+
67
+
68
+ def dedup_by(
69
+ candidates: Iterable[_T | None], seen: set[str], *, key: Callable[[_T], str]
70
+ ) -> list[_T]:
71
+ """按 key 去重地放行 candidates(未见过的),并把新键登记进 seen。
72
+
73
+ None(噪音卡)与键已见过的条目跳过;键为空(拿不到稳定 id)不去重
74
+ 直接放行——宁可重复不可丢条。FeedItem(dedup_append)/ Answer /
75
+ Comment / 源内原始 dict 的批次去重共用,「同一键只出现一次」的不变量
76
+ 只有这一处实现。
77
+ """
78
+ new: list[_T] = []
79
+ for it in candidates:
80
+ if it is None:
81
+ continue
82
+ k = key(it)
83
+ if not k or k not in seen:
84
+ if k:
85
+ seen.add(k)
86
+ new.append(it)
87
+ return new
88
+
89
+
90
+ def dedup_append(
91
+ items: list[FeedItem], seen: set[str], candidates: Iterable[FeedItem | None]
92
+ ) -> list[FeedItem]:
93
+ """把 candidates 中未见过的条目追加进 items,返回新增列表。
94
+
95
+ 去重键 = FeedItem.id(源内稳定 id);None(噪音卡)跳过。
96
+ 各源首批提取与翻页 Loader 共用(dedup_by 的 FeedItem 特化)。
97
+ """
98
+ new = dedup_by(candidates, seen, key=lambda it: it.id)
99
+ items.extend(new)
100
+ return new
101
+
102
+
103
+ def require_feed_items(items: list[FeedItem], label: str, feed: str = "") -> list[FeedItem]:
104
+ """信息流首屏守卫:取数成功但解析出 0 条 = 大概率站点改版,显式报错而非静默空列表。
105
+
106
+ 只用于 open_feed 的首批(各源编排层出口统一调用);load_more 翻页拉空
107
+ (到底/全重复)是正常语义,不套这层。
108
+ """
109
+ if not items:
110
+ raise SourceError(
111
+ t("errors.zero_items_with_feed", label=label, feed=feed)
112
+ if feed
113
+ else t("errors.zero_items", label=label)
114
+ )
115
+ return items
116
+
117
+
118
+ _MERGE_PARALLEL = 8 # 「全部」聚合的并发上限:太猛会被源侧限流(RSS 站点普遍小水管)
119
+
120
+
121
+ async def merge_feeds(
122
+ feeds: Sequence[Any],
123
+ fetch_one: Callable[[Any], Awaitable[list[FeedItem]]],
124
+ *,
125
+ error_label: Callable[[Any], str],
126
+ sort_key: Callable[[FeedItem], Any],
127
+ dedup_urls: bool = False,
128
+ ) -> tuple[list[FeedItem], list[str]]:
129
+ """「全部」聚合流的共享编排(RSS / Telegram 共用):并发拉全部订阅、按发布
130
+ 时间倒序合并,返回 (合并条目, 失败订阅的短消息列表)。
131
+
132
+ - 单订阅失败隔离:一个死源只损失自己的条目(宽捕获——取数/解析的任何
133
+ 失败都转短消息上交),全部失败由调用方决定报错
134
+ - 倒序合并用稳定排序:缺时间的条目沉底且保持订阅书写顺序
135
+ - dedup_urls 剔除跨订阅的重复 URL(转载/聚合源常见),保留书写顺序靠前
136
+ 的订阅版本;按次注入的差异(怎么拉、怎么排、错误标签叫什么)走参数
137
+ - 一次性快照:与各单订阅流的增量 Loader 相对(聚合流不做翻页)
138
+ """
139
+ sem = asyncio.Semaphore(_MERGE_PARALLEL)
140
+
141
+ async def one(feed: Any) -> tuple[list[FeedItem], str | None]:
142
+ async with sem:
143
+ try:
144
+ return await fetch_one(feed), None
145
+ except Exception as exc: # 单订阅失败隔离:错误上交,不拖垮聚合
146
+ return [], t("errors.merge_one_failed", label=error_label(feed), error=exc)
147
+
148
+ pairs = await asyncio.gather(*(one(f) for f in feeds))
149
+ errors = [err for _, err in pairs if err]
150
+ merged: list[FeedItem] = []
151
+ seen: set[str] = set()
152
+ for items, _ in pairs:
153
+ for it in items:
154
+ if dedup_urls and it.url:
155
+ if it.url in seen:
156
+ continue
157
+ seen.add(it.url)
158
+ merged.append(it)
159
+ merged.sort(key=sort_key, reverse=True)
160
+ return merged, errors
161
+
162
+
163
+ class DetailLoader(ABC):
164
+ """详情加载器:可能持有浏览器页签等资源,UI 用完必须 close()。
165
+
166
+ 不变量:doc 始终等于已加载的全部内容——load_more 拿到的新回答要
167
+ append 进 doc.answers。UI 依赖这一点做整屏重排(终端改宽度时)。
168
+ """
169
+
170
+ doc: DetailDoc
171
+
172
+ @property
173
+ @abstractmethod
174
+ def done(self) -> bool:
175
+ """真的没有更多内容了(只读派生态:拉空不等于 done,可能只是网慢)。"""
176
+
177
+ @abstractmethod
178
+ async def load_more(self) -> list[Answer]:
179
+ """加载下一批回答;空列表 = 没有更多。"""
180
+
181
+ async def open_comments(self, index: int) -> CommentLoader:
182
+ """按需打开第 index 个楼层(回答)的评论层,返回评论 loader。
183
+
184
+ 多层递进的关键:评论不随首批抓取(首载要快),用户进到某条回答
185
+ 才请求。不支持的源不覆写(配合 Source.supports_comments,UI 不给
186
+ 入口)。评论 loader 契约见 CommentLoader——通常与问题 loader 共享
187
+ 资源,close 只收评论层不关底座。
188
+ """
189
+ raise NotImplementedError(t("errors.comments_unsupported", name=type(self).__name__))
190
+
191
+ async def close(self) -> None:
192
+ """释放资源,幂等,出错自己吞(默认 no-op,语义同 FeedLoader.close)。"""
193
+
194
+
195
+ class CommentLoader(ABC):
196
+ """评论层加载器(DetailLoader.open_comments 的返回契约)。
197
+
198
+ 与楼层 loader 同形的不变量:comments 始终等于已加载全部(load_more
199
+ 的新评论要 append 进去);done 只在真到头时为真,拉空可能只是网慢
200
+ 可再试;close() 幂等且通常只收评论层,不关它所共享的问题底座。
201
+ """
202
+
203
+ comments: list[Comment]
204
+
205
+ @property
206
+ @abstractmethod
207
+ def done(self) -> bool:
208
+ """真的到头了(只读派生态:拉空不等于 done,可能只是网慢)。"""
209
+
210
+ @abstractmethod
211
+ async def load_more(self) -> list[Comment]:
212
+ """加载下一批评论;空列表 = 没有更多(或暂时没拉到,看 done)。"""
213
+
214
+ async def close(self) -> None:
215
+ """释放资源,幂等,出错自己吞(默认 no-op,语义同 FeedLoader.close)。"""
216
+
217
+
218
+ class SliceFeedLoader(FeedLoader):
219
+ """整批条目已在内存的信息流加载器:翻页是本地切片,零额外请求。
220
+
221
+ 「一次拉全」的流(RSS 聚合「全部」/ 单订阅)共用:数据全量在内存,
222
+ 但首屏只上屏 feed_page 条、Ctrl+N 增量补——Textual 的 ListView 没有
223
+ 虚拟化,几百行一次性挂载/改宽重排会冻结秒级,切片把它压到常数。
224
+ 与 SliceCommentsLoader / SliceDetailLoader 同形(「items = 已加载
225
+ 全部」的不变量由本类保证)。
226
+ """
227
+
228
+ feed_page: ClassVar[int] = 100 # 首批与每次 Ctrl+N 的条数(约三屏)
229
+
230
+ def __init__(self, items: Sequence[FeedItem]) -> None:
231
+ self.items = list(items[: self.feed_page])
232
+ self._rest = list(items[self.feed_page :])
233
+
234
+ @property
235
+ def done(self) -> bool:
236
+ return not self._rest
237
+
238
+ async def load_more(self) -> list[FeedItem]:
239
+ if self.done:
240
+ return []
241
+ batch, self._rest = self._rest[: self.feed_page], self._rest[self.feed_page :]
242
+ self.items.extend(batch) # 「items = 已加载全部」不变量
243
+ return batch
244
+
245
+
246
+ class StaticDetailLoader(DetailLoader):
247
+ """一次性到头的详情加载器:doc 即全部内容,无楼层可翻、无资源可释。
248
+
249
+ 正文已随列表带回的源(RSS / Telegram)用;feed 侧的对称物是
250
+ StaticFeedLoader。
251
+ """
252
+
253
+ def __init__(self, doc: DetailDoc) -> None:
254
+ self.doc = doc
255
+
256
+ @property
257
+ def done(self) -> bool:
258
+ return True
259
+
260
+ async def load_more(self) -> list[Answer]:
261
+ return []
262
+
263
+
264
+ class SliceCommentsLoader(CommentLoader):
265
+ """整串评论已在内存的评论层加载器:翻页是本地切片,零额外请求。
266
+
267
+ 「详情一次拉全树」的源(HN / Reddit)的评论层共用;页宽
268
+ comment_page 控制首批与 Ctrl+N 增量。
269
+ """
270
+
271
+ comment_page: ClassVar[int] = 20
272
+
273
+ def __init__(self, comments: Sequence[Comment]) -> None:
274
+ self.comments = list(comments[: self.comment_page])
275
+ self._rest = list(comments[self.comment_page :])
276
+
277
+ @property
278
+ def done(self) -> bool:
279
+ return not self._rest
280
+
281
+ async def load_more(self) -> list[Comment]:
282
+ if self.done:
283
+ return []
284
+ batch, self._rest = self._rest[: self.comment_page], self._rest[self.comment_page :]
285
+ self.comments.extend(batch) # 「comments = 已加载全部」不变量
286
+ return batch
287
+
288
+
289
+ class SliceDetailLoader(DetailLoader):
290
+ """楼层已全在内存的详情加载器:load_more 是本地切片,零额外请求。
291
+
292
+ 「详情一次拉全树」的源(HN / Reddit / V2EX)共用:构造时给抽好的楼层
293
+ Answer 序列(首批 = 前 floor_page 条);带评论层的源再给与楼层对齐的
294
+ comment_trees(open_comments 按下标切出对应树的 SliceCommentsLoader,
295
+ 不给则与本类约定不支持评论层)。「doc = 已加载全部」的不变量由本类
296
+ 保证(load_more 的新楼层 append 进 doc.answers)。子类只剩薄适配:
297
+ from_detail 吃源 parse 的 detail 产物,再声明 source_label(越界报错
298
+ 文案);页宽 floor_page 有默认值,真要差异化再覆写。
299
+ """
300
+
301
+ source_label: ClassVar[str] = "" # 报错文案里的源标签(如 "HN")
302
+ floor_page: ClassVar[int] = 15 # 首批与每次 Ctrl+N 的楼层数(内存切片,不是请求)
303
+
304
+ def __init__(
305
+ self,
306
+ floors: Sequence[Answer],
307
+ *,
308
+ title: str,
309
+ meta: str = "",
310
+ time: str = "",
311
+ description_md: str = "",
312
+ comment_trees: Sequence[Sequence[Comment]] | None = None,
313
+ ) -> None:
314
+ self._floors = list(floors)
315
+ self._comments = list(comment_trees) if comment_trees is not None else None
316
+ first = self._floors[: self.floor_page]
317
+ self._given = len(first)
318
+ self.doc = DetailDoc(
319
+ title=title,
320
+ meta=meta,
321
+ time=time,
322
+ description_md=description_md,
323
+ answers=list(first),
324
+ )
325
+
326
+ @classmethod
327
+ def from_detail(cls, detail: Any, *, fallback_title: str = "") -> SliceDetailLoader:
328
+ """楼层型 detail 产物的替代构造(HN / Reddit / V2EX 的 parse 产物)。
329
+
330
+ 两形鸭子类型(字段同名,title/meta/time/description_md + 楼层):
331
+ - .floors:Floor 列表(HN 的 StoryDetail / Reddit 的 ThreadDetail),
332
+ answer 与评论树成对进切片(支持评论层)
333
+ - .replies:裸 Answer 列表(V2EX 的 TopicDetail,无评论层)
334
+ title 为空时退 fallback_title(列表端标题)。
335
+ """
336
+ floors = getattr(detail, "floors", None)
337
+ kwargs: dict[str, Any] = {
338
+ "title": str(getattr(detail, "title", "") or "") or fallback_title,
339
+ "meta": getattr(detail, "meta", ""),
340
+ "time": getattr(detail, "time", ""),
341
+ "description_md": getattr(detail, "description_md", ""),
342
+ }
343
+ if floors is not None:
344
+ return cls(
345
+ [f.answer for f in floors],
346
+ comment_trees=[f.comments for f in floors],
347
+ **kwargs,
348
+ )
349
+ return cls(list(getattr(detail, "replies", [])), **kwargs)
350
+
351
+ @property
352
+ def done(self) -> bool:
353
+ return self._given >= len(self._floors)
354
+
355
+ async def load_more(self) -> list[Answer]:
356
+ if self.done:
357
+ return []
358
+ batch = self._floors[self._given : self._given + self.floor_page]
359
+ self._given += len(batch)
360
+ self.doc.answers.extend(batch) # 「doc = 已加载全部」不变量
361
+ return batch
362
+
363
+ async def open_comments(self, index: int) -> SliceCommentsLoader:
364
+ if self._comments is None:
365
+ raise NotImplementedError(
366
+ t("errors.comments_unsupported", name=self.source_label)
367
+ )
368
+ if not 0 <= index < len(self.doc.answers):
369
+ raise SourceError(
370
+ t("errors.comment_out_of_range", label=self.source_label)
371
+ )
372
+ return SliceCommentsLoader(self._comments[index])
373
+
374
+
375
+ async def login_gate(
376
+ url: str, ok: Callable[[dict[str, str]], bool], *, label: str, what: str
377
+ ) -> None:
378
+ """判登门的共享实现:读 profile cookie 过谓词,不过抛 LoginRequired。
379
+
380
+ x(auth_token+ct0 成对在)与 Reddit(token_v2 JWT 解码)共用——判据是
381
+ 源差异(注入谓词),cookie 读取与登录提示的消息模板只有这一份,不会
382
+ 措辞漂移。
383
+ """
384
+ from feedscope.core.browser import profile_cookies # 惰性导入:browser 已 import 本模块
385
+
386
+ if not ok(await profile_cookies(url)):
387
+ raise LoginRequired(t("errors.login_required", label=label, what=what))
388
+
389
+
390
+ class Source(ABC):
391
+ """源基类。子类用 @register 注册,由 TUI 装载。
392
+
393
+ 一个源可以暴露多个命名信息流(如知乎的热榜/推荐/关注),
394
+ feeds 的顺序就是左侧边栏的切流顺序。类里写死的是内置默认流;用户可在
395
+ sources.toml 隐藏内置流(见 core/config.py),__init__ 合成实例视图。
396
+
397
+ 类型词汇表归源所有,UI 不认识任何具体类型:
398
+ - kind_labels:kind -> 元信息列标签(如 video -> "视频"),预告 Enter 跳浏览器;
399
+ - detail_kinds:Enter 进终端详情屏的 kind,其余一律跳浏览器。
400
+ """
401
+
402
+ source_id: ClassVar[str] # 注册键,如 "zhihu"
403
+ label: ClassVar[str] # 展示名的文案键(如 "sources.zhihu.label"),展示经 label_text 解析
404
+ # feeds/feed_labels 两形同用(故不作 ClassVar):类体写内置默认流,
405
+ # __init__ 按配置合成实例视图(hide 剔减 / 订阅拼加),分派读实例
406
+ feeds: list[str] = [] # 命名信息流;空 = 无可选流(不进 Tab)
407
+ # 流名 -> 显示名的文案键(订阅型源的订阅行是用户数据不是键,t() 对
408
+ # 无点字符串直通),展示统一经 feed_label() 解析
409
+ feed_labels: dict[str, str] = {}
410
+ supports_detail: ClassVar[bool] = False # 决定 Enter 是否可能进详情屏
411
+ supports_comments: ClassVar[bool] = False # 决定详情屏 Enter 是否可能进评论层
412
+ subscribable: ClassVar[bool] = False # 信息流来自用户订阅:F2 里 → 进订阅管理屏
413
+ kind_labels: ClassVar[dict[str, str]] = {} # kind -> 元信息列标签的文案键(共享 kinds.* 词表)
414
+ detail_kinds: ClassVar[frozenset[str]] = frozenset({""})
415
+ # 详情屏楼层词键(X/V2EX 回复、HN/Reddit 评论);楼层内评论子区的单位词键
416
+ detail_unit_label: ClassVar[str] = "units.answer"
417
+ comment_unit_label: ClassVar[str] = "units.comment"
418
+ login_url: ClassVar[str] = "" # 有登录需求的源声明登录页;空 = 无需登录
419
+ login_cookie: ClassVar[str] = (
420
+ "" # 登录轮询的兜底 cookie(未声明 logout_cookies / login_verify 时用)
421
+ )
422
+ login_verify: ClassVar[Any | None] = (
423
+ None # async (browser_context) -> bool;登录成功的浏览器级判据(优先)
424
+ )
425
+ # (cookie 判登不可靠的源覆盖此钩子,如 Reddit:token_v2 匿名访客也发,
426
+ # 登录成功以 JWT 解码出 sub="user" 为准;None = 判登 cookie 全集(再退 login_cookie))
427
+ logout_cookies: ClassVar[list[str]] = (
428
+ [] # 判登/登出 cookie 全集;空 = 只认 login_cookie / login_verify
429
+ )
430
+ reserved_feed_keys: ClassVar[tuple[str, ...]] = (
431
+ () # 订阅 key 的保留字(内置流名/聚合流名):添加入口拒收撞名订阅的唯一依据
432
+ )
433
+
434
+ def __init__(self) -> None:
435
+ """按用户配置合成实例视图:feeds 在类默认之上剔除被隐藏的流。
436
+
437
+ 类属性 = 内置默认(ClassVar 语义即「默认值」);实例属性 = 合成后的
438
+ 当前视图,UI / open_feed 分派只读实例。
439
+ """
440
+ hidden = hidden_feeds(self.source_id)
441
+ feeds = [f for f in self.feeds if f not in hidden]
442
+ self.feeds = feeds
443
+ if not feeds:
444
+ logger.warning("%s 的信息流已被配置全部隐藏", self.label_text)
445
+
446
+ # ---- 词汇键 -> 展示文本(i18n;t() 对无点字符串直通,stub 源不受影响)----
447
+
448
+ @property
449
+ def label_text(self) -> str:
450
+ """源展示名(label 是文案键,这里给解析后的文本;日志/消息/展示用)。"""
451
+ return t(self.label)
452
+
453
+ @property
454
+ def detail_unit(self) -> str:
455
+ """详情屏楼层词(如 回答/评论/回复)的展示文本。"""
456
+ return t(self.detail_unit_label)
457
+
458
+ @property
459
+ def comment_unit(self) -> str:
460
+ """楼层内评论子区单位词的展示文本。"""
461
+ return t(self.comment_unit_label)
462
+
463
+ def feed_label(self, name: str) -> str:
464
+ """流名 -> 显示名(内置流查词汇键解析;未知流回退流名本身)。"""
465
+ return t(self.feed_labels.get(name, name))
466
+
467
+ @property
468
+ def judging_cookies(self) -> list[str]:
469
+ """判登要看的 cookie 名全集:logout_cookies 优先,未声明才退 login_cookie 单枚。
470
+
471
+ login() 轮询 / is_logged_in / logout 三条路径共用(口径全项目唯一),
472
+ 不给「login 报成功、账号屏仍显示未登录」的分裂留缝。
473
+ """
474
+ if self.logout_cookies:
475
+ return list(self.logout_cookies)
476
+ return [self.login_cookie] if self.login_cookie else []
477
+
478
+ @abstractmethod
479
+ async def open_feed(self, name: str) -> FeedLoader:
480
+ """打开信息流返回可翻页加载器;子类必须实现(唯一主入口)。"""
481
+
482
+ def label_of(self, name: str) -> str:
483
+ """流名 -> 显示名(词汇键解析后的文本);未知流统一抛 ValueError
484
+ (命名流的 open_feed 守卫收一处)。"""
485
+ label = self.feed_labels.get(name)
486
+ if label is None:
487
+ raise ValueError(t("errors.unknown_feed", name=name))
488
+ return t(label)
489
+
490
+ def static_feed(self, items: list[FeedItem], label: str) -> StaticFeedLoader:
491
+ """静态首屏出口:0 条守卫 + 一次性 loader(一次拉全的小流用)。
492
+
493
+ 与 first_batch(增量 Loader 形态)并列的静态形态——「parse 全量 →
494
+ require_feed_items → StaticFeedLoader」三行收一处(知乎热榜 / V2EX
495
+ 这类一页几十条的小流同形;上百条的静态流用 sliced_feed 切片上屏)。
496
+ """
497
+ require_feed_items(items, self.label_text, label)
498
+ return StaticFeedLoader(items)
499
+
500
+ def sliced_feed(self, items: list[FeedItem], label: str) -> SliceFeedLoader:
501
+ """切片首屏出口:0 条守卫 + 内存切片 loader(数据一次拉全但量大的流用)。
502
+
503
+ 与 static_feed 同一道守卫,区别只在首屏给 feed_page 条、Ctrl+N
504
+ 增量补(零请求)——几百条一次性挂载会把无虚拟化的列表冻住秒级。
505
+ """
506
+ require_feed_items(items, self.label_text, label)
507
+ return SliceFeedLoader(items)
508
+
509
+ async def first_batch(self, loader: FeedLoader, label: str) -> FeedLoader:
510
+ """open_feed 的首屏出口:拉首批 + 0 条守卫,返回 loader 供直接 return。
511
+
512
+ 「构造 loader → load_more 首批 → require_feed_items → return」的样板
513
+ (hn/x/zhihu 信息流同形)收一处;首批后有 bespoke 校验的源(reddit
514
+ 的空首页指引)自行拆开调这两步。
515
+ """
516
+ await loader.load_more()
517
+ require_feed_items(loader.items, self.label_text, label)
518
+ return loader
519
+
520
+ async def fetch_feed(self, name: str) -> list[FeedItem]:
521
+ """一次性抓取原语(脚本/测试用):打开即取首批并释放,子类不要覆写。"""
522
+ loader = await self.open_feed(name)
523
+ try:
524
+ return loader.items
525
+ finally:
526
+ await loader.close()
527
+
528
+ async def open_detail(self, item: FeedItem) -> DetailLoader:
529
+ """打开详情,返回加载器;不支持详情的源不用覆写。"""
530
+ raise NotImplementedError(t("errors.detail_unsupported", label=self.label_text))
531
+
532
+
533
+ class SubscribedSource(Source):
534
+ """纯订阅聚合源的共享骨架(RSS / Telegram 同形,第三个订阅型源继承即得)。
535
+
536
+ feeds 全部来自用户订阅表(数据驱动的加法合成,不走父类的 hide 减法,
537
+ 故不调 super().__init__()):非空时前置聚合流「全部」(hide 可分别隐藏
538
+ 「全部」或单个订阅),零订阅 = 没有任何可选信息流。订阅 key 撞
539
+ all_feed(保留字)的条目在这里跳过并警告(手编配置漏网的兜底,添加
540
+ 入口的拒收见 subscriptions)。
541
+
542
+ 「全部」流的四级错误出口收在本类:空表给指引 → 部分失败降级(时间线
543
+ 照出、原因进日志)→ 全失败报错 → 全空且无错才走 0 条改版守卫——单订阅
544
+ 失败隔离的语义订阅型源之间一致,修复只打一份。子类注入两个差异:
545
+ _fetch_merged(怎么并发聚合)与 _open_one(单个订阅的流怎么开)。
546
+ """
547
+
548
+ all_feed: ClassVar[str] # 聚合流名(「全部」),feeds 首位;订阅 key 的保留字
549
+ sub_noun: ClassVar[str] # 订阅物名词的文案键(「订阅」/「频道」),展示经 sub_noun_text
550
+ # 详情元信息首段读的 extra 键(rss: feed_label;telegram: channel)
551
+ _meta_extra_key: ClassVar[str] = ""
552
+
553
+ def __init__(self) -> None:
554
+ hidden = set(hidden_feeds(self.source_id))
555
+ self._by_key: dict[str, Subscription] = {}
556
+ for f in feeds_for(self.source_id):
557
+ if f.key in hidden:
558
+ continue
559
+ if f.key == self.all_feed:
560
+ logger.warning(
561
+ "订阅 key %r 与聚合流名 %s 撞名,已跳过(%s.feeds)",
562
+ f.key, self.all_feed, self.source_id,
563
+ )
564
+ continue
565
+ self._by_key[f.key] = f
566
+ keys = list(self._by_key)
567
+ self.feeds = ([self.all_feed] if keys and self.all_feed not in hidden else []) + keys
568
+ # 「全部」是词汇键(units.all),订阅行的 value 是用户标签(非键,
569
+ # t() 直通)——展示统一走 feed_label()
570
+ self.feed_labels = (
571
+ {self.all_feed: "units.all", **{k: f.label for k, f in self._by_key.items()}}
572
+ if self.feeds
573
+ else {}
574
+ )
575
+ if not keys:
576
+ logger.info(
577
+ "%s 暂无订阅:在 sources.toml 的 [%s.feeds] 配置后,F2 勾选启用",
578
+ self.label_text, self.source_id,
579
+ )
580
+
581
+ @property
582
+ def sub_noun_text(self) -> str:
583
+ """订阅物名词(订阅/频道)的展示文本。"""
584
+ return t(self.sub_noun)
585
+
586
+ def feed_label(self, name: str) -> str:
587
+ """订阅型流的显示名:聚合流查词汇键,订阅行直接用用户标签。"""
588
+ if name == self.all_feed:
589
+ return t(self.feed_labels[name])
590
+ feed = self._by_key.get(name)
591
+ return feed.label if feed is not None else name
592
+
593
+ async def open_feed(self, name: str) -> FeedLoader:
594
+ if name == self.all_feed:
595
+ items = await self._open_merged()
596
+ return SliceFeedLoader(items) # 几百条聚合一次拉全,切片上屏防秒冻
597
+ feed = self._by_key.get(name)
598
+ if feed is None:
599
+ raise ValueError(t("errors.unknown_feed", name=name))
600
+ return await self._open_one(feed)
601
+
602
+ async def _open_merged(self) -> list[FeedItem]:
603
+ """「全部」聚合流的全部条目(一次并发拉全;上屏走 SliceFeedLoader 切片)。"""
604
+ if not self._by_key:
605
+ # 订阅刚被删光的过渡窗口:旧实例还挂着「全部」流,别让空表走到
606
+ # 0 条守卫报「站点可能已改版」——那是误导,指引才是实话
607
+ raise SourceError(
608
+ t(
609
+ "errors.no_subscriptions_hint",
610
+ label=self.label_text, sub=self.sub_noun_text,
611
+ )
612
+ )
613
+ items, errors = await self._fetch_merged(list(self._by_key.values()))
614
+ if errors and items: # 部分失败降级:时间线照出,失败原因进日志
615
+ logger.warning(
616
+ "%s「全部」%d/%d 个%s拉取失败:%s",
617
+ self.label_text, len(errors), len(self._by_key),
618
+ self.sub_noun_text, ";".join(errors[:3]),
619
+ )
620
+ if not items and errors:
621
+ raise SourceError(
622
+ t(
623
+ "errors.all_feeds_failed",
624
+ label=self.label_text, feed=t("units.all"),
625
+ n=len(errors), errors=";".join(errors[:3]),
626
+ )
627
+ )
628
+ return require_feed_items(items, self.label_text, t("units.all")) # 全空且无错 = 可能改版
629
+
630
+ @abstractmethod
631
+ async def _fetch_merged(
632
+ self, feeds: list[Subscription]
633
+ ) -> tuple[list[FeedItem], list[str]]:
634
+ """并发拉全部订阅并聚合:(合并条目, 失败订阅的短消息列表)。"""
635
+
636
+ @abstractmethod
637
+ async def _open_one(self, feed: Subscription) -> FeedLoader:
638
+ """打开单个订阅的信息流(各源的取数/解析差异全在这里)。"""
639
+
640
+ async def open_detail(self, item: FeedItem) -> StaticDetailLoader:
641
+ """订阅型源的静态详情骨架:正文随列表带回(extra),进详情零请求。
642
+
643
+ meta =「来源标签 · 作者」;发布时间与正文的提取是源差异,见
644
+ _detail_time / _detail_body(声明 supports_detail 的子类必给)。
645
+ RSS / Telegram 共用,第三个订阅型源继承即得。
646
+ """
647
+ extra = item.extra
648
+ meta = " · ".join(
649
+ x
650
+ for x in (str(extra.get(self._meta_extra_key) or ""), str(extra.get("author") or ""))
651
+ if x
652
+ )
653
+ return StaticDetailLoader(
654
+ DetailDoc(
655
+ title=item.title,
656
+ meta=meta,
657
+ time=self._detail_time(extra),
658
+ description_md=self._detail_body(extra),
659
+ )
660
+ )
661
+
662
+ def _detail_time(self, extra: dict[str, Any]) -> str:
663
+ """extra 里的发布时间 -> 展示文本(源差异:unix 秒 / ISO 字符串)。"""
664
+ raise NotImplementedError
665
+
666
+ def _detail_body(self, extra: dict[str, Any]) -> str:
667
+ """extra 里的正文 -> markdown(源差异:content_html 转 / content_md 直取)。"""
668
+ raise NotImplementedError