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,490 @@
|
|
|
1
|
+
"""X 取数层:通道页签(知乎/Reddit 同款)——常驻 x.com 页签的页面 fetch 直调 GraphQL。
|
|
2
|
+
|
|
3
|
+
请求由真 Chrome 发出(取数原语见 core/browser.ChannelTab.fetch):TLS/头序/
|
|
4
|
+
cookie 与登录会话完全自洽,cf_clearance 与 Chrome TLS 绑定的封锁(搜索端点
|
|
5
|
+
已兑现过)对页内 fetch 天然不存在——手动只补浏览器不会自动带的头:公开
|
|
6
|
+
网页端 Bearer(多年稳定的公开常量)+ X 系声明头;csrf 由统一原语的
|
|
7
|
+
cookie_headers 把 ct0 页内就地提升为 X-Csrf-Token(与 SPA 同一动作)。
|
|
8
|
+
|
|
9
|
+
请求形状(queryId/variables/features)以种子常量起步(快照,零收割成本
|
|
10
|
+
零启动延迟),失效自动收割自愈(x_probe.py 的人工收割内建为自动)——
|
|
11
|
+
重开页签加载当前部署,收集它加载过的全部 JS chunk(resource entries 为主:
|
|
12
|
+
运行时动态加载的 chunk 只有那里可见)下载 grep queryId/operationName 对;
|
|
13
|
+
chunk 文件名自带端点名,按名优先下,通常一两个就命中。(2026-11 实测:
|
|
14
|
+
X 把自己的 GraphQL 请求从 resource timing 里隐藏了,「抄 SPA 发过的请求
|
|
15
|
+
URL」这条路被刻意封死,chunk 才是 qid 的家;页内下载 chunk 有 CORS 墙,
|
|
16
|
+
abs.twimg.com 对 httpx 无门禁。)请求报错(errors / 400/404/422,即形状
|
|
17
|
+
腐烂)时强制重割,形状确实换过才原参重试一次(Reddit 版本头的同款自愈
|
|
18
|
+
闭环)——X 改版换 queryId 从「跑脚本手抄常量」变成「当天自动恢复」;
|
|
19
|
+
重割也没换形状才报人话指引。
|
|
20
|
+
|
|
21
|
+
TweetDetail 的 features chunk 里 grep 不出(features 是调用时拼的,决定
|
|
22
|
+
响应形状,Grok 译文字段就在其中):用种子常量;失效时懒深度截获一次——
|
|
23
|
+
瞬态页打开稳定推文(jack/status/20),网络层(page.on("request"))收
|
|
24
|
+
浏览器自己发的完整请求 URL——resource timing 隐不掉网络层——收割
|
|
25
|
+
qid+variables+features 后关页重试(focalTweetId/cursor 是每次调用的参数,
|
|
26
|
+
收割时剔除防锁死)。
|
|
27
|
+
|
|
28
|
+
401 语义唯一(auth_token 被服务端吊销,cookie 只是还躺在本地):清失效
|
|
29
|
+
cookie(不清的话判登会一直被骗过)+ LoginRequired 走登录引导;403 是
|
|
30
|
+
风控/账号受限,重登救不了,提示里说清。搜索不做:SearchTimeline 双重
|
|
31
|
+
门禁——x-client-transaction-id(只有 X 自己的 JS 会算)+ cf_clearance
|
|
32
|
+
TLS 绑定,页内手动 fetch 同样进不去;「导航+网络层截获」原型验证过可
|
|
33
|
+
行但行为耦合重,为低频功能不值当——后人想恢复先读这段。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import json
|
|
40
|
+
import re
|
|
41
|
+
import urllib.parse
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from feedscope.core import http
|
|
46
|
+
from feedscope.core.browser import ChannelTab, drop_profile_cookies, get_session, run_page
|
|
47
|
+
from feedscope.core.const import PAGE_TIMEOUT_MS
|
|
48
|
+
from feedscope.core.models import Answer, DetailDoc, FeedItem
|
|
49
|
+
from feedscope.core.source import DetailLoader, FeedLoader, LoginRequired, SourceError, dedup_append
|
|
50
|
+
from feedscope.core.i18n import t
|
|
51
|
+
from feedscope.sources.x.parse import (
|
|
52
|
+
BASE,
|
|
53
|
+
_detail_doc,
|
|
54
|
+
_detail_entries_and_cursor,
|
|
55
|
+
_entries_and_cursor,
|
|
56
|
+
_focal_and_replies,
|
|
57
|
+
_iter_tweet_results,
|
|
58
|
+
_ordered_results,
|
|
59
|
+
_reply_answer,
|
|
60
|
+
_tweet_from_result,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_tab = ChannelTab(f"{BASE}/home", name="x") # 停靠 x.com/home:同源 fetch 与 cookie 的前提
|
|
64
|
+
|
|
65
|
+
# 公开网页端 Bearer(多年稳定的公开常量);浏览器不会自动带,页内手动补
|
|
66
|
+
_BEARER = (
|
|
67
|
+
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
|
|
68
|
+
"%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# feed -> GraphQL operationName(For You 与 Following 是同族端点:同容器同变量集)
|
|
72
|
+
_TIMELINE_OPS: dict[str, str] = {"foryou": "HomeTimeline", "following": "HomeLatestTimeline"}
|
|
73
|
+
_DETAIL_OP = "TweetDetail"
|
|
74
|
+
|
|
75
|
+
# 深度截获用的稳定推文(jack 首推,多年存在):打开它的详情页即触发 SPA 发 TweetDetail
|
|
76
|
+
_STABLE_TWEET = "https://x.com/jack/status/20"
|
|
77
|
+
|
|
78
|
+
# 每次调用按需拼接、绝不入缓存的变量(收割时剔除:烤进去会把所有详情锁死到截获推文)
|
|
79
|
+
_PER_CALL_VARS = ("cursor", "focalTweetId")
|
|
80
|
+
|
|
81
|
+
# 形状腐烂的状态码(queryId/变量集失效的典型信号);401/403 是登录/风控语义、
|
|
82
|
+
# 429/5xx 是限流/抖动,都不该触发重割
|
|
83
|
+
_SHAPE_ROT = frozenset({400, 404, 422})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class _Op:
|
|
88
|
+
"""一个 GraphQL 端点的请求形状(收割或种子的最新成果)。"""
|
|
89
|
+
|
|
90
|
+
qid: str
|
|
91
|
+
variables: dict
|
|
92
|
+
features: dict | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# 种子(2026-11 抓包快照):两条收割路都断时的保底,失效自愈会先走收割。
|
|
96
|
+
# 时间线必填变量集(缺一个都 422「must be defined」);翻页由调用方加 cursor
|
|
97
|
+
_SEEDS: dict[str, _Op] = {
|
|
98
|
+
"HomeTimeline": _Op(
|
|
99
|
+
qid="fedFLLzmiCAuzPgA8lyh8Q",
|
|
100
|
+
variables={
|
|
101
|
+
"count": 20,
|
|
102
|
+
"includePromotedContent": True,
|
|
103
|
+
"latestControlAvailable": True,
|
|
104
|
+
"requestContext": "launch",
|
|
105
|
+
"withDownvotePerspectives": False,
|
|
106
|
+
"withBirdwatchNotes": False,
|
|
107
|
+
"withCommunity": True,
|
|
108
|
+
"withQuickPromoteEligibilityTweetFields": False,
|
|
109
|
+
"withSuperFollowsUserFields": True,
|
|
110
|
+
"withReactionsMetadata": False,
|
|
111
|
+
"withReactionsPerspectives": False,
|
|
112
|
+
"withSuperFollowsTweetFields": True,
|
|
113
|
+
"withVoice": True,
|
|
114
|
+
"withArticleRichContent": False,
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
"HomeLatestTimeline": _Op(
|
|
118
|
+
qid="BKB7oi212Fi7kQtCBGE4zA", # 同族端点:直收对方必填集即 200(2026-11 实测)
|
|
119
|
+
variables={}, # 与 HomeTimeline 同变量集,运行时共用(见 _seed_for)
|
|
120
|
+
),
|
|
121
|
+
"TweetDetail": _Op(
|
|
122
|
+
qid="qpemjmVMoTWDvcRT43JIVQ",
|
|
123
|
+
variables={
|
|
124
|
+
"with_rux_injections": False,
|
|
125
|
+
"rankingMode": "Relevance",
|
|
126
|
+
"includePromotedContent": True,
|
|
127
|
+
"withCommunity": True,
|
|
128
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
|
129
|
+
"withBirdwatchNotes": True,
|
|
130
|
+
"withVoice": True,
|
|
131
|
+
},
|
|
132
|
+
# features 决定响应形状(Grok 译文字段靠其中的 grok_show_grok_translated_post
|
|
133
|
+
# 开关),chunk 里 grep 不出、只有真实请求截获才拿得到
|
|
134
|
+
features={
|
|
135
|
+
"rweb_video_screen_enabled": False,
|
|
136
|
+
"rweb_cashtags_enabled": True,
|
|
137
|
+
"profile_label_improvements_pcf_label_in_post_enabled": True,
|
|
138
|
+
"responsive_web_profile_redirect_enabled": True,
|
|
139
|
+
"rweb_tipjar_consumption_enabled": False,
|
|
140
|
+
"verified_phone_label_enabled": False,
|
|
141
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
|
142
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
|
143
|
+
"premium_content_api_read_enabled": False,
|
|
144
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
|
145
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
|
146
|
+
"responsive_web_grok_analyze_button_fetch_trends_enabled": False,
|
|
147
|
+
"responsive_web_grok_analyze_post_followups_enabled": True,
|
|
148
|
+
"rweb_cashtags_composer_attachment_enabled": True,
|
|
149
|
+
"responsive_web_jetfuel_frame": True,
|
|
150
|
+
"rweb_sports_post_context_enabled": True,
|
|
151
|
+
"x_elections_post_context_enabled": True,
|
|
152
|
+
"responsive_web_grok_share_attachment_enabled": True,
|
|
153
|
+
"responsive_web_grok_annotations_enabled": True,
|
|
154
|
+
"articles_preview_enabled": True,
|
|
155
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
|
156
|
+
"rweb_conversational_replies_downvote_enabled": False,
|
|
157
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
|
158
|
+
"view_counts_everywhere_api_enabled": True,
|
|
159
|
+
"longform_notetweets_consumption_enabled": True,
|
|
160
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
|
161
|
+
"content_disclosure_indicator_enabled": True,
|
|
162
|
+
"content_disclosure_ai_generated_indicator_enabled": True,
|
|
163
|
+
"responsive_web_grok_show_grok_translated_post": True,
|
|
164
|
+
"responsive_web_grok_analysis_button_from_backend": True,
|
|
165
|
+
"post_ctas_fetch_enabled": False,
|
|
166
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
|
167
|
+
"standardized_nudges_misinfo": True,
|
|
168
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
|
169
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
|
170
|
+
"longform_notetweets_inline_media_enabled": False,
|
|
171
|
+
"responsive_web_nested_quote_preview_enabled": True,
|
|
172
|
+
"responsive_web_grok_image_annotation_enabled": True,
|
|
173
|
+
"responsive_web_grok_imagine_annotation_enabled": True,
|
|
174
|
+
"responsive_web_made_on_x_label_enabled": False,
|
|
175
|
+
"responsive_web_grok_community_note_auto_translation_is_enabled": True,
|
|
176
|
+
"responsive_web_enhance_cards_enabled": False,
|
|
177
|
+
},
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_harvest: dict[str, _Op] = {} # operationName -> _Op(收割成果,覆盖同源种子)
|
|
182
|
+
|
|
183
|
+
_HARVEST_WAIT = 1.0 # 重开页签后等数据 chunk 挂进 resource entries 的时长(测试置 0)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---- 页签里执行的 JS(收集 chunk URL;取数走 ChannelTab.fetch 统一原语)----
|
|
187
|
+
|
|
188
|
+
# GraphQL 的手动头:Bearer(公开常量)+ X 系声明头;csrf 走 cookie_headers
|
|
189
|
+
# 把 ct0 页内就地提升为 X-Csrf-Token(永远新鲜,X 自己的 SPA 同一动作)
|
|
190
|
+
_GQL_HEADERS: dict[str, str] = {
|
|
191
|
+
"Authorization": f"Bearer {_BEARER}",
|
|
192
|
+
"X-Twitter-Active-User": "yes",
|
|
193
|
+
"X-Twitter-Client-Language": "zh-cn",
|
|
194
|
+
"X-Twitter-Auth-Type": "OAuth2Session",
|
|
195
|
+
}
|
|
196
|
+
_CSRF_FROM_COOKIE = {"X-Csrf-Token": "ct0"}
|
|
197
|
+
|
|
198
|
+
# 当前部署加载过的 JS chunk URL 三路并集:resource entries(运行时动态
|
|
199
|
+
# 加载的 chunk 只有这里可见——2026-11 实测 X 把自己的 GraphQL 请求从
|
|
200
|
+
# resource timing 里隐藏了、但 JS chunk 照记)+ 页面 HTML 文本(x_probe 的
|
|
201
|
+
# 老路)+ script 标签。chunk 里是 queryId 的家,Python 侧下载 grep
|
|
202
|
+
_SCRIPT_URLS_JS = """
|
|
203
|
+
() => {
|
|
204
|
+
const res = performance.getEntriesByType("resource").map(e => e.name);
|
|
205
|
+
const inHtml = document.documentElement.outerHTML
|
|
206
|
+
.match(/https:\\/\\/abs\\.twimg\\.com\\/[^"'\\s]+\\.js/g) || [];
|
|
207
|
+
const tags = [...document.querySelectorAll("script[src]")].map(s => s.src);
|
|
208
|
+
return [...new Set([...res, ...inHtml, ...tags])].filter(u => u.endsWith(".js"));
|
|
209
|
+
}
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_graphql_url(url: str) -> tuple[str, _Op] | None:
|
|
214
|
+
"""截获到的 GraphQL URL -> (operationName, 请求形状);残缺/非目标形状返回 None。
|
|
215
|
+
|
|
216
|
+
variables/features 从查询参数还原;每次调用的参数(cursor/focalTweetId)
|
|
217
|
+
剔除——它们由调用方按次拼接,烤进缓存会把翻页/详情锁死到截获那次。
|
|
218
|
+
(深度截获专用:resource timing 隐 API 请求,SPA 发过的 URL 拿不到了。)
|
|
219
|
+
"""
|
|
220
|
+
parts = urllib.parse.urlsplit(url)
|
|
221
|
+
m = re.search(r"/i/api/graphql/([^/]+)/([^/?]+)$", parts.path)
|
|
222
|
+
if not m:
|
|
223
|
+
return None
|
|
224
|
+
qs = urllib.parse.parse_qs(parts.query)
|
|
225
|
+
|
|
226
|
+
def _json_param(key: str) -> dict:
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(qs.get(key, ["{}"])[0])
|
|
229
|
+
return (
|
|
230
|
+
{k: v for k, v in data.items() if k not in _PER_CALL_VARS}
|
|
231
|
+
if isinstance(data, dict)
|
|
232
|
+
else {}
|
|
233
|
+
)
|
|
234
|
+
except ValueError:
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
return m.group(2), _Op(
|
|
238
|
+
qid=m.group(1), variables=_json_param("variables"), features=_json_param("features") or None
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _download_chunk(url: str) -> str | None:
|
|
243
|
+
"""下载一个公开 CDN chunk(abs.twimg.com 无门禁,httpx 即可);失败返回 None。
|
|
244
|
+
|
|
245
|
+
敌意源里的公开请求走 httpx(边界按请求定不按源定);sample=False——
|
|
246
|
+
JS bundle 不是 API 响应,落样本只占地方。"""
|
|
247
|
+
try:
|
|
248
|
+
r = await http.get(url, source="X chunk", sample=False)
|
|
249
|
+
except SourceError:
|
|
250
|
+
return None
|
|
251
|
+
return r.text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_QID_RE = re.compile(r'queryId:"([^"]+)",operationName:"([^"]+)"')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _seed_for(op_name: str) -> _Op | None:
|
|
258
|
+
"""种子里取形状(收割缺这个端点时兜底)。"""
|
|
259
|
+
op = _SEEDS.get(op_name)
|
|
260
|
+
if op is None:
|
|
261
|
+
return None
|
|
262
|
+
if not op.variables and op_name == "HomeLatestTimeline":
|
|
263
|
+
return _Op(
|
|
264
|
+
qid=op.qid, variables=dict(_SEEDS["HomeTimeline"].variables)
|
|
265
|
+
) # 同族端点共用变量集
|
|
266
|
+
return op
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def harvest() -> None:
|
|
270
|
+
"""重新收割时间线请求形状(失效自愈 / x_probe 巡检用)。
|
|
271
|
+
|
|
272
|
+
先重开页签(停靠的 SPA 可能还是旧部署),等数据 chunk 挂进 resource
|
|
273
|
+
entries,再收集当前部署的全部 chunk 下载 grep queryId/operationName 对。
|
|
274
|
+
chunk 文件名自带端点名(如 shared~bundle.Compose~bundle.HomeTimeline~):
|
|
275
|
+
名字带缺口端点的优先下,通常一两个 chunk 就命中;只补缺口端点(收割
|
|
276
|
+
成果不被只带 qid 的 grep 反复覆盖),variables 沿用已有形状。失败静默
|
|
277
|
+
——种子还在,取数不受阻。TweetDetail 不进 grep:种子 + 懒深度截获
|
|
278
|
+
(_deep_capture_detail)已覆盖。
|
|
279
|
+
"""
|
|
280
|
+
_tab.reset()
|
|
281
|
+
try:
|
|
282
|
+
await _tab.get() # 重开页签(goto 完成),收割才算数
|
|
283
|
+
except Exception:
|
|
284
|
+
return # 页签打不开等硬错误:交给种子兜底
|
|
285
|
+
await asyncio.sleep(_HARVEST_WAIT) # SPA 数据 chunk 异步加载,稍等它进 resource entries
|
|
286
|
+
try:
|
|
287
|
+
chunks = await _tab.eval(_SCRIPT_URLS_JS) or []
|
|
288
|
+
except Exception:
|
|
289
|
+
return
|
|
290
|
+
missing = [op for op in _TIMELINE_OPS.values() if op not in _harvest]
|
|
291
|
+
chunks = sorted(
|
|
292
|
+
set(chunks),
|
|
293
|
+
key=lambda u: (0 if any(op.lower() in u.lower() for op in missing) else 1, u),
|
|
294
|
+
)
|
|
295
|
+
for u in chunks:
|
|
296
|
+
if "twimg.com" not in u or not u.endswith(".js"):
|
|
297
|
+
continue
|
|
298
|
+
text = await _download_chunk(u)
|
|
299
|
+
if not text:
|
|
300
|
+
continue
|
|
301
|
+
for m in _QID_RE.finditer(text):
|
|
302
|
+
op_name = m.group(2)
|
|
303
|
+
if op_name not in missing:
|
|
304
|
+
continue
|
|
305
|
+
seed = _seed_for(op_name)
|
|
306
|
+
if seed is not None:
|
|
307
|
+
_harvest[op_name] = _Op(
|
|
308
|
+
qid=m.group(1), variables=dict(seed.variables), features=seed.features
|
|
309
|
+
)
|
|
310
|
+
missing.remove(op_name)
|
|
311
|
+
if not missing:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _resolve(op_name: str) -> _Op | None:
|
|
316
|
+
"""当前生效的形状:收割优先、种子兜底。"""
|
|
317
|
+
return _harvest.get(op_name) or _seed_for(op_name)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def _deep_capture_detail() -> _Op | None:
|
|
321
|
+
"""懒深度截获 TweetDetail 形状:瞬态页开稳定推文,网络层收 SPA 自己发的请求。
|
|
322
|
+
|
|
323
|
+
features 只有这条路拿得到(chunk grep 不出);页面用完即关(run_page),
|
|
324
|
+
不碰常驻通道页签。截不到(推文没了/改版没了这个端点)返回 None。
|
|
325
|
+
"""
|
|
326
|
+
box: dict[str, str] = {}
|
|
327
|
+
|
|
328
|
+
def on_request(req: Any) -> None:
|
|
329
|
+
if "/i/api/graphql/" in req.url and req.url.split("?")[0].endswith(f"/{_DETAIL_OP}"):
|
|
330
|
+
box["url"] = req.url
|
|
331
|
+
|
|
332
|
+
async def _watch(page: Any) -> None:
|
|
333
|
+
page.on("request", on_request)
|
|
334
|
+
await page.goto(_STABLE_TWEET, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT_MS)
|
|
335
|
+
for _ in range(30): # 最多等 15 秒(渲染期请求,等它出现)
|
|
336
|
+
if box:
|
|
337
|
+
return
|
|
338
|
+
await page.wait_for_timeout(500)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
await get_session().run(run_page, _watch)
|
|
342
|
+
except Exception:
|
|
343
|
+
return None # 瞬态页打不开/超时:回落到 harvest + 种子,不让自愈路径裸炸
|
|
344
|
+
if not box or not (parsed := _parse_graphql_url(box["url"])):
|
|
345
|
+
return None
|
|
346
|
+
_harvest[_DETAIL_OP] = parsed[1]
|
|
347
|
+
return parsed[1]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _raw(url: str, *, source: str) -> tuple[int | None, dict]:
|
|
351
|
+
"""一次页签 fetch -> (状态码或 None, body);解包/报错/样本落盘见 ChannelTab.fetch。"""
|
|
352
|
+
status, body = await _tab.fetch(
|
|
353
|
+
url, source=source, headers=_GQL_HEADERS, cookie_headers=_CSRF_FROM_COOKIE
|
|
354
|
+
)
|
|
355
|
+
# 登录墙 HTML / 代理错误页(不是 GraphQL JSON)由 ChannelTab.fetch 统一报
|
|
356
|
+
return status, (body or {})
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _url(op_name: str, op: _Op, extra_vars: dict | None) -> str:
|
|
360
|
+
"""operationName + 形状 + 按次变量(cursor/focalTweetId)-> 完整 GraphQL URL。"""
|
|
361
|
+
variables = dict(op.variables, **(extra_vars or {}))
|
|
362
|
+
params = {"variables": json.dumps(variables)}
|
|
363
|
+
if op.features:
|
|
364
|
+
params["features"] = json.dumps(op.features)
|
|
365
|
+
return f"{BASE}/i/api/graphql/{op.qid}/{op_name}?{urllib.parse.urlencode(params)}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def _graphql(op_name: str, extra_vars: dict | None, *, source: str) -> dict:
|
|
369
|
+
"""一次 GraphQL 调用 -> body;登录/风控语义转译,形状腐烂自动重割自愈一次。"""
|
|
370
|
+
op = _resolve(op_name)
|
|
371
|
+
if op is None:
|
|
372
|
+
await harvest()
|
|
373
|
+
op = _resolve(op_name)
|
|
374
|
+
if op is None:
|
|
375
|
+
raise SourceError(t("errors.x_no_shape", source=source))
|
|
376
|
+
for attempt in range(2):
|
|
377
|
+
url = _url(op_name, op, extra_vars)
|
|
378
|
+
status, body = await _raw(url, source=source)
|
|
379
|
+
if (
|
|
380
|
+
status == 401
|
|
381
|
+
): # auth_token 被服务端吊销:清失效 cookie(判登 cookie 在即算已登录会一直被骗)再引导重登
|
|
382
|
+
await drop_profile_cookies("https://x.com", ("auth_token", "ct0"))
|
|
383
|
+
raise LoginRequired(t("errors.x_login_expired")) from None
|
|
384
|
+
if status == 403:
|
|
385
|
+
raise SourceError(t("errors.x_403", source=source)) from None
|
|
386
|
+
if status and status not in _SHAPE_ROT:
|
|
387
|
+
raise SourceError(t("errors.x_status_retry", source=source, status=status)) from None
|
|
388
|
+
if not status and not body.get("errors"):
|
|
389
|
+
return body
|
|
390
|
+
if attempt == 0: # 形状腐烂(errors/400/404/422):重割一次,形状确实换过才重试
|
|
391
|
+
healed = await _deep_capture_detail() if op_name == _DETAIL_OP else None
|
|
392
|
+
if healed is None:
|
|
393
|
+
await harvest()
|
|
394
|
+
healed = _resolve(op_name)
|
|
395
|
+
if healed is None or healed == op:
|
|
396
|
+
break
|
|
397
|
+
op = healed
|
|
398
|
+
raise SourceError(t("errors.x_shape_stale", source=source))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def fetch_entries(feed: str, cursor: str | None) -> tuple[list[dict], str | None]:
|
|
402
|
+
"""时间线一次 GraphQL 调用 -> (entries, 下一页 cursor);feed 见 _TIMELINE_OPS。"""
|
|
403
|
+
op_name = _TIMELINE_OPS[feed]
|
|
404
|
+
data = await _graphql(
|
|
405
|
+
op_name, {"cursor": cursor} if cursor else None, source=t("sources.x.timeline")
|
|
406
|
+
)
|
|
407
|
+
return _entries_and_cursor(data)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def fetch_conversation(rest_id: str, cursor: str | None) -> tuple[list[dict], str | None]:
|
|
411
|
+
"""推文会话一次 GraphQL 调用 -> (entries, 回复翻页 cursor)。"""
|
|
412
|
+
extra = {"focalTweetId": rest_id}
|
|
413
|
+
if cursor:
|
|
414
|
+
extra["cursor"] = cursor
|
|
415
|
+
data = await _graphql(_DETAIL_OP, extra, source=t("sources.x.detail"))
|
|
416
|
+
return _detail_entries_and_cursor(data)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class XFeedLoader(FeedLoader):
|
|
420
|
+
"""主页时间线(推荐/正在关注):游标翻页 + 按推文 id 去重;不持有页签。
|
|
421
|
+
|
|
422
|
+
done 在响应里没有 cursor-bottom 时为真(无限流通常永远有下一页,拉空只是抖动)。
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def __init__(self, feed: str) -> None:
|
|
426
|
+
self.items: list[FeedItem] = []
|
|
427
|
+
self._seen: set[str] = set()
|
|
428
|
+
self._cursor: str | None = None # None = 首页
|
|
429
|
+
self._feed = feed
|
|
430
|
+
self._done = False
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def done(self) -> bool:
|
|
434
|
+
return self._done
|
|
435
|
+
|
|
436
|
+
async def load_more(self) -> list[FeedItem]:
|
|
437
|
+
if self._done:
|
|
438
|
+
return []
|
|
439
|
+
entries, cursor = await fetch_entries(self._feed, self._cursor)
|
|
440
|
+
self._cursor = cursor
|
|
441
|
+
self._done = cursor is None
|
|
442
|
+
return dedup_append(
|
|
443
|
+
self.items, self._seen, (_tweet_from_result(r) for r in _iter_tweet_results(entries))
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class XTweetDetailLoader(DetailLoader):
|
|
448
|
+
"""推文详情:焦点推文首批给出,回复沿 ShowMore cursor 翻页,按推文 id 去重。
|
|
449
|
+
|
|
450
|
+
TweetDetail 的 cursor 无回复也一直发(0 回复推文也带游标),故「空页/无回复」
|
|
451
|
+
直接判 done,否则 n 会无休空转。不持有页签(通道进程级共享)。
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def __init__(self, rest_id: str, entries: list[dict], cursor: str | None) -> None:
|
|
455
|
+
self._rest_id = rest_id
|
|
456
|
+
self._cursor = cursor
|
|
457
|
+
self._seen: set[str] = set() # 已输出的推文 id(焦点 + 父推 + 回复,跨页去重)
|
|
458
|
+
focal, parent, replies = _focal_and_replies(entries, rest_id)
|
|
459
|
+
if not focal:
|
|
460
|
+
raise SourceError(t("errors.x_detail_empty"))
|
|
461
|
+
title, meta, desc, time = _detail_doc(focal, parent)
|
|
462
|
+
self.doc = DetailDoc(title=title, meta=meta, description_md=desc, time=time)
|
|
463
|
+
self._seen.add(str(focal.get("rest_id") or ""))
|
|
464
|
+
if parent:
|
|
465
|
+
self._seen.add(str(parent.get("rest_id") or ""))
|
|
466
|
+
if not self._answers(replies):
|
|
467
|
+
self._cursor = None # 首批即无回复:会话确无回复,无谓的游标不再翻
|
|
468
|
+
|
|
469
|
+
def _answers(self, results: list[dict]) -> list[Answer]:
|
|
470
|
+
"""推文 result 列表 -> Answer 批次(残卡丢弃),写入 doc。
|
|
471
|
+
|
|
472
|
+
去重登记(rest_id -> self._seen)在 parse._ordered_results 里随
|
|
473
|
+
摊平一起做——残卡也登记过,翻页不会再吐同一条。"""
|
|
474
|
+
new = [a for a in map(_reply_answer, results) if a is not None]
|
|
475
|
+
self.doc.answers.extend(new)
|
|
476
|
+
return new
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def done(self) -> bool:
|
|
480
|
+
return self._cursor is None
|
|
481
|
+
|
|
482
|
+
async def load_more(self) -> list[Answer]:
|
|
483
|
+
if self._cursor is None:
|
|
484
|
+
return []
|
|
485
|
+
entries, cursor = await fetch_conversation(self._rest_id, self._cursor)
|
|
486
|
+
self._cursor = cursor
|
|
487
|
+
new = self._answers(_ordered_results(entries, self._seen))
|
|
488
|
+
if not new:
|
|
489
|
+
self._cursor = None # 空页 = 真到头(游标会一直发,空页判死避免空转)
|
|
490
|
+
return new
|