feedscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feedscope/__init__.py +3 -0
- feedscope/__main__.py +4 -0
- feedscope/cli.py +154 -0
- feedscope/core/__init__.py +1 -0
- feedscope/core/browser.py +367 -0
- feedscope/core/config.py +367 -0
- feedscope/core/const.py +64 -0
- feedscope/core/display.py +62 -0
- feedscope/core/http.py +104 -0
- feedscope/core/i18n/__init__.py +202 -0
- feedscope/core/i18n/en.toml +447 -0
- feedscope/core/i18n/zh-CN.toml +444 -0
- feedscope/core/media.py +150 -0
- feedscope/core/models.py +86 -0
- feedscope/core/registry.py +53 -0
- feedscope/core/samples.py +68 -0
- feedscope/core/source.py +668 -0
- feedscope/core/text.py +54 -0
- feedscope/core/timefmt.py +53 -0
- feedscope/core/tree.py +134 -0
- feedscope/doctor.py +54 -0
- feedscope/login.py +168 -0
- feedscope/probe.py +131 -0
- feedscope/report.py +19 -0
- feedscope/sources/__init__.py +38 -0
- feedscope/sources/hn/__init__.py +66 -0
- feedscope/sources/hn/fetch.py +82 -0
- feedscope/sources/hn/parse.py +154 -0
- feedscope/sources/reddit/__init__.py +178 -0
- feedscope/sources/reddit/fetch.py +181 -0
- feedscope/sources/reddit/parse.py +318 -0
- feedscope/sources/rss/__init__.py +64 -0
- feedscope/sources/rss/fetch.py +94 -0
- feedscope/sources/rss/parse.py +116 -0
- feedscope/sources/telegram/__init__.py +61 -0
- feedscope/sources/telegram/fetch.py +79 -0
- feedscope/sources/telegram/parse.py +182 -0
- feedscope/sources/v2ex/__init__.py +64 -0
- feedscope/sources/v2ex/fetch.py +46 -0
- feedscope/sources/v2ex/parse.py +116 -0
- feedscope/sources/x/__init__.py +68 -0
- feedscope/sources/x/fetch.py +490 -0
- feedscope/sources/x/parse.py +535 -0
- feedscope/sources/zhihu/__init__.py +81 -0
- feedscope/sources/zhihu/fetch.py +402 -0
- feedscope/sources/zhihu/parse.py +235 -0
- feedscope/subscriptions.py +423 -0
- feedscope/ui/__init__.py +10 -0
- feedscope/ui/account.py +125 -0
- feedscope/ui/app.py +105 -0
- feedscope/ui/dialogs.py +127 -0
- feedscope/ui/feedback.py +64 -0
- feedscope/ui/focus_nav.py +113 -0
- feedscope/ui/labels.py +38 -0
- feedscope/ui/layout.py +66 -0
- feedscope/ui/list_render.py +42 -0
- feedscope/ui/screens/__init__.py +1 -0
- feedscope/ui/screens/answer.py +296 -0
- feedscope/ui/screens/detail.py +274 -0
- feedscope/ui/screens/language.py +59 -0
- feedscope/ui/screens/main.py +736 -0
- feedscope/ui/screens/main_setup.py +226 -0
- feedscope/ui/screens/reading.py +384 -0
- feedscope/ui/screens/source_setup.py +217 -0
- feedscope/ui/screens/subscriptions.py +196 -0
- feedscope/ui/theme.py +45 -0
- feedscope/ui/wrap.py +157 -0
- feedscope-0.1.0.dist-info/METADATA +302 -0
- feedscope-0.1.0.dist-info/RECORD +72 -0
- feedscope-0.1.0.dist-info/WHEEL +4 -0
- feedscope-0.1.0.dist-info/entry_points.txt +2 -0
- feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""X(Twitter)解析层:GraphQL URT 响应 -> FeedItem / 详情,纯函数,不碰网络与浏览器。
|
|
2
|
+
|
|
3
|
+
合同(fetch 层只产这几类 dict / 调这几个函数):
|
|
4
|
+
- _tweet_from_result(result) 单条推文 result(rest_id / legacy / core / note_tweet)
|
|
5
|
+
- _entries_and_cursor(data) + _iter_tweet_results(entries) 从 instructions 摊平条目
|
|
6
|
+
- _detail_entries_and_cursor(data) TweetDetail 响应摊平(详情首批 + 回复翻页共用)
|
|
7
|
+
- _focal_and_replies(entries, rest_id) / _detail_doc / _reply_answer 详情三件套
|
|
8
|
+
- _grok_translation(result) Grok 译文块(TweetDetail 响应内嵌,外文推才有)
|
|
9
|
+
|
|
10
|
+
三类推文形态(2026-11 实弹抓包确认,字段都从这两种嵌套里出):
|
|
11
|
+
- 转发(RT):外壳 legacy.full_text 只是 X 截断的 140 字预览(带 …),媒体
|
|
12
|
+
entities / note_tweet / 被引推文全在内层 retweeted_status_result.result;
|
|
13
|
+
外壳的 display_text_range 还不排除媒体尾链(内层的排),故列表一律读内层
|
|
14
|
+
- 引用(quote):被引推文整卡嵌在外层 quoted_status_result.result(含作者与
|
|
15
|
+
全文),外层 full_text 本身干净;被引已删时是 TweetTombstone(无 legacy)
|
|
16
|
+
|
|
17
|
+
URT 条目三类:
|
|
18
|
+
- TimelineTimelineItem:推文(itemContent.promotedMetadata 有值 = 推广,丢弃)
|
|
19
|
+
- TimelineTimelineModule:conversationthread-* / home-conversation-* 内嵌
|
|
20
|
+
items[] 是推文;who-to-follow-* 丢弃
|
|
21
|
+
- TimelineTimelineCursor:cursor-bottom-* / cursor-showmorethread-* 是下一页游标
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Callable, Iterator
|
|
28
|
+
|
|
29
|
+
from feedscope.core.display import fmt_count, signature, votes_text
|
|
30
|
+
from feedscope.core.i18n import has, t
|
|
31
|
+
from feedscope.core.media import mark
|
|
32
|
+
from feedscope.core.models import Answer, FeedItem
|
|
33
|
+
from feedscope.core.source import dedup_by
|
|
34
|
+
from feedscope.core.text import strip_zwsp, truncate_display
|
|
35
|
+
from feedscope.core.timefmt import fmt_twitter
|
|
36
|
+
|
|
37
|
+
BASE = "https://x.com"
|
|
38
|
+
|
|
39
|
+
# 长文(蓝 V note_tweet,可达几千字)在列表截到 280 显示格;普通推文(≤280 字)
|
|
40
|
+
# 全文上屏不截断——完整内容才是列表的卖点,超长文 Enter 进详情看全文
|
|
41
|
+
_TITLE_MAX_CELLS = 280
|
|
42
|
+
|
|
43
|
+
# 引用片段缀尾预算:列表里提示「引了什么」够用,被引全文进详情看
|
|
44
|
+
_QUOTE_SNIPPET_CELLS = 80
|
|
45
|
+
|
|
46
|
+
# 详情标题截断:正文首行的显示格上限(详情屏标题行与 Header 副标题共用)
|
|
47
|
+
_DETAIL_TITLE_CELLS = 60
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rt_result(r: dict) -> dict:
|
|
51
|
+
"""转发(RT)的内层原推 result;非转发或残卡返回空 dict。"""
|
|
52
|
+
return ((r.get("legacy") or {}).get("retweeted_status_result") or {}).get("result") or {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _quote_result(r: dict) -> dict:
|
|
56
|
+
"""被引推文 result(quoted_status_result);非引用或已删(tombstone 无 legacy)返回空 dict。"""
|
|
57
|
+
q = _unwrap((r.get("quoted_status_result") or {}).get("result") or {})
|
|
58
|
+
return q if q.get("legacy") else {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tweet_text(r: dict) -> str:
|
|
62
|
+
"""推文全文(保留换行):长推取 note_tweet 全文,否则 legacy.full_text。"""
|
|
63
|
+
legacy = r.get("legacy") or {}
|
|
64
|
+
text = strip_zwsp(legacy.get("full_text", ""))
|
|
65
|
+
note = ((r.get("note_tweet") or {}).get("note_tweet_results") or {}).get("result") or {}
|
|
66
|
+
if note.get("text"):
|
|
67
|
+
text = strip_zwsp(note.get("text", ""))
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_long_form(r: dict) -> bool:
|
|
72
|
+
"""蓝 V 长文(note_tweet,可达几千字):列表截断只针对它。"""
|
|
73
|
+
note = ((r.get("note_tweet") or {}).get("note_tweet_results") or {}).get("result") or {}
|
|
74
|
+
return bool(note.get("text"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _media_markers(r: dict) -> str:
|
|
78
|
+
"""媒体记号([图片 N] / [视频]):扫描面用——列表缀尾、引用片段、详情标题回退。
|
|
79
|
+
|
|
80
|
+
只报数量不附地址(列表一行是扫的不是读的,知乎列表同样不带);地址
|
|
81
|
+
形态见 _media_links(详情正文/回复楼层)。
|
|
82
|
+
"""
|
|
83
|
+
media = ((r.get("legacy") or {}).get("entities") or {}).get("media") or []
|
|
84
|
+
n_img = sum(1 for m in media if m.get("type") == "photo")
|
|
85
|
+
has_video = any(m.get("type") in ("video", "animated_gif") for m in media)
|
|
86
|
+
markers = ""
|
|
87
|
+
if n_img:
|
|
88
|
+
markers += " " + t("media.images_n", n=n_img)
|
|
89
|
+
if has_video:
|
|
90
|
+
markers += " " + t("media.video")
|
|
91
|
+
return markers
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _best_video_url(m: dict) -> str:
|
|
95
|
+
"""视频媒体 -> 直链:最高码率 mp4(m3u8 是流清单不是播放地址,仅兜底)。"""
|
|
96
|
+
variants = ((m.get("video_info") or {}).get("variants")) or []
|
|
97
|
+
mp4 = [v for v in variants if v.get("content_type") == "video/mp4" and v.get("url")]
|
|
98
|
+
if mp4:
|
|
99
|
+
return str(max(mp4, key=lambda v: v.get("bitrate") or 0)["url"])
|
|
100
|
+
for v in variants:
|
|
101
|
+
if v.get("url"):
|
|
102
|
+
return str(v["url"])
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _media_links(r: dict) -> str:
|
|
107
|
+
"""媒体尾注(详情正文/回复楼层):每个媒体一个「[图片]/[视频](地址)」链接 token。
|
|
108
|
+
|
|
109
|
+
地址取 pbs.twimg.com 原图 / 最高码率 mp4——点击直达媒体本体;正文里
|
|
110
|
+
嵌的 entities.media[].url 是 t.co 跳板(不指向媒体)不用。残卡无地址退
|
|
111
|
+
纯记号([图片]/[视频])。与知乎/RSS 同词汇同形式(core.media.mark):
|
|
112
|
+
TUI 显示不了媒体,链接折叠地址、点击开浏览器。
|
|
113
|
+
"""
|
|
114
|
+
media = ((r.get("legacy") or {}).get("entities") or {}).get("media") or []
|
|
115
|
+
tokens: list[str] = []
|
|
116
|
+
for m in media:
|
|
117
|
+
if m.get("type") == "photo":
|
|
118
|
+
url = str(m.get("media_url_https") or m.get("media_url") or "")
|
|
119
|
+
tokens.append(mark(t("media.image"), url))
|
|
120
|
+
elif m.get("type") in ("video", "animated_gif"):
|
|
121
|
+
tokens.append(mark(t("media.video"), _best_video_url(m)))
|
|
122
|
+
return f" {' '.join(tokens)}" if tokens else ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Grok 译文源语言码 -> 语言名(括注在「译文」后):按语言分文件维护在
|
|
126
|
+
# core/i18n/*.toml 的 [x_langs] 表(中文「英语」/英文「English」);查无此码
|
|
127
|
+
# 退原码
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _lang_name(code: str) -> str:
|
|
131
|
+
key = f"x_langs.{code}"
|
|
132
|
+
return t(key) if code and has(key) else code
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _grok_translation(r: dict) -> str:
|
|
137
|
+
"""Grok 自动译文块:`> **译文**(英语):内容`;无译文返回空串。
|
|
138
|
+
|
|
139
|
+
数据随 TweetDetail 响应内嵌(grok_translated_post_with_availability,
|
|
140
|
+
X 网页版自动翻译同源,零额外请求)——服务端只给账号显示语言需要的外文
|
|
141
|
+
推译文,is_available + 非空译文双闸。译文自带 entities(t.co 短链与
|
|
142
|
+
display_url,坐标按译文文本),复用 _expand_urls 展开;换行压成单段
|
|
143
|
+
(与引用块压缩同形)。署名「译文」+ 括注源语种(语种名查无退原码、
|
|
144
|
+
缺省不括注),出处(Grok)不赘述。
|
|
145
|
+
"""
|
|
146
|
+
g = r.get("grok_translated_post_with_availability") or {}
|
|
147
|
+
data = g.get("data") or {}
|
|
148
|
+
text = str(data.get("translation") or "").strip()
|
|
149
|
+
if not g.get("is_available") or not text:
|
|
150
|
+
return ""
|
|
151
|
+
text = re.sub(r"\s+", " ", _expand_urls(text, data)).strip()
|
|
152
|
+
src_lang = str(data.get("source_language") or "")
|
|
153
|
+
lang = _lang_name(src_lang)
|
|
154
|
+
label = t("x.translation_label", lang=lang) if lang else t("x.translation_plain")
|
|
155
|
+
return t("x.translation_quote", label=label, text=text)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _author(r: dict) -> tuple[str, str]:
|
|
159
|
+
"""推文作者 -> (昵称, handle);署名格式化 _who 与判残卡共用。
|
|
160
|
+
|
|
161
|
+
用户对象新旧两态:HomeLatestTimeline 还是 legacy 形态,TweetDetail(2026)
|
|
162
|
+
已迁到 core 形态(user.core.name / screen_name),两处都认。
|
|
163
|
+
"""
|
|
164
|
+
user = ((r.get("core") or {}).get("user_results") or {}).get("result") or {}
|
|
165
|
+
ul = user.get("legacy") or {}
|
|
166
|
+
uc = user.get("core") or {}
|
|
167
|
+
name = str(ul.get("name") or uc.get("name") or "")
|
|
168
|
+
handle = str(ul.get("screen_name") or uc.get("screen_name") or "")
|
|
169
|
+
return strip_zwsp(name), strip_zwsp(handle)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _who(r: dict) -> str:
|
|
173
|
+
"""作者署名「名字 @handle」(X 网页同形):取作者字段,格式化归 core/display。
|
|
174
|
+
|
|
175
|
+
昵称常有装饰字符认不出人,handle 才是身份;单露一个(旧版列表只露
|
|
176
|
+
昵称、RT 前缀只露 handle)都拼不全作者是谁,全项目统一走这里。
|
|
177
|
+
"""
|
|
178
|
+
name, handle = _author(r)
|
|
179
|
+
return signature(name, handle)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _urt_entries_and_cursor(
|
|
183
|
+
data: dict,
|
|
184
|
+
urt_of: Callable[[dict], dict],
|
|
185
|
+
cursor_of: Callable[[dict], str | None],
|
|
186
|
+
) -> tuple[list[dict], str | None]:
|
|
187
|
+
"""GraphQL URT 响应 -> (entries, 游标):instructions 摊平 + 游标扫描的共享骨架。
|
|
188
|
+
|
|
189
|
+
时间线(home.home_timeline_urt)与详情(threaded_conversation_with_
|
|
190
|
+
injections_v2)同构;容器路径与「哪条算下一页游标」是端点差异,经
|
|
191
|
+
urt_of / cursor_of 注入(见 _entries_and_cursor / _detail_entries_and_cursor)。
|
|
192
|
+
"""
|
|
193
|
+
entries: list[dict] = []
|
|
194
|
+
for ins in urt_of(data).get("instructions") or []:
|
|
195
|
+
if ins.get("type") == "TimelineAddEntries":
|
|
196
|
+
entries.extend(ins.get("entries") or [])
|
|
197
|
+
cursor: str | None = None
|
|
198
|
+
for e in entries:
|
|
199
|
+
if value := cursor_of(e):
|
|
200
|
+
cursor = value
|
|
201
|
+
return entries, cursor
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _entries_and_cursor(data: dict) -> tuple[list[dict], str | None]:
|
|
205
|
+
"""从 GraphQL 响应提取全部 entries 与下一页游标(cursor-bottom 条目的 value)。
|
|
206
|
+
|
|
207
|
+
For You(HomeTimeline)与 Following(HomeLatestTimeline)同容器
|
|
208
|
+
(data.home.home_timeline_urt,2026-11 实测)。instructions 里可能有多个
|
|
209
|
+
TimelineAddEntries(首批只有一个),全部摊平;游标取最后一条 cursor-bottom
|
|
210
|
+
(顶部的 cursor-top 是「回到顶部」,不算下一页)。
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def _bottom(e: dict) -> str | None:
|
|
214
|
+
if (e.get("entryId") or "").startswith("cursor-bottom"):
|
|
215
|
+
return (e.get("content") or {}).get("value")
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
return _urt_entries_and_cursor(
|
|
219
|
+
data,
|
|
220
|
+
lambda d: (((d.get("data") or {}).get("home") or {}).get("home_timeline_urt") or {}),
|
|
221
|
+
_bottom,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _iter_tweet_results(entries: list[dict]) -> Iterator[dict]:
|
|
226
|
+
"""把 entries 里的推文摊平成 result dict 流:跳广告与关注推荐模块。"""
|
|
227
|
+
for e in entries:
|
|
228
|
+
content = e.get("content") or {}
|
|
229
|
+
etype = content.get("entryType")
|
|
230
|
+
if etype == "TimelineTimelineItem":
|
|
231
|
+
item = content.get("itemContent") or {}
|
|
232
|
+
if "promotedMetadata" in item: # 推广广告
|
|
233
|
+
continue
|
|
234
|
+
res = (item.get("tweet_results") or {}).get("result")
|
|
235
|
+
if res:
|
|
236
|
+
yield res
|
|
237
|
+
elif etype == "TimelineTimelineModule":
|
|
238
|
+
if (e.get("entryId") or "").startswith("who-to-follow"): # 关注推荐模块
|
|
239
|
+
continue
|
|
240
|
+
for sub in content.get("items") or []:
|
|
241
|
+
item = (sub.get("item") or {}).get("itemContent") or {}
|
|
242
|
+
if "promotedMetadata" in item:
|
|
243
|
+
continue
|
|
244
|
+
res = (item.get("tweet_results") or {}).get("result")
|
|
245
|
+
if res:
|
|
246
|
+
yield res
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _quote_snippet(r: dict) -> str:
|
|
250
|
+
"""引用片段(列表与回复楼层缀尾):「 ▸ 引用 名字 @handle:内容(≤80 格)」。
|
|
251
|
+
|
|
252
|
+
被引推文常是引用推文的实质内容(评论可能只有一句),不缀则完全不可见;
|
|
253
|
+
被引残卡(已删 tombstone)返回空,宁缺毋滥。
|
|
254
|
+
"""
|
|
255
|
+
q = _quote_result(r)
|
|
256
|
+
_, qhandle = _author(q)
|
|
257
|
+
if not qhandle:
|
|
258
|
+
return ""
|
|
259
|
+
body = re.sub(r"\s+", " ", _visible_text(q))
|
|
260
|
+
body = truncate_display((body + _media_markers(q)).strip(), _QUOTE_SNIPPET_CELLS)
|
|
261
|
+
if not body:
|
|
262
|
+
return ""
|
|
263
|
+
return t("x.quote_line", who=_who(q), body=body)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _tweet_from_result(r: dict) -> FeedItem | None:
|
|
267
|
+
"""单条推文 result -> FeedItem;拿不到 id 或作者的残卡返回 None(调用方丢弃)。
|
|
268
|
+
|
|
269
|
+
换行压成空格(列表标题是扫描用的单段文本);图片/视频在列表只换 [图片 N]/
|
|
270
|
+
[视频] 计数记号缀尾(列表是扫描面不附地址,知乎列表同;详情/楼层附直接
|
|
271
|
+
地址见 _media_links),记号不参与截断;正文按 display_text_range 裁(藏
|
|
272
|
+
开头 @ 提及与结尾媒体 t.co 短链,与 X 网页同形)。
|
|
273
|
+
普通推文(≤280 字)全文上屏,只有长文(note_tweet)才截断。
|
|
274
|
+
|
|
275
|
+
转发(RT)正文/媒体/长文/引用一律读内层原推(外壳只是 140 字截断预览,
|
|
276
|
+
见模块头抓包结论),标题加「RT 原作者 @handle: 」前缀(名字与账号同显),
|
|
277
|
+
元信息列仍是转发者;详情按 conversation_id 跳原推会话。引用推文缀
|
|
278
|
+
「▸ 引用」片段。
|
|
279
|
+
"""
|
|
280
|
+
rest_id = str(r.get("rest_id") or "").strip()
|
|
281
|
+
if not rest_id:
|
|
282
|
+
return None
|
|
283
|
+
name, handle = _author(r)
|
|
284
|
+
if not handle:
|
|
285
|
+
return None
|
|
286
|
+
rt = _rt_result(r)
|
|
287
|
+
src = rt or r # 转发的实质内容全在内层
|
|
288
|
+
text = re.sub(r"\s+", " ", _visible_text(src))
|
|
289
|
+
if rt:
|
|
290
|
+
orig_who = _who(rt)
|
|
291
|
+
if orig_who:
|
|
292
|
+
text = f"RT {orig_who}: {text}"
|
|
293
|
+
body = truncate_display(text, _TITLE_MAX_CELLS) if _is_long_form(src) else text
|
|
294
|
+
title = (body + _media_markers(src) + _quote_snippet(src)).strip()
|
|
295
|
+
if not title:
|
|
296
|
+
return None # 正文与媒体全无 = 残卡
|
|
297
|
+
# 转发(RT)本身没有回复串:会话在原推那里,详情按 conversation_id 跳到原推
|
|
298
|
+
conversation_id = str(rt.get("rest_id") or "")
|
|
299
|
+
return FeedItem(
|
|
300
|
+
source="x",
|
|
301
|
+
id=f"status/{rest_id}",
|
|
302
|
+
title=title,
|
|
303
|
+
url=f"{BASE}/{handle}/status/{rest_id}",
|
|
304
|
+
kind="tweet",
|
|
305
|
+
meta=signature(name, handle), # 署名「名字 @handle」;截断统一在 UI
|
|
306
|
+
extra={"conversation_id": conversation_id} if conversation_id else {},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---- 详情(TweetDetail 响应:threaded_conversation_with_injections_v2)----
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _detail_entries_and_cursor(data: dict) -> tuple[list[dict], str | None]:
|
|
314
|
+
"""TweetDetail 响应 -> (entries, 回复翻页 cursor)。
|
|
315
|
+
|
|
316
|
+
与 _entries_and_cursor 同构(共享 _urt_entries_and_cursor 骨架),游标取
|
|
317
|
+
最后一条 cursor-*(ShowMore 回复;itemContent 形态优先,content.value 兜底)。
|
|
318
|
+
焦点推文是 tweet-{id} 条目,回复在 conversationthread-* 模块里,条目序
|
|
319
|
+
即阅读序(祖先推文混在最前,v1 当上下文读,不单独标注)。
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def _any_cursor(e: dict) -> str | None:
|
|
323
|
+
if (e.get("entryId") or "").startswith("cursor-"):
|
|
324
|
+
item = (e.get("content") or {}).get("itemContent") or {}
|
|
325
|
+
return item.get("value") or (e.get("content") or {}).get("value")
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
return _urt_entries_and_cursor(
|
|
329
|
+
data,
|
|
330
|
+
lambda d: (d.get("data") or {}).get("threaded_conversation_with_injections_v2") or {},
|
|
331
|
+
_any_cursor,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _unwrap(res: dict | None) -> dict:
|
|
336
|
+
"""TweetWithVisibilityResults 拆包:受限推文的 result 套一层 .tweet。"""
|
|
337
|
+
if not res:
|
|
338
|
+
return {}
|
|
339
|
+
if res.get("__typename") == "TweetWithVisibilityResults":
|
|
340
|
+
return res.get("tweet") or {}
|
|
341
|
+
return res
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _ordered_results(entries: list[dict], seen: set[str]) -> list[dict]:
|
|
345
|
+
"""entries 里的推文按条目序摊平,rest_id 去重并直接登记进 seen(持久集)。
|
|
346
|
+
|
|
347
|
+
tweetdetailrelatedtweets-* 模块是「相关推文」推荐(详情页尾部),不是回复,
|
|
348
|
+
整个丢弃。同一条回复可在顶层与 conversationthread 模块里各出现一次;
|
|
349
|
+
翻页页也用它,seen 装焦点与已输出过的回复 id——本函数边过滤边登记
|
|
350
|
+
(残卡转换失败也已记下,翻页不会再吐同一条)。去重规则本体在
|
|
351
|
+
core.source.dedup_by(空 rest_id 不去重直接放行)。
|
|
352
|
+
"""
|
|
353
|
+
clean = [
|
|
354
|
+
e for e in entries if not (e.get("entryId") or "").startswith("tweetdetailrelatedtweets")
|
|
355
|
+
]
|
|
356
|
+
return dedup_by(
|
|
357
|
+
(_unwrap(res) for res in _iter_tweet_results(clean)),
|
|
358
|
+
seen,
|
|
359
|
+
key=lambda r: str(r.get("rest_id") or ""),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _focal_and_replies(entries: list[dict], rest_id: str) -> tuple[dict, dict | None, list[dict]]:
|
|
364
|
+
"""首批 entries -> (焦点推文, 父推「原推」, 回复列表)。
|
|
365
|
+
|
|
366
|
+
焦点按 rest_id 匹配,匹配不到时退第一条(接口异常形态,保底不空屏)。
|
|
367
|
+
回复型推文的父推(focal.in_reply_to_status_id_str 指向的原推)从回复列表
|
|
368
|
+
拆出来单独展示,不混进楼层;匹配不到(父推被删/不在首屏)则 None。
|
|
369
|
+
"""
|
|
370
|
+
results = _ordered_results(entries, set())
|
|
371
|
+
focal = next((r for r in results if str(r.get("rest_id") or "") == rest_id), None)
|
|
372
|
+
if focal is None:
|
|
373
|
+
focal = results[0] if results else {}
|
|
374
|
+
parent_id = str((focal.get("legacy") or {}).get("in_reply_to_status_id_str") or "")
|
|
375
|
+
parent = None
|
|
376
|
+
if parent_id:
|
|
377
|
+
parent = next((r for r in results if str(r.get("rest_id") or "") == parent_id), None)
|
|
378
|
+
replies = [r for r in results if r is not focal and r is not parent]
|
|
379
|
+
return focal, parent, replies
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _quote_context(r: dict, label: str) -> str:
|
|
383
|
+
"""引用块(详情):label「原推」= 回复的父推(置正文上方),「被引推文」= quote(置正文下方)。
|
|
384
|
+
|
|
385
|
+
换行压成单段(块引用逐行加 > 不值当),带作者署名(名字 @handle)与媒体
|
|
386
|
+
记号;外文推的 Grok 译文作第二段附在块内(与正文同词汇)。
|
|
387
|
+
"""
|
|
388
|
+
who = _who(r) or "?"
|
|
389
|
+
body = (re.sub(r"\s+", " ", _visible_text(r)) + _media_markers(r)).strip()
|
|
390
|
+
out = f"> **{label}** · {who}:{body}"
|
|
391
|
+
tr = _grok_translation(r)
|
|
392
|
+
if tr:
|
|
393
|
+
out += "\n>\n" + tr
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _detail_doc(focal: dict, parent: dict | None) -> tuple[str, str, str, str]:
|
|
398
|
+
"""焦点推文 -> (title, meta, description_md, time)。
|
|
399
|
+
|
|
400
|
+
title 取正文首行截 _DETAIL_TITLE_CELLS 格(详情屏标题行与 Header 副标题共用,标题保持
|
|
401
|
+
原文——扫描面);正文全文(含换行,开头的 @ 提及已藏)放 description_md,
|
|
402
|
+
媒体附直接地址(_media_links,与知乎同形),外文推的 Grok 译文块接在
|
|
403
|
+
正文与媒体之后(阅读面才有译文,列表/标题不带),单行短文与标题重复时
|
|
404
|
+
不再二刷(有译文时不折叠,译文不能丢)。回复型推文(有父推)时,父推
|
|
405
|
+
作为「原推」引用块放在正文上方;引用推文(quote)的被引块放在正文下方
|
|
406
|
+
(与 X 网页的阅读序一致);引用块是压缩摘要,媒体只记号不附地址,译文
|
|
407
|
+
随块附(原推/被引是外文时)。
|
|
408
|
+
"""
|
|
409
|
+
legacy = focal.get("legacy") or {}
|
|
410
|
+
name, handle = _author(focal)
|
|
411
|
+
body = _visible_text(focal)
|
|
412
|
+
title = (
|
|
413
|
+
truncate_display(body.split("\n", 1)[0].strip(), _DETAIL_TITLE_CELLS)
|
|
414
|
+
or _media_markers(focal).strip()
|
|
415
|
+
or t("x.tweet_fallback")
|
|
416
|
+
)
|
|
417
|
+
meta = signature(name, handle) # 署名「名字 @handle」(详情行全宽,不再截 8 格)
|
|
418
|
+
if legacy.get("reply_count"):
|
|
419
|
+
meta += " · " + t("display.replies_plain", n=fmt_count(legacy['reply_count']))
|
|
420
|
+
if legacy.get("favorite_count"):
|
|
421
|
+
meta += " · " + t("display.favorites", n=fmt_count(legacy['favorite_count']))
|
|
422
|
+
desc = (body + _media_links(focal)).strip()
|
|
423
|
+
tr = _grok_translation(focal)
|
|
424
|
+
if tr:
|
|
425
|
+
desc = f"{desc}\n\n{tr}" if desc else tr
|
|
426
|
+
if parent:
|
|
427
|
+
desc = _quote_context(parent, t("x.parent_tweet")) + "\n\n" + desc
|
|
428
|
+
quoted = _quote_result(focal)
|
|
429
|
+
if quoted:
|
|
430
|
+
desc += "\n\n" + _quote_context(quoted, t("x.quoted_tweet"))
|
|
431
|
+
return title, meta, "" if desc == title else desc, fmt_twitter(legacy.get("created_at"))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _visible_text(r: dict) -> str:
|
|
435
|
+
"""推文可见文本:用 display_text_range 藏掉开头的 @ 提及与结尾的媒体
|
|
436
|
+
t.co 短链(详情里焦点与回复楼层都要;索引按 UTF-16 码元,先转码再切,
|
|
437
|
+
emoji 不偏移),再把正文里的内联 t.co 短链展开成 X 网页同款 display_url。
|
|
438
|
+
"""
|
|
439
|
+
text = _tweet_text(r)
|
|
440
|
+
legacy = r.get("legacy") or {}
|
|
441
|
+
rng = legacy.get("display_text_range")
|
|
442
|
+
offset = 0
|
|
443
|
+
if rng and len(rng) == 2 and not _is_long_form(r):
|
|
444
|
+
try:
|
|
445
|
+
u16 = text.encode("utf-16-le")
|
|
446
|
+
clipped = u16[rng[0] * 2 : rng[1] * 2].decode("utf-16-le", errors="ignore")
|
|
447
|
+
if clipped:
|
|
448
|
+
text = clipped
|
|
449
|
+
offset = rng[0] # url 实体索引仍是原文坐标,裁剪后要平移
|
|
450
|
+
except UnicodeError:
|
|
451
|
+
pass # 转码失败(异常形态)就保留原文,别丢内容
|
|
452
|
+
# 长文的 url 实体坐标在 note_tweet 侧,legacy 里没有;硬套 legacy indices
|
|
453
|
+
# 会在 note 全文上错位替换(见 _expand_urls 的说明),长文一律不展开
|
|
454
|
+
expanded = text if _is_long_form(r) else _expand_urls(text, legacy, offset)
|
|
455
|
+
return _strip_trailing_media(expanded, legacy)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _strip_trailing_media(text: str, legacy: dict) -> str:
|
|
459
|
+
"""剥结尾的媒体 t.co 短链(媒体已有 [图片 N]/[视频] 记号代偿,X 网页也不显示它)。
|
|
460
|
+
|
|
461
|
+
display_text_range 通常已把它排除,但实测个别推文会把它划进显示区(索引
|
|
462
|
+
不可靠)——按「结尾恰好是某条媒体 url」剥,只剥尾不碰中段。
|
|
463
|
+
"""
|
|
464
|
+
media_urls = {
|
|
465
|
+
str(m.get("url")) for m in (legacy.get("entities") or {}).get("media") or [] if m.get("url")
|
|
466
|
+
}
|
|
467
|
+
for _ in range(len(media_urls)):
|
|
468
|
+
trailing = text.rstrip()
|
|
469
|
+
hit = next((u for u in media_urls if trailing.endswith(u)), None)
|
|
470
|
+
if not hit:
|
|
471
|
+
break
|
|
472
|
+
text = trailing[: -len(hit)].rstrip()
|
|
473
|
+
return text
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _expand_urls(text: str, legacy: dict, offset: int = 0) -> str:
|
|
477
|
+
"""正文内联 t.co 短链 -> display_url(如 wapo.st/4zmXHM1,X 网页同款)。
|
|
478
|
+
|
|
479
|
+
索引按 UTF-16 码元(与 display_text_range 同规则),offset 是裁剪掉的
|
|
480
|
+
前缀码元数;只换完整落在当前文本内的实体(越界/重叠的不碰,截断残余
|
|
481
|
+
原样保留)。长文(note_tweet)不走这里——其 url 实体坐标在 note 侧,
|
|
482
|
+
legacy 里没有,硬套会错位。
|
|
483
|
+
"""
|
|
484
|
+
urls = (legacy.get("entities") or {}).get("urls") or []
|
|
485
|
+
if not urls:
|
|
486
|
+
return text
|
|
487
|
+
try:
|
|
488
|
+
u16 = text.encode("utf-16-le")
|
|
489
|
+
except UnicodeError:
|
|
490
|
+
return text
|
|
491
|
+
total = len(u16) // 2
|
|
492
|
+
out: list[bytes] = []
|
|
493
|
+
pos = 0 # 已拷贝到的码元游标
|
|
494
|
+
for u in sorted(urls, key=lambda u: (u.get("indices") or [0, 0])[0]):
|
|
495
|
+
rng = u.get("indices") or []
|
|
496
|
+
disp = str(u.get("display_url") or u.get("url") or "")
|
|
497
|
+
if len(rng) != 2 or not disp:
|
|
498
|
+
continue
|
|
499
|
+
a, b = rng[0] - offset, rng[1] - offset
|
|
500
|
+
if a < pos or b > total or a >= b: # 越界 / 重叠 / 倒序:不碰
|
|
501
|
+
continue
|
|
502
|
+
out.append(u16[pos * 2 : a * 2])
|
|
503
|
+
out.append(disp.encode("utf-16-le"))
|
|
504
|
+
pos = b
|
|
505
|
+
if not out:
|
|
506
|
+
return text
|
|
507
|
+
out.append(u16[pos * 2 :])
|
|
508
|
+
try:
|
|
509
|
+
return b"".join(out).decode("utf-16-le", errors="ignore")
|
|
510
|
+
except UnicodeError:
|
|
511
|
+
return text
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _reply_answer(r: dict) -> Answer | None:
|
|
515
|
+
"""一条回复推文 -> Answer;残卡(无 id 或作者)返回 None(调用方丢弃)。
|
|
516
|
+
|
|
517
|
+
内容序:正文(换行保留)→ 媒体地址 → 引用片段 → Grok 译文块(外文回复,
|
|
518
|
+
与详情正文同词汇)。译文块必须缀尾——它是 blockquote,引用片段若接在其后
|
|
519
|
+
会粘进引用行里,看起来像译文的一部分。
|
|
520
|
+
"""
|
|
521
|
+
rest_id = str(r.get("rest_id") or "").strip()
|
|
522
|
+
_, handle = _author(r)
|
|
523
|
+
if not rest_id or not handle:
|
|
524
|
+
return None
|
|
525
|
+
legacy = r.get("legacy") or {}
|
|
526
|
+
content = (_visible_text(r) + _media_links(r) + _quote_snippet(r)).strip()
|
|
527
|
+
tr = _grok_translation(r)
|
|
528
|
+
if tr:
|
|
529
|
+
content = f"{content}\n\n{tr}" if content else tr
|
|
530
|
+
return Answer(
|
|
531
|
+
author=_who(r),
|
|
532
|
+
votes=votes_text(legacy.get("favorite_count") or 0),
|
|
533
|
+
content_md=content,
|
|
534
|
+
time=fmt_twitter(legacy.get("created_at")),
|
|
535
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""知乎源(zhihu):热榜 / 推荐 / 关注 三个信息流 + 问题详情。
|
|
2
|
+
|
|
3
|
+
包内分层(全项目统一的源模板):
|
|
4
|
+
- fetch.py 取数层:全部数据走免签 API(常驻通道页签的页面 fetch,带 x-zse-93
|
|
5
|
+
版本头解锁 v4),不加载任何内容页面
|
|
6
|
+
- parse.py 解析层:API JSON -> 统一模型,纯函数可测
|
|
7
|
+
- __init__.py 编排层:Source 的能力声明(词汇表/登录)与 open_* 分派
|
|
8
|
+
|
|
9
|
+
知乎 2026 起在登录墙后:通道页签撞墙由 URL 复检成 LoginRequired。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
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
|
+
SourceError,
|
|
25
|
+
)
|
|
26
|
+
from feedscope.sources.zhihu.fetch import (
|
|
27
|
+
FOLLOW_API,
|
|
28
|
+
HOT_API,
|
|
29
|
+
RECOMMEND_API,
|
|
30
|
+
ZhihuApiFeedLoader,
|
|
31
|
+
fetch_hot_list,
|
|
32
|
+
open_question,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 各流的 (API URL, referer):URL 逐字照抄页面自己发的首请求;referer 是页面
|
|
36
|
+
# 发该请求时所在的页面(fetch referrer 选项指定)。热榜页是 SSR、页面不发
|
|
37
|
+
# 列表请求(热榜走 www 网关的 v3 孪生门,全源流量统一 www 域),referer 借对应页面 URL。
|
|
38
|
+
_FEED_CTX = {
|
|
39
|
+
"hot": (HOT_API, "https://www.zhihu.com/hot"),
|
|
40
|
+
"recommend": (RECOMMEND_API, "https://www.zhihu.com/"),
|
|
41
|
+
"follow": (FOLLOW_API, "https://www.zhihu.com/follow"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@register
|
|
46
|
+
class ZhihuSource(Source):
|
|
47
|
+
source_id = "zhihu"
|
|
48
|
+
label = "sources.zhihu.label"
|
|
49
|
+
supports_detail = True
|
|
50
|
+
supports_comments = True # 详情屏 Enter 进楼层选择 -> 回答的评论层(按需抓取)
|
|
51
|
+
feeds: list[str] = ["hot", "recommend", "follow"]
|
|
52
|
+
feed_labels: dict[str, str] = {
|
|
53
|
+
"hot": "sources.zhihu.feeds.hot",
|
|
54
|
+
"recommend": "sources.zhihu.feeds.recommend",
|
|
55
|
+
"follow": "sources.zhihu.feeds.follow",
|
|
56
|
+
}
|
|
57
|
+
# 信息流只留问题卡(parse 层过滤想法/文章/视频等其余类型):
|
|
58
|
+
# 所有条目 Enter 都是 TUI 详情,无跳浏览器的分叉
|
|
59
|
+
detail_kinds = frozenset({"", "question"})
|
|
60
|
+
detail_unit_label = "units.answer"
|
|
61
|
+
login_url = "https://www.zhihu.com/signin" # 2026 起热榜/信息流都在登录墙后
|
|
62
|
+
login_cookie = "z_c0" # feedscope login 扫码后写入 profile 的判登 cookie
|
|
63
|
+
logout_cookies: ClassVar[list[str]] = ["z_c0"] # 登出清掉判登 cookie
|
|
64
|
+
|
|
65
|
+
# ---- 信息流 ----
|
|
66
|
+
|
|
67
|
+
async def open_feed(self, name: str) -> FeedLoader:
|
|
68
|
+
label = self.label_of(name)
|
|
69
|
+
url, referer = _FEED_CTX[name]
|
|
70
|
+
if name == "hot":
|
|
71
|
+
return self.static_feed(await fetch_hot_list(referer), label)
|
|
72
|
+
# 推荐/关注走游标 API:首批在通道页签里创建并判登,0 条守卫与 hn/x 同路
|
|
73
|
+
return await self.first_batch(ZhihuApiFeedLoader(url, referer), label)
|
|
74
|
+
|
|
75
|
+
# ---- 问题详情 ----
|
|
76
|
+
|
|
77
|
+
async def open_detail(self, item: FeedItem) -> DetailLoader:
|
|
78
|
+
m = re.search(r"question/(\d+)", item.url or "")
|
|
79
|
+
if not m:
|
|
80
|
+
raise SourceError(t("errors.zhihu_no_question_id", label=t("sources.zhihu.label")))
|
|
81
|
+
return await open_question(m.group(1))
|