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
feedscope/__init__.py
ADDED
feedscope/__main__.py
ADDED
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)
|