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.
- feedscope/__init__.py +3 -0
- feedscope/__main__.py +4 -0
- feedscope/cli.py +154 -0
- feedscope/core/__init__.py +1 -0
- feedscope/core/browser.py +367 -0
- feedscope/core/config.py +367 -0
- feedscope/core/const.py +64 -0
- feedscope/core/display.py +62 -0
- feedscope/core/http.py +104 -0
- feedscope/core/i18n/__init__.py +202 -0
- feedscope/core/i18n/en.toml +447 -0
- feedscope/core/i18n/zh-CN.toml +444 -0
- feedscope/core/media.py +150 -0
- feedscope/core/models.py +86 -0
- feedscope/core/registry.py +53 -0
- feedscope/core/samples.py +68 -0
- feedscope/core/source.py +668 -0
- feedscope/core/text.py +54 -0
- feedscope/core/timefmt.py +53 -0
- feedscope/core/tree.py +134 -0
- feedscope/doctor.py +54 -0
- feedscope/login.py +168 -0
- feedscope/probe.py +131 -0
- feedscope/report.py +19 -0
- feedscope/sources/__init__.py +38 -0
- feedscope/sources/hn/__init__.py +66 -0
- feedscope/sources/hn/fetch.py +82 -0
- feedscope/sources/hn/parse.py +154 -0
- feedscope/sources/reddit/__init__.py +178 -0
- feedscope/sources/reddit/fetch.py +181 -0
- feedscope/sources/reddit/parse.py +318 -0
- feedscope/sources/rss/__init__.py +64 -0
- feedscope/sources/rss/fetch.py +94 -0
- feedscope/sources/rss/parse.py +116 -0
- feedscope/sources/telegram/__init__.py +61 -0
- feedscope/sources/telegram/fetch.py +79 -0
- feedscope/sources/telegram/parse.py +182 -0
- feedscope/sources/v2ex/__init__.py +64 -0
- feedscope/sources/v2ex/fetch.py +46 -0
- feedscope/sources/v2ex/parse.py +116 -0
- feedscope/sources/x/__init__.py +68 -0
- feedscope/sources/x/fetch.py +490 -0
- feedscope/sources/x/parse.py +535 -0
- feedscope/sources/zhihu/__init__.py +81 -0
- feedscope/sources/zhihu/fetch.py +402 -0
- feedscope/sources/zhihu/parse.py +235 -0
- feedscope/subscriptions.py +423 -0
- feedscope/ui/__init__.py +10 -0
- feedscope/ui/account.py +125 -0
- feedscope/ui/app.py +105 -0
- feedscope/ui/dialogs.py +127 -0
- feedscope/ui/feedback.py +64 -0
- feedscope/ui/focus_nav.py +113 -0
- feedscope/ui/labels.py +38 -0
- feedscope/ui/layout.py +66 -0
- feedscope/ui/list_render.py +42 -0
- feedscope/ui/screens/__init__.py +1 -0
- feedscope/ui/screens/answer.py +296 -0
- feedscope/ui/screens/detail.py +274 -0
- feedscope/ui/screens/language.py +59 -0
- feedscope/ui/screens/main.py +736 -0
- feedscope/ui/screens/main_setup.py +226 -0
- feedscope/ui/screens/reading.py +384 -0
- feedscope/ui/screens/source_setup.py +217 -0
- feedscope/ui/screens/subscriptions.py +196 -0
- feedscope/ui/theme.py +45 -0
- feedscope/ui/wrap.py +157 -0
- feedscope-0.1.0.dist-info/METADATA +302 -0
- feedscope-0.1.0.dist-info/RECORD +72 -0
- feedscope-0.1.0.dist-info/WHEEL +4 -0
- feedscope-0.1.0.dist-info/entry_points.txt +2 -0
- feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""订阅管理业务:rss 的增删查 + OPML 进出,reddit 的 subreddit 与 telegram
|
|
2
|
+
的频道增删查。
|
|
3
|
+
|
|
4
|
+
只做管理编排:读改写 sources.toml(写路径统一走 config.update_feeds,
|
|
5
|
+
enabled/hide 联动在那里)、rss add 拉一次 feed 取标题当默认显示名、
|
|
6
|
+
reddit add 拉一次 r/{name}/ 页面校验存在性并取 subreddit 名、telegram
|
|
7
|
+
add 拉一次 t.me/s 预览页校验可读并取频道名。TUI 订阅管理屏不直接调这里
|
|
8
|
+
的函数,走 SubsStore 适配器(屏保持源无关);CLI 的 rss 子命令直接调
|
|
9
|
+
rss 函数。OPML 用 stdlib ElementTree 解析(嵌套目录拍平),导出手写
|
|
10
|
+
模板——结构固定,不值得引库。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
from xml.etree import ElementTree as ET
|
|
21
|
+
from xml.sax.saxutils import escape
|
|
22
|
+
|
|
23
|
+
from feedscope.core import config
|
|
24
|
+
from feedscope.core.config import Subscription
|
|
25
|
+
from feedscope.core.registry import get_class
|
|
26
|
+
from feedscope.core.i18n import t
|
|
27
|
+
from feedscope.core.source import SourceError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reserved(source_id: str) -> tuple[str, ...]:
|
|
31
|
+
"""订阅 key 的保留字(聚合流名 / 内置流名):一律读源自己的声明
|
|
32
|
+
(Source.reserved_feed_keys,单一出处)——源新增内置流后保留字自动
|
|
33
|
+
跟上,不会再出现这里抄一份、那边忘了同步的窗口。"""
|
|
34
|
+
import feedscope.sources # noqa: F401 import 即注册;只在这条保留字检查路径拉起源
|
|
35
|
+
|
|
36
|
+
cls = get_class(source_id)
|
|
37
|
+
return cls.reserved_feed_keys if cls is not None else ()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SubscriptionError(Exception):
|
|
41
|
+
"""订阅操作的业务失败(重复 / key 冲突 / 文件读不了):消息面向用户可直接打印。"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BadSubscriptionFile(SubscriptionError):
|
|
45
|
+
"""OPML 文件读不了 / 不是合法 OPML:用法级错误(CLI 退出码 2,与 argparse 约定一致)。"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def derive_key(url: str, existing: Iterable[str]) -> str:
|
|
49
|
+
"""URL -> 订阅 key:域名清洗成 ASCII 段(www 剥掉、非法字符折叠成 -),
|
|
50
|
+
与 existing 撞名则加 -2、-3…;域名清洗后为空(如纯 IP 端口)回退 feed。
|
|
51
|
+
|
|
52
|
+
existing 是「要避开的 key 全集」——调用方把保留字(聚合流名等,见
|
|
53
|
+
_reserved)并进已有订阅一起传:派生出的 key 撞保留字会同样加后缀绕开,
|
|
54
|
+
显式 key 撞保留字的报错路径只在用户真的手输时才触发。"""
|
|
55
|
+
host = urlparse(url).netloc.lower().split("@")[-1].split(":")[0]
|
|
56
|
+
host = re.sub(r"^www\.", "", host)
|
|
57
|
+
base = re.sub(r"[^a-z0-9]+", "-", host).strip("-") or "feed"
|
|
58
|
+
keys = set(existing)
|
|
59
|
+
if base not in keys:
|
|
60
|
+
return base
|
|
61
|
+
n = 2
|
|
62
|
+
while f"{base}-{n}" in keys:
|
|
63
|
+
n += 1
|
|
64
|
+
return f"{base}-{n}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _fetch_body_or_none(url: str) -> bytes | None:
|
|
68
|
+
"""拉一次地址;失败回 None——取名/自动发现是尽力而为,不挡加订阅。"""
|
|
69
|
+
from feedscope.core import http
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return (await http.get(url, source="RSS")).content
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def add_feed(url: str, *, key: str | None = None, label: str | None = None) -> Subscription:
|
|
78
|
+
"""加一条订阅并落盘;显示名缺省拉一次 feed 标题,再缺省回 key。
|
|
79
|
+
|
|
80
|
+
地址不是 feed 而是站点首页时自动发现 feed 声明(<link rel="alternate">)
|
|
81
|
+
并改订发现的地址(key 沿用);重复地址 / 显式 key 撞已有或保留字 all →
|
|
82
|
+
SubscriptionError(不动配置)。
|
|
83
|
+
"""
|
|
84
|
+
# 源 parse 层按需导入:rss list / export 这类纯配置命令不必拉起全部源模块
|
|
85
|
+
from feedscope.sources.rss.parse import discover_feed_link, probe
|
|
86
|
+
|
|
87
|
+
feeds = config.rss_feeds()
|
|
88
|
+
if any(f.url == url for f in feeds):
|
|
89
|
+
dup = next(f.key for f in feeds if f.url == url)
|
|
90
|
+
raise SubscriptionError(t("subs.dup_url", key=dup))
|
|
91
|
+
if key is None:
|
|
92
|
+
# 保留字并进避让集:https://all.com/… 派生出的 key 是 all-2 而不是 all
|
|
93
|
+
#(撞上聚合流名的订阅会被读取侧静默跳过,加了等于没加)
|
|
94
|
+
key = derive_key(url, (*_reserved("rss"), *(f.key for f in feeds)))
|
|
95
|
+
elif key in _reserved("rss"):
|
|
96
|
+
raise SubscriptionError(t("subs.reserved_key", key=key))
|
|
97
|
+
elif key in {f.key for f in feeds}:
|
|
98
|
+
raise SubscriptionError(t("subs.key_taken", key=repr(key)))
|
|
99
|
+
if not label:
|
|
100
|
+
body = await _fetch_body_or_none(url)
|
|
101
|
+
if body is not None:
|
|
102
|
+
title, entries = probe(body)
|
|
103
|
+
if not title and not entries:
|
|
104
|
+
# 拉回来不像 feed:当首页试自动发现(粘博客首页也能订上)
|
|
105
|
+
alt = discover_feed_link(body.decode("utf-8", "ignore"), url)
|
|
106
|
+
if alt and alt != url:
|
|
107
|
+
return await add_feed(alt, key=key)
|
|
108
|
+
label = title
|
|
109
|
+
feed = Subscription(key=key, label=label or key, url=url)
|
|
110
|
+
config.update_rss_feeds([*feeds, feed])
|
|
111
|
+
return feed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rename(source_id: str, key: str, new_label: str) -> Subscription | None:
|
|
115
|
+
"""改显示名(rss / reddit 共用);名字清空 / key 不存在返回 None(不动配置)。"""
|
|
116
|
+
new_label = new_label.strip()
|
|
117
|
+
if not new_label:
|
|
118
|
+
return None
|
|
119
|
+
feeds = config.feeds_for(source_id)
|
|
120
|
+
if key not in {f.key for f in feeds}:
|
|
121
|
+
return None
|
|
122
|
+
renamed = Subscription(key=key, label=new_label, url=next(f.url for f in feeds if f.key == key))
|
|
123
|
+
config.update_feeds(source_id, [renamed if f.key == key else f for f in feeds])
|
|
124
|
+
return renamed
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def rename_feed(key: str, new_label: str) -> Subscription | None:
|
|
128
|
+
"""改 rss 订阅的显示名。"""
|
|
129
|
+
return _rename("rss", key, new_label)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def add_from_input(text: str) -> tuple[list[Subscription], str]:
|
|
133
|
+
"""添加入口的智能分流:http(s) 地址 → 单条添加;本地路径 → OPML 导入。
|
|
134
|
+
|
|
135
|
+
返回 (本次新增, 结果描述);`~` 开头的路径自动展开。CLI 的 add 与 TUI
|
|
136
|
+
订阅管理屏的添加框共用这里,行为永远一致。
|
|
137
|
+
"""
|
|
138
|
+
text = text.strip()
|
|
139
|
+
if not text:
|
|
140
|
+
raise SubscriptionError(t("subs.empty_input"))
|
|
141
|
+
if text.startswith(("http://", "https://")):
|
|
142
|
+
feed = await add_feed(text)
|
|
143
|
+
return [feed], t("subs.added_feed", key=feed.key, label=feed.label)
|
|
144
|
+
added, skipped = import_opml(read_opml_file(text))
|
|
145
|
+
detail = t("subs.imported_skip", n=len(skipped)) if skipped else ""
|
|
146
|
+
return added, t("subs.imported", n=len(added), detail=detail)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _remove(source_id: str, key: str, *, drop_when_empty: bool) -> Subscription | None:
|
|
150
|
+
"""删一条订阅(各源共用),返回被删的;key 不存在返回 None(不动配置)。
|
|
151
|
+
|
|
152
|
+
drop_when_empty:删到空是否从 enabled 摘掉该源——rss/telegram 的 feeds
|
|
153
|
+
全来自订阅(删空 = 没有任何流)摘;reddit 有内置流不摘。
|
|
154
|
+
"""
|
|
155
|
+
feeds = config.feeds_for(source_id)
|
|
156
|
+
kept = [f for f in feeds if f.key != key]
|
|
157
|
+
if len(kept) == len(feeds):
|
|
158
|
+
return None
|
|
159
|
+
config.update_feeds(source_id, kept, drop_enabled_when_empty=drop_when_empty)
|
|
160
|
+
return next(f for f in feeds if f.key == key)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def remove_feed(key: str) -> Subscription | None:
|
|
164
|
+
"""删一条 rss 订阅。"""
|
|
165
|
+
return _remove("rss", key, drop_when_empty=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---- OPML 进出 ----
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_opml_file(path: str | Path) -> str:
|
|
172
|
+
"""读 OPML 文件文本(`~` 展开);读不了抛 BadSubscriptionFile(CLI/TUI 同一出口)。"""
|
|
173
|
+
try:
|
|
174
|
+
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise BadSubscriptionFile(t("subs.opml_unreadable", error=exc)) from None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_opml(text: str) -> list[tuple[str, str]]:
|
|
180
|
+
"""OPML 文本 -> [(label, xmlUrl)];嵌套目录拍平,无 xmlUrl 的节点只当容器。"""
|
|
181
|
+
try:
|
|
182
|
+
root = ET.fromstring(text)
|
|
183
|
+
except ET.ParseError as exc:
|
|
184
|
+
raise BadSubscriptionFile(t("subs.opml_invalid", error=exc)) from None
|
|
185
|
+
out: list[tuple[str, str]] = []
|
|
186
|
+
for outline in root.iter("outline"):
|
|
187
|
+
url = (outline.get("xmlUrl") or "").strip()
|
|
188
|
+
if not url:
|
|
189
|
+
continue
|
|
190
|
+
label = (outline.get("text") or outline.get("title") or "").strip()
|
|
191
|
+
out.append((label, url))
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def import_opml(text: str) -> tuple[list[Subscription], list[str]]:
|
|
196
|
+
"""OPML 批量导入(幂等合并):返回 (新增订阅, 跳过原因列表)。
|
|
197
|
+
|
|
198
|
+
文件读不了 / 不是合法 OPML 抛 BadSubscriptionFile(用法级错误)。
|
|
199
|
+
已订阅的地址(含 OPML 内部重复)跳过;label 用 outline 的 text/title,
|
|
200
|
+
缺了回 key;一次落盘(中途失败不写半截配置)。
|
|
201
|
+
"""
|
|
202
|
+
feeds = config.rss_feeds()
|
|
203
|
+
known_urls = {f.url for f in feeds}
|
|
204
|
+
# 保留字并进避让集(与 add_feed 的派生路径同一条规则):OPML 里的
|
|
205
|
+
# all.com 订阅派生 all-2,不会产出被读取侧静默跳过的撞名 key
|
|
206
|
+
known_keys = {f.key for f in feeds} | set(_reserved("rss"))
|
|
207
|
+
added: list[Subscription] = []
|
|
208
|
+
skipped: list[str] = []
|
|
209
|
+
for label, url in parse_opml(text):
|
|
210
|
+
if url in known_urls:
|
|
211
|
+
skipped.append(t("subs.opml_dup", name=label or url))
|
|
212
|
+
continue
|
|
213
|
+
key = derive_key(url, known_keys)
|
|
214
|
+
known_urls.add(url)
|
|
215
|
+
known_keys.add(key)
|
|
216
|
+
added.append(Subscription(key=key, label=label or key, url=url))
|
|
217
|
+
if added:
|
|
218
|
+
config.update_rss_feeds([*feeds, *added])
|
|
219
|
+
return added, skipped
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_XML_QUOTE = '"'
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _attr(value: str) -> str:
|
|
226
|
+
"""字符串 -> XML 属性值:双引号包裹,& < > 与引号本体转义。"""
|
|
227
|
+
escaped = escape(value, {_XML_QUOTE: """})
|
|
228
|
+
return f"{_XML_QUOTE}{escaped}{_XML_QUOTE}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def export_opml(feeds: list[Subscription]) -> str:
|
|
232
|
+
"""订阅表 -> OPML 2.0 文本(标准阅读器都认,可再迁去别家)。"""
|
|
233
|
+
lines = [
|
|
234
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
235
|
+
'<opml version="2.0">',
|
|
236
|
+
f" <head><title>{t('subs.opml_title')}</title></head>",
|
|
237
|
+
" <body>",
|
|
238
|
+
]
|
|
239
|
+
lines += [
|
|
240
|
+
f' <outline type="rss" text={_attr(f.label)} xmlUrl={_attr(f.url)} />' for f in feeds
|
|
241
|
+
]
|
|
242
|
+
lines += [" </body>", "</opml>", ""]
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---- Reddit subreddit 订阅 ----
|
|
247
|
+
# (订阅 key 的保留字=内置流名 home/popular,读源声明:见 _reserved)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---- Telegram 频道订阅 ----
|
|
251
|
+
# (订阅 key 的保留字=聚合流名 all,读源声明:见 _reserved)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def add_subreddit(text: str, *, label: str | None = None) -> Subscription:
|
|
255
|
+
"""加一个 subreddit 订阅并落盘;显示名缺省取 r/{name}/ 页面里的 subreddit 名。
|
|
256
|
+
|
|
257
|
+
输入四形态归一(裸名 / r/name / /r/name/ / 站点地址,见 parse_subreddit_input);
|
|
258
|
+
重复 / 撞内置流 / 页面拉不到(subreddit 不存在或未登录)→ SubscriptionError
|
|
259
|
+
(不动配置)。未登录时报错消息自带「需要登录」指引。
|
|
260
|
+
"""
|
|
261
|
+
from feedscope.sources.reddit import require_login
|
|
262
|
+
from feedscope.sources.reddit.fetch import fetch_subreddit
|
|
263
|
+
from feedscope.sources.reddit.parse import parse_subreddit_input
|
|
264
|
+
|
|
265
|
+
name = parse_subreddit_input(text)
|
|
266
|
+
if not name:
|
|
267
|
+
raise SubscriptionError(t("subs.subreddit_unrecognized"))
|
|
268
|
+
if name in _reserved("reddit"):
|
|
269
|
+
raise SubscriptionError(t("subs.subreddit_reserved", name=name))
|
|
270
|
+
feeds = config.feeds_for("reddit")
|
|
271
|
+
if any(f.key == name for f in feeds):
|
|
272
|
+
raise SubscriptionError(t("subs.subreddit_dup", name=name))
|
|
273
|
+
try:
|
|
274
|
+
await require_login(f"r/{name}")
|
|
275
|
+
real = await fetch_subreddit(name)
|
|
276
|
+
except SourceError as exc: # 含 LoginRequired:订阅管理屏没有账号动线,消息自带指引
|
|
277
|
+
raise SubscriptionError(t("subs.subreddit_failed", name=name, error=exc)) from None
|
|
278
|
+
display = real or name
|
|
279
|
+
feed = Subscription(key=name, label=label or display, url=f"https://www.reddit.com/r/{name}/")
|
|
280
|
+
config.update_feeds("reddit", [*feeds, feed])
|
|
281
|
+
return feed
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def add_subreddit_from_input(text: str) -> tuple[list[Subscription], str]:
|
|
285
|
+
"""订阅管理屏的 reddit 添加入口(与 rss 的 add_from_input 同形,store 注入用)。"""
|
|
286
|
+
feed = await add_subreddit(text)
|
|
287
|
+
return [feed], t("subs.added_subreddit", key=feed.key, label=feed.label)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def remove_subreddit(key: str) -> Subscription | None:
|
|
291
|
+
"""删一个 subreddit 订阅;key 不存在返回 None(不动配置)。"""
|
|
292
|
+
return _remove("reddit", key, drop_when_empty=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def rename_subreddit(key: str, new_label: str) -> Subscription | None:
|
|
296
|
+
"""改 subreddit 订阅的显示名;名字清空 / key 不存在返回 None(不动配置)。"""
|
|
297
|
+
return _rename("reddit", key, new_label)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def add_channel(text: str, *, label: str | None = None) -> Subscription:
|
|
301
|
+
"""加一个 Telegram 频道订阅并落盘;显示名缺省拉一次预览页的频道名。
|
|
302
|
+
|
|
303
|
+
输入形态见 parse_telegram_input(durov / @durov / t.me/durov / 帖子直链);
|
|
304
|
+
重复 / 撞保留字 all / 预览页拉不到任何消息(不存在、为空、站长关了 Web
|
|
305
|
+
预览)→ SubscriptionError(不动配置)。
|
|
306
|
+
"""
|
|
307
|
+
from feedscope.sources.telegram.fetch import fetch_channel_html
|
|
308
|
+
from feedscope.sources.telegram.parse import (
|
|
309
|
+
channel_title,
|
|
310
|
+
parse_channel_html,
|
|
311
|
+
parse_telegram_input,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
name = parse_telegram_input(text)
|
|
315
|
+
if not name:
|
|
316
|
+
raise SubscriptionError(t("subs.channel_unrecognized"))
|
|
317
|
+
if name in _reserved("telegram"):
|
|
318
|
+
# 防御分支:当前保留字 all 只有 3 字符,Telegram 用户名最短 5 字符,
|
|
319
|
+
# 实际到不了这里(先被输入归一拦下)——留给保留字集日后扩展长词
|
|
320
|
+
raise SubscriptionError(t("subs.channel_reserved", name=name))
|
|
321
|
+
feeds = config.feeds_for("telegram")
|
|
322
|
+
if any(f.key == name for f in feeds):
|
|
323
|
+
raise SubscriptionError(t("subs.channel_dup", name=name))
|
|
324
|
+
html = await fetch_channel_html(name) # SourceError 顺势上抛(HTTP 层的中文消息够用)
|
|
325
|
+
items = parse_channel_html(html, channel=name, label=name)
|
|
326
|
+
if not items:
|
|
327
|
+
raise SubscriptionError(t("subs.channel_no_items", name=name))
|
|
328
|
+
display = channel_title(html) or name
|
|
329
|
+
feed = Subscription(key=name, label=label or display, url=f"https://t.me/{name}/")
|
|
330
|
+
config.update_feeds("telegram", [*feeds, feed])
|
|
331
|
+
return feed
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def add_channel_from_input(text: str) -> tuple[list[Subscription], str]:
|
|
335
|
+
"""订阅管理屏的 Telegram 添加入口(与 rss 的 add_from_input 同形,store 注入用)。"""
|
|
336
|
+
feed = await add_channel(text)
|
|
337
|
+
return [feed], t("subs.added_channel", key=feed.key, label=feed.label)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def remove_channel(key: str) -> Subscription | None:
|
|
341
|
+
"""删一个频道订阅;key 不存在返回 None(不动配置)。"""
|
|
342
|
+
return _remove("telegram", key, drop_when_empty=True)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def rename_channel(key: str, new_label: str) -> Subscription | None:
|
|
346
|
+
"""改频道订阅的显示名;名字清空 / key 不存在返回 None(不动配置)。"""
|
|
347
|
+
return _rename("telegram", key, new_label)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---- 订阅管理屏的源适配器 ----
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass(frozen=True)
|
|
354
|
+
class SubsStore:
|
|
355
|
+
"""一个 subscribable 源的订阅管理适配器:四操作 + 文案,订阅管理屏只认它。
|
|
356
|
+
|
|
357
|
+
订阅概念不再只有 rss(reddit 的 subreddit、之后的 Telegram 频道共用
|
|
358
|
+
一屏):源的差异收进这个对象,屏保持通用(列表 + 增删改名),业务
|
|
359
|
+
编排全部留在本模块。add 与 rss 的 add_from_input 同形——(新增, 概要)。
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
label: str # 屏标题「{label} 订阅管理」
|
|
363
|
+
add_placeholder: str # 添加输入框占位(各源可输入的形态不同)
|
|
364
|
+
hint: str # 底部键位提示行
|
|
365
|
+
empty: str # 零订阅占位文案
|
|
366
|
+
feeds: Callable[[], list[Subscription]] # 当前订阅表(列表顺序 = 配置顺序)
|
|
367
|
+
add: Callable[[str], Awaitable[tuple[list[Subscription], str]]]
|
|
368
|
+
remove: Callable[[str], Subscription | None]
|
|
369
|
+
rename: Callable[[str, str], Subscription | None]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def rss_store() -> SubsStore:
|
|
373
|
+
return SubsStore(
|
|
374
|
+
label="RSS",
|
|
375
|
+
add_placeholder=t("subs.rss_placeholder"),
|
|
376
|
+
hint=t("subs.rss_hint"),
|
|
377
|
+
empty=t("subs.rss_empty"),
|
|
378
|
+
feeds=config.rss_feeds,
|
|
379
|
+
add=add_from_input,
|
|
380
|
+
remove=remove_feed,
|
|
381
|
+
rename=rename_feed,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def reddit_store() -> SubsStore:
|
|
386
|
+
return SubsStore(
|
|
387
|
+
label="Reddit",
|
|
388
|
+
add_placeholder=t("subs.reddit_placeholder"),
|
|
389
|
+
hint=t("subs.hint_basic"),
|
|
390
|
+
empty=t("subs.reddit_empty"),
|
|
391
|
+
feeds=lambda: config.feeds_for("reddit"),
|
|
392
|
+
add=add_subreddit_from_input,
|
|
393
|
+
remove=remove_subreddit,
|
|
394
|
+
rename=rename_subreddit,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def telegram_store() -> SubsStore:
|
|
399
|
+
return SubsStore(
|
|
400
|
+
label="Telegram",
|
|
401
|
+
add_placeholder=t("subs.telegram_placeholder"),
|
|
402
|
+
hint=t("subs.hint_basic"),
|
|
403
|
+
empty=t("subs.telegram_empty"),
|
|
404
|
+
feeds=lambda: config.feeds_for("telegram"),
|
|
405
|
+
add=add_channel_from_input,
|
|
406
|
+
remove=remove_channel,
|
|
407
|
+
rename=rename_channel,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# 源 id -> store 工厂:新增 subscribable 源在这里登记一个(订阅屏的入口分派)
|
|
412
|
+
_STORES: dict[str, Callable[[], SubsStore]] = {
|
|
413
|
+
"rss": rss_store,
|
|
414
|
+
"reddit": reddit_store,
|
|
415
|
+
"telegram": telegram_store,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def store_for(source_id: str) -> SubsStore:
|
|
420
|
+
"""订阅管理屏的入口工厂;未登记的 id = subscribable 声明与 store 不一致,尽早炸。"""
|
|
421
|
+
if build := _STORES.get(source_id):
|
|
422
|
+
return build()
|
|
423
|
+
raise ValueError(t("subs.no_store", source=source_id))
|
feedscope/ui/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""TUI 层:Textual 应用与界面。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
|
|
7
|
+
# 键位声明的类型别名:Textual 基类把 BINDINGS 声明为宽松的历史联合
|
|
8
|
+
# (Binding | 两种元组形态),子类全用 Binding 对象声明时按它对齐,
|
|
9
|
+
# mypy 的 list 不变检查才放行——别名在此一处,各屏不再各自与基类搏斗。
|
|
10
|
+
Bindings = list[Binding | tuple[str, str] | tuple[str, str, str]]
|
feedscope/ui/account.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""账号动线:撞墙自动询问与 Ctrl+U 手动登录/登出的编排(从 app.py 拆出)。
|
|
2
|
+
|
|
3
|
+
MainScreen 只留 Ctrl+U 转发,状态与全过程在这里:
|
|
4
|
+
- in_progress(有头登录进行中):挡下刷新/切源,独占 profile 锁期间不碰浏览器
|
|
5
|
+
- refused(本次运行内已拒绝询问的源):撞墙不再自动弹框,只留常驻
|
|
6
|
+
「Ctrl+U 登录」提示;Ctrl+U 手动登录照常可用,登录成功/登出后清除,
|
|
7
|
+
后续撞墙(登录过期、换账号)恢复自动询问
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from feedscope import login
|
|
15
|
+
from feedscope.core.source import Source
|
|
16
|
+
from feedscope.core.i18n import t
|
|
17
|
+
from feedscope.ui import feedback
|
|
18
|
+
from feedscope.ui.dialogs import AccountScreen
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from feedscope.ui.app import FeedScopeApp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AccountFlow:
|
|
25
|
+
"""一个 App 一份账号动线(当前源语境由调用方给,不自己记选中位)。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, app: FeedScopeApp) -> None:
|
|
28
|
+
self.app = app
|
|
29
|
+
self.in_progress = False # 有头登录进行中(挡刷新,见 action_refresh)
|
|
30
|
+
self.refused: set[str] = set() # 已拒绝自动询问的源 id(本运行内有效)
|
|
31
|
+
|
|
32
|
+
async def on_login_wall(self, source: Source, hint: str) -> None:
|
|
33
|
+
"""取数撞登录墙:弹登录框(等用户选「登录」才开浏览器,不擅自弹窗)。
|
|
34
|
+
|
|
35
|
+
每个源一次;取消即记住(本运行内不再自动问),切页/刷新不被反复
|
|
36
|
+
打断。调用方(_refresh)已在 worker 里,wait_for_dismiss 合法。
|
|
37
|
+
"""
|
|
38
|
+
proceed = await self.app.push_screen(
|
|
39
|
+
AccountScreen(source.label_text, "dialogs.login"), wait_for_dismiss=True
|
|
40
|
+
)
|
|
41
|
+
if proceed:
|
|
42
|
+
self.app.run_worker(self.login(source), group="login", exclusive=True)
|
|
43
|
+
else:
|
|
44
|
+
self.refused.add(source.source_id)
|
|
45
|
+
if (main := self.app.main) is not None:
|
|
46
|
+
main.show_empty(hint)
|
|
47
|
+
feedback.info(self.app, t("account.login_refused"))
|
|
48
|
+
|
|
49
|
+
async def open(self, source: Source) -> None:
|
|
50
|
+
"""Ctrl+U 的账号框:按当前源状态分派——匿名源提示、已登录弹登出、
|
|
51
|
+
未登录弹登录。
|
|
52
|
+
|
|
53
|
+
走 worker 因为 push_screen 的 wait_for_dismiss 只能在 worker 里用。
|
|
54
|
+
group 独立于列表加载的 default 组,Ctrl+U 不会打断在途的 _refresh。
|
|
55
|
+
"""
|
|
56
|
+
if not source.login_url:
|
|
57
|
+
feedback.info(self.app, t("account.no_login_needed", label=source.label_text))
|
|
58
|
+
return
|
|
59
|
+
if self.in_progress:
|
|
60
|
+
feedback.info(self.app, t("account.login_in_progress"))
|
|
61
|
+
return
|
|
62
|
+
logged_in = await login.is_logged_in(source.source_id)
|
|
63
|
+
action_key = "dialogs.logout" if logged_in else "dialogs.login"
|
|
64
|
+
proceed = await self.app.push_screen(
|
|
65
|
+
AccountScreen(source.label_text, action_key), wait_for_dismiss=True
|
|
66
|
+
)
|
|
67
|
+
if not proceed:
|
|
68
|
+
if action_key == "dialogs.login":
|
|
69
|
+
# 手动框里取消同样算拒绝:之后撞墙不再自动弹(登出框取消无此语义)
|
|
70
|
+
self.refused.add(source.source_id)
|
|
71
|
+
return
|
|
72
|
+
# 后续动作放独立的 login 组:exclusive 会取消同组在途 worker,若与本
|
|
73
|
+
# worker(account 组)同组,等于把调用者自己也排进取消名单——现在能
|
|
74
|
+
# 活着只是因为派发后不再 await;分组错开即消除对这层时序的依赖。
|
|
75
|
+
if logged_in:
|
|
76
|
+
self.app.run_worker(self.logout(source), group="login", exclusive=True)
|
|
77
|
+
else:
|
|
78
|
+
self.app.run_worker(self.login(source), group="login", exclusive=True)
|
|
79
|
+
|
|
80
|
+
async def login(self, source: Source) -> None:
|
|
81
|
+
"""关 headless session 让出 profile 锁 → 有头浏览器登录 → 刷新。
|
|
82
|
+
|
|
83
|
+
登录期间置 in_progress,挡下刷新误触发 profile 锁冲突;完成后
|
|
84
|
+
恢复并刷新(成功则列表出内容,失败/超时仍撞墙)。
|
|
85
|
+
"""
|
|
86
|
+
self.in_progress = True
|
|
87
|
+
try:
|
|
88
|
+
if (main := self.app.main) is not None:
|
|
89
|
+
await main.release_feed_loader() # 释放活页签,别跟有头登录抢 profile
|
|
90
|
+
await login.yield_profile() # 关无头会话让出 profile 锁(语义在 login 侧)
|
|
91
|
+
try:
|
|
92
|
+
ok = await login.login(source.source_id, quiet=True)
|
|
93
|
+
except Exception:
|
|
94
|
+
ok = False
|
|
95
|
+
if ok:
|
|
96
|
+
self.refused.discard(source.source_id) # 后续撞墙(过期)恢复自动询问
|
|
97
|
+
feedback.info(self.app, t("account.login_ok", label=source.label_text))
|
|
98
|
+
else:
|
|
99
|
+
# 未完成(超时/关窗放弃):别再自动追问,重试入口在 toast 与常驻提示里
|
|
100
|
+
self.refused.add(source.source_id)
|
|
101
|
+
feedback.warn(
|
|
102
|
+
self.app,
|
|
103
|
+
t("account.login_incomplete", label=source.label_text),
|
|
104
|
+
title=t("account.title_login"),
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
self.in_progress = False
|
|
108
|
+
if self.app.main is not None:
|
|
109
|
+
self.app.main.action_refresh()
|
|
110
|
+
# action_refresh 有 in_progress 挡门:复位必须在刷新之前(finally 保序)
|
|
111
|
+
|
|
112
|
+
async def logout(self, source: Source) -> None:
|
|
113
|
+
"""清掉当前源登录 cookie → 刷新(撞墙弹登录框,即换账号动线)。"""
|
|
114
|
+
self.refused.discard(source.source_id) # 主动登出 = 想换账号:撞墙要重新弹登录框
|
|
115
|
+
try:
|
|
116
|
+
had = await login.logout(source.source_id)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
feedback.fail(self.app, source, exc, action="feedback.action_logout_failed")
|
|
119
|
+
return
|
|
120
|
+
if had:
|
|
121
|
+
feedback.info(self.app, t("account.logged_out", label=source.label_text))
|
|
122
|
+
else:
|
|
123
|
+
feedback.info(self.app, t("account.not_logged_in", label=source.label_text))
|
|
124
|
+
if self.app.main is not None:
|
|
125
|
+
self.app.main.action_refresh()
|
feedscope/ui/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""TUI 应用壳:主题/生命周期/全局键位与账号动线的宿主,主屏见 screens/main.py。
|
|
2
|
+
|
|
3
|
+
App 只保留跨屏职责——主题注册、启动序列(注册表检查 + 浏览器预热)、
|
|
4
|
+
退出清理(会话/连接池)、全局退出键;主屏(tab 条/边栏/列表/导航)整体
|
|
5
|
+
住在 MainScreen(get_default_screen 返回,框架启动即挂为默认屏),经 app.main
|
|
6
|
+
访问。这样键位归属与守卫自然对齐:主屏键只绑在主屏(弹层/详情在前台时
|
|
7
|
+
不命中),全局键只剩 Ctrl+Q。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from textual.app import App
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
|
|
18
|
+
import feedscope.sources # noqa: F401 import 即注册
|
|
19
|
+
from feedscope.core import config, http
|
|
20
|
+
from feedscope.core import i18n
|
|
21
|
+
from feedscope.core.i18n import t
|
|
22
|
+
from feedscope.core.browser import close_session, get_session
|
|
23
|
+
from feedscope.core.registry import source_classes
|
|
24
|
+
from feedscope.ui import Bindings
|
|
25
|
+
from feedscope.ui.account import AccountFlow
|
|
26
|
+
from feedscope.ui.labels import retranslate_bindings
|
|
27
|
+
from feedscope.ui.screens.main import MainScreen
|
|
28
|
+
from feedscope.ui.theme import FEEDSCOPE_THEME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _no_prewarm() -> bool:
|
|
32
|
+
"""禁预热开关:FEEDSCOPE_NO_PREWARM=1 时启动不拉浏览器。
|
|
33
|
+
|
|
34
|
+
测试与截图脚本用(stub 源不需要真 Chrome,拉起来既慢又可能挂起);
|
|
35
|
+
运行时读环境变量而非模块级开关,工具侧设 env 即生效、不用掰模块属性。
|
|
36
|
+
"""
|
|
37
|
+
return os.environ.get("FEEDSCOPE_NO_PREWARM") == "1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FeedScopeApp(App[None]):
|
|
41
|
+
# 标题随语言:TITLE 类属性在 import 期求值会冻住语言,on_mount 里按 t() 设
|
|
42
|
+
|
|
43
|
+
# 主屏即默认屏:框架启动时实例化(无需显式 push),详情/弹层盖在其上
|
|
44
|
+
def get_default_screen(self) -> MainScreen:
|
|
45
|
+
return MainScreen()
|
|
46
|
+
|
|
47
|
+
# 关闭 Textual 自带命令面板(页脚 palette 链接 + Ctrl+P):里面只有框架默认的
|
|
48
|
+
# Theme/Quit/Keys/Screenshot 系统命令,对本应用全是冗余——主题定死编辑器风、
|
|
49
|
+
# 退出有 q,其余是调试入口,不该暴露给用户。
|
|
50
|
+
ENABLE_COMMAND_PALETTE = False
|
|
51
|
+
|
|
52
|
+
# 键位全部非打印键:字母/数字会被中文输入法拦进候选框,一个动作只留
|
|
53
|
+
# 一个键,不给字母留别名。全局只剩退出(Ctrl+C 连按退出是 Textual 自带
|
|
54
|
+
# 兜底,不占页脚);主屏键(刷新/翻页/导航/F2/Ctrl+U)在 MainScreen——
|
|
55
|
+
# 屏级绑定只在该屏前台时命中,App 级动作不再需要「是否在主屏」守卫。
|
|
56
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
57
|
+
Binding("ctrl+q", "quit", "bindings.quit", priority=True),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
# 进程语言按配置设定(未设置 = 缺省英文,不是系统猜测——猜测只作
|
|
63
|
+
# 首启语言屏的预选高亮;这里保证直构 App 的路径(截图脚本 / 测试)
|
|
64
|
+
# 与 cli.main 组装的一致)。语言随进程固定:改语言走 Ctrl+L 落盘 + 重启
|
|
65
|
+
i18n.set_language(config.get_language() or i18n.DEFAULT_LANGUAGE)
|
|
66
|
+
# 克制的编辑器风配色(见 ui/theme.py),产品需求而非保守
|
|
67
|
+
self.register_theme(FEEDSCOPE_THEME)
|
|
68
|
+
self.theme = FEEDSCOPE_THEME.name
|
|
69
|
+
# 账号动线(撞墙自动询问 / Ctrl+U 登录登出):状态与编排都在 ui/account.py
|
|
70
|
+
self.accounts = AccountFlow(self)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def main(self) -> MainScreen | None:
|
|
74
|
+
"""主屏实例(栈底,被弹层/详情覆盖时也在);启动完成前可能为 None。"""
|
|
75
|
+
for screen in reversed(self.screen_stack):
|
|
76
|
+
if isinstance(screen, MainScreen):
|
|
77
|
+
return screen
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
self.title = t("app.title")
|
|
82
|
+
retranslate_bindings(self) # App 级键位(ctrl+q)的键式文案解析,见 ui/labels.py
|
|
83
|
+
if not source_classes():
|
|
84
|
+
self.exit(message=t("app.exit_no_sources"))
|
|
85
|
+
return
|
|
86
|
+
if not _no_prewarm():
|
|
87
|
+
self.run_worker(self._prewarm(), group="prewarm") # 后台预热,见 _prewarm
|
|
88
|
+
|
|
89
|
+
async def _prewarm(self) -> None:
|
|
90
|
+
"""后台预热浏览器会话:首条列表不用再付冷启动(Chrome 拉起几秒;
|
|
91
|
+
UA 探针另有 7 天落盘缓存,多数启动只付 Chrome 拉起)。
|
|
92
|
+
|
|
93
|
+
知乎/Reddit(通道页签)与 X/Reddit(profile 读 cookie)都要浏览器,
|
|
94
|
+
预热几乎总用得上;失败静默——首次真正取数时自然再试并给正式报错。
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
await get_session().start()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# ---- 清理 ----
|
|
102
|
+
|
|
103
|
+
async def on_unmount(self) -> None:
|
|
104
|
+
await close_session()
|
|
105
|
+
await http.close()
|