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,367 @@
1
+ """用户配置:sources.toml 选启用的源 + 隐藏不想看的内置信息流 + 各源订阅表。
2
+
3
+ - 路径:FEEDSCOPE_CONFIG 环境变量(测试、多套配置切换),缺省用户配置目录下
4
+ sources.toml(platformdirs,Linux 为 ~/.config/feedscope/sources.toml)
5
+ - 容错哲学:配置问题绝不挡启动——文件不存在 = 全默认;TOML 语法错 / 结构
6
+ 不符 = 警告日志 + 当作不存在(feedscope doctor 可诊断)。
7
+ - 写入走 save_enabled(首启引导 / F2 管理界面)、update_feeds(订阅管理
8
+ 屏 / rss 子命令)与 save_language(语言选择屏):保留各源 hide 段与
9
+ feeds 订阅表(订阅是用户数据,覆写丢段 = 丢订阅;rss 的 feed、reddit
10
+ 的 subreddit、telegram 的频道同款)与顶层 language,无法识别的键丢弃;
11
+ 原子落盘(临时文件 + os.replace),不留半截文件。
12
+ - 本模块对「源」只认 id 字符串,不认识任何具体源:订阅 key 的保留字
13
+ (聚合流名 / 内置流名)由各源自己声明(Source.reserved_feed_keys),
14
+ 读取侧的过滤在 SubscribedSource、添加侧的拒收在 subscriptions。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from platformdirs import user_config_path
28
+
29
+ from feedscope.core.i18n import t
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Subscription:
36
+ """一条订阅(rss 的 feed / reddit 的 subreddit / telegram 的频道同形):
37
+ key 即信息流名(侧栏与 open_feed 的寻址键)。"""
38
+
39
+ key: str
40
+ label: str # 侧栏显示名;配置缺省 = key 本身
41
+ url: str
42
+
43
+
44
+ def config_path() -> Path:
45
+ if p := os.environ.get("FEEDSCOPE_CONFIG"):
46
+ return Path(p)
47
+ return user_config_path("feedscope") / "sources.toml"
48
+
49
+
50
+ def _read() -> tuple[dict, str]:
51
+ """读配置返回 (数据, 状态描述);不存在 / 坏 = ({}, 原因),不抛异常。"""
52
+ path = config_path()
53
+ try:
54
+ with path.open("rb") as f:
55
+ return tomllib.load(f), ""
56
+ except FileNotFoundError:
57
+ return {}, t("config.describe_not_created")
58
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
59
+ # 非 UTF-8 字节(手编/别的工具写坏)也是「坏配置」:容错哲学要求
60
+ # 绝不挡启动,不能让它以 UnicodeDecodeError 逃出 _read
61
+ logger.warning("用户配置 %s 无法解析,已忽略:%s", path, exc)
62
+ return {}, t("config.describe_unparsable", error=exc)
63
+
64
+
65
+ def hidden_feeds(source_id: str) -> list[str]:
66
+ """读某源要隐藏的流名列表。
67
+
68
+ 期望结构(本源的结构不符 = 警告并当全默认,不波及其他源):
69
+
70
+ [zhihu]
71
+ hide = ["follow"]
72
+ """
73
+ section = _read()[0].get(source_id)
74
+ if not isinstance(section, dict):
75
+ return []
76
+ hidden = section.get("hide")
77
+ if hidden is not None and (
78
+ not isinstance(hidden, list) or not all(isinstance(n, str) for n in hidden)
79
+ ):
80
+ logger.warning("配置 [%s] hide 应为流名列表,已忽略", source_id)
81
+ hidden = []
82
+ return list(hidden or [])
83
+
84
+
85
+ def feeds_for(source_id: str) -> list[Subscription]:
86
+ """读某源的订阅表(书写顺序即侧栏顺序),结构与坏条目逐条容错。
87
+
88
+ 期望结构(label 缺省用 key;url 必填):
89
+
90
+ [rss.feeds]
91
+ ruanyq = { label = "阮一峰周刊", url = "https://….com/atom.xml" }
92
+ hn = { url = "https://hnrss.org/frontpage" }
93
+
94
+ [reddit.feeds] # url 存 subreddit 站内地址,自解释
95
+ rust = { url = "https://www.reddit.com/r/rust/" }
96
+
97
+ [telegram.feeds]
98
+ durov = { url = "https://t.me/durov/" }
99
+
100
+ 条目不是表 / 缺 url = 警告并跳过该条,不波及其他订阅(同一容错粒度:
101
+ 文件级坏 = 全默认已在上层 _read,这里是条目级)。保留字(聚合流名 /
102
+ 内置流名)撞名的 key 原样返回——过滤在各源的 SubscribedSource(读取
103
+ 侧会跳过并警告),拒收在 subscriptions 的添加入口,本模块不认识源。
104
+ """
105
+ section = _read()[0].get(source_id)
106
+ feeds_raw = section.get("feeds") if isinstance(section, dict) else None
107
+ if feeds_raw is None:
108
+ return []
109
+ if not isinstance(feeds_raw, dict):
110
+ logger.warning("配置 [%s] feeds 应为订阅表,已忽略", source_id)
111
+ return []
112
+ feeds: list[Subscription] = []
113
+ for key, entry in feeds_raw.items():
114
+ feed = _subscription_from_entry(key, entry)
115
+ if feed is not None:
116
+ feeds.append(feed)
117
+ return feeds
118
+
119
+
120
+ def _subscription_from_entry(
121
+ key: str, entry: object, *, context: str = ""
122
+ ) -> Subscription | None:
123
+ """订阅表条目 -> Subscription;坏条目记警告并跳过。
124
+
125
+ 读路径(feeds_for)与写路径(_valid_feeds)共用同一条校验规则——
126
+ 「读得进来、写得回去」不再靠两处各自维护。
127
+ """
128
+ prefix = f"{context}:" if context else ""
129
+ if not isinstance(entry, dict) or not isinstance(entry.get("url"), str) or not entry["url"]:
130
+ logger.warning("%s订阅 %r 应为含非空 url 的表,已跳过", prefix, key)
131
+ return None
132
+ label = entry.get("label")
133
+ if label is not None and not isinstance(label, str):
134
+ logger.warning("%s订阅 %r 的 label 应为字符串,已回退用 key", prefix, key)
135
+ label = None
136
+ return Subscription(key=key, label=label or key, url=entry["url"])
137
+
138
+
139
+ def rss_feeds() -> list[Subscription]:
140
+ """rss 源的订阅表(feeds_for 的 rss 特例,保留既有调用名)。"""
141
+ return feeds_for("rss")
142
+
143
+
144
+ def enabled_ids() -> list[str] | None:
145
+ """读顶层启用的源 id 列表;None = 未配置(默认全部启用,老文件只有 hide 的用户不受影响)。
146
+
147
+ 期望结构:
148
+
149
+ enabled = ["zhihu", "x"]
150
+
151
+ 列表里的未知 id 不在此报错——注册表过滤在调用方(新装/卸载源后残留条目无害)。
152
+ 手编重复 id 按首次出现去重:重复项会让主屏建出重复 tab(DuplicateIds),
153
+ 顺序语义也只剩第一份有意义。
154
+ """
155
+ enabled = _read()[0].get("enabled")
156
+ if enabled is None:
157
+ return None
158
+ if not isinstance(enabled, list) or not all(isinstance(i, str) for i in enabled):
159
+ logger.warning("配置 enabled 应为源 id 列表,已忽略(默认全部启用)")
160
+ return None
161
+ return list(dict.fromkeys(enabled))
162
+
163
+
164
+ def is_configured() -> bool:
165
+ """是否已完成首次设置(配置文件存在)。
166
+
167
+ 只看文件存在与否:TOML 坏文件是 doctor 的职责,不重新引导——
168
+ 引导保存会覆写,坏文件里可能还有用户手写的其他内容。
169
+ """
170
+ return config_path().exists()
171
+
172
+
173
+ def get_language() -> str | None:
174
+ """读顶层语言设置(i18n 语言代码,如 "zh-CN" / "en");None = 未设置。
175
+
176
+ 未设置不是错误:读侧缺省在 core.i18n(回退基准语言),未设置的用户
177
+ 会在下次启动被语言选择屏询问(首启流程见 ui/screens/language.py)。
178
+ """
179
+ lang = _read()[0].get("language")
180
+ if lang is None:
181
+ return None
182
+ if not isinstance(lang, str) or not lang:
183
+ logger.warning("配置 language 应为语言代码字符串,已忽略")
184
+ return None
185
+ return lang
186
+
187
+
188
+ def save_language(code: str) -> None:
189
+ """保存语言设置(语言选择屏的落盘路径);enabled / hide / feeds 原样保留。"""
190
+ data = _read()[0]
191
+ data["language"] = code
192
+ _write_config(_render_config(_enabled_from_data(data), data))
193
+
194
+
195
+ def _toml_str(s: str) -> str:
196
+ # json 字符串即合法 TOML 基本串(转义规则同源),不为此引入写库
197
+ return json.dumps(s, ensure_ascii=False)
198
+
199
+
200
+ _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
201
+
202
+
203
+ def _toml_key(s: str) -> str:
204
+ """键/段名:能裸写就裸写([rss.feeds] 好读),含特殊字符才引用 _toml_str。"""
205
+ return s if _BARE_KEY.match(s) else _toml_str(s)
206
+
207
+
208
+ def _toml_array(values: list[str]) -> str:
209
+ return "[" + ", ".join(_toml_str(v) for v in values) + "]"
210
+
211
+
212
+ def _valid_feeds(raw: object) -> list[tuple[str, str, str]]:
213
+ """feeds 表的写前校验:留 (key, label, url),坏条目丢弃记日志。
214
+
215
+ 与 feeds_for 共用 _subscription_from_entry(表 + 非空 str url、label
216
+ 只收 str),读写往返规则只有一处。
217
+ """
218
+ if raw is None:
219
+ return []
220
+ if not isinstance(raw, dict):
221
+ logger.warning("覆写配置:feeds 应为订阅表,已丢弃 %r", raw)
222
+ return []
223
+ out: list[tuple[str, str, str]] = []
224
+ for key, entry in raw.items():
225
+ feed = _subscription_from_entry(key, entry, context="覆写配置")
226
+ if feed is not None:
227
+ out.append((feed.key, feed.label, feed.url))
228
+ return out
229
+
230
+
231
+ def _enabled_from_data(data: dict) -> list[str] | None:
232
+ """从配置数据里取出规范化的 enabled(坏值按未配置;空列表是合法的零启用)。"""
233
+ raw = data.get("enabled")
234
+ if not isinstance(raw, list):
235
+ return None
236
+ kept = list(dict.fromkeys(x for x in raw if isinstance(x, str)))
237
+ return kept if kept or not raw else None
238
+
239
+
240
+ def _render_config(ids: list[str] | None, data: dict) -> list[str]:
241
+ """配置全量渲染为 TOML 行;ids 为 None = 不写 enabled 行(缺省 = 全启用)。
242
+
243
+ 只写本工具认识的键:language + enabled + 各源非空 hide 段 + feeds 订阅表
244
+ (其余丢弃,记 info——这里的「重新生成」与手改文件的自由度是此消彼长,
245
+ 注释头已说明归属)。feeds 是用户数据(不是工具生成的状态),必须原样
246
+ 保留——丢段等于丢订阅;language 同理(i18n 的落盘键,漏写 = 丢语言设置)。
247
+ """
248
+ lines = [f"# {line}" for line in t("config.file_header").splitlines()]
249
+ language = data.get("language")
250
+ if isinstance(language, str) and language:
251
+ lines += ["", f"language = {_toml_str(language)}"]
252
+ if ids is not None:
253
+ lines += ["", f"enabled = {_toml_array(ids)}"]
254
+ for sid, section in data.items():
255
+ if sid in ("enabled", "language"):
256
+ continue # 已由参数 ids / language 行统一重写,不是「无法识别的顶层键」
257
+ if not isinstance(section, dict):
258
+ logger.info("覆写配置:忽略无法识别的顶层键 %r", sid)
259
+ continue
260
+ if set(section) - {"hide", "feeds"}:
261
+ logger.info(
262
+ "覆写配置:[%s] 里除 hide/feeds 外的键不被保留 %r",
263
+ sid, sorted(set(section) - {"hide", "feeds"}),
264
+ )
265
+ hide = section.get("hide")
266
+ feeds = _valid_feeds(section.get("feeds"))
267
+ if (isinstance(hide, list) and hide) or feeds:
268
+ lines += ["", f"[{_toml_key(sid)}]"]
269
+ if isinstance(hide, list) and hide:
270
+ lines.append(f"hide = {_toml_array(hide)}")
271
+ if feeds:
272
+ # 订阅行用内联表:一行一条,手编/比对都清爽;键引用字符串形式
273
+ # (json 串即合法 TOML),key 带空格等字符也不会写出坏文件
274
+ lines.append(f"[{_toml_key(sid)}.feeds]")
275
+ lines += [
276
+ f"{_toml_key(key)} = {{ url = {_toml_str(url)}, label = {_toml_str(label)} }}"
277
+ for key, label, url in feeds
278
+ ]
279
+ return lines
280
+
281
+
282
+ def _write_config(lines: list[str]) -> None:
283
+ """原子落盘(临时文件 + os.replace):读者要么旧文件要么新文件,没有半截。"""
284
+ path = config_path()
285
+ path.parent.mkdir(parents=True, exist_ok=True)
286
+ tmp = path.with_name(path.name + ".tmp")
287
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
288
+ os.replace(tmp, path)
289
+
290
+
291
+ def save_enabled(ids: list[str]) -> None:
292
+ """保存启用的源列表(首启引导 / F2 管理界面的落盘路径)。"""
293
+ _write_config(_render_config(list(dict.fromkeys(ids)), _read()[0]))
294
+
295
+
296
+ def update_feeds(
297
+ source_id: str, feeds: list[Subscription], *, drop_enabled_when_empty: bool = False
298
+ ) -> None:
299
+ """整体替换某源的订阅表(订阅管理屏 / rss 子命令的落盘路径);enabled / hide 原样保留。
300
+
301
+ enabled 联动让「加订阅」一步到位:订阅非空且 enabled 已配置但缺该源
302
+ → 自动补上(加订阅显然是想用,省掉再开 TUI 勾一次)。删到空是否摘
303
+ enabled 由 drop_enabled_when_empty 决定:rss / telegram 摘(feeds 全
304
+ 来自订阅,没订阅就没有任何流);reddit 不摘(还有首页/热门内置流)。
305
+ enabled 未配置不动——缺省本就是全启用,写了反而丢失「未配置」语义。
306
+ """
307
+ data = _read()[0]
308
+ section = data.get(source_id)
309
+ if not isinstance(section, dict):
310
+ section = {}
311
+ if feeds:
312
+ section["feeds"] = {f.key: {"label": f.label, "url": f.url} for f in feeds}
313
+ else:
314
+ section.pop("feeds", None)
315
+ if section:
316
+ data[source_id] = section
317
+ elif source_id in data:
318
+ del data[source_id]
319
+ # 坏 enabled(字符串/表/全是非 str)按「未配置」处理:既不参与联动,
320
+ # 也不把畸形值原样写回去——读侧本来就是当默认全启用;空列表是
321
+ # 合法状态(零启用),保留
322
+ enabled = _enabled_from_data(data)
323
+ if enabled is not None:
324
+ if feeds and source_id not in enabled:
325
+ enabled.append(source_id)
326
+ if not feeds and drop_enabled_when_empty and source_id in enabled:
327
+ enabled.remove(source_id)
328
+ _write_config(_render_config(enabled, data))
329
+
330
+
331
+ def update_rss_feeds(feeds: list[Subscription]) -> None:
332
+ """整体替换 rss 订阅表(update_feeds 的 rss 特例,保留既有调用名)。"""
333
+ update_feeds("rss", feeds, drop_enabled_when_empty=True)
334
+
335
+
336
+ def describe() -> str:
337
+ """doctor 用的一句话状态:未创建 / 解析失败原因 / 语言、启用与隐藏概况。"""
338
+ data, reason = _read()
339
+ if reason:
340
+ return reason
341
+ parts: list[str] = []
342
+ lang = data.get("language")
343
+ if isinstance(lang, str) and lang:
344
+ parts.append(t("config.describe_language", lang=lang))
345
+ enabled = data.get("enabled")
346
+ if isinstance(enabled, list):
347
+ joiner = t("common.enum_comma")
348
+ ids = joiner.join(str(i) for i in enabled)
349
+ parts.append(
350
+ t("config.describe_enabled_with_ids", n=len(enabled), ids=ids)
351
+ if enabled
352
+ else t("config.describe_enabled", n=len(enabled))
353
+ )
354
+ n_hidden = n_sources = n_feeds = 0
355
+ for section in data.values():
356
+ if not isinstance(section, dict):
357
+ continue
358
+ n_sources += 1
359
+ hide = section.get("hide") or []
360
+ n_hidden += len(hide) if isinstance(hide, list) else 0
361
+ feeds = section.get("feeds")
362
+ n_feeds += len(feeds) if isinstance(feeds, dict) else 0
363
+ if n_sources:
364
+ parts.append(t("config.describe_overrides", n=n_sources, hidden=n_hidden))
365
+ if n_feeds:
366
+ parts.append(t("config.describe_feeds", n=n_feeds))
367
+ return " · ".join(parts) if parts else t("config.describe_not_created")
@@ -0,0 +1,64 @@
1
+ """跨源共享常量与小工具:改这里即改全部源。
2
+
3
+ 站点特有调参(滚动步长、选择器、httpx 之外的取数细节)仍留在各源文件--
4
+ 只有「多源复制粘贴且必须同步变」的值才收进来。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ import html2text
13
+
14
+ # 桌面 UA 与请求超时:core/http 共享客户端的默认头(友好源的请求全走它)
15
+ DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
16
+ HTTP_TIMEOUT = 20 # 秒
17
+
18
+ # Playwright 页面导航超时(毫秒)
19
+ PAGE_TIMEOUT_MS = 30_000
20
+
21
+ # channel="chrome" 硬编码依赖的标准安装位置(macOS)与 PATH 候选名(Linux)
22
+ CHROME_PATH = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
23
+ CHROME_BINS = ("google-chrome", "google-chrome-stable", "chrome", "google-chrome-beta")
24
+
25
+
26
+ def find_chrome() -> str:
27
+ """Chrome 可执行文件路径;找不到回空串(doctor 自检与截图脚本共用的唯一实现)。"""
28
+ if CHROME_PATH.exists():
29
+ return str(CHROME_PATH)
30
+ return next((p for name in CHROME_BINS if (p := shutil.which(name))), "")
31
+
32
+
33
+ def safe_int(value: object) -> int:
34
+ """野数据防御的整数化:None/空/非数字/无穷大回 0(解析层对 API 野值的统一口径)。"""
35
+ if not isinstance(value, (int, float, str)):
36
+ return 0 # 非数字类型(None/bool 之外的对象)原也走 except -> 0
37
+ try:
38
+ return int(value or 0)
39
+ except (TypeError, ValueError, OverflowError):
40
+ return 0
41
+
42
+
43
+ def new_h2t() -> html2text.HTML2Text:
44
+ """HTML2Text 统一配置:不硬换行(body_width 会把中文撕碎);img/video 在进它
45
+ 之前已换成记号(见各源 _md),ignore_images 兜底丢掉漏网残片。
46
+ """
47
+ h2t = html2text.HTML2Text()
48
+ h2t.body_width = 0
49
+ h2t.ignore_images = True
50
+ return h2t
51
+
52
+
53
+ def chrome_launch_kwargs() -> dict:
54
+ """Chrome persistent context 公共启动参数(channel 固定本机 Chrome,无开关)。
55
+
56
+ browser.py(TUI 内无头)与 login.py(手动登录有头)共用;headless 由调用方给。
57
+ """
58
+ return {
59
+ "channel": "chrome",
60
+ "viewport": {"width": 1280, "height": 900}, # 桌面布局,避免命中移动端 DOM
61
+ "locale": "zh-CN",
62
+ "timezone_id": "Asia/Shanghai",
63
+ "args": ["--disable-blink-features=AutomationControlled"],
64
+ }
@@ -0,0 +1,62 @@
1
+ """全 app 展示词汇的唯一出处:署名、计数、赞文本。
2
+
3
+ 各源数据形态不同(X 的 GraphQL、知乎的 API JSON),但上屏词汇必须同形——
4
+ 用户在源之间的体验是同一个软件,不是 N 个爬虫的拼接。源的 parse 层只把
5
+ 原始数值/字段传进来,拼接规则全部收口在这里;新增源照用,别自拼。
6
+
7
+ 词汇表(与 UI 层的既定形态对齐;各源行为见 README 的站点说明):
8
+ - 署名 signature(name, handle):「名字 @handle」——账号体系双要素的源
9
+ (X/论坛)两样都露;没有 @ 账号惯例的源(知乎,站方 @ 提及用姓名)只传
10
+ name,不硬造站方没有的形态。各语言形态一致,不经 i18n
11
+ - 计数 fmt_count:随语言二形——中文过万折「万」保留一位小数(45000 ->
12
+ 4.5 万),其余语言按千分位 K/M 折(45000 -> 45K、1234000 -> 1.2M)。
13
+ 折法是书写惯例不是文案,代码分支按语言族收在这里(zh* 用万,其余
14
+ K/M——新语言默认进 K/M 形)
15
+ - 赞 votes_text:「赞 4.5 万」/「45K likes」;0 赞空文本(与站方按钮同
16
+ 形态,楼层头不占位)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from feedscope.core import i18n
22
+ from feedscope.core.i18n import t
23
+
24
+
25
+ def signature(name: str = "", handle: str = "") -> str:
26
+ """作者署名「名字 @handle」:两样都露,缺谁剩谁,全缺空串。"""
27
+ if name and handle:
28
+ return f"{name} @{handle}"
29
+ return f"@{handle}" if handle else name
30
+
31
+
32
+ def _use_wan() -> bool:
33
+ """计数折法用「万」还是 K/M:中文书写惯例用万,其余按 K/M。"""
34
+ return i18n.language().startswith("zh")
35
+
36
+
37
+ def fmt_count(n: int) -> str:
38
+ """整数计数 -> 展示文本(随语言二形,见模块头词汇表)。"""
39
+ if _use_wan():
40
+ return f"{n / 10000:.1f} {t('display.wan')}".replace(".0 ", " ") if n >= 10000 else str(n)
41
+ if n >= 1_000_000:
42
+ return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
43
+ if n >= 1000:
44
+ return f"{n / 1000:.1f}K".replace(".0K", "K")
45
+ return str(n)
46
+
47
+
48
+ def votes_text(n: int) -> str:
49
+ """赞数 -> 楼层头赞文本;0 赞空文本(无赞不占位)。"""
50
+ return t("display.votes", n=fmt_count(n)) if n else ""
51
+
52
+
53
+ def view_count_text(n: int) -> str:
54
+ """知乎详情 meta 的浏览数段(带前导「 · 」分隔符):「 · 1234 万浏览」。
55
+
56
+ 「万浏览」是站方复合词(fmt_count 出「1234 万」会多个空格),中文分支
57
+ 拼写与站方同形;其余语言走 K/M 计数一条模板。分隔符进模板:语种想换
58
+ 分隔形态改文件即可。
59
+ """
60
+ if _use_wan():
61
+ return t("display.visits_wan", n=n // 10000) if n >= 10000 else t("display.visits", n=n)
62
+ return t("display.visits", n=fmt_count(n))
feedscope/core/http.py ADDED
@@ -0,0 +1,104 @@
1
+ """共享 HTTP 客户端池:httpx 源的统一取数通道与错误出口。
2
+
3
+ - 进程级单例 AsyncClient:同 host 连接复用(翻页时省掉每次的 TCP/TLS 握手)
4
+ - httpx 异常统一在边界包成 SourceError(中文、含源标签,UI 可直接展示)
5
+ - 惰性创建:只有真正发请求才建(测试/脚本不碰网络就不建)
6
+ - FEEDSCOPE_SAMPLE_DIR 设定时把响应体落盘(feedscope probe 巡检用,平时零开销)
7
+
8
+ 友好源(RSS/HN/V2EX/Telegram)走这里;敌意源(知乎/Reddit/X)的数据
9
+ 通道走 core/browser.ChannelTab——但边界按请求定不按源定:敌意源里的公开
10
+ 请求(X 收割的 CDN chunk 下载)也走这里。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, TypeVar
16
+
17
+ import httpx
18
+
19
+ from feedscope.core import samples
20
+ from feedscope.core.const import DESKTOP_UA, HTTP_TIMEOUT
21
+ from feedscope.core.i18n import t
22
+ from feedscope.core.source import SourceError
23
+
24
+ _J = TypeVar("_J", dict, list) # get_json 的期望顶层形状(dict / list)
25
+
26
+ _client: httpx.AsyncClient | None = None
27
+
28
+
29
+ def get_client() -> httpx.AsyncClient:
30
+ global _client
31
+ if _client is None:
32
+ _client = httpx.AsyncClient(
33
+ headers={"User-Agent": DESKTOP_UA},
34
+ timeout=HTTP_TIMEOUT,
35
+ follow_redirects=True,
36
+ )
37
+ return _client
38
+
39
+
40
+ async def get(
41
+ url: str,
42
+ *,
43
+ source: str,
44
+ params: dict[str, Any] | None = None,
45
+ headers: dict[str, str] | None = None,
46
+ allow_304: bool = False,
47
+ sample: bool = True,
48
+ ) -> httpx.Response:
49
+ """GET 并校验状态码;httpx 异常统一包成 SourceError。
50
+
51
+ source 是展示标签(如 "X"),只进错误消息;params / headers 原样透传
52
+ (headers 供带鉴权头的源用,如 X 的 Bearer/Cookie)。allow_304 给条件
53
+ 请求的源用(rss 的 ETag/Last-Modified 协商):304 是「内容没变」的正
54
+ 常语义而非失败,放行给调用方自己回放缓存;其余状态码照常校验。sample
55
+ 关掉样本落盘(X 收割 chunk 下载的 JS bundle 不是 API 响应,留着只占地方)。
56
+ """
57
+ try:
58
+ r = await get_client().get(url, params=params, headers=headers)
59
+ if not (allow_304 and r.status_code == 304):
60
+ r.raise_for_status() # httpx 新版对 3xx 也抛,304 必须先放行
61
+ except httpx.TimeoutException:
62
+ raise SourceError(
63
+ t("errors.http_timeout", source=source, seconds=HTTP_TIMEOUT, url=url)
64
+ ) from None
65
+ except httpx.HTTPStatusError as exc:
66
+ raise SourceError(
67
+ t("errors.http_status", source=source, code=exc.response.status_code, url=url)
68
+ ) from None
69
+ except httpx.HTTPError as exc:
70
+ raise SourceError(t("errors.http_failed", source=source, error=exc)) from exc
71
+ if sample:
72
+ samples.save(samples.url_name(source, str(r.url)), r.content)
73
+ return r
74
+
75
+
76
+ async def get_json(
77
+ url: str,
78
+ *,
79
+ source: str,
80
+ params: dict[str, Any] | None = None,
81
+ expect: type[_J],
82
+ ) -> _J:
83
+ """GET 并按 JSON 解析 + 形状校验:JSON API 源(HN/V2EX)共用的小原语。
84
+
85
+ 两种失败分开报:「返回的不是 JSON」是网络/代理层问题,「返回结构异常,
86
+ 接口可能已变」是改版信号(probe 巡检据此定位)。expect 是期望的顶层
87
+ 形状(dict / list),形状不对视为改版而非崩溃。
88
+ """
89
+ r = await get(url, source=source, params=params)
90
+ try:
91
+ data = r.json()
92
+ except ValueError:
93
+ raise SourceError(t("errors.not_json", source=source)) from None
94
+ if not isinstance(data, expect):
95
+ raise SourceError(t("errors.bad_structure", source=source)) from None
96
+ return data
97
+
98
+
99
+ async def close() -> None:
100
+ """退出前释放连接池;幂等,未创建则无事发生。"""
101
+ global _client
102
+ if _client is not None:
103
+ await _client.aclose()
104
+ _client = None