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,444 @@
1
+ # FeedScope 文案目录 · 简体中文(基准语言)
2
+ #
3
+ # - 键为点分嵌套域,代码里用 core.i18n.t("domain.key", 占位=值) 查找
4
+ # - 占位符 {name} 走 str.format;文案里要出现字面花括号双写 {{ }}
5
+ # - 各语言文件键树必须完全一致(tests/test_i18n.py 奇偶校验),漏译会红
6
+ # - 新增语言:复制本文件为 <code>.toml 改 [meta] name 与文案即可被自动发现
7
+
8
+ [meta]
9
+ name = "简体中文"
10
+
11
+ [common]
12
+ enum_comma = "、"
13
+
14
+ [app]
15
+ exit_onboarding_cancel = "未完成初始设置,重新运行 feedscope 再次选择"
16
+ exit_no_sources = "没有注册任何源"
17
+
18
+ title = "FeedScope(观流)"
19
+
20
+ [bindings]
21
+ language = "语言"
22
+
23
+ # 语言选择屏是「语言未定时无法翻译自身」的元控件:标题/提示双语同值(en.toml 同)
24
+ quit = "退出"
25
+ cancel = "取消"
26
+ back = "返回"
27
+ add = "添加"
28
+ remove = "删除"
29
+ refresh = "刷新"
30
+ browser = "浏览器"
31
+ manage_sources = "源管理"
32
+ account = "账号"
33
+ more = "更多"
34
+ sidebar = "边栏"
35
+ switch_source = "切站"
36
+
37
+ [language]
38
+ title = "选择语言 / Choose Language"
39
+ hint = "Enter 确认 · Esc 取消 / Enter to confirm · Esc to cancel"
40
+
41
+ [main]
42
+ # 双语同值(en.toml 同):切换方未必读得懂当前语言的提示,两个方向都要看懂
43
+ language_saved_restart = "已保存,重启 feedscope 后生效 · Saved — restart feedscope to apply"
44
+ language_unchanged = "语言未变:当前已是 {name}"
45
+ hint_no_subscriptions = "{label} 已启用但还没有订阅——F2 里选中它按 → 添加"
46
+
47
+ # 源与请求层的错误出口(SourceError 消息,UI 直接展示)
48
+ sidebar_no_sources = "(未启用任何源,F2 管理)"
49
+ sidebar_no_feeds = "(无信息流)"
50
+ empty_no_sources = "没有启用的源 · 按 F2 管理"
51
+ toast_no_sources = "没有启用的源 · F2 管理"
52
+ empty_no_content = "没有内容 · F5 刷新 · Tab 或点击顶部 tab 切换站点"
53
+ empty_load_failed = "加载失败:{text}"
54
+ status_signed_out = "未登录"
55
+ hint_login = "{error} · Ctrl+U 登录"
56
+ status_count = "{n} 条"
57
+ status_more_loading = "{n} 条 · 加载更多…"
58
+ status_more_failed = "{n} 条 · 加载更多失败"
59
+ status_no_more = "{n} 条 · 没有更多了"
60
+ status_no_new = "{n} 条 · 这轮没拉到新内容"
61
+ toast_no_more = "没有更多了"
62
+ toast_no_new_retry = "这轮没拉到新内容,稍后再按 Ctrl+N 重试"
63
+ toast_last_feed = "已是最后一个流"
64
+ toast_no_selection = "当前没有选中的条目"
65
+ toast_nothing_to_load = "当前列表没有可加载的内容"
66
+ toast_cjk_input = "检测到中文输入:快捷键全部是非打印键,不受输入法影响"
67
+ hint_top_bar = "顶部源栏:←→ 切站,↓ 进边栏"
68
+ hint_sidebar = "左侧边栏:↑↓ 切流,→ 回列表"
69
+ saved_n_sources = "已保存:{n} 源启用"
70
+ saved_zero_sources = "已保存:当前没有启用的源(F2 可再开启)"
71
+ [errors]
72
+ zero_items = "{label} 解析到 0 条内容,站点可能已改版(feedscope probe 可复查)"
73
+ zero_items_with_feed = "{label}「{feed}」解析到 0 条内容,站点可能已改版(feedscope probe 可复查)"
74
+ merge_one_failed = "{label}:{error}"
75
+ comments_unsupported = "{name} 不支持评论层"
76
+ comment_out_of_range = "{label} 该评论不在已加载范围内,先按 Ctrl+N 加载更多楼层"
77
+ login_required = "{label} 需要登录后才能查看{what}"
78
+ unknown_feed = "未知信息流:{name}"
79
+ detail_unsupported = "{label} 暂不支持终端内详情"
80
+ no_subscriptions_hint = "{label} 还没有订阅——F2 里选中 {label} 按 → 添加{sub}"
81
+ all_feeds_failed = "{label}「{feed}」订阅全部拉取失败(共 {n} 个):{errors}"
82
+ http_timeout = "{source} 请求超时({seconds} 秒):{url}"
83
+ http_status = "{source} 请求失败(HTTP {code}):{url}"
84
+ http_failed = "{source} 请求失败:{error}"
85
+ not_json = "{source} 返回的不是 JSON"
86
+ bad_structure = "{source} 返回结构异常,接口可能已变"
87
+ bad_structure_short = "{source} 返回结构异常"
88
+ bad_structure_not_object = "{source} 返回结构异常(不是 JSON 对象)"
89
+ what_feed = "「{label}」流"
90
+ what_post_detail = "帖子详情"
91
+ what_timeline = "「{label}」时间线"
92
+ what_tweet_detail = "推文详情"
93
+ hn_no_hits = "HN「{label}」返回里没有 hits,接口可能已变"
94
+ hn_no_id = "HN 详情返回里没有 id,接口可能已变"
95
+ reddit_status_login = "{source} 请求失败(HTTP {status})——登录态可能已过期或被风控,Ctrl+U 重新登录"
96
+ reddit_subreddit_gone = "r/{name} 不存在或不可访问"
97
+ reddit_home_empty = "Reddit「首页」是你账号在站内关注版块的聚合,当前为空——去 reddit.com 关注一些版块,或 F2 → Reddit 添加 subreddit 订阅(单独成流)"
98
+ rss_304_no_cache = "RSS「{label}」返回 304 但本地没有可用的缓存内容"
99
+ telegram_no_items = "Telegram @{key} 一条也拉不到:频道可能不存在、为空、或站长关闭了 Web 预览(t.me/s/ 页面需要站长开启)"
100
+ v2ex_no_context = "V2EX 详情缺少列表上下文,回到列表重进该主题"
101
+ x_no_shape = "{source} 拿不到接口请求形状(收割与种子都缺),请反馈维护者"
102
+ x_login_expired = "X 登录已失效(服务端已不认本地登录态),Ctrl+U 重新登录"
103
+ x_403 = "{source} 请求失败(HTTP 403)——可能被风控或账号受限,重新登录救不了,稍后再试或换网络"
104
+ x_status_retry = "{source} 请求失败(HTTP {status}):接口限流或抖动,稍后再试"
105
+ x_shape_stale = "{source} 接口报错(请求形状可能已失效,自动收割未修复——跑 scripts/x_probe.py 诊断或反馈维护者)"
106
+ x_detail_empty = "X 详情数据为空(推文可能已被删除或隐藏)"
107
+ zhihu_api_bad_data = "接口返回异常:{kind}"
108
+ zhihu_api_status = "接口请求失败(HTTP {status})"
109
+ zhihu_api_failed = "接口请求失败:{error}"
110
+ zhihu_login_wall = "{label}需要登录后才能查看此内容"
111
+ zhihu_session_expired = "{label} 登录已过期,需要重新登录"
112
+ zhihu_api_structure = "{label} 开题接口返回结构异常"
113
+ zhihu_question_gone = "{label} 该问题可能已被删除或不存在"
114
+ zhihu_answer_out_of_range = "{label} 该回答不在已加载范围内,先在问题里按 Ctrl+N 加载更多"
115
+ zhihu_answer_no_id = "{label} 拿不到该回答的 id,暂时无法进评论层"
116
+ zhihu_comments_failed = "{label} 评论区打开失败:{error}"
117
+ zhihu_no_question_id = "{label} 该条目没有问题 id,无法进详情"
118
+
119
+ # 共享词汇(源词汇表 ClassVar 指向这里;各源专有词在 sources.<id>.*)
120
+ [units]
121
+ all = "全部"
122
+ answer = "回答"
123
+ comment = "评论"
124
+ reply = "回复"
125
+ body = "正文"
126
+ subscription = "订阅"
127
+ channel = "频道"
128
+
129
+ [source_setup]
130
+ not_subscribable = "{label} 的信息流是内置的,没有可管理的订阅"
131
+
132
+ onboarding_title = "欢迎使用观流(FeedScope)"
133
+ onboarding_prompt = "选择要启用的信息源(空格勾选 / 取消);序号即站点排列顺序"
134
+ onboarding_confirm = "开始使用"
135
+ manage_title = "源管理"
136
+ manage_prompt = "空格/Enter 勾选;序号即站点顺序,取消再勾选 = 移到末尾;→ 管理订阅"
137
+ manage_confirm = "保存"
138
+ n_feeds_more = " 等 {n} 个流"
139
+ no_subs = "无订阅 · → 添加"
140
+
141
+ [media]
142
+ image = "[图片]"
143
+ video = "[视频]"
144
+ gallery = "[相册]"
145
+ generic = "[媒体]"
146
+ images_n = "[图片 {n}]"
147
+
148
+
149
+ [display]
150
+ wan = "万"
151
+ votes = "赞 {n}"
152
+ comments_n = "{n} 条评论"
153
+ replies_n = "{n} 条回复"
154
+ replies_plain = "{n} 回复"
155
+ favorites = "{n} 赞"
156
+ visits = " · {n} 浏览"
157
+ visits_wan = " · {n} 万浏览"
158
+
159
+
160
+ [config]
161
+ file_header = """FeedScope 源配置:language = 界面语言;enabled = 启用的源;各源 hide = 隐藏的信息流
162
+ feeds = 订阅表(key = {{ label, url }}):rss 订 feed、reddit 订 subreddit、
163
+ telegram 订频道;由 feedscope 自动维护(首启引导 / F2 管理 / rss 子命令),
164
+ 也可手动编辑"""
165
+ describe_not_created = "未创建(全部用内置默认)"
166
+ describe_unparsable = "无法解析({error})"
167
+ describe_language = "语言 {lang}"
168
+ describe_enabled = "启用 {n} 源"
169
+ describe_enabled_with_ids = "启用 {n} 源({ids})"
170
+ describe_overrides = "{n} 源覆写:{hidden} 隐藏"
171
+ describe_feeds = "{n} 订阅"
172
+
173
+ [words]
174
+ deleted_author = "[已删除]"
175
+ deleted_body = "(已删除)"
176
+ points = "{n} 点"
177
+ comments_short = "{n} 评"
178
+ no_title = "(无标题)"
179
+ no_body = "(无正文)"
180
+ anonymous = "匿名用户"
181
+ original = "[原文]"
182
+ views = "{n} 观看"
183
+ zhihu_asked = "{asker} 提问"
184
+ zhihu_n_answers = " · {n} 个回答"
185
+ more_unexpanded = "另有 {n} 条未展开"
186
+
187
+ [sources.zhihu]
188
+ label = "知乎"
189
+
190
+ [sources.zhihu.feeds]
191
+ hot = "热榜"
192
+ recommend = "推荐"
193
+ follow = "关注"
194
+
195
+ [sources.hn]
196
+ detail = "HN 详情"
197
+
198
+ [sources.hn.feeds]
199
+ front = "首页"
200
+ new = "最新"
201
+ ask = "提问"
202
+ show = "展示"
203
+
204
+ [sources.v2ex]
205
+ with_label = "V2EX「{label}」"
206
+ replies = "V2EX 回复"
207
+
208
+ [sources.v2ex.feeds]
209
+ hot = "热门"
210
+ new = "最新"
211
+
212
+ [sources.x]
213
+ timeline = "X 时间线"
214
+ detail = "X 详情"
215
+
216
+ [sources.x.feeds]
217
+ foryou = "推荐"
218
+ following = "正在关注"
219
+
220
+ [sources.reddit]
221
+ post = "Reddit 帖子"
222
+
223
+ [sources.reddit.feeds]
224
+ home = "首页"
225
+ popular = "热门"
226
+
227
+ [sources.rss]
228
+ with_label = "RSS「{label}」"
229
+ [x]
230
+ translation_label = "**译文**({lang})"
231
+ translation_plain = "**译文**"
232
+ translation_quote = "> {label}:{text}"
233
+ quote_line = " ▸ 引用 {who}:{body}"
234
+ tweet_fallback = "推文"
235
+ parent_tweet = "原推"
236
+ quoted_tweet = "被引推文"
237
+
238
+
239
+ [x_langs]
240
+ en = "英语"
241
+ ja = "日语"
242
+ ko = "韩语"
243
+ ru = "俄语"
244
+ fr = "法语"
245
+ de = "德语"
246
+ es = "西班牙语"
247
+ pt = "葡萄牙语"
248
+ it = "意大利语"
249
+ ar = "阿拉伯语"
250
+ tr = "土耳其语"
251
+ th = "泰语"
252
+ vi = "越南语"
253
+ id = "印尼语"
254
+ hi = "印地语"
255
+ uk = "乌克兰语"
256
+ nl = "荷兰语"
257
+ pl = "波兰语"
258
+ sv = "瑞典语"
259
+ da = "丹麦语"
260
+ fi = "芬兰语"
261
+ no = "挪威语"
262
+ el = "希腊语"
263
+ he = "希伯来语"
264
+ cs = "捷克语"
265
+ hu = "匈牙利语"
266
+ ro = "罗马尼亚语"
267
+ ms = "马来语"
268
+ fa = "波斯语"
269
+ ur = "乌尔都语"
270
+ bn = "孟加拉语"
271
+ tl = "菲律宾语"
272
+
273
+ # 订阅管理(SubsStore 文案 + SubscriptionError 消息)
274
+ [subs]
275
+ dup_url = "这个地址已经订阅过了(key:{key})"
276
+ reserved_key = "{key} 是聚合流保留字,不能用作订阅 key"
277
+ key_taken = "key {key} 已被占用(feedscope rss list 查看)"
278
+ empty_input = "输入为空"
279
+ added_feed = "已订阅 {key}:{label}"
280
+ imported = "导入 {n} 条订阅{detail}"
281
+ imported_skip = ",跳过 {n} 条"
282
+ opml_unreadable = "OPML 文件读不了:{error}"
283
+ opml_invalid = "不是合法的 OPML:{error}"
284
+ opml_dup = "{name}:地址已订阅"
285
+ opml_title = "FeedScope 订阅导出"
286
+ subreddit_unrecognized = "没认出 subreddit 名(支持:rust、r/rust、/r/rust/ 或站点地址)"
287
+ subreddit_reserved = "{name} 与内置流重名(首页/热门),不能用作订阅名"
288
+ subreddit_dup = "r/{name} 已经订阅过了"
289
+ subreddit_failed = "r/{name} 加不上:{error}"
290
+ added_subreddit = "已订阅 r/{key}:{label}"
291
+ channel_unrecognized = "没认出频道名(支持:durov、@durov、t.me/durov 或帖子链接)"
292
+ channel_reserved = "{name} 是聚合流保留字,不能用作订阅名"
293
+ channel_dup = "@{name} 已经订阅过了"
294
+ channel_no_items = "@{name} 拉不到任何帖子:频道可能不存在、为空,或关闭了 Web 预览"
295
+ added_channel = "已订阅 @{key}:{label}"
296
+ rss_placeholder = "RSS/Atom 地址、站点首页或 OPML 文件路径(可从 Finder 拖入终端)"
297
+ rss_hint = "↑↓ 选择 · Enter 改名 · Insert 添加(地址 / 首页 / OPML 路径)· Delete 删除"
298
+ rss_empty = "还没有订阅 · Insert 添加(可粘贴 OPML 路径)"
299
+ reddit_placeholder = "subreddit 名或地址(如 rust、r/rust、reddit.com/r/rust)"
300
+ hint_basic = "↑↓ 选择 · Enter 改名 · Insert 添加 · Delete 删除"
301
+ reddit_empty = "还没有订阅 · Insert 添加(输入 subreddit 名即可)"
302
+ telegram_placeholder = "频道名或链接(如 durov、@durov、t.me/durov)"
303
+ telegram_empty = "还没有订阅 · Insert 添加(输入频道名或链接即可)"
304
+ no_store = "{source} 没有订阅管理适配器(subscribable 与 SubsStore 缺一不可)"
305
+
306
+ [doctor]
307
+ title = "FeedScope(观流){version} 环境自检"
308
+ need_py = "需要 3.11+"
309
+ chrome_missing = "未找到(macOS 标准位置或 PATH 里的 google-chrome),channel=\"chrome\" 依赖它"
310
+ deps_label = "依赖 textual / playwright"
311
+ importable = "可导入"
312
+ import_failed = "导入失败:{error}(先 uv sync)"
313
+ profile_label = "浏览器 profile(信息)"
314
+ profile_exists = "已存在"
315
+ profile_will_create = "首次运行时自动创建"
316
+ config_label = "用户配置 sources.toml(信息)"
317
+ all_ok = "全部通过"
318
+ has_failures = "存在失败项:按提示处理后重试"
319
+
320
+ [probe]
321
+ unexpected = "未预期异常:{exc}"
322
+ detail_label = "{label} 详情"
323
+ n_items = "{n} 条"
324
+ detail_empty = "标题/正文/楼层全空:详情页可能已改版"
325
+ detail_ok = "「{title}」{n} {unit}"
326
+ unknown_ids = "未知源 id:{unknown}(可选:{known})"
327
+ title = "FeedScope(观流)源巡检(原始样本存 {dir})"
328
+ summary = "{ok} 通过 / {fail} 失败 / {skip} 跳过"
329
+ fail_hint = "失败源的原始样本在 {dir},可对着样本修 parse.py 并补 fixture"
330
+
331
+ [login]
332
+ unknown_source = "未知源:{source}(已注册:{ids})"
333
+ not_needed = "{label} 无需登录(匿名可访问)"
334
+ starting = "启动浏览器(profile: {dir})"
335
+ prompt = "请在弹出的 Chrome 窗口里登录 {label},完成后本命令自动退出。"
336
+ check_error = "登录检测出错:{error}"
337
+ browser_failed = "浏览器启动或打开登录页失败:{error}"
338
+ timeout = "超时:{seconds} 秒内未检测到登录,重新运行 feedscope login 再试。"
339
+ success_state = "登录态已写入 profile"
340
+ success_cookies = "{cookies} 已写入 profile"
341
+ success = "登录成功:{how},之后无头复用。"
342
+
343
+ [cli]
344
+ description = "FeedScope(观流)- 终端信息流聚合阅读器"
345
+ help_tui = "启动 TUI(默认)"
346
+ help_login = "打开浏览器窗口登录某个源"
347
+ help_login_source = "源 id(默认 zhihu)"
348
+ help_probe = "巡检全部源(改版诊断 + 原始样本落盘)"
349
+ help_probe_sources = "只测这些源 id(缺省全部;未登录的源报 SKIP)"
350
+ help_doctor = "环境自检(Chrome / profile / 用户配置)"
351
+ help_rss = "RSS 订阅管理(list / add / remove / import / export)"
352
+ help_rss_list = "列出全部订阅"
353
+ help_rss_add = "添加订阅(显示名缺省拉一次 feed 标题)"
354
+ help_rss_add_url = "RSS / Atom 地址、站点首页(自动发现 feed)或 OPML 文件路径"
355
+ help_rss_add_key = "订阅 key(缺省按域名生成)"
356
+ help_rss_add_label = "显示名(缺省用 feed 自述标题)"
357
+ help_rss_remove = "删除订阅"
358
+ help_rss_remove_key = "要删的订阅 key(feedscope rss list 查看)"
359
+ help_rss_import = "从 OPML 批量导入(幂等合并,已订阅的跳过)"
360
+ help_rss_import_path = "OPML 文件路径"
361
+ help_rss_export = "导出订阅为 OPML 2.0"
362
+ help_rss_export_path = "写入的文件路径(缺省打印到 stdout,可重定向)"
363
+ rss_list_empty = "还没有订阅:feedscope rss add <地址> 或 import <OPML 文件>"
364
+ rss_list_total = "共 {n} 条订阅"
365
+ rss_added = "已订阅 {key}:{label} <{url}>"
366
+ not_added = "未添加:{error}"
367
+ restart_hint = "(重启 TUI 生效)"
368
+ no_such_key = "没有 key 为 {key} 的订阅(feedscope rss list 查看)"
369
+ removed = "已删除 {key}:{label}"
370
+ import_failed = "导入失败:{error}"
371
+ skipped_line = "跳过 {line}"
372
+ more_skipped = "…另有 {n} 条跳过"
373
+ import_result = "导入 {added} 条,跳过 {skipped} 条"
374
+ exported = "已导出 {n} 条订阅到 {path}"
375
+
376
+ [dialogs]
377
+ confirm = "确认"
378
+ cancel = "取消"
379
+ ok = "确定"
380
+ login = "登录"
381
+ logout = "登出"
382
+ login_prompt = "{label}需要登录"
383
+ logout_prompt = "登出 {label}?"
384
+
385
+ [account]
386
+ login_refused = "已取消,本次运行不再自动询问;Ctrl+U 可登录"
387
+ no_login_needed = "{label} 无需登录"
388
+ login_in_progress = "登录进行中,稍候"
389
+ login_refresh_later = "登录进行中,稍候刷新"
390
+ title_login = "登录"
391
+ login_ok = "{label} 登录成功"
392
+ login_incomplete = "{label} 登录未完成,可再试一次"
393
+ logged_out = "已登出 {label}"
394
+ not_logged_in = "{label} 本就没有登录"
395
+
396
+ [feedback]
397
+ failure = "{action}:{error}"
398
+ failure_with_source = "{source} {action}:{error}"
399
+ title_fetch_failed = "抓取失败"
400
+ action_fetch = "请求失败"
401
+ action_add_failed = "添加失败"
402
+ action_logout_failed = "登出失败"
403
+ action_comments_failed = "评论加载失败"
404
+ action_more_failed = "加载更多失败"
405
+
406
+ [subscriptions]
407
+ title = "{label} 订阅管理"
408
+ add = "添加"
409
+ rename = "改名"
410
+ remove = "删除"
411
+ add_title = "添加订阅"
412
+ rename_title = "改显示名"
413
+ pick_first = "先选中一条订阅"
414
+ remove_confirm = "删除订阅「{label}」?"
415
+ removed = "已删除 {label}(想看回来可重新添加)"
416
+ rename_noop = "名字没变(空名或订阅已不存在)"
417
+ renamed = "已改名为 {name}"
418
+
419
+ [read]
420
+ loading = "加载中…"
421
+ loading_more = "加载更多…"
422
+ status_failed = "加载失败"
423
+ eof = "(完)"
424
+ no_more = "(没有更多{unit}了)"
425
+ no_more_plain = "没有更多{unit}了"
426
+ none = "(没有{unit})"
427
+ unit_loading = "{unit}加载中…"
428
+ load_failed_retry = "加载失败:{text} · Ctrl+N 重试"
429
+ comments_failed_retry = "评论加载失败:{text} · Ctrl+N 重试"
430
+ hint_more = "Ctrl+N 加载更多{unit}"
431
+ hint_comments = "Enter 看{unit}的{cunit}"
432
+ floor_head = "{unit} {n}"
433
+ answer_title = "{author} 的{unit}"
434
+ still_loading = "内容还在加载"
435
+ no_floors_yet = "还没有可进入的回答"
436
+ single_floor_unsupported = "{label} 暂不支持进入单条{unit}"
437
+ no_next_readable = "后面没有可读的了"
438
+ no_prev_readable = "前面没有可读的了"
439
+ no_next_loaded = "后面没有已加载的{unit}了"
440
+ no_prev_unit = "前面没有{unit}了"
441
+ binding_more_unit = "更多{unit}"
442
+ binding_next_unit = "下一{unit}"
443
+ binding_prev_unit = "上一{unit}"
444
+
@@ -0,0 +1,150 @@
1
+ """HTML 媒体标签 -> 文本标记的共用件:TUI 显示不了图/视频,正文里换成
2
+ 「[图片]/[视频]」markdown 链接(地址折叠可点击,见 mark)。知乎/X/RSS/
3
+ V2EX/Reddit/Telegram 在用,源内特例留在源文件里。
4
+
5
+ 地址不能直接进 html2text:它会把 URL 里的 & 当实体转义坏(&x=1 -> &x;=1)。
6
+ 所以先换成 NUL 包裹的记号(NUL 不会出现在正文里,html2text 原样放过),
7
+ 跑完 html2text 再由 Tokens 还原。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html as _html
13
+ import re
14
+ import threading
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ import html2text
19
+
20
+ from feedscope.core.const import new_h2t
21
+ from feedscope.core.i18n import t
22
+ from feedscope.core.text import strip_zwsp
23
+
24
+ # 开标签属性:键 [\w-]+,值双/单引号皆可(反向引用配对——双引号值里的
25
+ # 英文撇号(Don't)不会被单引号误截断,单引号值里的双引号同理)
26
+ _ATTR_RE = re.compile(r"""([\w-]+)\s*=\s*(["'])(.*?)\2""")
27
+ _TOKEN_RE = re.compile("\x00(\\d+)\x00")
28
+
29
+ # <video>…</video> 整块(嵌套 <source> 一起吃掉),自闭合也认;没有闭合标签的残片不碰
30
+ VIDEO_RE = re.compile(r"<video\b[^>]*(?:/>|>.*?</video>)", re.IGNORECASE | re.DOTALL)
31
+
32
+
33
+ def attrs(tag: str) -> dict[str, str]:
34
+ """开标签 -> 属性字典:键转小写,值反转义(src 里的 &amp; 还原成 &)。"""
35
+ return {k.lower(): _html.unescape(v) for k, _q, v in _ATTR_RE.findall(tag)}
36
+
37
+
38
+ def pick_url(tag: str, attr_order: tuple[str, ...]) -> str:
39
+ """按优先级取第一个可用地址:跳过 data: 懒加载占位,// 开头补全 https:。"""
40
+ a = attrs(tag)
41
+ for name in attr_order:
42
+ url = a.get(name, "").strip()
43
+ if url and not url.startswith("data:"):
44
+ return "https:" + url if url.startswith("//") else url
45
+ return ""
46
+
47
+
48
+ def video_url(block: str) -> str:
49
+ """<video> 整块 -> 播放地址:嵌套 <source src> 优先,其次 video 标签自己的 src。"""
50
+ m = re.search(r"<source\b[^>]*>", block, re.IGNORECASE)
51
+ if m:
52
+ url = pick_url(m.group(0), ("src",))
53
+ if url:
54
+ return url
55
+ m = re.search(r"<video\b[^>]*>", block, re.IGNORECASE)
56
+ return pick_url(m.group(0), ("src", "data-src")) if m else ""
57
+
58
+
59
+ def mark(label: str, url: str) -> str:
60
+ """媒体记号 -> markdown 链接「[图片](地址)」;地址拿不到时退回纯标记。
61
+
62
+ 链接形式比裸 URL 平铺好两截:Textual 的 Markdown 把它渲染成可点击
63
+ 组件(点击即开浏览器),URL 折叠不占行宽;显式链接语法也不会被
64
+ markdown-it 的 linkify 吞掉后续中文。URL 里的 ) 截断链接语法,转义
65
+ 成 %29(维基类图片地址常见)。
66
+ """
67
+ if not url:
68
+ return label
69
+ return f"{label}({url.replace(')', '%29')})"
70
+
71
+
72
+ class Tokens:
73
+ """NUL 记号池:stash(最终文本) 换记号绕过 html2text,expand 统一还原。"""
74
+
75
+ def __init__(self) -> None:
76
+ self._texts: list[str] = []
77
+
78
+ def stash(self, text: str) -> str:
79
+ self._texts.append(text)
80
+ return f"\x00{len(self._texts) - 1}\x00"
81
+
82
+ def swap(self, index: int, text: str) -> None:
83
+ """替换已 stash 的文本(锚点图换成 <a> 指向的原图直链时用)。"""
84
+ self._texts[index] = text
85
+
86
+ def expand(self, text: str) -> str:
87
+ return _TOKEN_RE.sub(lambda m: self._texts[int(m.group(1))], text)
88
+
89
+
90
+ # img 标签(要求带 src):进 html2text 前先 stash 成 [图片](src) 记号——
91
+ # ignore_images 会连 src 一起丢,原图直链就没;绕行也防 & 被实体转义
92
+ IMG_RE = re.compile(r"""<img[^>]*?src=(["'])([^"']+)\1[^>]*>""", re.IGNORECASE)
93
+
94
+ # 只包一张图的锚点(V2EX 自动内联 / 博客 lightbox):整个 <a> 塌缩成那个
95
+ # 图片记号。不塌的话外层 <a> 会被 html2text 再转一层链接,生成
96
+ # [[图片](src)](href) 嵌套——markdown 不允许嵌套链接,整段按字面露出
97
+ _ANCHOR_IMG_RE = re.compile(r"(<a\b[^>]*>)\s*\x00(\d+)\x00\s*</a>", re.IGNORECASE)
98
+
99
+
100
+ _H2T_LOCAL = threading.local()
101
+
102
+
103
+ def h2t() -> html2text.HTML2Text:
104
+ """本线程的 HTML2Text 实例——全库取实例的唯一入口。
105
+
106
+ html2text 的 handle() 自带状态复位,但实例内部缓冲跨调用复用——两个
107
+ 线程同时 handle 同一实例会互踩输出。大正文解析会放工作线程跑(见
108
+ reddit 的 open_detail),各线程经这里各持一份实例:复用语义不变
109
+ (每线程一次构造),线程间不共享。源内**不要**自建 new_h2t() 模块级
110
+ 实例——解析一旦线程化就会静默互踩(知乎侧曾有一份,已收编到这里)。"""
111
+ h = getattr(_H2T_LOCAL, "h2t", None)
112
+ if h is None:
113
+ h = _H2T_LOCAL.h2t = new_h2t()
114
+ return h
115
+
116
+
117
+ def text_to_md(html: str) -> str:
118
+ """受限 HTML(p/a/i/code 等,不含 img/video)-> markdown:直通版管线。
119
+
120
+ HN 的评论正文用(站点只放行这几个标签,没有媒体标签可换记号),
121
+ 与 html_to_md 同一收尾(strip_zwsp + strip);带媒体的正文走 html_to_md。"""
122
+ if not html:
123
+ return ""
124
+ return strip_zwsp(h2t().handle(html)).strip()
125
+
126
+
127
+ def html_to_md(html: str) -> str:
128
+ """整段 HTML -> markdown:img 换 [图片](原图直链)。RSS 与 V2EX 同管线。
129
+
130
+ 图片必须 stash 成 NUL 记号绕过 html2text 而不是先变 <a> 再转:链接文字
131
+ 若带字面方括号(<a>[图片]</a>),html2text 会生成 [[图片]](url) 双层括号,
132
+ markdown 渲染不了;URL 里的 & 也会被它实体转义弄坏。
133
+ """
134
+
135
+ def _unwrap_anchor(m: re.Match[str]) -> str:
136
+ # 外层 <a> 的 href 是绝对地址时优先:lightbox 场景它指向原图,
137
+ # 缩略图 src 只是预览(V2EX 自动内联两者相同,无行为差异)
138
+ href = attrs(m.group(1)).get("href", "").strip()
139
+ if href.startswith(("http://", "https://")):
140
+ tok.swap(int(m.group(2)), mark(t("media.image"), href))
141
+ return f"\x00{m.group(2)}\x00"
142
+
143
+ if not html:
144
+ return ""
145
+ tok = Tokens()
146
+ stashed = IMG_RE.sub(
147
+ lambda m: tok.stash(mark(t("media.image"), pick_url(m.group(0), ("src",)))), html
148
+ )
149
+ stashed = _ANCHOR_IMG_RE.sub(_unwrap_anchor, stashed)
150
+ return strip_zwsp(tok.expand(h2t().handle(stashed))).strip()
@@ -0,0 +1,86 @@
1
+ """核心数据模型:FeedItem 是「源 -> UI」的唯一契约。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class FeedItem:
11
+ """列表级条目:所有源都产出它,UI 只消费它。
12
+
13
+ extra 的键约定(列表传给详情抓取的上下文,属源内私有约定,UI 不读取):
14
+ - x:conversation_id(转发详情跳原推会话)
15
+ - v2ex:topic(列表端点正文即全量,详情只再拉回复)
16
+ - reddit:subreddit(详情 meta 拼装用)
17
+ - rss:feed_key / feed_label / author / published / content_html(详情读回正文)
18
+ - telegram:channel / author / published / content_md(详情读回正文)
19
+ - zhihu:不使用
20
+ """
21
+
22
+ source: str # 源 id,如 "zhihu"
23
+ id: str # 源内稳定 id(url token、hn id 等)
24
+ title: str
25
+ url: str
26
+ kind: str = "" # 内容类型;语义由源定义(Source.kind_labels / detail_kinds)
27
+ meta: str = "" # 列表元信息列语义文本(热度/作者/节点名…),UI 统一截断对齐
28
+ extra: dict[str, Any] = field(default_factory=dict) # 传给 detail 抓取的补充字段
29
+
30
+
31
+ @dataclass
32
+ class Comment:
33
+ """一条评论(楼层内的讨论;知乎 / HN / Reddit 共用)。
34
+
35
+ author / votes / content_md / time 是展示字段;replies 是拍平后的子回复
36
+ (@父作者规则见 core.tree);raw_id 是源内评论 id(翻页去重)。
37
+ """
38
+
39
+ author: str
40
+ votes: str # 展示文本,如 "赞 3";无赞为空
41
+ content_md: str
42
+ time: str = ""
43
+ replies: list[Comment] = field(default_factory=list)
44
+ raw_id: str = ""
45
+
46
+
47
+ @dataclass
48
+ class Answer:
49
+ """一条楼层内容(知乎回答 / X 回复 / HN·Reddit 评论 / V2EX 回复)。
50
+
51
+ author 为展示名:知乎是昵称;X 是署名「名字 @handle」(名字与账号同显,
52
+ 见 sources/x/parse.py 的 _who)。评论是更深一层,不随楼层首批抓取
53
+ (按需经 DetailLoader.open_comments 进评论层),这里只带 comment_count
54
+ 引导(如 "158 条评论" / "4 条回复",缺省空)。
55
+ """
56
+
57
+ author: str
58
+ votes: str # 展示文本,如 "赞同 148"
59
+ content_md: str
60
+ time: str = "" # 发布时间展示文本,如 "2026-10-19 14:32"(源尽力而为)
61
+ url: str = "" # 楼层直达链接;空 = 用父条目 url
62
+ comment_count: str = "" # 展示文本,如 "158 条评论"(缺省空)
63
+
64
+
65
+ @dataclass
66
+ class Floor:
67
+ """一个楼层(顶层评论/回答):本体 + 它的回复树(已是 Comment 列表)。
68
+
69
+ 「详情一次拉全树」的源(HN / Reddit)的 parse 产物单元,楼层切片
70
+ Loader(core.source.SliceDetailLoader 的调用方)按 answer 建楼层、
71
+ 按下标切 comments 进评论层。
72
+ """
73
+
74
+ answer: Answer
75
+ comments: list[Comment] = field(default_factory=list)
76
+
77
+
78
+ @dataclass
79
+ class DetailDoc:
80
+ """详情级文档:open_detail() 的首批内容,更多靠 loader.load_more() 增量拿。"""
81
+
82
+ title: str
83
+ meta: str = "" # 一行元信息(如 "30 回答 · 12 万浏览")
84
+ time: str = "" # 正文发布时间展示文本(源尽力而为,缺省空)
85
+ description_md: str = "" # 问题描述(markdown)
86
+ answers: list[Answer] = field(default_factory=list)