@sns-parse/core 0.3.0-alpha.1 → 0.4.0-alpha.10

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 (47) hide show
  1. package/lib/config.d.ts +7 -0
  2. package/lib/config.js +28 -0
  3. package/lib/engine/config-io.d.ts +44 -0
  4. package/lib/engine/config-io.js +155 -0
  5. package/lib/engine/config.d.ts +9 -0
  6. package/lib/engine/config.js +265 -0
  7. package/lib/engine/fetcher.d.ts +21 -0
  8. package/lib/engine/fetcher.js +228 -0
  9. package/lib/engine/gateway.d.ts +21 -0
  10. package/lib/engine/gateway.js +19 -0
  11. package/lib/engine/parser.d.ts +2 -0
  12. package/lib/engine/parser.js +162 -0
  13. package/lib/engine/platform-config.d.ts +24 -0
  14. package/lib/engine/platform-config.js +76 -0
  15. package/lib/engine/runtime.d.ts +49 -0
  16. package/lib/engine/runtime.js +90 -0
  17. package/lib/engine/translate.d.ts +15 -0
  18. package/lib/engine/translate.js +107 -0
  19. package/lib/engine/twitter-user.d.ts +67 -0
  20. package/lib/engine/twitter-user.js +185 -0
  21. package/lib/engine/twitter.d.ts +102 -0
  22. package/lib/engine/twitter.js +791 -0
  23. package/lib/index.d.ts +21 -0
  24. package/lib/index.js +21 -0
  25. package/lib/registry.d.ts +20 -0
  26. package/lib/registry.js +77 -0
  27. package/lib/utils/cache.d.ts +9 -0
  28. package/lib/utils/cache.js +34 -0
  29. package/lib/utils/common.d.ts +7 -0
  30. package/lib/utils/common.js +58 -0
  31. package/lib/utils/concurrency.d.ts +8 -0
  32. package/lib/utils/concurrency.js +29 -0
  33. package/lib/utils/field-mapping.d.ts +2 -0
  34. package/lib/utils/field-mapping.js +29 -0
  35. package/lib/utils/format.d.ts +4 -0
  36. package/lib/utils/format.js +84 -0
  37. package/lib/utils/gif.d.ts +10 -0
  38. package/lib/utils/gif.js +104 -0
  39. package/lib/utils/image-size.d.ts +10 -0
  40. package/lib/utils/image-size.js +69 -0
  41. package/lib/utils/merge.d.ts +64 -0
  42. package/lib/utils/merge.js +402 -0
  43. package/lib/utils/tls-client.d.ts +30 -0
  44. package/lib/utils/tls-client.js +120 -0
  45. package/lib/utils/url.d.ts +10 -0
  46. package/lib/utils/url.js +89 -0
  47. package/package.json +16 -4
@@ -0,0 +1,791 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIMELINE_FEATURES = exports.DEFAULT_QUERY_IDS = void 0;
4
+ exports.twitterGraphql = twitterGraphql;
5
+ exports.categorizeTweetResult = categorizeTweetResult;
6
+ exports.walkTimelineResults = walkTimelineResults;
7
+ exports.fetchGrokTranslation = fetchGrokTranslation;
8
+ exports.extractTweetId = extractTweetId;
9
+ exports.mapGraphql = mapGraphql;
10
+ exports.unwrapTweetResult = unwrapTweetResult;
11
+ exports.fetchGraphqlRaw = fetchGraphqlRaw;
12
+ exports.parseTwitter = parseTwitter;
13
+ exports.guestQuoteId = guestQuoteId;
14
+ exports.guestReplyToId = guestReplyToId;
15
+ exports.assembleReplies = assembleReplies;
16
+ exports.fetchTweetTree = fetchTweetTree;
17
+ const tls_client_1 = require("../utils/tls-client");
18
+ /**
19
+ * X / Twitter 原生解析器。
20
+ *
21
+ * - 公开推文:走 syndication API(cdn.syndication.twimg.com),无需登录,Node 友好。
22
+ * - 需登录推文(syndication 返回 tombstone):若提供 {authToken, ct0},回退到
23
+ * GraphQL TweetResultByRestId(仅用 auth_token + ct0 两个 cookie,最小化)。
24
+ *
25
+ * ⚠️ 重要限制:X 的 GraphQL 端点受 Cloudflare TLS 指纹校验保护。纯 Node/axios 的
26
+ * TLS 握手(JA3/JA4)与 Chrome 不同,会被 CF 直接 403(cookie 到不了应用层)。
27
+ * 因此登录态回退由 tlsget-rs(wreq/BoringSSL,Chrome 指纹)完成;未安装该
28
+ * 可选依赖时会返回明确错误。
29
+ */
30
+ const SYNDICATION_URL = 'https://cdn.syndication.twimg.com/tweet-result';
31
+ // X 网页端公开 bearer(抓包固定值,非密钥)
32
+ const WEB_BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
33
+ // TweetResultByRestId 的 queryId(随 web 版本变动,可被覆盖)
34
+ const TWEET_RESULT_QUERY_ID = 'GZsN2Pc4knAoit6pXa4HSA';
35
+ const GRAPHQL_FEATURES = {
36
+ creator_subscriptions_tweet_preview_api_enabled: true,
37
+ communities_web_enable_tweet_community_results_fetch: true,
38
+ view_counts_everywhere_api_enabled: true,
39
+ longform_notetweets_consumption_enabled: true,
40
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
41
+ responsive_web_edit_tweet_api_enabled: true,
42
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
43
+ standardized_nudges_misinfo: true,
44
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
45
+ longform_notetweets_rich_text_read_enabled: true,
46
+ responsive_web_graphql_timeline_navigation_enabled: true,
47
+ };
48
+ /** 默认 queryId 清单(随 x.com web 版本漂移;调用方可整体覆盖) */
49
+ exports.DEFAULT_QUERY_IDS = {
50
+ UserByScreenName: 'sLVLhk0bGj3MVFEKTdax1w',
51
+ UserTweets: 'HuTx74BxAnezK1gWvYY7zg',
52
+ UserTweetsAndReplies: 'RIWc55YCNyUJ-U3HHGYkdg',
53
+ Likes: 'nXEl0lfN_XSznVMlprThgQ',
54
+ Followers: 'pd8Tt1qUz1YWrICegqZ8cw',
55
+ Following: 'wjvx62Hye2dGVvnvVco0xA',
56
+ TweetDetail: 'zXaXQgfyR4GxE21uwYQSyA',
57
+ };
58
+ /** 时间线类查询的特性集(UserTweets/Likes/Followers/TweetDetail 共用) */
59
+ exports.TIMELINE_FEATURES = {
60
+ blue_business_profile_image_shape_enabled: true,
61
+ creator_subscriptions_tweet_preview_api_enabled: true,
62
+ freedom_of_speech_not_reach_fetch_enabled: true,
63
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
64
+ graphql_timeline_v2_bookmark_timeline: true,
65
+ hidden_profile_likes_enabled: true,
66
+ highlights_tweets_tab_ui_enabled: true,
67
+ interactive_text_enabled: true,
68
+ longform_notetweets_consumption_enabled: true,
69
+ longform_notetweets_inline_media_enabled: true,
70
+ longform_notetweets_rich_text_read_enabled: true,
71
+ longform_notetweets_richtext_consumption_enabled: true,
72
+ profile_foundations_tweet_stats_enabled: true,
73
+ profile_foundations_tweet_stats_tweet_frequency: true,
74
+ responsive_web_birdwatch_note_limit_enabled: true,
75
+ responsive_web_edit_tweet_api_enabled: true,
76
+ responsive_web_enhance_cards_enabled: false,
77
+ responsive_web_graphql_exclude_directive_enabled: true,
78
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
79
+ responsive_web_graphql_timeline_navigation_enabled: true,
80
+ responsive_web_media_download_video_enabled: false,
81
+ responsive_web_text_conversations_enabled: false,
82
+ responsive_web_twitter_article_data_v2_enabled: true,
83
+ responsive_web_twitter_article_tweet_consumption_enabled: false,
84
+ responsive_web_twitter_blue_verified_badge_is_enabled: true,
85
+ rweb_lists_timeline_redesign_enabled: true,
86
+ spaces_2022_h2_clipping: true,
87
+ spaces_2022_h2_spaces_communities: true,
88
+ standardized_nudges_misinfo: true,
89
+ subscriptions_verification_info_verified_since_enabled: true,
90
+ tweet_awards_web_tipping_enabled: false,
91
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
92
+ tweetypie_unmention_optimization_enabled: true,
93
+ verified_phone_label_enabled: false,
94
+ vibe_api_enabled: true,
95
+ view_counts_everywhere_api_enabled: true,
96
+ };
97
+ /** 鉴权 GraphQL 通用请求(headers/cookie 与 TweetResultByRestId 同源) */
98
+ async function twitterGraphql(opName, queryId, variables, creds, get, features = exports.TIMELINE_FEATURES) {
99
+ const url = `https://x.com/i/api/graphql/${queryId}/${opName}` +
100
+ `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`;
101
+ let res;
102
+ try {
103
+ res = await get(url, {
104
+ headers: {
105
+ authorization: `Bearer ${WEB_BEARER}`,
106
+ 'x-csrf-token': creds.ct0,
107
+ 'x-twitter-auth-type': 'OAuth2Session',
108
+ 'x-twitter-active-user': 'yes',
109
+ 'x-twitter-client-language': 'en',
110
+ },
111
+ cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
112
+ timeout: 30000,
113
+ });
114
+ }
115
+ catch (e) {
116
+ throw new Error(`X GraphQL 请求失败:${e?.message || e}`);
117
+ }
118
+ if (res.status === 403 || res.status === 429) {
119
+ throw new Error(`X GraphQL 被 Cloudflare 拦截 (HTTP ${res.status}):TLS 指纹校验未通过。` +
120
+ `请确认已安装可选依赖 @char46/tlsget-rs(npm i @char46/tlsget-rs),它提供浏览器级 TLS 指纹。`);
121
+ }
122
+ if (res.status !== 200) {
123
+ throw new Error(`X GraphQL ${opName} 返回 HTTP ${res.status}:${typeof res.data === 'string' ? res.data.slice(0, 120) : JSON.stringify(res.data || {}).slice(0, 120)}`);
124
+ }
125
+ return res.data;
126
+ }
127
+ /** 纯函数:把单条 tweet result 归类(含转推解包) */
128
+ function categorizeTweetResult(result) {
129
+ const raw = unwrapTweetResult(result);
130
+ const rtInner = raw?.legacy?.retweeted_status_result?.result;
131
+ const inner = rtInner ? unwrapTweetResult(rtInner) : raw;
132
+ const legacy = inner?.legacy || {};
133
+ const media = Array.isArray(legacy.extended_entities?.media) ? legacy.extended_entities.media : [];
134
+ const hasImage = media.some((m) => m?.type === 'photo');
135
+ const hasVideo = media.some((m) => m?.type === 'video' || m?.type === 'animated_gif');
136
+ const isRetweet = !!rtInner;
137
+ const replyToId = typeof legacy.in_reply_to_status_id_str === 'string' ? legacy.in_reply_to_status_id_str : undefined;
138
+ const isReply = !!replyToId;
139
+ const isText = !hasImage && !hasVideo && !isRetweet;
140
+ return {
141
+ inner,
142
+ isRetweet,
143
+ isReply,
144
+ hasVideo,
145
+ hasImage,
146
+ isText,
147
+ id: String(inner?.rest_id ?? legacy.id_str ?? ''),
148
+ replyToId,
149
+ };
150
+ }
151
+ /** 纯函数:从 timeline instructions 抽取推文结果与底部游标(Item/Module 均收) */
152
+ function walkTimelineResults(instructions) {
153
+ const results = [];
154
+ let bottomCursor;
155
+ for (const ins of instructions || []) {
156
+ const entries = ins?.entries || ins?.entry ? [].concat(ins.entry, ins.entries).filter(Boolean) : [];
157
+ for (const entry of entries) {
158
+ const content = entry?.content;
159
+ if (!content)
160
+ continue;
161
+ if (content.entryType === 'TimelineTimelineCursor' && content.contentType === 'Bottom') {
162
+ bottomCursor = String(content.value || bottomCursor || '');
163
+ continue;
164
+ }
165
+ if (content.entryType === 'TimelineTimelineItem' && content.itemContent?.tweet_results?.result) {
166
+ results.push(content.itemContent.tweet_results.result);
167
+ continue;
168
+ }
169
+ if (content.entryType === 'TimelineTimelineModule') {
170
+ for (const item of content.items || []) {
171
+ const r = item?.itemContent?.tweet_results?.result;
172
+ if (r)
173
+ results.push(r);
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return { results, bottomCursor };
179
+ }
180
+ /** Grok 翻译特性集(与网页端抓包一致;关键是 grok_show_grok_translated_post 开关) */
181
+ const GROK_FEATURES = {
182
+ creator_subscriptions_tweet_preview_api_enabled: true,
183
+ premium_content_api_read_enabled: false,
184
+ communities_web_enable_tweet_community_results_fetch: true,
185
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
186
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
187
+ responsive_web_grok_analyze_post_followups_enabled: true,
188
+ rweb_cashtags_composer_attachment_enabled: true,
189
+ responsive_web_jetfuel_frame: true,
190
+ responsive_web_grok_share_attachment_enabled: true,
191
+ responsive_web_grok_annotations_enabled: true,
192
+ articles_preview_enabled: true,
193
+ responsive_web_edit_tweet_api_enabled: true,
194
+ rweb_conversational_replies_downvote_enabled: false,
195
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
196
+ view_counts_everywhere_api_enabled: true,
197
+ longform_notetweets_consumption_enabled: true,
198
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
199
+ content_disclosure_indicator_enabled: true,
200
+ content_disclosure_ai_generated_indicator_enabled: true,
201
+ responsive_web_grok_show_grok_translated_post: true,
202
+ responsive_web_grok_analysis_button_from_backend: true,
203
+ post_ctas_fetch_enabled: false,
204
+ rweb_cashtags_enabled: true,
205
+ freedom_of_speech_not_reach_fetch_enabled: true,
206
+ standardized_nudges_misinfo: true,
207
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
208
+ longform_notetweets_rich_text_read_enabled: true,
209
+ longform_notetweets_inline_media_enabled: false,
210
+ profile_label_improvements_pcf_label_in_post_enabled: true,
211
+ responsive_web_profile_redirect_enabled: true,
212
+ rweb_tipjar_consumption_enabled: false,
213
+ verified_phone_label_enabled: false,
214
+ responsive_web_grok_image_annotation_enabled: true,
215
+ responsive_web_grok_imagine_annotation_enabled: true,
216
+ responsive_web_grok_community_note_auto_translation_is_enabled: true,
217
+ responsive_web_graphql_timeline_navigation_enabled: true,
218
+ };
219
+ const GROK_FIELD_TOGGLES = {
220
+ withArticleRichContentState: true,
221
+ withArticlePlainText: false,
222
+ withArticleSummaryText: true,
223
+ withArticleVoiceOver: true,
224
+ };
225
+ /** 目标语言 → x-twitter-client-language(译文语言由该头驱动) */
226
+ function clientLanguage(target) {
227
+ const t = target.toLowerCase();
228
+ if (t === 'zh')
229
+ return 'zh-cn';
230
+ if (t === 'zh-tw')
231
+ return 'zh-tw';
232
+ return t;
233
+ }
234
+ /**
235
+ * X 原生 Grok 翻译(与网页"翻译推文"同源)。
236
+ * 需登录态;网页抓包实证:grok 特性开关 + client-language 头即可,无需
237
+ * x-client-transaction-id / cf_clearance。任何失败返回 null 由调用方回落通用翻译。
238
+ */
239
+ async function fetchGrokTranslation(url, targetLang, creds, get = tls_client_1.tlsGet) {
240
+ const id = extractTweetId(url);
241
+ if (!id)
242
+ return null;
243
+ const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
244
+ const gqlUrl = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
245
+ `?variables=${encodeURIComponent(JSON.stringify(variables))}` +
246
+ `&features=${encodeURIComponent(JSON.stringify(GROK_FEATURES))}` +
247
+ `&fieldToggles=${encodeURIComponent(JSON.stringify(GROK_FIELD_TOGGLES))}`;
248
+ let res;
249
+ try {
250
+ res = await get(gqlUrl, {
251
+ headers: {
252
+ authorization: `Bearer ${WEB_BEARER}`,
253
+ 'x-csrf-token': creds.ct0,
254
+ 'x-twitter-auth-type': 'OAuth2Session',
255
+ 'x-twitter-active-user': 'yes',
256
+ 'x-twitter-client-language': clientLanguage(targetLang),
257
+ },
258
+ cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
259
+ timeout: 30000,
260
+ });
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ if (res.status !== 200)
266
+ return null;
267
+ const node = res.data?.data?.tweetResult?.result?.grok_translated_post_with_availability;
268
+ if (!node || node.is_available !== true)
269
+ return null;
270
+ const text = node.data?.translation;
271
+ if (typeof text !== 'string' || !text.trim())
272
+ return null;
273
+ return { text: text.trim(), sourceLang: node.data?.source_language ? String(node.data.source_language) : undefined };
274
+ }
275
+ function extractTweetId(url) {
276
+ const m = /\/status(?:es)?\/(\d+)/.exec(url);
277
+ return m ? m[1] : null;
278
+ }
279
+ function pick(...vals) {
280
+ for (const v of vals)
281
+ if (v !== undefined && v !== null && v !== '')
282
+ return v;
283
+ return '';
284
+ }
285
+ /** BFS 深取子树中第一个非空的指定键值(X GraphQL 用户结构随版本漂移:legacy ↔ core/avatar) */
286
+ function deepPick(node, key) {
287
+ const queue = [node];
288
+ while (queue.length) {
289
+ const cur = queue.shift();
290
+ if (!cur || typeof cur !== 'object')
291
+ continue;
292
+ const v = cur[key];
293
+ if (v !== undefined && v !== null && v !== '')
294
+ return v;
295
+ for (const k of Object.keys(cur))
296
+ queue.push(cur[k]);
297
+ }
298
+ return undefined;
299
+ }
300
+ /** BFS 找子树中最长的 text 字符串(笔记正文位置随版本漂移,取最长以避开截断副本) */
301
+ function deepFindLongestText(node) {
302
+ let best;
303
+ const queue = [node];
304
+ while (queue.length) {
305
+ const cur = queue.shift();
306
+ if (!cur || typeof cur !== 'object')
307
+ continue;
308
+ if (typeof cur.text === 'string' && cur.text && (!best || cur.text.length > best.length))
309
+ best = cur.text;
310
+ for (const k of Object.keys(cur))
311
+ queue.push(cur[k]);
312
+ }
313
+ return best;
314
+ }
315
+ /** 清理描述中的 t.co 短链(Twitter 自动附加的截断 URL,无实际内容价值)。
316
+ * 注意保留段落换行:仅折叠水平空白,≥3 连续换行压成空行一档。 */
317
+ function cleanDesc(text) {
318
+ if (!text)
319
+ return text;
320
+ // 去掉全部 t.co URL(文末自动附加 + 文中内嵌)
321
+ let cleaned = text.replace(/https?:\/\/t\.co\/[A-Za-z0-9]+(?:\?[^\s]*)?/g, '');
322
+ // 仅折叠空格/制表(不动换行);去掉行首尾空格;连续空行压成一个空行
323
+ cleaned = cleaned.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
324
+ return cleaned || text;
325
+ }
326
+ function baseParsed() {
327
+ return {
328
+ type: 'video', title: '', desc: '', author: '', uid: '', avatar: '', cover: '',
329
+ video: '', videos: [], images: [], live_photo: [], music: {},
330
+ like: 0, comment: 0, collect: 0, share: 0, play: 0, duration: 0, publishTime: 0,
331
+ author_followers: 0, author_signature: '', admire: 0,
332
+ };
333
+ }
334
+ /** 归一化变体列表(三种来源的字段名不同:url/src、content_type/type) */
335
+ function normalizeVariants(variants) {
336
+ return variants
337
+ .filter((v) => v && (v.url || v.src))
338
+ .map((v) => ({
339
+ quality: v.bitrate ? `${v.bitrate}bps` : (v.content_type || v.type || 'unknown'),
340
+ url: v.url || v.src,
341
+ bit_rate: Number(v.bitrate || 0),
342
+ }))
343
+ .sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
344
+ }
345
+ /**
346
+ * 统一媒体提取(新结构优先,旧结构兜底):
347
+ * - 新:mediaDetails[](photo/video_info.variants)+ 顶层 video(poster/src variants)
348
+ * - 旧:videoDetails(posterUrl/url variants)+ photos[]
349
+ */
350
+ function extractSyndicationMedia(tw, p) {
351
+ // 1) mediaDetails[]:新版标准位置
352
+ if (Array.isArray(tw.mediaDetails)) {
353
+ for (const m of tw.mediaDetails) {
354
+ if (!m)
355
+ continue;
356
+ if (m.type === 'photo') {
357
+ if (m.media_url_https && !p.images.includes(m.media_url_https))
358
+ p.images.push(m.media_url_https);
359
+ }
360
+ else if ((m.type === 'video' || m.type === 'animated_gif') && Array.isArray(m.video_info?.variants)) {
361
+ const vs = normalizeVariants(m.video_info.variants);
362
+ if (vs.length) {
363
+ p.videos.push(...vs);
364
+ const dur = m.video_info.duration_millis ? Math.floor(Number(m.video_info.duration_millis) / 1000) : 0;
365
+ if (!p.video) {
366
+ p.video = vs[0].url;
367
+ p.cover = String(pick(m.media_url_https, p.cover));
368
+ if (dur)
369
+ p.duration = dur;
370
+ if (m.type === 'animated_gif')
371
+ p.isGif = true;
372
+ }
373
+ else {
374
+ // 多视频推文:其余视频逐条携带(发送层每条独立发送,封面随行用于展示与单独审核)
375
+ ;
376
+ (p.extraVideos || (p.extraVideos = [])).push({ url: vs[0].url, isGif: m.type === 'animated_gif', duration: dur, cover: String(pick(m.media_url_https, '')) });
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+ // 2) 顶层 video(amplify 等):poster + variants
383
+ if (!p.video && Array.isArray(tw.video?.variants)) {
384
+ const vs = normalizeVariants(tw.video.variants);
385
+ if (vs.length) {
386
+ p.videos.push(...vs);
387
+ p.video = vs[0].url;
388
+ p.cover = String(pick(tw.video.poster, p.cover));
389
+ if (tw.video.durationMs)
390
+ p.duration = Math.floor(Number(tw.video.durationMs) / 1000);
391
+ }
392
+ }
393
+ // 3) 旧结构兜底:videoDetails + photos
394
+ if (!p.video && Array.isArray(tw.videoDetails?.variants)) {
395
+ const vs = normalizeVariants(tw.videoDetails.variants);
396
+ if (vs.length) {
397
+ p.videos.push(...vs);
398
+ p.video = vs[0].url;
399
+ p.cover = String(pick(tw.videoDetails.posterUrl, p.cover));
400
+ if (tw.videoDetails.durationMs)
401
+ p.duration = Math.floor(Number(tw.videoDetails.durationMs) / 1000);
402
+ }
403
+ }
404
+ if (!p.images.length && Array.isArray(tw.photos)) {
405
+ p.images = tw.photos.map((x) => (typeof x === 'string' ? x : x?.url)).filter((u) => !!u);
406
+ }
407
+ }
408
+ /** 把 syndication 响应映射为 ParsedData */
409
+ function mapSyndication(tw) {
410
+ const user = tw.user || {};
411
+ const text = String(pick(tw.note_tweet?.text, tw.text, ''));
412
+ const p = baseParsed();
413
+ extractSyndicationMedia(tw, p);
414
+ if (!p.cover)
415
+ p.cover = String(pick(p.images[0], ''));
416
+ // 纯文字推文:type=text(无任何媒体但有正文,属合法内容)
417
+ p.type = p.video ? 'video' : (p.images.length ? 'image' : 'text');
418
+ // 标题/简介同源;标题也须基于清理后的文本,否则 t.co 短链会让去重判断失效
419
+ const cleaned = cleanDesc(text);
420
+ p.title = cleaned.slice(0, 100);
421
+ p.desc = cleaned;
422
+ p.lang = tw.lang ? String(tw.lang) : undefined;
423
+ p.author = String(pick(user.name, user.screen_name, ''));
424
+ p.uid = String(pick(user.screen_name, user.id_str, ''));
425
+ p.avatar = String(pick(user.profile_image_url_https, user.profile_image_url, ''));
426
+ p.like = Number(pick(tw.favorite_count, 0)) || 0;
427
+ p.comment = Number(pick(tw.conversation_count, tw.reply_count, 0)) || 0;
428
+ p.share = Number(pick(tw.retweet_count, 0)) || 0;
429
+ p.play = Number(pick(tw.video?.viewCount, tw.videoDetails?.viewCount, tw.views, 0)) || 0;
430
+ p.collect = Number(pick(tw.bookmark_count, 0)) || 0;
431
+ if (tw.created_at) {
432
+ const t = Date.parse(tw.created_at);
433
+ if (!isNaN(t))
434
+ p.publishTime = t;
435
+ }
436
+ p.author_followers = Number(pick(user.followers_count, 0)) || 0;
437
+ p.author_signature = String(pick(user.description, ''));
438
+ if (p.title && p.desc && p.desc.startsWith(p.title))
439
+ p.title = '';
440
+ return p;
441
+ }
442
+ /** 把 GraphQL TweetResultByRestId 响应映射为 ParsedData */
443
+ function mapGraphql(rawResult) {
444
+ // NSFW/受限推文会被 TweetWithVisibilityResults 包裹,真实推文在其 .tweet 下
445
+ let result = rawResult;
446
+ if (result && result.__typename === 'TweetWithVisibilityResults' && result.tweet) {
447
+ result = result.tweet;
448
+ }
449
+ const legacy = result.legacy || {};
450
+ const user = result.core?.user_results?.result;
451
+ const ures = user || {};
452
+ // 长推(note tweet):legacy.full_text 是截断版,全文在 note_tweet 子树(位置随版本漂移,深取最长 text)
453
+ // 用户字段同理:legacy ↔ core/avatar/profile_bio 漂移,深取键值
454
+ const text = String(pick(deepFindLongestText(result.note_tweet), legacy.full_text, ''));
455
+ const p = baseParsed();
456
+ const media = Array.isArray(legacy.entities?.media) ? legacy.entities.media : [];
457
+ const photos = [];
458
+ for (const m of media) {
459
+ if (!m)
460
+ continue;
461
+ if (m.type === 'photo') {
462
+ if (m.media_url_https)
463
+ photos.push(m.media_url_https);
464
+ }
465
+ else if ((m.type === 'video' || m.type === 'animated_gif') && m.video_info?.variants?.length) {
466
+ const vs = m.video_info.variants
467
+ .filter((v) => v && v.url && (v.content_type || '').includes('mp4'))
468
+ .map((v) => ({ quality: v.bitrate ? `${v.bitrate}bps` : 'unknown', url: v.url, bit_rate: Number(v.bitrate || 0) }))
469
+ .sort((a, b) => (b.bit_rate || 0) - (a.bit_rate || 0));
470
+ if (vs.length) {
471
+ p.videos.push(...vs);
472
+ const dur = m.video_info.duration_millis ? Math.floor(Number(m.video_info.duration_millis) / 1000) : 0;
473
+ if (!p.video) {
474
+ p.video = vs[0].url;
475
+ p.cover = String(pick(m.media_url_https, p.cover));
476
+ if (dur)
477
+ p.duration = dur;
478
+ if (m.type === 'animated_gif')
479
+ p.isGif = true;
480
+ }
481
+ else {
482
+ ;
483
+ (p.extraVideos || (p.extraVideos = [])).push({ url: vs[0].url, isGif: m.type === 'animated_gif', duration: dur, cover: String(pick(m.media_url_https, '')) });
484
+ }
485
+ }
486
+ }
487
+ }
488
+ p.images = photos;
489
+ if (!p.cover)
490
+ p.cover = String(pick(photos[0], ''));
491
+ // 纯文字推文:type=text
492
+ p.type = p.video ? 'video' : (p.images.length ? 'image' : 'text');
493
+ // 标题/简介同源;标题也须基于清理后的文本,否则 t.co 短链会让去重判断失效
494
+ const cleaned = cleanDesc(text);
495
+ p.title = cleaned.slice(0, 100);
496
+ p.desc = cleaned;
497
+ p.lang = legacy.lang ? String(legacy.lang) : undefined;
498
+ p.author = String(pick(deepPick(ures, 'name'), ''));
499
+ p.uid = String(pick(deepPick(ures, 'screen_name'), user?.rest_id, ''));
500
+ p.avatar = String(pick(deepPick(ures, 'profile_image_url_https'), deepPick(ures, 'image_url'), ''));
501
+ p.like = Number(pick(legacy.favorite_count, 0)) || 0;
502
+ p.comment = Number(pick(legacy.reply_count, 0)) || 0;
503
+ p.share = Number(pick(legacy.retweet_count, 0)) || 0;
504
+ p.play = Number(pick(result.views?.count, 0)) || 0;
505
+ p.collect = Number(pick(legacy.bookmark_count, 0)) || 0;
506
+ if (legacy.created_at) {
507
+ const t = Date.parse(legacy.created_at);
508
+ if (!isNaN(t))
509
+ p.publishTime = t;
510
+ }
511
+ p.author_followers = Number(pick(deepPick(ures, 'followers_count'), 0)) || 0;
512
+ p.author_signature = String(pick(deepPick(ures, 'description'), ''));
513
+ if (p.title && p.desc && p.desc.startsWith(p.title))
514
+ p.title = '';
515
+ return p;
516
+ }
517
+ /** 解除 TweetWithVisibilityResults 包裹(NSFW/受限推文) */
518
+ function unwrapTweetResult(result) {
519
+ return result && result.__typename === 'TweetWithVisibilityResults' && result.tweet ? result.tweet : result;
520
+ }
521
+ /** 鉴权 GraphQL 原始获取:返回未映射的 tweet result 节点(树构建复用) */
522
+ async function fetchGraphqlRaw(id, creds, get) {
523
+ const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
524
+ const url = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
525
+ `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(GRAPHQL_FEATURES))}`;
526
+ let res;
527
+ try {
528
+ res = await get(url, {
529
+ headers: {
530
+ authorization: 'Bearer ' + WEB_BEARER,
531
+ 'x-csrf-token': creds.ct0,
532
+ 'x-twitter-auth-type': 'OAuth2Session',
533
+ 'x-twitter-active-user': 'yes',
534
+ 'x-twitter-client-language': 'en',
535
+ },
536
+ cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
537
+ timeout: 30000,
538
+ });
539
+ }
540
+ catch (e) {
541
+ throw new Error(`X GraphQL 请求失败:${e?.message || e}(需登录的推文)`);
542
+ }
543
+ if (res.status === 403 || res.status === 429) {
544
+ throw new Error(`X GraphQL 被 Cloudflare 拦截 (HTTP ${res.status}):TLS 指纹校验未通过。` +
545
+ `请确认已安装可选依赖 @char46/tlsget-rs(npm i @char46/tlsget-rs),它提供浏览器级 TLS 指纹。`);
546
+ }
547
+ if (res.status !== 200) {
548
+ throw new Error(`X GraphQL 返回 HTTP ${res.status}:${typeof res.data === 'string' ? res.data.slice(0, 120) : JSON.stringify(res.data || {}).slice(0, 120)}`);
549
+ }
550
+ const result = res.data?.data?.tweetResult?.result;
551
+ if (!result)
552
+ throw new Error('X GraphQL 返回无数据');
553
+ if (result.__typename === 'TweetTombstone' || result.__typename === 'TweetUnavailable') {
554
+ const tb = result?.tombstone?.text?.text || result?.tombstone?.text;
555
+ throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${tb ? ':' + tb : ''}`);
556
+ }
557
+ return unwrapTweetResult(result);
558
+ }
559
+ /** 鉴权 GraphQL:仅用 auth_token + ct0,回退取登录受限推文 */
560
+ async function fetchGraphqlTweet(id, creds, get) {
561
+ return mapGraphql(await fetchGraphqlRaw(id, creds, get));
562
+ }
563
+ async function parseTwitter(url, http, creds, getGraphql) {
564
+ const id = extractTweetId(url);
565
+ if (!id)
566
+ throw new Error('无法从 X 链接提取推文 ID');
567
+ // 1) 公开 syndication 路径
568
+ const res = await http.get(SYNDICATION_URL, {
569
+ params: { id, token: 'a' },
570
+ timeout: 30000,
571
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
572
+ });
573
+ const tw = res.data;
574
+ if (tw && tw.__typename === 'Tweet' && tw.user) {
575
+ const p = mapSyndication(tw);
576
+ // 长推:syndication 现仅返回 note_tweet 的 id 引用(无 text),tw.text 被
577
+ // display_text_range 截断。有登录态时改走 GraphQL 取全文;失败回退截断结果。
578
+ const noteTruncated = tw.note_tweet && !tw.note_tweet.text;
579
+ if (noteTruncated && creds && creds.authToken && creds.ct0) {
580
+ try {
581
+ return await fetchGraphqlTweet(id, creds, getGraphql || tls_client_1.tlsGet);
582
+ }
583
+ catch {
584
+ return p;
585
+ }
586
+ }
587
+ return p;
588
+ }
589
+ // 2) tombstone(需登录):回退到鉴权 GraphQL(TLS 指纹模拟)
590
+ if (creds && creds.authToken && creds.ct0) {
591
+ return fetchGraphqlTweet(id, creds, getGraphql || tls_client_1.tlsGet);
592
+ }
593
+ const reasonRaw = pick(tw?.tombstone?.text, tw?.tombstone?.name);
594
+ throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${reasonRaw ? ':' + reasonRaw : ''}`);
595
+ }
596
+ const TREE_QUOTE_DEPTH = 6;
597
+ const TREE_REPLY_DEPTH = 12;
598
+ const TREE_REPLIES_LIMIT = 100;
599
+ /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
600
+ * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
601
+ function guestQuoteId(tw) {
602
+ const q = tw?.quoted_tweet || tw?.quoted_status_result;
603
+ if (q) {
604
+ const id = String(pick(q.id_str, q.rest_id, ''));
605
+ if (/^\d+$/.test(id))
606
+ return id;
607
+ }
608
+ const urls = Array.isArray(tw?.entities?.urls) ? tw.entities.urls : [];
609
+ const rawText = String(tw?.text || '');
610
+ const textEnd = rawText.trimEnd();
611
+ for (const u of urls) {
612
+ if (!u?.url || !textEnd.endsWith(u.url))
613
+ continue;
614
+ const m = /(?:x|twitter)\.com\/[^/]+\/status(?:es)?\/(\d+)/.exec(String(u.expanded_url || ''));
615
+ if (m)
616
+ return m[1];
617
+ }
618
+ return undefined;
619
+ }
620
+ /** 游客态(syndication)取回复目标推文 ID */
621
+ function guestReplyToId(tw) {
622
+ const id = pick(tw?.in_reply_to_status_id_str, tw?.in_reply_to_status_id);
623
+ if (typeof id === 'string' && /^\d+$/.test(id))
624
+ return id;
625
+ if (typeof id === 'number')
626
+ return String(id);
627
+ return undefined;
628
+ }
629
+ async function fetchSyndicationRaw(tweetId, http) {
630
+ const res = await http.get(SYNDICATION_URL, {
631
+ params: { id: tweetId, token: 'a' },
632
+ timeout: 30000,
633
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
634
+ });
635
+ const tw = res.data;
636
+ return tw && tw.__typename === 'Tweet' && tw.user ? tw : null;
637
+ }
638
+ /**
639
+ * 纯函数:由会话内的全部 tweet result 装配回复树(挂在 focal 节点下)。
640
+ * 依据 in_reply_to_status_id_str 建父子边;父不在集合内的孤儿回复跳过。
641
+ */
642
+ function assembleReplies(focalId, results, limit, makeNode) {
643
+ const byId = new Map();
644
+ for (const r of results) {
645
+ const cat = categorizeTweetResult(r);
646
+ if (!cat.id || byId.has(cat.id) || cat.id === focalId)
647
+ continue;
648
+ byId.set(cat.id, { node: makeNode(cat.inner), replyToId: cat.replyToId });
649
+ }
650
+ const childrenOf = new Map();
651
+ const roots = [];
652
+ for (const { node, replyToId } of byId.values()) {
653
+ if (replyToId && byId.has(replyToId)) {
654
+ ;
655
+ (childrenOf.get(replyToId) || childrenOf.set(replyToId, []).get(replyToId)).push(node);
656
+ }
657
+ else if (replyToId === focalId) {
658
+ roots.push(node);
659
+ }
660
+ /* 父不在集合内(祖先线程外)→ 跳过 */
661
+ }
662
+ let budget = limit;
663
+ const attach = (nodes) => {
664
+ const out = [];
665
+ for (const n of nodes) {
666
+ if (budget <= 0)
667
+ break;
668
+ budget--;
669
+ n.replies = attach(childrenOf.get(n.id) || []);
670
+ out.push(n);
671
+ }
672
+ return out;
673
+ };
674
+ return attach(roots);
675
+ }
676
+ /**
677
+ * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)
678
+ * + 可选会话回复树(replies,向下递归,TweetDetail 装配)。
679
+ * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
680
+ * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
681
+ */
682
+ async function fetchTweetTree(url, http, creds, getGraphql, opts = {}) {
683
+ const id = extractTweetId(url);
684
+ if (!id)
685
+ throw new Error('无法从 X 链接提取推文 ID');
686
+ const visited = new Set();
687
+ async function buildGuest(tweetId, depth) {
688
+ visited.add(tweetId);
689
+ const tw = await fetchSyndicationRaw(tweetId, http);
690
+ if (!tw)
691
+ throw new Error(`推文 ${tweetId} 不可访问(可能需要登录或已删除)`);
692
+ const node = { id: tweetId, tweet: mapSyndication(tw) };
693
+ const qid = guestQuoteId(tw);
694
+ if (qid && !visited.has(qid) && depth < TREE_QUOTE_DEPTH) {
695
+ try {
696
+ node.quoted = await buildGuest(qid, depth + 1);
697
+ }
698
+ catch { /* 引用不可达则截断 */ }
699
+ }
700
+ const rid = guestReplyToId(tw);
701
+ if (rid && !visited.has(rid) && depth < TREE_REPLY_DEPTH) {
702
+ try {
703
+ node.replyTo = await buildGuest(rid, depth + 1);
704
+ }
705
+ catch { /* 父推不可达则截断 */ }
706
+ }
707
+ return node;
708
+ }
709
+ async function buildFromGraphqlResult(raw, depth) {
710
+ raw = unwrapTweetResult(raw);
711
+ const rid0 = String(pick(raw?.rest_id, raw?.legacy?.id_str, ''));
712
+ if (rid0)
713
+ visited.add(rid0);
714
+ const node = { id: rid0, tweet: mapGraphql(raw) };
715
+ const q = unwrapTweetResult(raw?.quoted_status_result?.result);
716
+ if (q && q.legacy && q.__typename !== 'TweetTombstone' && depth < TREE_QUOTE_DEPTH) {
717
+ const qid = String(pick(q.rest_id, q.legacy?.id_str, ''));
718
+ if (!qid || !visited.has(qid))
719
+ node.quoted = await buildFromGraphqlResult(q, depth + 1);
720
+ }
721
+ const rid = raw?.legacy?.in_reply_to_status_id_str;
722
+ if (typeof rid === 'string' && /^\d+$/.test(rid) && !visited.has(rid) && depth < TREE_REPLY_DEPTH) {
723
+ try {
724
+ node.replyTo = await buildGraphqlById(rid, depth + 1);
725
+ }
726
+ catch { /* 父推不可达则截断 */ }
727
+ }
728
+ return node;
729
+ }
730
+ async function buildGraphqlById(tweetId, depth) {
731
+ if (visited.has(tweetId))
732
+ throw new Error('引用环');
733
+ const raw = await fetchGraphqlRaw(tweetId, creds, getGraphql || tls_client_1.tlsGet);
734
+ return buildFromGraphqlResult(raw, depth);
735
+ }
736
+ /** 同步构建会话回复节点:嵌套引用链照常递归(无需网络),父链不拉(由会话集合装配) */
737
+ function nodeFromResultSync(raw) {
738
+ raw = unwrapTweetResult(raw);
739
+ const rid0 = String(pick(raw?.rest_id, raw?.legacy?.id_str, ''));
740
+ if (rid0)
741
+ visited.add(rid0);
742
+ const node = { id: rid0, tweet: mapGraphql(raw) };
743
+ const q = unwrapTweetResult(raw?.quoted_status_result?.result);
744
+ if (q && q.legacy && q.__typename !== 'TweetTombstone') {
745
+ const qid = String(pick(q.rest_id, q.legacy?.id_str, ''));
746
+ if (!qid || !visited.has(qid))
747
+ node.quoted = nodeFromResultSync(q);
748
+ }
749
+ return node;
750
+ }
751
+ async function attachConversation(node) {
752
+ if (!node)
753
+ return;
754
+ const qids = { ...exports.DEFAULT_QUERY_IDS, ...opts.queryIds };
755
+ const variables = {
756
+ focalTweetId: node.id,
757
+ with_rux_injections: false,
758
+ rankingMode: 'Relevance',
759
+ includePromotedContent: true,
760
+ withCommunity: true,
761
+ withQuickPromoteEligibilityTweetQuery: false,
762
+ withBirdwatchNotes: true,
763
+ withVoice: true,
764
+ };
765
+ const data = await twitterGraphql('TweetDetail', qids.TweetDetail, variables, creds, getGraphql || tls_client_1.tlsGet);
766
+ const instructions = data?.data?.threaded_conversation_with_injections_v2?.timeline?.instructions;
767
+ const { results } = walkTimelineResults(instructions || []);
768
+ node.replies = assembleReplies(node.id, results, opts.repliesLimit ?? TREE_REPLIES_LIMIT, nodeFromResultSync);
769
+ }
770
+ const usedGraphql = !!(creds && creds.authToken && creds.ct0);
771
+ let tree;
772
+ if (usedGraphql) {
773
+ try {
774
+ tree = await buildGraphqlById(id, 0);
775
+ }
776
+ catch {
777
+ /* 登录态失败(如 Cloudflare)回退游客路径 */
778
+ tree = await buildGuest(id, 0);
779
+ }
780
+ }
781
+ else {
782
+ tree = await buildGuest(id, 0);
783
+ }
784
+ if (opts.withReplies && usedGraphql && tree) {
785
+ try {
786
+ await attachConversation(tree);
787
+ }
788
+ catch { /* 会话不可达则保留无回复树 */ }
789
+ }
790
+ return tree;
791
+ }