@sns-parse/core 0.4.0-alpha.6 → 0.4.0-alpha.8

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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * X 用户维度查询:时间线(推文/回复/点赞)与关注/粉丝列表。
3
+ *
4
+ * 全部走鉴权 GraphQL(auth_token + ct0,TLS 指纹由 tlsget-rs 提供)。
5
+ * queryId 与特性集等公共件见 ./twitter(DEFAULT_QUERY_IDS 可被调用方覆盖)。
6
+ */
7
+ import type { ParsedData } from '../types';
8
+ import { categorizeTweetResult, DEFAULT_QUERY_IDS, TIMELINE_FEATURES, type GraphqlGetter, type TwitterCreds, type TwitterQueryIds } from './twitter';
9
+ export { categorizeTweetResult, DEFAULT_QUERY_IDS, TIMELINE_FEATURES };
10
+ export type { TwitterQueryIds };
11
+ export interface TwitterUserInfo {
12
+ userId: string;
13
+ name: string;
14
+ screenName: string;
15
+ description: string;
16
+ followers: number;
17
+ friends: number;
18
+ }
19
+ export declare function resolveTwitterUser(screenName: string, creds: TwitterCreds, get?: GraphqlGetter, queryIds?: TwitterQueryIds): Promise<TwitterUserInfo>;
20
+ export type TimelineTab = 'tweets' | 'replies' | 'likes';
21
+ export interface TimelineEntry {
22
+ id: string;
23
+ tweet: ParsedData;
24
+ /** 文字/视频/图片/转推/回复 中的若干类 */
25
+ kinds: ('text' | 'video' | 'image' | 'retweet' | 'reply')[];
26
+ isRetweet: boolean;
27
+ isReply: boolean;
28
+ replyToId?: string;
29
+ url: string;
30
+ }
31
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
32
+ export declare function parseTimeline(instructions: any[]): {
33
+ results: any[];
34
+ bottomCursor?: string;
35
+ };
36
+ /** 用户时间线(tab:tweets=推文/转推;replies=含回复;likes=当前登录用户的点赞) */
37
+ export declare function fetchUserTimeline(opts: {
38
+ screenName?: string;
39
+ userId?: string;
40
+ tab?: TimelineTab;
41
+ limit?: number;
42
+ creds: TwitterCreds;
43
+ get?: GraphqlGetter;
44
+ queryIds?: TwitterQueryIds;
45
+ }): Promise<TimelineEntry[]>;
46
+ export interface TwitterConnectionUser {
47
+ name: string;
48
+ screenName: string;
49
+ description: string;
50
+ followers: number;
51
+ verified: boolean;
52
+ followedBy: boolean;
53
+ }
54
+ /** 纯函数:从 Followers/Following instructions 抽取用户与底部游标 */
55
+ export declare function parseConnections(instructions: any[]): {
56
+ users: TwitterConnectionUser[];
57
+ bottomCursor?: string;
58
+ };
59
+ /** 关注列表(following)或粉丝列表(followers) */
60
+ export declare function fetchUserConnections(opts: {
61
+ screenName: string;
62
+ type: 'followers' | 'following';
63
+ limit?: number;
64
+ creds: TwitterCreds;
65
+ get?: GraphqlGetter;
66
+ queryIds?: TwitterQueryIds;
67
+ }): Promise<TwitterConnectionUser[]>;
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIMELINE_FEATURES = exports.DEFAULT_QUERY_IDS = exports.categorizeTweetResult = void 0;
4
+ exports.resolveTwitterUser = resolveTwitterUser;
5
+ exports.parseTimeline = parseTimeline;
6
+ exports.fetchUserTimeline = fetchUserTimeline;
7
+ exports.parseConnections = parseConnections;
8
+ exports.fetchUserConnections = fetchUserConnections;
9
+ const tls_client_1 = require("../utils/tls-client");
10
+ const twitter_1 = require("./twitter");
11
+ Object.defineProperty(exports, "categorizeTweetResult", { enumerable: true, get: function () { return twitter_1.categorizeTweetResult; } });
12
+ Object.defineProperty(exports, "DEFAULT_QUERY_IDS", { enumerable: true, get: function () { return twitter_1.DEFAULT_QUERY_IDS; } });
13
+ Object.defineProperty(exports, "TIMELINE_FEATURES", { enumerable: true, get: function () { return twitter_1.TIMELINE_FEATURES; } });
14
+ async function resolveTwitterUser(screenName, creds, get = tls_client_1.tlsGet, queryIds = {}) {
15
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...queryIds };
16
+ const variables = {
17
+ screen_name: screenName.replace(/^@/, ''),
18
+ withSafetyModeUserFields: true,
19
+ withSuperFollowsUserFields: true,
20
+ };
21
+ const data = await (0, twitter_1.twitterGraphql)('UserByScreenName', ids.UserByScreenName, variables, creds, get);
22
+ const user = data?.data?.user?.result;
23
+ if (!user || user.__typename === 'UserUnavailable') {
24
+ throw new Error(`用户 @${screenName} 不可访问(不存在、被封或需登录)`);
25
+ }
26
+ const legacy = user.legacy || user.core || {};
27
+ return {
28
+ userId: String(user.rest_id ?? legacy.id_str ?? ''),
29
+ name: String(legacy.name ?? ''),
30
+ screenName: String(legacy.screen_name ?? screenName.replace(/^@/, '')),
31
+ description: String(user.profile_bio?.description ?? legacy.description ?? ''),
32
+ followers: Number(legacy.followers_count ?? 0) || 0,
33
+ friends: Number(legacy.friends_count ?? 0) || 0,
34
+ };
35
+ }
36
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
37
+ function parseTimeline(instructions) {
38
+ return (0, twitter_1.walkTimelineResults)(instructions);
39
+ }
40
+ /** 用户时间线(tab:tweets=推文/转推;replies=含回复;likes=当前登录用户的点赞) */
41
+ async function fetchUserTimeline(opts) {
42
+ const tab = opts.tab || 'tweets';
43
+ const limit = Math.max(1, Math.min(200, opts.limit ?? 20));
44
+ const get = opts.get || tls_client_1.tlsGet;
45
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
46
+ let userId = opts.userId;
47
+ if (!userId) {
48
+ const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
49
+ userId = u.userId;
50
+ }
51
+ const [opName, queryId] = tab === 'likes'
52
+ ? ['Likes', ids.Likes]
53
+ : tab === 'replies'
54
+ ? ['UserTweetsAndReplies', ids.UserTweetsAndReplies]
55
+ : ['UserTweets', ids.UserTweets];
56
+ const out = [];
57
+ const seen = new Set();
58
+ let cursor;
59
+ let pages = 0;
60
+ while (out.length < limit && pages < 20) {
61
+ pages++;
62
+ const variables = {
63
+ userId,
64
+ count: 20,
65
+ includePromotedContent: false,
66
+ withQuickPromoteEligibilityTweetQuery: false,
67
+ withVoice: true,
68
+ withV2Timeline: true,
69
+ };
70
+ if (cursor)
71
+ variables.cursor = cursor;
72
+ const data = await (0, twitter_1.twitterGraphql)(opName, queryId, variables, opts.creds, get);
73
+ const instructions = (() => {
74
+ const user = data?.data?.user?.result;
75
+ return (user?.timeline_v2?.timeline || user?.timeline?.timeline)?.instructions || [];
76
+ })();
77
+ const { results, bottomCursor } = (0, twitter_1.walkTimelineResults)(instructions);
78
+ for (const r of results) {
79
+ const cat = (0, twitter_1.categorizeTweetResult)(r);
80
+ if (!cat.id || seen.has(cat.id))
81
+ continue;
82
+ seen.add(cat.id);
83
+ const kinds = [];
84
+ if (cat.isRetweet)
85
+ kinds.push('retweet');
86
+ if (cat.isReply)
87
+ kinds.push('reply');
88
+ if (cat.hasVideo)
89
+ kinds.push('video');
90
+ else if (cat.hasImage)
91
+ kinds.push('image');
92
+ else if (!cat.isRetweet)
93
+ kinds.push('text');
94
+ out.push({
95
+ id: cat.id,
96
+ tweet: (0, twitter_1.mapGraphql)(cat.inner),
97
+ kinds,
98
+ isRetweet: cat.isRetweet,
99
+ isReply: cat.isReply,
100
+ replyToId: cat.replyToId,
101
+ url: `https://x.com/i/web/status/${cat.id}`,
102
+ });
103
+ if (out.length >= limit)
104
+ break;
105
+ }
106
+ if (!bottomCursor || !results.length)
107
+ break;
108
+ cursor = bottomCursor;
109
+ }
110
+ return out;
111
+ }
112
+ function userOfEntry(itemContent) {
113
+ const user = itemContent?.user_results?.result;
114
+ if (!user || user.__typename === 'UserUnavailable')
115
+ return null;
116
+ const legacy = user.legacy || {};
117
+ return {
118
+ name: String(legacy.name ?? user.core?.name ?? ''),
119
+ screenName: String(legacy.screen_name ?? user.core?.screen_name ?? ''),
120
+ description: String(user.profile_bio?.description ?? legacy.description ?? ''),
121
+ followers: Number(legacy.followers_count ?? 0) || 0,
122
+ verified: !!user.is_blue_verified || !!legacy.verified,
123
+ followedBy: !!legacy.followed_by,
124
+ };
125
+ }
126
+ /** 纯函数:从 Followers/Following instructions 抽取用户与底部游标 */
127
+ function parseConnections(instructions) {
128
+ const users = [];
129
+ let bottomCursor;
130
+ for (const ins of instructions || []) {
131
+ for (const entry of ins?.entries || []) {
132
+ const content = entry?.content;
133
+ if (!content)
134
+ continue;
135
+ if (content.entryType === 'TimelineTimelineCursor' && content.contentType === 'Bottom') {
136
+ bottomCursor = String(content.value || bottomCursor || '');
137
+ continue;
138
+ }
139
+ if (content.entryType === 'TimelineTimelineItem' && content.itemContent) {
140
+ const u = userOfEntry(content.itemContent);
141
+ if (u && u.screenName)
142
+ users.push(u);
143
+ }
144
+ }
145
+ }
146
+ return { users, bottomCursor };
147
+ }
148
+ /** 关注列表(following)或粉丝列表(followers) */
149
+ async function fetchUserConnections(opts) {
150
+ const limit = Math.max(1, Math.min(1000, opts.limit ?? 50));
151
+ const get = opts.get || tls_client_1.tlsGet;
152
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
153
+ const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
154
+ const [opName, queryId] = opts.type === 'following'
155
+ ? ['Following', ids.Following]
156
+ : ['Followers', ids.Followers];
157
+ const out = [];
158
+ const seen = new Set();
159
+ let cursor;
160
+ let pages = 0;
161
+ while (out.length < limit && pages < 50) {
162
+ pages++;
163
+ const variables = { userId: u.userId, count: 50, includePromotedContent: false };
164
+ if (cursor)
165
+ variables.cursor = cursor;
166
+ const data = await (0, twitter_1.twitterGraphql)(opName, queryId, variables, opts.creds, get);
167
+ const instructions = (() => {
168
+ const user = data?.data?.user?.result;
169
+ return (user?.timeline_v2?.timeline || user?.timeline?.timeline)?.instructions || [];
170
+ })();
171
+ const { users, bottomCursor } = parseConnections(instructions);
172
+ for (const user of users) {
173
+ if (seen.has(user.screenName))
174
+ continue;
175
+ seen.add(user.screenName);
176
+ out.push(user);
177
+ if (out.length >= limit)
178
+ break;
179
+ }
180
+ if (!bottomCursor || !users.length)
181
+ break;
182
+ cursor = bottomCursor;
183
+ }
184
+ return out;
185
+ }
@@ -13,6 +13,37 @@ export interface TwitterCreds {
13
13
  authToken: string;
14
14
  ct0: string;
15
15
  }
16
+ export interface TwitterQueryIds {
17
+ UserByScreenName?: string;
18
+ UserTweets?: string;
19
+ UserTweetsAndReplies?: string;
20
+ Likes?: string;
21
+ Followers?: string;
22
+ Following?: string;
23
+ TweetDetail?: string;
24
+ }
25
+ /** 默认 queryId 清单(随 x.com web 版本漂移;调用方可整体覆盖) */
26
+ export declare const DEFAULT_QUERY_IDS: Required<TwitterQueryIds>;
27
+ /** 时间线类查询的特性集(UserTweets/Likes/Followers/TweetDetail 共用) */
28
+ export declare const TIMELINE_FEATURES: Record<string, boolean>;
29
+ /** 鉴权 GraphQL 通用请求(headers/cookie 与 TweetResultByRestId 同源) */
30
+ export declare function twitterGraphql(opName: string, queryId: string, variables: Record<string, any>, creds: TwitterCreds, get: GraphqlGetter, features?: Record<string, boolean>): Promise<any>;
31
+ /** 纯函数:把单条 tweet result 归类(含转推解包) */
32
+ export declare function categorizeTweetResult(result: any): {
33
+ inner: any;
34
+ isRetweet: boolean;
35
+ isReply: boolean;
36
+ hasVideo: boolean;
37
+ hasImage: boolean;
38
+ isText: boolean;
39
+ id: string;
40
+ replyToId?: string;
41
+ };
42
+ /** 纯函数:从 timeline instructions 抽取推文结果与底部游标(Item/Module 均收) */
43
+ export declare function walkTimelineResults(instructions: any[]): {
44
+ results: any[];
45
+ bottomCursor?: string;
46
+ };
16
47
  export interface GrokTranslation {
17
48
  text: string;
18
49
  sourceLang?: string;
@@ -23,4 +54,49 @@ export interface GrokTranslation {
23
54
  * x-client-transaction-id / cf_clearance。任何失败返回 null 由调用方回落通用翻译。
24
55
  */
25
56
  export declare function fetchGrokTranslation(url: string, targetLang: string, creds: TwitterCreds, get?: GraphqlGetter): Promise<GrokTranslation | null>;
57
+ export declare function extractTweetId(url: string): string | null;
58
+ /** 把 GraphQL TweetResultByRestId 响应映射为 ParsedData */
59
+ export declare function mapGraphql(rawResult: any): ParsedData;
60
+ /** 解除 TweetWithVisibilityResults 包裹(NSFW/受限推文) */
61
+ export declare function unwrapTweetResult(result: any): any;
62
+ /** 鉴权 GraphQL 原始获取:返回未映射的 tweet result 节点(树构建复用) */
63
+ export declare function fetchGraphqlRaw(id: string, creds: TwitterCreds, get: GraphqlGetter): Promise<any>;
26
64
  export declare function parseTwitter(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter): Promise<ParsedData>;
65
+ /**
66
+ * 推文树节点:
67
+ * - quoted = 本推引用的推文(递归)
68
+ * - replyTo = 本推回复的目标(向根方向递归)
69
+ * - replies = 本推收到的回复(评论,向下递归;withReplies 时填充)
70
+ */
71
+ export interface TweetTree {
72
+ id: string;
73
+ tweet: ParsedData;
74
+ quoted?: TweetTree;
75
+ replyTo?: TweetTree;
76
+ replies?: TweetTree[];
77
+ }
78
+ /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
79
+ * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
80
+ export declare function guestQuoteId(tw: any): string | undefined;
81
+ /** 游客态(syndication)取回复目标推文 ID */
82
+ export declare function guestReplyToId(tw: any): string | undefined;
83
+ export interface FetchTweetTreeOptions {
84
+ /** 拉取会话回复树(TweetDetail;需登录态) */
85
+ withReplies?: boolean;
86
+ /** 回复条数上限(默认 100) */
87
+ repliesLimit?: number;
88
+ /** queryId 覆盖 */
89
+ queryIds?: TwitterQueryIds;
90
+ }
91
+ /**
92
+ * 纯函数:由会话内的全部 tweet result 装配回复树(挂在 focal 节点下)。
93
+ * 依据 in_reply_to_status_id_str 建父子边;父不在集合内的孤儿回复跳过。
94
+ */
95
+ export declare function assembleReplies(focalId: string, results: any[], limit: number, makeNode: (raw: any) => TweetTree): TweetTree[];
96
+ /**
97
+ * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)
98
+ * + 可选会话回复树(replies,向下递归,TweetDetail 装配)。
99
+ * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
100
+ * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
101
+ */
102
+ export declare function fetchTweetTree(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter, opts?: FetchTweetTreeOptions): Promise<TweetTree>;
@@ -1,7 +1,19 @@
1
1
  "use strict";
2
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;
3
7
  exports.fetchGrokTranslation = fetchGrokTranslation;
8
+ exports.extractTweetId = extractTweetId;
9
+ exports.mapGraphql = mapGraphql;
10
+ exports.unwrapTweetResult = unwrapTweetResult;
11
+ exports.fetchGraphqlRaw = fetchGraphqlRaw;
4
12
  exports.parseTwitter = parseTwitter;
13
+ exports.guestQuoteId = guestQuoteId;
14
+ exports.guestReplyToId = guestReplyToId;
15
+ exports.assembleReplies = assembleReplies;
16
+ exports.fetchTweetTree = fetchTweetTree;
5
17
  const tls_client_1 = require("../utils/tls-client");
6
18
  /**
7
19
  * X / Twitter 原生解析器。
@@ -33,6 +45,138 @@ const GRAPHQL_FEATURES = {
33
45
  longform_notetweets_rich_text_read_enabled: true,
34
46
  responsive_web_graphql_timeline_navigation_enabled: true,
35
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
+ }
36
180
  /** Grok 翻译特性集(与网页端抓包一致;关键是 grok_show_grok_translated_post 开关) */
37
181
  const GROK_FEATURES = {
38
182
  creator_subscriptions_tweet_preview_api_enabled: true,
@@ -370,8 +514,12 @@ function mapGraphql(rawResult) {
370
514
  p.title = '';
371
515
  return p;
372
516
  }
373
- /** 鉴权 GraphQL:仅用 auth_token + ct0,回退取登录受限推文 */
374
- async function fetchGraphqlTweet(id, creds, get) {
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) {
375
523
  const variables = { tweetId: id, includePromotedContent: true, withBirdwatchNotes: true, withVoice: true, withCommunity: true };
376
524
  const url = `https://x.com/i/api/graphql/${TWEET_RESULT_QUERY_ID}/TweetResultByRestId` +
377
525
  `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(GRAPHQL_FEATURES))}`;
@@ -406,7 +554,11 @@ async function fetchGraphqlTweet(id, creds, get) {
406
554
  const tb = result?.tombstone?.text?.text || result?.tombstone?.text;
407
555
  throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${tb ? ':' + tb : ''}`);
408
556
  }
409
- return mapGraphql(result);
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));
410
562
  }
411
563
  async function parseTwitter(url, http, creds, getGraphql) {
412
564
  const id = extractTweetId(url);
@@ -441,3 +593,199 @@ async function parseTwitter(url, http, creds, getGraphql) {
441
593
  const reasonRaw = pick(tw?.tombstone?.text, tw?.tombstone?.name);
442
594
  throw new Error(`推文不可访问(可能需要登录、已被删除或为非公开内容)${reasonRaw ? ':' + reasonRaw : ''}`);
443
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
+ }
package/lib/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export * from './engine/config';
23
23
  export * from './engine/platform-config';
24
24
  export * from './engine/translate';
25
25
  export * from './engine/twitter';
26
+ export * from './engine/twitter-user';
26
27
  export * from './engine/parser';
27
28
  export * from './engine/fetcher';
28
29
  export * from './engine/runtime';
package/lib/index.js CHANGED
@@ -39,6 +39,7 @@ __exportStar(require("./engine/config"), exports);
39
39
  __exportStar(require("./engine/platform-config"), exports);
40
40
  __exportStar(require("./engine/translate"), exports);
41
41
  __exportStar(require("./engine/twitter"), exports);
42
+ __exportStar(require("./engine/twitter-user"), exports);
42
43
  __exportStar(require("./engine/parser"), exports);
43
44
  __exportStar(require("./engine/fetcher"), exports);
44
45
  __exportStar(require("./engine/runtime"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sns-parse/core",
3
- "version": "0.4.0-alpha.6+upstream.1.6.7",
3
+ "version": "0.4.0-alpha.8+upstream.1.6.7",
4
4
  "description": "sns-parse 核心:契约、宿主抽象、配置 DSL 与解析引擎(fetcher/parser/翻译/X 原生解析/合并/GIF/TLS 指纹客户端)",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",