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
feedscope/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FeedScope(观流):终端信息流聚合阅读器(知乎 / X / Reddit / HN / V2EX / RSS / Telegram)。"""
2
+
3
+ __version__ = "0.1.0"
feedscope/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from feedscope.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
feedscope/cli.py ADDED
@@ -0,0 +1,154 @@
1
+ """命令行入口:`feedscope` 启动 TUI;子命令 login / probe / doctor / rss。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from feedscope.core.i18n import t
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="feedscope", description=t("cli.description")
14
+ )
15
+ sub = parser.add_subparsers(dest="command")
16
+
17
+ sub.add_parser("tui", help=t("cli.help_tui"))
18
+
19
+ login = sub.add_parser("login", help=t("cli.help_login"))
20
+ login.add_argument("source", nargs="?", default="zhihu", help=t("cli.help_login_source"))
21
+
22
+ probe = sub.add_parser("probe", help=t("cli.help_probe"))
23
+ probe.add_argument("sources", nargs="*", help=t("cli.help_probe_sources"))
24
+
25
+ sub.add_parser("doctor", help=t("cli.help_doctor"))
26
+
27
+ rss = sub.add_parser("rss", help=t("cli.help_rss"))
28
+ rss_sub = rss.add_subparsers(dest="rss_command", required=True)
29
+ rss_sub.add_parser("list", help=t("cli.help_rss_list"))
30
+ add_p = rss_sub.add_parser("add", help=t("cli.help_rss_add"))
31
+ add_p.add_argument("url", help=t("cli.help_rss_add_url"))
32
+ add_p.add_argument("--key", help=t("cli.help_rss_add_key"))
33
+ add_p.add_argument("--label", help=t("cli.help_rss_add_label"))
34
+ remove_p = rss_sub.add_parser("remove", help=t("cli.help_rss_remove"))
35
+ remove_p.add_argument("key", help=t("cli.help_rss_remove_key"))
36
+ import_p = rss_sub.add_parser("import", help=t("cli.help_rss_import"))
37
+ import_p.add_argument("path", help=t("cli.help_rss_import_path"))
38
+ export_p = rss_sub.add_parser("export", help=t("cli.help_rss_export"))
39
+ export_p.add_argument("path", nargs="?", help=t("cli.help_rss_export_path"))
40
+
41
+ return parser
42
+
43
+
44
+ def _run_rss(args: argparse.Namespace) -> int:
45
+ """rss 子命令组分发:订阅管理不走 Textual,加载轻。"""
46
+ import asyncio
47
+
48
+ from feedscope import subscriptions as subs
49
+ from feedscope.core import config
50
+
51
+ if args.rss_command == "list":
52
+ feeds = config.rss_feeds()
53
+ if not feeds:
54
+ print(t("cli.rss_list_empty"))
55
+ return 0
56
+ for f in feeds:
57
+ print(f"{f.key:<16} {f.label} <{f.url}>")
58
+ print(t("cli.rss_list_total", n=len(feeds)))
59
+ return 0
60
+
61
+ if args.rss_command == "add":
62
+ # add_from_input 只分流 URL / OPML 路径;显式 key/label 仍走 add_feed。
63
+ # 坏 OPML 文件 = 用法级错误退 2(与 import 同一规则),业务冲突退 1
64
+ try:
65
+ if args.key or args.label:
66
+ feed = asyncio.run(subs.add_feed(args.url, key=args.key, label=args.label))
67
+ summary = t("cli.rss_added", key=feed.key, label=feed.label, url=feed.url)
68
+ added = [feed]
69
+ else:
70
+ added, summary = asyncio.run(subs.add_from_input(args.url))
71
+ except subs.BadSubscriptionFile as exc:
72
+ print(t("cli.not_added", error=exc))
73
+ return 2
74
+ except subs.SubscriptionError as exc:
75
+ print(t("cli.not_added", error=exc))
76
+ return 1
77
+ if len(added) > 1: # OPML 批量:逐条列出,方便核对
78
+ for f in added:
79
+ print(f" {f.key:<16} {f.label} <{f.url}>")
80
+ print(summary + t("cli.restart_hint"))
81
+ return 0
82
+
83
+ if args.rss_command == "remove":
84
+ removed = subs.remove_feed(args.key)
85
+ if removed is None:
86
+ print(t("cli.no_such_key", key=repr(args.key)))
87
+ return 1
88
+ print(t("cli.removed", key=removed.key, label=removed.label) + t("cli.restart_hint"))
89
+ return 0
90
+
91
+ if args.rss_command == "import":
92
+ try:
93
+ added, skipped = subs.import_opml(subs.read_opml_file(args.path))
94
+ except subs.BadSubscriptionFile as exc:
95
+ print(t("cli.import_failed", error=exc))
96
+ return 2
97
+ for f in added:
98
+ print(t("cli.rss_added", key=f.key, label=f.label, url=f.url))
99
+ for line in skipped[:5]:
100
+ print(t("cli.skipped_line", line=line))
101
+ if len(skipped) > 5:
102
+ print(t("cli.more_skipped", n=len(skipped) - 5))
103
+ print(
104
+ t("cli.import_result", added=len(added), skipped=len(skipped))
105
+ + t("cli.restart_hint")
106
+ )
107
+ return 0
108
+
109
+ # export:给路径写文件,缺省 stdout(> 订阅.opml 重定向也行)
110
+ opml = subs.export_opml(config.rss_feeds())
111
+ if args.path:
112
+ Path(args.path).write_text(opml, encoding="utf-8")
113
+ print(t("cli.exported", n=len(config.rss_feeds()), path=args.path))
114
+ else:
115
+ print(opml, end="")
116
+ return 0
117
+
118
+
119
+ def main(argv: list[str] | None = None) -> int:
120
+ # 进程语言按配置设定(未设置 = 缺省):CLI 的 help/输出与 TUI 同语言;
121
+ # 必须在 build_parser 之前——argparse 的 help 在构造时就成型
122
+ from feedscope.core import config, i18n
123
+
124
+ i18n.set_language(config.get_language() or i18n.DEFAULT_LANGUAGE)
125
+ args = build_parser().parse_args(argv)
126
+
127
+ if args.command in (None, "tui"):
128
+ # 惰性导入:让 doctor / login / probe / rss 子命令不用加载 Textual
129
+ from feedscope.ui.app import FeedScopeApp
130
+
131
+ FeedScopeApp().run()
132
+ return 0
133
+
134
+ if args.command == "login":
135
+ import asyncio
136
+
137
+ from feedscope.login import login
138
+
139
+ return 0 if asyncio.run(login(args.source)) else 1
140
+
141
+ if args.command == "probe":
142
+ from feedscope.probe import run_probes
143
+
144
+ return run_probes(args.sources)
145
+
146
+ if args.command == "doctor":
147
+ from feedscope.doctor import run_checks
148
+
149
+ return run_checks()
150
+
151
+ if args.command == "rss":
152
+ return _run_rss(args)
153
+
154
+ return 0
@@ -0,0 +1 @@
1
+ """核心层:数据模型、Source 插件契约、注册器、浏览器会话。"""
@@ -0,0 +1,367 @@
1
+ """BrowserSession:本机 Chrome + 持久 profile 的唯一入口。
2
+
3
+ 约定:
4
+ - 全 app 单例、惰性启动(首次用到才拉起浏览器)
5
+ - 所有页面操作走 run(),持锁串行执行 -> 无并发竞争
6
+ - profile 是独立目录,绝不碰用户日常 Chrome 的默认 profile
7
+ - App 退出时 close_session() 兜底清理
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import re
16
+ import time
17
+ from collections.abc import Awaitable, Callable
18
+ from contextlib import suppress
19
+ from typing import Any, TypeVar
20
+
21
+ from platformdirs import user_cache_path
22
+
23
+ from feedscope.core import samples
24
+ from feedscope.core.const import PAGE_TIMEOUT_MS, chrome_launch_kwargs
25
+ from feedscope.core.i18n import t
26
+ from feedscope.core.source import SourceError
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ PROFILE_DIR = user_cache_path("feedscope") / "chromium-profile"
31
+
32
+
33
+ def drop_stale_locks() -> None:
34
+ """异常退出残留的 Singleton* 锁会让 Chrome 拒绝启动,启动前清掉。"""
35
+ for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
36
+ lock = PROFILE_DIR / name
37
+ try:
38
+ if lock.is_symlink() or lock.exists():
39
+ lock.unlink()
40
+ except OSError:
41
+ logger.warning("无法清理残留锁 %s", lock)
42
+
43
+
44
+ _T = TypeVar("_T")
45
+
46
+ _HEADLESS_UA_RE = re.compile(r"HeadlessChrome/[\d.]+")
47
+
48
+
49
+ async def run_page(context: Any, op: Callable[[Any], Awaitable[_T]]) -> _T:
50
+ """开新页执行 op(page) 并保证页签关闭(成功、异常、被取消都关);页不跨调用存活。
51
+
52
+ 适用于「开页 → 取数 → 关页」的瞬态操作(知乎热榜)。
53
+ 需要跨多次 load_more 活着的页签不要用它(知乎信息流/详情),
54
+ 那类页签由持有它的 Loader 用 close_page() 负责关闭。
55
+ """
56
+ page = await context.new_page()
57
+ try:
58
+ return await op(page)
59
+ finally:
60
+ with suppress(Exception):
61
+ await page.close()
62
+
63
+
64
+ async def close_page(page: Any) -> None:
65
+ """关闭 Loader 持有的页签:幂等,出错自己吞(与 Loader.close 同约定)。"""
66
+ try:
67
+ if not page.is_closed():
68
+ await page.close()
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ class ChannelTab:
74
+ """常驻取数通道页签:停靠某 URL 的进程级单例 + evaluate 自愈 + 页内 fetch 原语。
75
+
76
+ 敌意源(知乎/Reddit/X)共用的取数通道形态——请求由真 Chrome 的页面
77
+ fetch 发出,TLS/头序/cookie 与登录会话完全自洽。生命周期全在这里:
78
+ 单例复用、页签被关重建、evaluate 撞上在途跳转(context destroyed)
79
+ 换页签重试一次。页签随 BrowserSession 生灭,无需显式释放。
80
+
81
+ 各源差异走四个显式扩展点,不往本类里加源名分支:
82
+ - check:开页校验钩子(知乎的登录墙)
83
+ - fetch() 的 headers / cookie_headers / referrer / as_text:每次请求的
84
+ 头形状与正文形态(Reddit 的 Accept+版本头、X 的 Bearer+ct0 csrf、
85
+ 知乎的 x-zse-93 与 referer)
86
+ - eval():任意复合脚本的逃生口(知乎的开题并行拉取/子回复补全——
87
+ 多请求一 evaluate 的形态单次 fetch 表达不了)
88
+ - 登录/自愈语义(401/403 转译、版本头与 queryId 的收割重试)留在
89
+ 各源 fetch 层:策略各站不同,统一反而互相缠绕
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ park_url: str,
95
+ *,
96
+ check: Callable[[Any], Awaitable[None]] | None = None,
97
+ name: str = "",
98
+ ) -> None:
99
+ self._park_url = park_url
100
+ self._check = check # 开页后校验(登录墙等):抛即弃页原样上交
101
+ self._name = name or park_url # 定位标签(日志/排错)
102
+ self._page: Any = None
103
+
104
+ async def get(self) -> Any:
105
+ """取(必要时开)通道页签;页签被关后下一次取重新开。"""
106
+ if self._page is not None and not self._page.is_closed():
107
+ return self._page
108
+
109
+ async def _open(ctx: Any) -> Any:
110
+ page = await ctx.new_page()
111
+ try:
112
+ await page.goto(
113
+ self._park_url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS
114
+ )
115
+ if self._check is not None:
116
+ await self._check(page)
117
+ except BaseException:
118
+ await close_page(page)
119
+ raise
120
+ return page
121
+
122
+ self._page = await get_session().run(_open)
123
+ return self._page
124
+
125
+ async def eval(self, script: str, arg: Any = None) -> Any:
126
+ """页签里 evaluate;页签废了(在途跳转/被关)换新页签重试一次。
127
+
128
+ 页面 fetch 自身的失败不走这层(JS 里按约定回 __status/__error,
129
+ 不抛异常穿过 evaluate),只有 Playwright 层的页签失效才重建。
130
+ """
131
+ for attempt in range(2):
132
+ page = await self.get()
133
+ try:
134
+ return await page.evaluate(script, arg)
135
+ except Exception:
136
+ # 弃掉的页签要真关掉:只置 None 会让它在 context 里留成孤儿页,
137
+ # 自愈几次就多出几只(浏览器会话关闭前一直占内存)
138
+ await close_page(page)
139
+ if attempt:
140
+ raise
141
+ self._page = None
142
+
143
+ async def fetch(
144
+ self,
145
+ url: str,
146
+ *,
147
+ source: str,
148
+ headers: dict[str, str] | None = None,
149
+ cookie_headers: dict[str, str] | None = None,
150
+ referrer: str | None = None,
151
+ as_text: bool = False,
152
+ ) -> tuple[int | None, Any]:
153
+ """页签里发一次 fetch -> (HTTP 状态码或 None, 正文);网络/结构失败转 SourceError。
154
+
155
+ 统一的页内取数原语(知乎/Reddit/X 共用,返回约定 __status/__error/
156
+ __body 见 _FETCH_JS):UA/头序/cookie/TLS 全是真 Chrome 的,调用方只
157
+ 补浏览器不会自动带的东西——headers 手动头;cookie_headers「cookie 值
158
+ 提升为头」的映射(如 X 的 ct0 -> X-Csrf-Token,页内就地读、永远新鲜,
159
+ 与 SPA 自己同一动作);referrer 指定则覆盖页签自然 referer;as_text
160
+ 拿 HTML 文本(Reddit)否则按 JSON 解析(知乎/X),此时顶层不是 JSON
161
+ 对象(登录墙 HTML / 代理错误页)也在这里一并报「结构异常」。成功且
162
+ 拿到正文时顺手落样本(samples 未开启零开销,probe 改版修复流水线
163
+ 三源同权)。状态码原样上交,401/403 的语义转译归各源。
164
+ """
165
+ try:
166
+ data = await self.eval(
167
+ _FETCH_JS, [url, headers or {}, cookie_headers or {}, referrer, as_text]
168
+ )
169
+ except Exception as exc: # Playwright 层失败(重建重试也没救回来)
170
+ raise SourceError(t("errors.http_failed", source=source, error=exc)) from exc
171
+ if not isinstance(data, dict):
172
+ raise SourceError(t("errors.bad_structure_short", source=source))
173
+ if err := data.get("__error"):
174
+ raise SourceError(t("errors.http_failed", source=source, error=err))
175
+ status = data.get("__status")
176
+ body = data.get("__body")
177
+ if status is None and body is None:
178
+ raise SourceError(t("errors.bad_structure_short", source=source))
179
+ if not as_text and body is not None and not isinstance(body, dict):
180
+ raise SourceError(t("errors.bad_structure_not_object", source=source))
181
+ status_i = int(status) if status else None
182
+ if status_i is None and body is not None:
183
+ # 样本名用通道名(ascii:zhihu/reddit/x),source 只管错误文案——
184
+ # 纯中文标签过 url_name 会被清成 src,样本就没主了
185
+ text = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
186
+ samples.save(samples.url_name(self._name, url), text)
187
+ return status_i, body
188
+
189
+ def reset(self) -> None:
190
+ """弃单例(下一次 get 重开);出错自愈路径用。"""
191
+ self._page = None
192
+
193
+
194
+ # 页签里执行的统一 fetch:失败按约定回 __status(非 2xx)/__error(网络或
195
+ # 解析异常),成功回 __body(文本或 JSON)。cookie_headers 把指定 cookie 的
196
+ # 值就地提升为请求头(页内读永远新鲜);不抛 JS 异常穿过 evaluate
197
+ _FETCH_JS = """
198
+ async ([url, headers, cookieHeaders, referrer, asText]) => {
199
+ try {
200
+ const h = Object.assign({}, headers);
201
+ for (const [name, cookie] of Object.entries(cookieHeaders)) {
202
+ // cookie 名里的正则元字符先转义(名字是调用方给的,不当正则源用)
203
+ const esc = cookie.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&");
204
+ const v = (document.cookie.match(new RegExp("(?:^|;\\\\s*)" + esc + "=([^;]+)")) || [])[1];
205
+ if (v) h[name] = decodeURIComponent(v);
206
+ }
207
+ const opts = { headers: h, credentials: "include" };
208
+ if (referrer) opts.referrer = referrer;
209
+ const r = await fetch(url, opts);
210
+ if (!r.ok) return { __status: r.status };
211
+ return { __body: asText ? await r.text() : await r.json() };
212
+ } catch (e) { return { __error: String(e) }; }
213
+ }
214
+ """
215
+
216
+
217
+ _UA_CACHE_TTL = 7 * 24 * 3600 # Chrome 版本几天到几周才变,探针结果落盘缓存
218
+
219
+
220
+ def _read_ua_cache() -> str | None:
221
+ """读 UA 缓存(含 Chrome 版本探针的完整结果);过期/缺失/损坏返回 None。"""
222
+ try:
223
+ data = json.loads((PROFILE_DIR.parent / "chrome-ua.json").read_text())
224
+ if data.get("ua") and time.time() - data.get("ts", 0) < _UA_CACHE_TTL:
225
+ return data["ua"]
226
+ except (OSError, ValueError):
227
+ pass
228
+ return None
229
+
230
+
231
+ def _write_ua_cache(ua: str) -> None:
232
+ """落盘 UA 缓存(写失败不声张:下次再探一次而已)。"""
233
+ try:
234
+ PROFILE_DIR.parent.mkdir(parents=True, exist_ok=True)
235
+ (PROFILE_DIR.parent / "chrome-ua.json").write_text(
236
+ json.dumps({"ua": ua, "ts": time.time()})
237
+ )
238
+ except OSError:
239
+ logger.warning("UA 缓存写盘失败(不影响本次运行)")
240
+
241
+
242
+ async def _chrome_ua(pw: Any) -> str | None:
243
+ """把无头 UA 修成与本机有头 Chrome 逐字一致(探针浏览器问真实版本)。
244
+
245
+ 无头 Chrome 的 UA 自带 HeadlessChrome/<major>.0.0.0 标记(小版本被冻结),
246
+ 反爬(如 Cloudflare 的托管挑战)见到它就挂死不自解。其余指纹
247
+ (brands/WebGL/webdriver)本就干净,只改 UA 即可全过;用探针浏览器
248
+ 拿完整版本号,Chrome 自动升级后 UA 也跟着对。探针失败则返回 None 不覆写,
249
+ 别让这条反爬路径挡住其他源。探针要额外拉起一只 Chrome(~1s),结果落盘
250
+ 缓存 7 天(见 _read_ua_cache)——常规启动直接命中,不再付这笔钱。
251
+ """
252
+ if cached := _read_ua_cache():
253
+ return cached
254
+ browser = await pw.chromium.launch(channel="chrome", headless=True)
255
+ try:
256
+ page = await browser.new_page()
257
+ headless_ua: str = await page.evaluate("navigator.userAgent")
258
+ version: str = browser.version.removeprefix("Chrome/") # 关掉前取,如 152.0.7977.65
259
+ finally:
260
+ await browser.close()
261
+ # 整只 HeadlessChrome/<版本> token 换成 Chrome/<完整版本>:冻结形态
262
+ # (major.0.0.0)与完整小版本形态都吃得下,不会静默漏替换
263
+ ua, n = _HEADLESS_UA_RE.subn(lambda _: f"Chrome/{version}", headless_ua)
264
+ if n == 0:
265
+ return None # 未来 Chrome 不再打 Headless 标记,就无须修
266
+ _write_ua_cache(ua)
267
+ logger.info("headless UA 修正: %s", ua)
268
+ return ua
269
+
270
+
271
+ class BrowserSession:
272
+ """管理一个 Playwright persistent context(channel 固定为本机 Chrome)。"""
273
+
274
+ def __init__(self) -> None:
275
+ self._pw: Any = None # Playwright 句柄
276
+ self._context: Any = None # BrowserContext
277
+ self._lock = asyncio.Lock()
278
+
279
+ @property
280
+ def started(self) -> bool:
281
+ return self._context is not None
282
+
283
+ async def start(self) -> None:
284
+ if self._context is not None:
285
+ return
286
+ async with self._lock:
287
+ if self._context is not None: # double-check:并发 start 只拉起一次
288
+ return
289
+ from playwright.async_api import async_playwright
290
+
291
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
292
+ drop_stale_locks()
293
+ logger.info("launch chrome, profile=%s", PROFILE_DIR)
294
+ self._pw = await async_playwright().start()
295
+ try:
296
+ ua = await _chrome_ua(self._pw)
297
+ except Exception:
298
+ logger.warning("探测 Chrome 版本失败,沿用原生 headless UA", exc_info=True)
299
+ ua = None
300
+ ua_kwargs: dict[str, Any] = {"user_agent": ua} if ua else {}
301
+ self._context = await self._pw.chromium.launch_persistent_context(
302
+ user_data_dir=str(PROFILE_DIR),
303
+ headless=True, # TUI 内无头;login 子命令另有有头入口
304
+ **chrome_launch_kwargs(), # channel/viewport/locale 等,与 login 共用
305
+ **ua_kwargs,
306
+ )
307
+
308
+ async def run(self, op: Callable[..., Awaitable[_T]], *args: Any, **kwargs: Any) -> _T:
309
+ """串行执行一次浏览器操作:op(context, *args, **kwargs) -> awaitable,全 app 排队走这里。"""
310
+ await self.start()
311
+ async with self._lock:
312
+ return await op(self._context, *args, **kwargs)
313
+
314
+ async def close(self) -> None:
315
+ """直接关闭,不抢锁:退出路径上可能还有持锁的取数任务,不能被它卡死。"""
316
+ context, pw = self._context, self._pw
317
+ self._context = self._pw = None
318
+ if context is not None:
319
+ with suppress(Exception):
320
+ await context.close()
321
+ if pw is not None:
322
+ with suppress(Exception):
323
+ await pw.stop()
324
+
325
+
326
+ _SESSION: BrowserSession | None = None
327
+
328
+
329
+ def get_session() -> BrowserSession:
330
+ global _SESSION
331
+ if _SESSION is None:
332
+ _SESSION = BrowserSession()
333
+ return _SESSION
334
+
335
+
336
+ async def close_session() -> None:
337
+ global _SESSION
338
+ if _SESSION is not None:
339
+ await _SESSION.close()
340
+ _SESSION = None
341
+
342
+
343
+ async def profile_cookies(url: str) -> dict[str, str]:
344
+ """读持久 profile 某域的 cookie(本地操作,不导航、不触发反爬)。
345
+
346
+ 判登门这类「本地看 cookie」的检查用(X/Reddit 的源声明层):返回全部
347
+ name->value,判登规则(哪个键在、怎么解码)由调用方自己定,不在这里
348
+ 替它做决定。
349
+ """
350
+ cookies = await get_session().run(lambda ctx: ctx.cookies(url))
351
+ return {c["name"]: c["value"] for c in cookies}
352
+
353
+
354
+ async def drop_profile_cookies(url: str, names: tuple[str, ...]) -> None:
355
+ """清掉持久 profile 里指定名字的 cookie(本地操作,与 logout 同一动作)。
356
+
357
+ 登录态被服务端吊销后 cookie 只是「还躺在本地」——不清掉的话判登
358
+ (cookie 在即算已登录)与账号屏会一直被骗过,用户只能反复撞 401/403。
359
+ 源在确认登录态失效后调这里清掉再抛 LoginRequired(X 的 401 自愈路径)。
360
+ url 只为对齐 profile_cookies 的形态:clear_cookies 按名字全域清。
361
+ """
362
+
363
+ async def _clear(ctx: Any) -> None:
364
+ for name in names:
365
+ await ctx.clear_cookies(name=name)
366
+
367
+ await get_session().run(_clear)