feedscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. feedscope/__init__.py +3 -0
  2. feedscope/__main__.py +4 -0
  3. feedscope/cli.py +154 -0
  4. feedscope/core/__init__.py +1 -0
  5. feedscope/core/browser.py +367 -0
  6. feedscope/core/config.py +367 -0
  7. feedscope/core/const.py +64 -0
  8. feedscope/core/display.py +62 -0
  9. feedscope/core/http.py +104 -0
  10. feedscope/core/i18n/__init__.py +202 -0
  11. feedscope/core/i18n/en.toml +447 -0
  12. feedscope/core/i18n/zh-CN.toml +444 -0
  13. feedscope/core/media.py +150 -0
  14. feedscope/core/models.py +86 -0
  15. feedscope/core/registry.py +53 -0
  16. feedscope/core/samples.py +68 -0
  17. feedscope/core/source.py +668 -0
  18. feedscope/core/text.py +54 -0
  19. feedscope/core/timefmt.py +53 -0
  20. feedscope/core/tree.py +134 -0
  21. feedscope/doctor.py +54 -0
  22. feedscope/login.py +168 -0
  23. feedscope/probe.py +131 -0
  24. feedscope/report.py +19 -0
  25. feedscope/sources/__init__.py +38 -0
  26. feedscope/sources/hn/__init__.py +66 -0
  27. feedscope/sources/hn/fetch.py +82 -0
  28. feedscope/sources/hn/parse.py +154 -0
  29. feedscope/sources/reddit/__init__.py +178 -0
  30. feedscope/sources/reddit/fetch.py +181 -0
  31. feedscope/sources/reddit/parse.py +318 -0
  32. feedscope/sources/rss/__init__.py +64 -0
  33. feedscope/sources/rss/fetch.py +94 -0
  34. feedscope/sources/rss/parse.py +116 -0
  35. feedscope/sources/telegram/__init__.py +61 -0
  36. feedscope/sources/telegram/fetch.py +79 -0
  37. feedscope/sources/telegram/parse.py +182 -0
  38. feedscope/sources/v2ex/__init__.py +64 -0
  39. feedscope/sources/v2ex/fetch.py +46 -0
  40. feedscope/sources/v2ex/parse.py +116 -0
  41. feedscope/sources/x/__init__.py +68 -0
  42. feedscope/sources/x/fetch.py +490 -0
  43. feedscope/sources/x/parse.py +535 -0
  44. feedscope/sources/zhihu/__init__.py +81 -0
  45. feedscope/sources/zhihu/fetch.py +402 -0
  46. feedscope/sources/zhihu/parse.py +235 -0
  47. feedscope/subscriptions.py +423 -0
  48. feedscope/ui/__init__.py +10 -0
  49. feedscope/ui/account.py +125 -0
  50. feedscope/ui/app.py +105 -0
  51. feedscope/ui/dialogs.py +127 -0
  52. feedscope/ui/feedback.py +64 -0
  53. feedscope/ui/focus_nav.py +113 -0
  54. feedscope/ui/labels.py +38 -0
  55. feedscope/ui/layout.py +66 -0
  56. feedscope/ui/list_render.py +42 -0
  57. feedscope/ui/screens/__init__.py +1 -0
  58. feedscope/ui/screens/answer.py +296 -0
  59. feedscope/ui/screens/detail.py +274 -0
  60. feedscope/ui/screens/language.py +59 -0
  61. feedscope/ui/screens/main.py +736 -0
  62. feedscope/ui/screens/main_setup.py +226 -0
  63. feedscope/ui/screens/reading.py +384 -0
  64. feedscope/ui/screens/source_setup.py +217 -0
  65. feedscope/ui/screens/subscriptions.py +196 -0
  66. feedscope/ui/theme.py +45 -0
  67. feedscope/ui/wrap.py +157 -0
  68. feedscope-0.1.0.dist-info/METADATA +302 -0
  69. feedscope-0.1.0.dist-info/RECORD +72 -0
  70. feedscope-0.1.0.dist-info/WHEEL +4 -0
  71. feedscope-0.1.0.dist-info/entry_points.txt +2 -0
  72. feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,79 @@
1
+ """取数层:t.me/s 频道预览页抓取 + 「全部」聚合流(共享编排)。
2
+
3
+ - 单频道:GET https://t.me/s/<名>(可选 ?before=<最小id> 翻更旧一页,页内
4
+ ~20 条);纯 HTML 无 JSON,免登录免 JS。预览页没有 ETag/Last-Modified
5
+ (不走 RSS 的条件请求),刷新就是整页重拉——一页 148KB 级,阅读节奏下无害
6
+ - 聚合「全部」的编排(并发/失败隔离/倒序合并)见 core.source.merge_feeds;
7
+ Telegram 的差异是按发布时间(ISO 字符串)倒序、错误标签带 @
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from feedscope.core import http
13
+ from feedscope.core.config import Subscription
14
+ from feedscope.core.models import FeedItem
15
+ from feedscope.core.source import FeedLoader, dedup_append, merge_feeds
16
+ from feedscope.sources.telegram.parse import parse_channel_html
17
+
18
+ _SITE = "https://t.me/s"
19
+
20
+
21
+ async def fetch_channel_html(name: str, before: int | None = None) -> str:
22
+ """一个频道的一页预览 HTML;before = 已见最小消息号(翻更旧一页)。"""
23
+ params = {"before": str(before)} if before else None
24
+ r = await http.get(f"{_SITE}/{name}", source=f"Telegram @{name}", params=params)
25
+ return r.text
26
+
27
+
28
+ class TelegramFeedLoader(FeedLoader):
29
+ """单频道翻页:下一页游标 = 本页解析出的最小消息号;翻空即到头。
30
+
31
+ 每页倒排追加(预览页是聊天式旧→新,阅读器时间线要新→旧):翻页后
32
+ 更旧的整页缀在更旧的位置上,全局严格最新在前——与「全部」聚合流
33
+ 及所有源的时间线方向一致。
34
+ """
35
+
36
+ def __init__(self, channel: str, label: str) -> None:
37
+ self._channel = channel
38
+ self._label = label
39
+ self.items: list[FeedItem] = []
40
+ self._seen: set[str] = set()
41
+ self._before: int | None = None
42
+ self._done = False
43
+
44
+ @property
45
+ def done(self) -> bool:
46
+ return self._done
47
+
48
+ async def load_more(self) -> list[FeedItem]:
49
+ if self._done:
50
+ return []
51
+ html = await fetch_channel_html(self._channel, self._before)
52
+ items = parse_channel_html(html, channel=self._channel, label=self._label)
53
+ new = dedup_append(self.items, self._seen, items[::-1])
54
+ if not new:
55
+ self._done = True # 翻到头(或该 before 段空页):真到头不再打扰
56
+ return []
57
+ self._before = min(int(it.id.rsplit("/", 1)[-1]) for it in new)
58
+ return new
59
+
60
+
61
+ async def _fetch_one(feed: Subscription) -> list[FeedItem]:
62
+ """拉一个频道的首页并解析;时间倒序(最新在前)。"""
63
+ html = await fetch_channel_html(feed.key)
64
+ items = parse_channel_html(html, channel=feed.key, label=feed.label, all_view=True)
65
+ return items[::-1]
66
+
67
+
68
+ async def fetch_merged(feeds: list[Subscription]) -> tuple[list[FeedItem], list[str]]:
69
+ """并发拉全部频道 ->「全部」聚合:(条目, 失败原因列表)。
70
+
71
+ 编排见 core.source.merge_feeds;Telegram 的差异:按发布时间(ISO 字符串)
72
+ 倒序、错误标签带 @(时间缺失的条目垫底,罕见,预览页都有 datetime)。
73
+ """
74
+ return await merge_feeds(
75
+ feeds,
76
+ _fetch_one,
77
+ error_label=lambda f: f"@{f.key}",
78
+ sort_key=lambda it: str(it.extra.get("published") or ""),
79
+ )
@@ -0,0 +1,182 @@
1
+ """解析层:t.me/s 频道预览 HTML -> FeedItem / 正文 markdown,纯函数不碰网络。
2
+
3
+ Telegram 公开频道的 Web 预览页(t.me/s/<名>)是服务端渲染 HTML,免登录
4
+ 免 JS:一页约 20 条消息,更旧一页走 ?before=<最小id>。结构映射与 RSS 同形
5
+ (纯订阅、无评论层、详情读列表自带正文零请求):
6
+
7
+ - 每条消息(data-post="频道/编号")-> FeedItem:正文 HTML 走 html_to_md
8
+ 共用管线(a/b/br 等行内标签全兼容),媒体(图/视频)换 [图片]/[视频] 记号
9
+ - 标题取正文首行截断(频道帖没有标题,同 X 拿推文当标题的思路);纯媒体帖
10
+ 用记号本身当标题
11
+ - 元信息列:观看数(预览页原生就是格式化文本,如 5.87M)+ 署名(频道
12
+ 管理员签名);「全部」聚合视图再前缀频道名
13
+
14
+ 实测锚点(2026-11 对 t.me/s/durov 抓样):消息容器 `tgme_widget_message
15
+ … js-widget_message` 带 data-post;正文 `message_text js-message_text`;
16
+ 观看 `message_views`;图片是 photo_wrap 锚点的 background-image(不是
17
+ <img>,走属性提取);视频播放器没有直链,记号指向帖子页。频道关闭 Web
18
+ 预览时页面没有任何消息节点——取数侧以「解析出 0 条」报错兜底。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ from feedscope.core.media import html_to_md, mark
26
+ from feedscope.core.i18n import t
27
+ from feedscope.core.models import FeedItem
28
+ from feedscope.core.text import truncate_display
29
+ from feedscope.core.timefmt import fmt_iso
30
+
31
+ SOURCE_ID = "telegram"
32
+
33
+ _SITE = "https://t.me"
34
+
35
+ # 标题截断的显示宽度:频道帖首行常常很长,列表要的是扫读不是阅读
36
+ TITLE_WIDTH = 60
37
+
38
+ # 频道名(username)域:字母开头、字母数字下划线;下限放宽到 4——校验
39
+ # 过松的代价(拉一次预览 404/无消息)由添加时的实测校验兜住
40
+ _NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{3,31}$")
41
+
42
+ # 链接里的频道段:t.me/durov、t.me/s/durov、telegram.me/durov、帖子直链
43
+ # t.me/durov/531 都归一到频道名(没链接上下文时整串当裸名走 _NAME_RE)
44
+ _LINK_RE = re.compile(r"(?:t\.me|telegram\.me)/(?:s/)?([A-Za-z0-9_]+)")
45
+
46
+ # 消息块切分:每个 tgme_widget_message 容器起一段(wrap 的闭合标签在下一
47
+ # 个容器之前,块内即本条消息的全部元素);data-post 缺失(服务消息)跳过
48
+ _CHUNK_SPLIT = re.compile(r'(?=<div class="tgme_widget_message )')
49
+
50
+ _POST_RE = re.compile(r'data-post="([^"]+)"')
51
+ _TEXT_RE = re.compile(r'<div class="tgme_widget_message_text[^"]*"[^>]*>(.*?)</div>', re.DOTALL)
52
+ _VIEWS_RE = re.compile(r'<span class="tgme_widget_message_views">\s*([^<]+?)\s*</span>')
53
+ _AUTHOR_RE = re.compile(r'tgme_widget_message_from_author[^>]*>([^<]*)<')
54
+ _TIME_RE = re.compile(r'<time datetime="([^"]+)"')
55
+ _PHOTO_RE = re.compile(r'<a class="tgme_widget_message_photo_wrap[^>]*>')
56
+ _BG_URL_RE = re.compile(r"background-image:url\('([^']+)'\)")
57
+ _VIDEO_RE = re.compile(r"tgme_widget_message_video_player")
58
+ _TITLE_RE = re.compile(r"<title>([^<]*)</title>")
59
+ _OG_TITLE_RE = re.compile(r'og:title" content="([^"]*)"')
60
+
61
+ # 预览页把 emoji 包成 <tg-emoji><i class="emoji" style="…"><b>🩵</b></i></tg-emoji>
62
+ # (i/b 壳是旧客户端兜底):整只塌缩成字符本身,否则 html2text 给每个表情
63
+ # 套一层 **强调**,正文满屏星号
64
+ _TG_EMOJI_RE = re.compile(
65
+ r'<tg-emoji[^>]*>(?:<i class="emoji"[^>]*>)?(?:<b>(.*?)</b>)?(?:</i>)?([^<]*)</tg-emoji>'
66
+ )
67
+
68
+
69
+ def channel_title(html: str) -> str:
70
+ """预览页的频道显示名(og:title 优先,缺了回 <title> 的尾巴)。"""
71
+ m = _OG_TITLE_RE.search(html) or _TITLE_RE.search(html)
72
+ if not m:
73
+ return ""
74
+ title = m.group(1).strip()
75
+ return re.sub(r"\s*\(via @[\w]+\)$", "", title) # 部分频道尾巴带 "(via @x)",去掉归 channel
76
+
77
+
78
+ def _media_marks(chunk: str, post_url: str) -> list[str]:
79
+ """消息块里的媒体 -> 记号列表:图片取原图直链,视频没有直链指向帖子页。"""
80
+ marks: list[str] = []
81
+ if (m := _PHOTO_RE.search(chunk)) and (u := _BG_URL_RE.search(m.group(0))):
82
+ marks.append(mark(t("media.image"), u.group(1)))
83
+ if _VIDEO_RE.search(chunk):
84
+ marks.append(mark(t("media.video"), post_url))
85
+ return marks
86
+
87
+
88
+ def _strip_emoji(text_html: str) -> str:
89
+ """tg-emoji 三层壳塌缩成字符本身(否则 html2text 给每个表情套 **强调**)。"""
90
+ return _TG_EMOJI_RE.sub(lambda m: m.group(1) or m.group(2) or "", text_html)
91
+
92
+
93
+ def _plain_text(text_html: str) -> str:
94
+ """已剥 emoji 壳的正文 HTML -> 纯文本(标题用):去标签 + 合并空白。"""
95
+ plain = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", text_html)).strip()
96
+ return re.sub(r"\s+([,.!?;:,。!?;:])", r"\1", plain) # 标签位换成的空格别挤到标点前
97
+
98
+
99
+ def _content_md(text_html: str, marks: list[str]) -> str:
100
+ """正文:已剥 emoji 壳的行内 HTML -> markdown + 媒体记号缀尾(与 X/RSS 同观感)。"""
101
+ blocks: list[str] = []
102
+ if md := html_to_md(text_html):
103
+ blocks.append(md)
104
+ blocks.extend(marks)
105
+ return "\n\n".join(blocks)
106
+
107
+
108
+ def _title_of(text_html: str, marks: list[str]) -> str:
109
+ """标题 = 正文首行纯文本按显示宽度截断(标题列不要 markdown 记号);
110
+ 纯媒体帖用记号的标签文字(如「[图片]」),不带链接尾巴。"""
111
+ if text := _plain_text(text_html):
112
+ return truncate_display(text, TITLE_WIDTH)
113
+ labels = [
114
+ m[: m.index("]") + 1] if m.startswith("[") and "]" in m else m for m in marks
115
+ ]
116
+ return " ".join(labels) if labels else ""
117
+
118
+
119
+ def parse_channel_html(
120
+ html: str, *, channel: str, label: str, all_view: bool = False
121
+ ) -> list[FeedItem]:
122
+ """一页预览 HTML -> FeedItem 列表(时间正序,调用方自行倒排/合并)。
123
+
124
+ all_view=True 是「全部」聚合视图:元信息列前缀频道名(单频道流里频道
125
+ 名已在侧栏,不重复占位)。
126
+ """
127
+ items: list[FeedItem] = []
128
+ for chunk in _CHUNK_SPLIT.split(html):
129
+ post = _POST_RE.search(chunk)
130
+ if post is None:
131
+ continue # 服务消息(无 data-post):没有正文,跳条
132
+ post_id = post.group(1)
133
+ url = f"{_SITE}/{post_id}"
134
+ marks = _media_marks(chunk, url)
135
+ text_html = _strip_emoji(m.group(1)) if (m := _TEXT_RE.search(chunk)) else ""
136
+ md = _content_md(text_html, marks)
137
+ title = _title_of(text_html, marks)
138
+ if not title:
139
+ continue # 既无正文也无媒体:不可展示,跳条不跳流
140
+ views = (m.group(1) if (m := _VIEWS_RE.search(chunk)) else "").strip()
141
+ author = (m.group(1) if (m := _AUTHOR_RE.search(chunk)) else "").strip()
142
+ parts = ([label] if all_view else []) + ([t("words.views", n=views)] if views else [])
143
+ # 聚合视图频道名已前缀:署名与频道同名时不再重复占位
144
+ if author and author != (label if all_view else ""):
145
+ parts.append(author)
146
+ items.append(
147
+ FeedItem(
148
+ source=SOURCE_ID,
149
+ id=post_id,
150
+ title=title,
151
+ url=url,
152
+ meta=" · ".join(parts),
153
+ extra={
154
+ "content_md": md,
155
+ "published": m.group(1) if (m := _TIME_RE.search(chunk)) else "",
156
+ "author": author,
157
+ "channel": label,
158
+ },
159
+ )
160
+ )
161
+ return items
162
+
163
+
164
+ def parse_telegram_input(text: str) -> str:
165
+ """输入归一成裸频道名(小写):durov / @durov / t.me/durov / 帖子直链都认。
166
+
167
+ 认不出回空串(调用方给「没认出」反馈)。
168
+ """
169
+ s = (text or "").strip()
170
+ if not s:
171
+ return ""
172
+ s = s.removeprefix("@")
173
+ if m := _LINK_RE.search(s):
174
+ name = m.group(1)
175
+ else:
176
+ name = s.rstrip("/") # 裸名形态:整个串(去尾斜杠)就是频道名
177
+ return name.lower() if _NAME_RE.match(name) else ""
178
+
179
+
180
+ def display_time(published: str) -> str:
181
+ """extra 里存的 ISO 串 -> 展示文本(详情层时间行用)。"""
182
+ return fmt_iso(published or None)
@@ -0,0 +1,64 @@
1
+ """V2EX 源(v2ex):热门/最新两个信息流 + 主题详情(正文与回复楼层)。
2
+
3
+ 包内分层(全项目统一的源模板):
4
+ - fetch.py 取数层:官方 v1 公开 API(免 token 免登录、无登录态)
5
+ - parse.py 解析层:v1 JSON -> FeedItem / 主题详情,纯函数可测
6
+ - __init__.py 编排层:Source 能力声明与 open_* 分派(楼层翻页走 core 的
7
+ 内存切片基类)
8
+
9
+ 结构映射是 X 式两层:topic 本体 -> 回复平铺当楼层(unit「回复」);V2EX
10
+ 回复没有嵌套结构,无评论层(supports_comments=False,Enter 键位预告按此
11
+ 退场)。回复一次全量已在内存,楼层翻页是切片——详情打开后零额外请求;
12
+ 正文不走网络(列表透传,见 parse)。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from feedscope.core.models import FeedItem
18
+ from feedscope.core.i18n import t
19
+ from feedscope.core.registry import register
20
+ from feedscope.core.source import (
21
+ SliceDetailLoader,
22
+ Source,
23
+ SourceError,
24
+ StaticFeedLoader,
25
+ )
26
+ from feedscope.sources.v2ex.fetch import fetch_replies, fetch_topics
27
+ from feedscope.sources.v2ex.parse import SOURCE_ID, parse_detail, parse_topics
28
+
29
+
30
+ class V2exDetailLoader(SliceDetailLoader):
31
+ """V2EX 详情加载器:回复已全在内存——楼层翻页是切片,零额外请求。
32
+
33
+ 全部结构在 SliceDetailLoader.from_detail(裸 Answer 楼层、无评论树),
34
+ 本类只声明报错文案里的源标签(楼层页宽用基类默认)。"""
35
+
36
+ source_label = "V2EX"
37
+
38
+
39
+ @register
40
+ class V2exSource(Source):
41
+ source_id = SOURCE_ID
42
+ label = "V2EX"
43
+ supports_detail = True
44
+ supports_comments = False # 回复平铺无嵌套(楼中楼只是 @ 文字),详情到楼层为止
45
+ feeds: list[str] = ["hot", "new"]
46
+ feed_labels: dict[str, str] = {
47
+ "hot": "sources.v2ex.feeds.hot", "new": "sources.v2ex.feeds.new",
48
+ }
49
+ detail_kinds = frozenset({"topic"}) # 全部条目 Enter 都进终端详情
50
+ detail_unit_label = "units.reply" # 楼层单位词:帖内回复(知乎「回答」、HN「评论」)
51
+
52
+ async def open_feed(self, name: str) -> StaticFeedLoader:
53
+ label = self.label_of(name)
54
+ # v1 一页全量(hot 10 / latest 40),无翻页:静态首屏出口收 0 条守卫
55
+ return self.static_feed(parse_topics(await fetch_topics(name, label)), label)
56
+
57
+ async def open_detail(self, item: FeedItem) -> SliceDetailLoader:
58
+ # 正文随列表透传(列表端点 content 即全量);缺上下文的旧条目(如
59
+ # 跨进程恢复的列表)回列表重进即可,不值得为此多打一个端点
60
+ topic = item.extra.get("topic")
61
+ if not isinstance(topic, dict) or not topic:
62
+ raise SourceError(t("errors.v2ex_no_context"))
63
+ replies = await fetch_replies(item.id)
64
+ return V2exDetailLoader.from_detail(parse_detail(topic, replies), fallback_title=item.title)
@@ -0,0 +1,46 @@
1
+ """取数层:V2EX 走官方 v1 公开 API(免 token 免登录)——列表 + 全量回复。
2
+
3
+ 端点都是单请求水合 JSON:hot 10 条 / latest 40 条一页全量(无分页参数,
4
+ 列表到头即止);replies/show.json 实测忽略 page、一次返回全部回复——
5
+ 楼层翻页全在内存切片(见 __init__ 的内存 Loader),fetch 层只有两个
6
+ 无状态 GET。正文不走网络:列表端点的 content 就是全量(透传见 parse)。
7
+
8
+ 频率限制官方未明示(社区口径约 120 次/时/IP):刷一次流 1 请求、开一篇
9
+ 详情 1 请求,正常阅读离上限很远。要按节点订阅时另有 v2 API(需 Personal
10
+ Access Token)与 v1 的 node_name 查询,本层不做。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from feedscope.core import http
16
+ from feedscope.core.i18n import t
17
+
18
+ _API = "https://www.v2ex.com/api"
19
+
20
+ # 各内置流的端点(feeds 词汇表与顺序见 __init__)
21
+ FEEDS: dict[str, str] = {
22
+ "hot": f"{_API}/topics/hot.json",
23
+ "new": f"{_API}/topics/latest.json",
24
+ }
25
+
26
+
27
+ async def _get_list(url: str, source: str, params: dict[str, str] | None = None) -> list[dict]:
28
+ """GET 一个返回数组的端点 -> 原始 dict 列表(解析/校验见 http.get_json;非 dict 成员剔除)。"""
29
+ data = await http.get_json(url, source=source, params=params, expect=list)
30
+ return [d for d in data if isinstance(d, dict)]
31
+
32
+
33
+ async def fetch_topics(name: str, label: str) -> list[dict]:
34
+ """一个信息流的全部主题(v1 一页全量,无翻页)。"""
35
+ try:
36
+ url = FEEDS[name]
37
+ except KeyError:
38
+ raise ValueError(t("errors.unknown_feed", name=name)) from None
39
+ return await _get_list(url, t("sources.v2ex.with_label", label=label))
40
+
41
+
42
+ async def fetch_replies(topic_id: str) -> list[dict]:
43
+ """一条主题的全部回复(v1 端点一次给全量,page 参数形同虚设)。"""
44
+ return await _get_list(
45
+ f"{_API}/replies/show.json", t("sources.v2ex.replies"), params={"topic_id": topic_id}
46
+ )
@@ -0,0 +1,116 @@
1
+ """解析层:v1 JSON -> FeedItem / 主题详情,纯函数不碰网络。
2
+
3
+ 结构映射是 X 式两层(词汇重映射,UI 零特判):topic 本体(正文 =
4
+ content_rendered 转 markdown,自动内联图片换 [图片] 记号)-> 回复平铺
5
+ 当楼层(unit「回复」)。V2EX 回复没有嵌套字段——「楼中楼」只是正文里的
6
+ @ 文字引用,还原不出树,故无评论层(supports_comments=False)。
7
+
8
+ v1 列表端点的 content/content_rendered 是全量正文(实测无截断),列表
9
+ 条目把整个 topic dict 藏进 extra 透传给详情——打开详情只需再拉回复。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+
17
+ from feedscope.core.const import safe_int
18
+ from feedscope.core.i18n import t
19
+ from feedscope.core.media import html_to_md
20
+ from feedscope.core.models import Answer, FeedItem
21
+ from feedscope.core.timefmt import fmt_unix
22
+
23
+ SOURCE_ID = "v2ex"
24
+
25
+ _TOPIC_URL = "https://www.v2ex.com/t/"
26
+
27
+ # 相对 href(@ 提及 /member/xxx、站内帖 /t/xxx):TUI 点开要完整 URL,先绝对化;
28
+ # // 开头的协议相对地址(CDN)不碰
29
+ _REL_HREF = re.compile(r"""href=(["'])/((?!/)[^"']*)\1""")
30
+
31
+
32
+ def _md(html: object) -> str:
33
+ """content_rendered HTML -> markdown:相对链接绝对化后走共用 img 记号管线。"""
34
+ if not isinstance(html, str) or not html:
35
+ return ""
36
+ return html_to_md(_REL_HREF.sub(r"href=\1https://www.v2ex.com/\2\1", html))
37
+
38
+
39
+ def _username(member: object) -> str:
40
+ return str(member.get("username") or "") if isinstance(member, dict) else ""
41
+
42
+
43
+ def _meta(topic: dict) -> str:
44
+ """一行元信息:节点 · 回复数 · 作者(节点即板块,放最前好扫读)。"""
45
+ parts: list[str] = []
46
+ node = topic.get("node")
47
+ if isinstance(node, dict) and (title := str(node.get("title") or "")):
48
+ parts.append(title)
49
+ if replies := safe_int(topic.get("replies")):
50
+ parts.append(t("display.replies_plain", n=replies))
51
+ if author := _username(topic.get("member")):
52
+ parts.append(author)
53
+ return " · ".join(parts)
54
+
55
+
56
+ def parse_topics(topics: list[dict]) -> list[FeedItem]:
57
+ """列表端点 -> FeedItem 列表(保持返回序,野数据逐条防御)。
58
+
59
+ 整个 topic dict 进 extra:列表正文就是全量,详情打开只再拉回复一个请求。
60
+ """
61
+ items: list[FeedItem] = []
62
+ for tp in topics:
63
+ tid = str(tp.get("id") or "")
64
+ if not tid.isdigit():
65
+ continue # 无 id 无法寻址,跳条不跳流
66
+ items.append(
67
+ FeedItem(
68
+ source=SOURCE_ID,
69
+ id=tid,
70
+ title=str(tp.get("title") or "").strip() or t("words.no_title"),
71
+ url=f"{_TOPIC_URL}{tid}",
72
+ kind="topic",
73
+ meta=_meta(tp),
74
+ extra={"topic": tp},
75
+ )
76
+ )
77
+ return items
78
+
79
+
80
+ @dataclass
81
+ class TopicDetail:
82
+ """主题详情的解析结果:本体 + 全部回复楼层(平铺,无评论层)。"""
83
+
84
+ title: str
85
+ meta: str
86
+ time: str
87
+ description_md: str
88
+ replies: list[Answer] = field(default_factory=list)
89
+
90
+
91
+ def parse_detail(topic: dict, replies: list[dict]) -> TopicDetail:
92
+ """topic(列表透传)+ 全量回复 -> TopicDetail(楼层 = 回复平铺直出)。"""
93
+ floors: list[Answer] = []
94
+ for r in replies:
95
+ content = _md(r.get("content_rendered"))
96
+ if not content:
97
+ continue # 空壳回复(被移除/占位),跳条不跳流
98
+ rid = str(r.get("id") or "")
99
+ topic_id = str(r.get("topic_id") or "")
100
+ floors.append(
101
+ Answer(
102
+ author=_username(r.get("member")),
103
+ votes="", # v1 回复不带赞数
104
+ content_md=content,
105
+ time=fmt_unix(r.get("created")),
106
+ # 楼层直达 = 原帖页的 reply 锚点(V2EX 的楼层 permalink 形态)
107
+ url=f"{_TOPIC_URL}{topic_id}#reply{rid}" if rid else "",
108
+ )
109
+ )
110
+ return TopicDetail(
111
+ title=str(topic.get("title") or "").strip(),
112
+ meta=_meta(topic),
113
+ time=fmt_unix(topic.get("created")),
114
+ description_md=_md(topic.get("content_rendered")),
115
+ replies=floors,
116
+ )
@@ -0,0 +1,68 @@
1
+ """X(Twitter)源(x):推荐/正在关注时间线 + 推文详情(正文与回复)。
2
+
3
+ 包内分层(全项目统一的源模板):
4
+ - fetch.py 取数层:常驻 x.com 通道页签的页面 fetch 直调 GraphQL + 运行时
5
+ 收割请求形状 + 游标翻页 Loader(搜索为何不做,见其模块头)
6
+ - parse.py 解析层:URT JSON -> FeedItem / 详情,纯函数可测
7
+ - __init__.py 编排层:Source 的能力声明(词汇表/登录)与 open_* 分派
8
+
9
+ 全部能力在登录墙后:`feedscope login x` 弹有头 Chrome 手动输账号密码(+2FA),
10
+ 轮询到 auth_token 即成功(login.py 源无关,零改动)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import ClassVar
16
+
17
+ from feedscope.core.models import FeedItem
18
+ from feedscope.core.i18n import t
19
+ from feedscope.core.registry import register
20
+ from feedscope.core.source import (
21
+ DetailLoader,
22
+ FeedLoader,
23
+ Source,
24
+ login_gate,
25
+ )
26
+ from feedscope.sources.x.fetch import (
27
+ XFeedLoader,
28
+ XTweetDetailLoader,
29
+ fetch_conversation,
30
+ )
31
+
32
+
33
+ async def require_login(what: str) -> None:
34
+ """判登门:auth_token/ct0 成对在 profile 里即已登录(通道页签的 cookie 由浏览器自带)。"""
35
+ await login_gate(
36
+ "https://x.com",
37
+ lambda c: bool(c.get("auth_token") and c.get("ct0")),
38
+ label="X",
39
+ what=what,
40
+ )
41
+
42
+
43
+ @register
44
+ class XSource(Source):
45
+ source_id = "x"
46
+ label = "X"
47
+ supports_detail = True
48
+ feeds: list[str] = ["foryou", "following"] # 与 X 主页页签同序:默认进「推荐」
49
+ feed_labels: dict[str, str] = {
50
+ "foryou": "sources.x.feeds.foryou", "following": "sources.x.feeds.following",
51
+ }
52
+ detail_kinds = frozenset({"tweet"}) # Enter 进终端详情屏(正文 + 回复)
53
+ detail_unit_label = "units.reply"
54
+ login_url = "https://x.com/login"
55
+ login_cookie = "auth_token" # feedscope login 轮询的判登 cookie(手动输密码后写入)
56
+ logout_cookies: ClassVar[list[str]] = ["auth_token", "ct0"] # 登出清掉判登成对 cookie
57
+
58
+ async def open_feed(self, name: str) -> FeedLoader:
59
+ label = self.label_of(name)
60
+ await require_login(t("errors.what_timeline", label=label))
61
+ return await self.first_batch(XFeedLoader(name), label)
62
+
63
+ async def open_detail(self, item: FeedItem) -> DetailLoader:
64
+ await require_login(t("errors.what_tweet_detail"))
65
+ # 转发(RT)的会话在原推那里:列表已把原推 id 放进 extra(见 parse._tweet_from_result)
66
+ rest_id = str(item.extra.get("conversation_id") or item.id.rsplit("/", 1)[-1])
67
+ entries, cursor = await fetch_conversation(rest_id, None)
68
+ return XTweetDetailLoader(rest_id, entries, cursor)