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/core/text.py ADDED
@@ -0,0 +1,54 @@
1
+ """终端文本工具:显示宽度、按宽截断与零宽字符清理,渲染层与解析层共用。
2
+
3
+ 显示宽度 = 全宽(F/W)字符 2 列、其余 1 列;中文网页文本里到处是 U+200B
4
+ 零宽字符,进终端前清掉。这些是「终端显示」的通用算术,既不属于 ui(渲染)
5
+ 也不属于某个源(个性化解析),放 core 让两侧都能 import——parse 层不许依赖
6
+ ui(sources/x/parse.py 曾为此本地复制这两行),core 是它唯一能共享的地方。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import unicodedata
12
+
13
+ # 源码里用转义、别放字面量:字面零宽字符在源码里不可见,极易漏改
14
+ _ZWSP = "\u200b"
15
+
16
+
17
+ def char_width(ch: str) -> int:
18
+ """终端显示宽度:全宽(F/W)字符 2 列,其余 1 列。"""
19
+ return 2 if unicodedata.east_asian_width(ch) in "FW" else 1
20
+
21
+
22
+ def display_width(text: str) -> int:
23
+ """整串的终端显示宽度(逐字符求和)。"""
24
+ return sum(char_width(c) for c in text)
25
+
26
+
27
+ def truncate_display(text: str, limit: int) -> str:
28
+ """按显示宽度截断(超长补省略号「…」);不超长原样返回。"""
29
+ if display_width(text) <= limit:
30
+ return text
31
+ out: list[str] = []
32
+ used = 0
33
+ for ch in text:
34
+ w = char_width(ch)
35
+ if used + w > limit:
36
+ break
37
+ out.append(ch)
38
+ used += w
39
+ return "".join(out) + "…"
40
+
41
+
42
+ def strip_zwsp(text: str) -> str:
43
+ """清掉零宽字符(U+200B)并 strip,中文网页文本进终端前的统一清洗。"""
44
+ return (text or "").replace(_ZWSP, "").strip()
45
+
46
+
47
+ def is_cjk(ch: str) -> bool:
48
+ """是否中日韩字符(表意文字 / 中文标点 / 全角形式):输入法上屏内容的判定范围。"""
49
+ return (
50
+ "\u4e00" <= ch <= "\u9fff"
51
+ or "\u3400" <= ch <= "\u4dbf"
52
+ or "\u3000" <= ch <= "\u303f"
53
+ or "\uff00" <= ch <= "\uffef"
54
+ )
@@ -0,0 +1,53 @@
1
+ """时间展示格式化:源的原始时间形态 -> 北京时间(UTC+8)「YYYY-MM-DD HH:MM」。
2
+
3
+ 各源原始形态不同(X 是英文 Twitter 串、知乎是页面上的 DOM 文本、HN 是
4
+ UTC 的 ISO 串),统一在这里转成同一展示文本。一律折算成固定的北京时间而非本机时区:
5
+ 跨时区设备(服务器/海外机器)不该看到不一样的时间。解析失败
6
+ 回空串——时间只是元信息,缺了不致命,别让一行脏数据炸掉整版内容。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ _FMT = "%Y-%m-%d %H:%M"
14
+ _BJT = timezone(timedelta(hours=8)) # 北京时间:固定 UTC+8,无夏令时
15
+
16
+
17
+ def _bj(dt: datetime) -> str:
18
+ try:
19
+ if dt.tzinfo is None:
20
+ dt = dt.replace(tzinfo=_BJT) # 无时区标记的串:当作北京时间(中文站源)
21
+ return dt.astimezone(_BJT).strftime(_FMT)
22
+ except (OverflowError, OSError, ValueError):
23
+ return ""
24
+
25
+
26
+ def fmt_twitter(s: str | None) -> str:
27
+ """X 的 created_at("Wed Oct 15 08:23:11 +0000 2025",UTC)-> 北京时间展示文本。"""
28
+ if not s:
29
+ return ""
30
+ try:
31
+ return _bj(datetime.strptime(s, "%a %b %d %H:%M:%S %z %Y"))
32
+ except ValueError:
33
+ return ""
34
+
35
+
36
+ def fmt_unix(seconds: float | None) -> str:
37
+ """unix 秒时间戳(知乎 initialData 的 created 等)-> 北京时间展示文本。"""
38
+ if not seconds:
39
+ return ""
40
+ try:
41
+ return _bj(datetime.fromtimestamp(int(seconds), tz=_BJT))
42
+ except (OverflowError, OSError, ValueError):
43
+ return ""
44
+
45
+
46
+ def fmt_iso(s: str | None) -> str:
47
+ """ISO 8601 串(HN Algolia 的 created_at,UTC 带 Z)-> 北京时间展示文本。"""
48
+ if not s:
49
+ return ""
50
+ try:
51
+ return _bj(datetime.fromisoformat(s))
52
+ except (OverflowError, OSError, ValueError):
53
+ return ""
feedscope/core/tree.py ADDED
@@ -0,0 +1,134 @@
1
+ """讨论树拍平共用件:深层嵌套 -> Comment 模型的两层(顶层 + replies)。
2
+
3
+ Comment 只有两层而讨论站常嵌四五层(HN / Reddit):楼层的直接子回复做
4
+ 顶层 Comment、更深的后代拍平进它的 replies。「拍成两层」与 @「父作者」
5
+ 前缀的规则在这里唯一实现,两站的观感由同一份代码保证:
6
+
7
+ - 紧贴上一条的那层不加 @(回复对象视觉相邻,@ 冗余);隔层起加(被拍平
8
+ 后父不一定相邻,@ 是唯一指向)
9
+ - 父已删不给前缀(@ 不到人);本尊已删有内容照常保留(作者栏是删除占位)
10
+ - 空壳节点(无正文也无孩子,典型是被删且无回复)整支跳过
11
+
12
+ 节点形态各站不同(Algolia 的 dict / HTML 重建的 _CNode),差异经
13
+ TreeAccessors 四件套注入(keep / kids_of / author_of / comment_of;后者
14
+ 产本体 Comment,@ 前缀由本模块按上述规则统一拼装,源内不再自拼)。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import Generic, TypeVar
22
+
23
+ from feedscope.core.i18n import t
24
+ from feedscope.core.models import Answer, Comment, Floor
25
+
26
+ _N = TypeVar("_N")
27
+
28
+ # 删除态占位词(作者栏 / 正文),HN 与 Reddit 同形,UI 不特判。按调用解析:
29
+ # 常量在 import 期求值会冻住语言;@ 规则靠「同语言的相等比较」辨认删除态,
30
+ # 进程内语言固定(见 core/i18n 模块头),调用点各自解析即自洽
31
+ def deleted_author() -> str:
32
+ return t("words.deleted_author")
33
+
34
+
35
+ def deleted_body() -> str:
36
+ return t("words.deleted_body")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TreeAccessors(Generic[_N]):
41
+ """站点树节点 -> 通用树遍历的四件套注入(节点形态差异的收口)。
42
+
43
+ - keep:空壳判定(整支跳过的规则见模块 docstring)
44
+ - kids_of:直接子节点序列
45
+ - author_of:作者名(删除态给 DELETED_AUTHOR,@ 规则靠它辨认)
46
+ - comment_of:节点 -> Comment 本体(@ 前缀由本模块统一拼装,不自拼)
47
+ """
48
+
49
+ keep: Callable[[_N], bool]
50
+ kids_of: Callable[[_N], Sequence[_N]]
51
+ author_of: Callable[[_N], str]
52
+ comment_of: Callable[[_N], Comment]
53
+
54
+
55
+ def _at(parent_author: str, *, direct: bool) -> str:
56
+ """这层回复的 @ 前缀:direct(紧贴楼上)/ 父缺席 / 父已删 → 不加。"""
57
+ if direct or not parent_author or parent_author == deleted_author():
58
+ return ""
59
+ return f"@{parent_author}"
60
+
61
+
62
+ def flatten_comments(
63
+ nodes: Sequence[_N],
64
+ acc: TreeAccessors[_N],
65
+ *,
66
+ parent_author: str = "",
67
+ direct: bool = False,
68
+ ) -> list[Comment]:
69
+ """nodes 的全部后代拍平成一串 Comment(@ 前缀标出回复对象,规则见模块 docstring)。"""
70
+ flat: list[Comment] = []
71
+ for node in nodes:
72
+ if not acc.keep(node):
73
+ continue
74
+ who = acc.author_of(node)
75
+ c = acc.comment_of(node)
76
+ if at := _at(parent_author, direct=direct):
77
+ c.content_md = f"{at}:{c.content_md}"
78
+ flat.append(c)
79
+ flat.extend(
80
+ flatten_comments(acc.kids_of(node), acc, parent_author=who)
81
+ )
82
+ return flat
83
+
84
+
85
+ def floor_comments(nodes: Sequence[_N], acc: TreeAccessors[_N]) -> list[Comment]:
86
+ """楼层(顶层评论)的直接子回复 -> 顶层 Comment;各自更深的后代拍平进 replies。
87
+
88
+ 直接子回复不加 @:楼层作者就在屏首(与知乎「回答下的评论」同观感);
89
+ replies 里第一层(紧贴楼上那条)也不加,@ 从隔层起才有指向价值。
90
+ """
91
+ out: list[Comment] = []
92
+ for node in nodes:
93
+ if not acc.keep(node):
94
+ continue
95
+ c = acc.comment_of(node)
96
+ c.replies = flatten_comments(
97
+ acc.kids_of(node), acc, parent_author=acc.author_of(node), direct=True
98
+ )
99
+ out.append(c)
100
+ return out
101
+
102
+
103
+ def count_kept(node: _N, acc: TreeAccessors[_N]) -> int:
104
+ """node 子树内保留下来的评论总数(楼层头「N 条回复」与 meta 评论数用)。
105
+
106
+ 只用 acc 的 keep/kids_of 两件;与拍平共用同一个注入对象(HN/Reddit
107
+ 的 _ACC 即此),不另造两件套。"""
108
+ return sum(1 + count_kept(c, acc) for c in acc.kids_of(node) if acc.keep(c))
109
+
110
+
111
+ def build_floors(
112
+ nodes: Sequence[_N],
113
+ acc: TreeAccessors[_N],
114
+ *,
115
+ answer_of: Callable[[_N, int], Answer],
116
+ ) -> list[Floor]:
117
+ """顶层节点序列 -> Floor 列表(HN / Reddit 的「详情一次拉全树」共用)。
118
+
119
+ 骨架:keep 过滤 → count_kept 数子树 → answer_of(node, n) 造楼层本体
120
+ (字段来源与 comment_count 引导词是站差异,n 为该层回复数)→
121
+ floor_comments 拍平该层的回复树。
122
+ """
123
+ floors: list[Floor] = []
124
+ for node in nodes:
125
+ if not acc.keep(node):
126
+ continue
127
+ n = count_kept(node, acc)
128
+ floors.append(
129
+ Floor(
130
+ answer=answer_of(node, n),
131
+ comments=floor_comments(acc.kids_of(node), acc),
132
+ )
133
+ )
134
+ return floors
feedscope/doctor.py ADDED
@@ -0,0 +1,54 @@
1
+ """`feedscope doctor`:环境自检。逐项检查、逐项打印,退出码 0 = 全部通过。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from feedscope import __version__
8
+ from feedscope.core.browser import PROFILE_DIR
9
+ from feedscope.core.const import find_chrome
10
+ from feedscope.report import line
11
+ from feedscope.core.i18n import t
12
+
13
+
14
+ def _check(label: str, ok: bool, detail: str = "") -> bool:
15
+ return line("OK" if ok else "FAIL", label, detail)
16
+
17
+
18
+ def run_checks() -> int:
19
+ print(t("doctor.title", version=__version__))
20
+ print()
21
+
22
+ py_ok = sys.version_info >= (3, 11)
23
+ ok = _check(
24
+ f"Python {sys.version_info.major}.{sys.version_info.minor}",
25
+ py_ok,
26
+ "" if py_ok else t("doctor.need_py"),
27
+ )
28
+
29
+ chrome = find_chrome()
30
+ ok &= _check(
31
+ "Google Chrome",
32
+ bool(chrome),
33
+ chrome or t("doctor.chrome_missing"),
34
+ )
35
+
36
+ try:
37
+ import playwright # noqa: F401
38
+ import textual # noqa: F401
39
+
40
+ ok &= _check(t("doctor.deps_label"), True, t("doctor.importable"))
41
+ except ImportError as exc:
42
+ ok &= _check(t("doctor.deps_label"), False, t("doctor.import_failed", error=exc))
43
+
44
+ # 信息项:不算失败
45
+ state = t("doctor.profile_exists") if PROFILE_DIR.exists() else t("doctor.profile_will_create")
46
+ _check(t("doctor.profile_label"), True, f"{PROFILE_DIR}({state})")
47
+
48
+ from feedscope.core.config import config_path, describe
49
+
50
+ _check(t("doctor.config_label"), True, f"{config_path()} - {describe()}")
51
+
52
+ print()
53
+ print(t("doctor.all_ok") if ok else t("doctor.has_failures"))
54
+ return 0 if ok else 1
feedscope/login.py ADDED
@@ -0,0 +1,168 @@
1
+ """登录流程:有头 Chrome 手动登录(知乎扫码、X/Reddit 账号密码),检测到源声明的判据即成功。
2
+
3
+ 为什么有头:二维码在手机上、账号密码要手输,窗口必须可见。
4
+ 为什么独立命令:persistent profile 同一时刻只允许一个实例;`feedscope login`
5
+ 子命令仍保留(纯命令行场景),但 TUI 内现在也能登录了(见下)。
6
+ 哪些源需要登录、登录页在哪、拿什么判登,全部由 Source 的 ClassVar 声明
7
+ (login_url / login_cookie / login_verify / logout_cookies),本文件不认识
8
+ 任何具体源——新增需要登录的源只改它自己的包,不动这里。判据优先级:
9
+ login_verify(浏览器级钩子,如 Reddit 的 JWT 解码)> logout_cookies 全集
10
+ (如 X 的 auth_token+ct0)> login_cookie 单枚兜底。
11
+
12
+ 本文件也承载 TUI 内的登录 / 登出:
13
+ - `is_logged_in` / `logout` 复用同一份源声明判据,TUI 撞登录墙后无需退出
14
+ 即可弹浏览器登录、或一键登出换账号。
15
+ - `login(quiet=True)` 供 TUI 调用:抑制 stdout 打印(Textual 会捕获 stdout,
16
+ 落进退出时的刷屏),反馈改由 UI 层自己弹。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from urllib.parse import urlparse
23
+
24
+ from feedscope.core.browser import PROFILE_DIR, close_session, drop_stale_locks
25
+ from feedscope.core.const import PAGE_TIMEOUT_MS, chrome_launch_kwargs
26
+ from feedscope.core.i18n import t
27
+
28
+ LOGIN_TIMEOUT = 180 # 秒;3 分钟没扫上就退出
29
+ POLL_INTERVAL = 2
30
+
31
+
32
+ async def login(source_id: str = "zhihu", *, quiet: bool = False) -> bool:
33
+ import feedscope.sources # noqa: F401 import 即注册
34
+ from feedscope.core.registry import all_sources, get_source
35
+
36
+ try:
37
+ source = get_source(source_id)
38
+ except KeyError:
39
+ ids = ", ".join(s.source_id for s in all_sources())
40
+ _say(quiet, t("login.unknown_source", source=source_id, ids=ids))
41
+ return False
42
+ if not source.login_url:
43
+ _say(quiet, t("login.not_needed", label=source.label_text))
44
+ return False
45
+
46
+ from playwright.async_api import async_playwright
47
+
48
+ _say(quiet, t("login.starting", dir=PROFILE_DIR))
49
+ _say(quiet, t("login.prompt", label=source.label_text))
50
+ drop_stale_locks()
51
+ try:
52
+ async with async_playwright() as pw:
53
+ context = await pw.chromium.launch_persistent_context(
54
+ user_data_dir=str(PROFILE_DIR),
55
+ headless=False, # 登录窗口必须可见(扫码/手输密码)
56
+ **chrome_launch_kwargs(), # channel/viewport/locale 等,与 browser.py 共用
57
+ )
58
+ try:
59
+ page = context.pages[0] if context.pages else await context.new_page()
60
+ await page.goto(
61
+ source.login_url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS
62
+ )
63
+ # 轮询单独兜底:判据钩子抛错报「登录检测出错」,不被外层误标成
64
+ # 「浏览器启动失败」(浏览器明明活着,误导排障)
65
+ try:
66
+ if await _await_login(context, source, quiet):
67
+ return True
68
+ except Exception as exc:
69
+ _say(quiet, t("login.check_error", error=exc))
70
+ return False
71
+ finally:
72
+ await context.close()
73
+ except Exception as exc: # 浏览器没装/起不来/导航超时:给中文出口,不裸栈
74
+ _say(quiet, t("login.browser_failed", error=exc))
75
+ return False
76
+ _say(quiet, t("login.timeout", seconds=LOGIN_TIMEOUT))
77
+ return False
78
+
79
+
80
+ async def _await_login(context, source, quiet: bool) -> bool:
81
+ """轮询源声明的判据(口径见 _logged_in)直到成功(True,已报喜)或超时(False)。"""
82
+ deadline = asyncio.get_running_loop().time() + LOGIN_TIMEOUT
83
+ while asyncio.get_running_loop().time() < deadline:
84
+ if await _logged_in(context, source):
85
+ if source.login_verify is not None:
86
+ how = t("login.success_state")
87
+ else:
88
+ joiner = t("common.enum_comma")
89
+ how = t("login.success_cookies", cookies=joiner.join(source.judging_cookies))
90
+ _say(quiet, t("login.success", how=how))
91
+ return True
92
+ await asyncio.sleep(POLL_INTERVAL)
93
+ return False
94
+
95
+
96
+ async def yield_profile() -> None:
97
+ """关掉无头会话让出 profile 锁(有头登录前必经;TUI 账号动线调用)。
98
+
99
+ persistent profile 同一时刻只允许一个实例:无头取数会话不先关掉,
100
+ 有头登录窗口会撞锁起不来。登录完成后取数按需自然重开(get_session
101
+ 惰性启动)。调用方还要先释放各自持有的活页签(TUI 的列表 loader)。
102
+ """
103
+ await close_session()
104
+
105
+
106
+ async def _present_cookies(context, source) -> list[dict]:
107
+ """读某源登录域下的全部 cookie(本地操作,不导航、不触发反爬)。"""
108
+ domain = urlparse(source.login_url).netloc
109
+ return await context.cookies(f"https://{domain}")
110
+
111
+
112
+ async def _logged_in(context, source) -> bool:
113
+ """源声明的判据:login_verify 优先,否则判登 cookie 全集都在。
114
+
115
+ cookie 全集口径与 login() 轮询一致(Source.judging_cookies:X 要
116
+ auth_token+ct0 成对,未声明全集才退单枚 login_cookie);Reddit 这类
117
+ 匿名访客也发 cookie 的源由 login_verify 做 JWT 解码。
118
+ """
119
+ if source.login_verify is not None:
120
+ return bool(await source.login_verify(context))
121
+ needed = source.judging_cookies
122
+ if not needed:
123
+ return False
124
+ names = {c["name"] for c in await _present_cookies(context, source)}
125
+ return set(needed) <= names
126
+
127
+
128
+ async def _clear_cookies(context, source) -> bool:
129
+ """清掉源声明的登录 cookie;返回是否确有登录态被清掉。
130
+
131
+ 「确有」与判登共用 _logged_in(login_verify 优先);半套 cookie
132
+ (如只剩 auth_token 没有 ct0)本就不算已登录,登出反馈「本就未登录」;
133
+ cookie 照样全清,不留脏状态。"""
134
+
135
+ had = await _logged_in(context, source)
136
+ for name in source.judging_cookies:
137
+ await context.clear_cookies(name=name)
138
+ return had
139
+
140
+
141
+ async def is_logged_in(source_id: str = "zhihu") -> bool:
142
+ """当前 profile 是否已登录某源;无任何判据(login_verify / cookie)的源恒为 False。"""
143
+ from feedscope.core.registry import get_source
144
+
145
+ source = get_source(source_id)
146
+ if source.login_verify is None and not source.judging_cookies:
147
+ return False
148
+ from feedscope.core.browser import get_session
149
+
150
+ return await get_session().run(_logged_in, source)
151
+
152
+
153
+ async def logout(source_id: str = "zhihu") -> bool:
154
+ """登出某源:清掉判登 cookie;返回是否确有登录态被清掉。"""
155
+ from feedscope.core.registry import get_source
156
+
157
+ source = get_source(source_id)
158
+ if not source.judging_cookies:
159
+ return False
160
+ from feedscope.core.browser import get_session
161
+
162
+ return await get_session().run(_clear_cookies, source)
163
+
164
+
165
+ def _say(quiet: bool, msg: str) -> None:
166
+ """CLI 入口打印;TUI(quiet=True)由 UI 层自行反馈,不在 stdout 落字。"""
167
+ if not quiet:
168
+ print(msg)
feedscope/probe.py ADDED
@@ -0,0 +1,131 @@
1
+ """`feedscope probe`:源巡检——每个源的每个信息流拉一次首批,改版当天一条命令定位。
2
+
3
+ - 每流一行:[OK] N 条 / [SKIP] 未登录(LoginRequired,feedscope login 后可测)/
4
+ [FAIL] 错误消息(含首屏 0 条守卫的「可能已改版」)
5
+ - 首个成功流里挑一条可进详情的再测 open_detail:标题/正文/楼层全空才算坏
6
+ (0 回复的帖子是正常数据,不算)
7
+ - 默认开样本落盘(FEEDSCOPE_SAMPLE_DIR 未设时设到 ~/.cache/feedscope/samples/):
8
+ 站点改版后对着真实样本修 parse.py、补 fixture,或把样本交给 AI 重新生成解析器
9
+ - 退出码:有 FAIL = 1(可挂 CI 定时跑,改版当天就知道哪个源坏了);未知源 id = 2
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ from collections.abc import Sequence
17
+ from typing import Literal
18
+
19
+ from platformdirs import user_cache_path
20
+
21
+ import feedscope.sources # noqa: F401 import 即注册,否则巡检不到任何源
22
+ from feedscope.core import http
23
+ from feedscope.core.browser import close_session
24
+ from feedscope.core.models import FeedItem
25
+ from feedscope.core.registry import all_sources
26
+ from feedscope.core.source import FeedLoader, LoginRequired, Source, SourceError
27
+ from feedscope.report import line as _line
28
+ from feedscope.core.i18n import t
29
+
30
+
31
+ def _classify(exc: BaseException) -> tuple[Literal["SKIP", "FAIL"], str]:
32
+ """异常 -> (状态, 消息):LoginRequired=SKIP、SourceError=FAIL、其余兜住也算 FAIL。
33
+
34
+ 巡检要兜住一切——崩溃也是发现(取数/详情两处共用同一套分类)。"""
35
+ if isinstance(exc, LoginRequired):
36
+ return "SKIP", str(exc)
37
+ if isinstance(exc, SourceError):
38
+ return "FAIL", str(exc)
39
+ return "FAIL", t("probe.unexpected", exc=repr(exc))
40
+
41
+
42
+ async def _try_open_feed(
43
+ source: Source, name: str
44
+ ) -> tuple[Literal["OK", "SKIP", "FAIL"], FeedLoader | None, str]:
45
+ """打开一个流,返回 (状态, loader, 消息);异常一律兜住转成状态行。"""
46
+ label = f"{source.label_text} {name}"
47
+ try:
48
+ return "OK", await source.open_feed(name), label
49
+ except Exception as exc:
50
+ state, msg = _classify(exc)
51
+ return state, None, f"{label} - {msg}"
52
+
53
+
54
+ async def _probe_source(source: Source, stats: dict[str, int]) -> None:
55
+ """测一个源的全部信息流 + 一条详情;统计写进 stats。"""
56
+ detail_item: FeedItem | None = None
57
+ for name in source.feeds:
58
+ state, loader, line = await _try_open_feed(source, name)
59
+ if loader is None:
60
+ _line(state, line)
61
+ stats[state.lower()] += 1
62
+ continue
63
+ try:
64
+ _line("OK", line, t("probe.n_items", n=len(loader.items)))
65
+ stats["ok"] += 1
66
+ if detail_item is None: # 挑一条可进详情屏的条目测 open_detail
67
+ detail_item = next(
68
+ (it for it in loader.items if it.kind in source.detail_kinds), None
69
+ )
70
+ finally:
71
+ await loader.close()
72
+
73
+ if source.supports_detail and detail_item is not None:
74
+ await _probe_detail(source, detail_item, stats)
75
+
76
+
77
+ async def _probe_detail(source: Source, item: FeedItem, stats: dict[str, int]) -> None:
78
+ label = t("probe.detail_label", label=source.label_text)
79
+ try:
80
+ loader = await source.open_detail(item)
81
+ except Exception as exc:
82
+ state, msg = _classify(exc)
83
+ _line(state, label, msg)
84
+ stats[state.lower()] += 1
85
+ return
86
+ try:
87
+ doc = loader.doc
88
+ if not (doc.title or doc.description_md or doc.answers):
89
+ _line("FAIL", label, t("probe.detail_empty"))
90
+ stats["fail"] += 1
91
+ else:
92
+ title = doc.title[:24] + ("…" if len(doc.title) > 24 else "")
93
+ _line(
94
+ "OK", label,
95
+ t("probe.detail_ok", title=title, n=len(doc.answers), unit=source.detail_unit),
96
+ )
97
+ stats["ok"] += 1
98
+ finally:
99
+ await loader.close()
100
+
101
+
102
+ async def _run(only: Sequence[str]) -> int:
103
+ known = {s.source_id for s in all_sources()}
104
+ if unknown := [i for i in only if i not in known]:
105
+ joiner = t("common.enum_comma")
106
+ print(
107
+ t("probe.unknown_ids", unknown=joiner.join(unknown), known=joiner.join(sorted(known)))
108
+ )
109
+ return 2
110
+ sample_dir = os.environ.setdefault(
111
+ "FEEDSCOPE_SAMPLE_DIR", str(user_cache_path("feedscope") / "samples")
112
+ )
113
+ print(t("probe.title", dir=sample_dir))
114
+ print()
115
+ stats = {"ok": 0, "fail": 0, "skip": 0}
116
+ try:
117
+ for source in (s for s in all_sources() if not only or s.source_id in only):
118
+ await _probe_source(source, stats)
119
+ finally:
120
+ await http.close()
121
+ await close_session()
122
+ print()
123
+ print(t("probe.summary", ok=stats['ok'], fail=stats['fail'], skip=stats['skip']))
124
+ if stats["fail"]:
125
+ print(t("probe.fail_hint", dir=sample_dir))
126
+ return 1 if stats["fail"] else 0
127
+
128
+
129
+ def run_probes(only: Sequence[str] = ()) -> int:
130
+ """同步入口(cli 用):巡检全部源或指定源,返回进程退出码。"""
131
+ return asyncio.run(_run(only))
feedscope/report.py ADDED
@@ -0,0 +1,19 @@
1
+ """行式检查输出:doctor / probe 共用的 [OK]/[FAIL]/[SKIP] 状态行。
2
+
3
+ 标记的宽度与配色取向只在这里定义一次——两个子命令的输出永远同形,
4
+ 用户在一个工具里认得的标记在另一个里不用重学。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ State = Literal["OK", "FAIL", "SKIP"] # 状态行三态(doctor / probe 共用)
12
+
13
+ MARKS: dict[str, str] = {"OK": "[OK] ", "FAIL": "[FAIL]", "SKIP": "[SKIP]"} # OK 补空格对齐三态
14
+
15
+
16
+ def line(state: State, label: str, detail: str = "") -> bool:
17
+ """打一行状态;返回是否 OK(调用方累计通过项用)。"""
18
+ print(f"{MARKS[state]} {label}" + (f" - {detail}" if detail else ""))
19
+ return state == "OK"
@@ -0,0 +1,38 @@
1
+ """源插件包:import 即注册(类装饰器 @register),import 顺序 = 列表顺序。
2
+
3
+ import 按 source_id 字母正序书写 = 源选择列表与未配置时的默认 Tab 序(该
4
+ 不变量由 test_source_caps 钉住:新增源插错位置测试即红)。
5
+
6
+ 新增一个源 = 按下面的模板建一个包 + 在这里挂一行 import,UI 与 core 零改动
7
+ (用户侧隐藏内置流走 sources.toml,见 core/config.py,不动这里;可订阅源
8
+ 另在 subscriptions 登记一个 SubsStore,test_source_caps 会查漏):
9
+
10
+ sources/<id>/
11
+ fetch.py 取数层:拿原始数据(httpx / 通道页签 fetch / DOM 提取),
12
+ 持资源的增量 Loader 也放这里;不碰 UI。
13
+ parse.py 解析层:raw dict/HTML -> FeedItem/Answer/DetailDoc/markdown,
14
+ 纯函数,直接可写 fixture 测试;不碰网络。
15
+ __init__.py 编排层:@register 的 Source 子类,做能力声明与 open_* 分派
16
+ (出口统一过 require_feed_items 首屏守卫)、判登门
17
+ (core.source.login_gate,判据是源差异)。
18
+
19
+ 编排层按源形态直接继承共享骨架,别重写机械件(详情骨架 / 批次去重 /
20
+ 判登 cookie 读取都已收口):
21
+ - 纯订阅聚合(RSS / Telegram 形):继承 core.source.SubscribedSource——
22
+ feeds 加法合成、「全部」流的四级错误出口全在基类,子类只注入
23
+ _fetch_merged / _open_one 两个差异
24
+ - 详情一次拉全树(HN / Reddit / V2EX 形):楼层/评论 loader 继承
25
+ core.source.SliceDetailLoader,翻页切片零代码
26
+ - 正文随列表带回(RSS / Telegram 形):详情用 core.source.StaticDetailLoader,
27
+ 进详情零请求
28
+ - 讨论树深层拍平:core.tree(@ 父作者前缀规则全库唯一实现)
29
+
30
+ 依赖方向只允许 __init__ -> fetch -> parse -> core(parse 永远是最底层叶子)。
31
+ """
32
+ from feedscope.sources import hn # noqa: F401
33
+ from feedscope.sources import reddit # noqa: F401
34
+ from feedscope.sources import rss # noqa: F401
35
+ from feedscope.sources import telegram # noqa: F401
36
+ from feedscope.sources import v2ex # noqa: F401
37
+ from feedscope.sources import x # noqa: F401
38
+ from feedscope.sources import zhihu # noqa: F401