@sns-parse/core 0.4.0-alpha.7 → 0.4.0-alpha.9

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.
@@ -2,21 +2,12 @@
2
2
  * X 用户维度查询:时间线(推文/回复/点赞)与关注/粉丝列表。
3
3
  *
4
4
  * 全部走鉴权 GraphQL(auth_token + ct0,TLS 指纹由 tlsget-rs 提供)。
5
- * queryId 随 x.com web 版本漂移:默认值取自活跃维护的开源清单
6
- * (twitter-api-client),并支持调用方整体覆盖(queryIds 参数)。
5
+ * queryId 与特性集等公共件见 ./twitter(DEFAULT_QUERY_IDS 可被调用方覆盖)。
7
6
  */
8
7
  import type { ParsedData } from '../types';
9
- import { type GraphqlGetter, type TwitterCreds } from './twitter';
10
- export interface TwitterQueryIds {
11
- UserByScreenName?: string;
12
- UserTweets?: string;
13
- UserTweetsAndReplies?: string;
14
- Likes?: string;
15
- Followers?: string;
16
- Following?: string;
17
- }
18
- /** 默认 queryId 清单(漂移时可用 queryIds 参数整体覆盖) */
19
- export declare const DEFAULT_QUERY_IDS: Required<TwitterQueryIds>;
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 };
20
11
  export interface TwitterUserInfo {
21
12
  userId: string;
22
13
  name: string;
@@ -37,18 +28,7 @@ export interface TimelineEntry {
37
28
  replyToId?: string;
38
29
  url: string;
39
30
  }
40
- /** 纯函数:把单条 tweet result 归类(含转推解包) */
41
- export declare function categorizeTweetResult(result: any): {
42
- inner: any;
43
- isRetweet: boolean;
44
- isReply: boolean;
45
- hasVideo: boolean;
46
- hasImage: boolean;
47
- isText: boolean;
48
- id: string;
49
- replyToId?: string;
50
- };
51
- /** 纯函数:从 timeline instructions 抽取推文结果与底部游标 */
31
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
52
32
  export declare function parseTimeline(instructions: any[]): {
53
33
  results: any[];
54
34
  bottomCursor?: string;
@@ -1,100 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_QUERY_IDS = void 0;
3
+ exports.TIMELINE_FEATURES = exports.DEFAULT_QUERY_IDS = exports.categorizeTweetResult = void 0;
4
4
  exports.resolveTwitterUser = resolveTwitterUser;
5
- exports.categorizeTweetResult = categorizeTweetResult;
6
5
  exports.parseTimeline = parseTimeline;
7
6
  exports.fetchUserTimeline = fetchUserTimeline;
8
7
  exports.parseConnections = parseConnections;
9
8
  exports.fetchUserConnections = fetchUserConnections;
10
9
  const tls_client_1 = require("../utils/tls-client");
11
10
  const twitter_1 = require("./twitter");
12
- // x.com 网页端公开 bearer(与 twitter.ts 同源固定值)
13
- const WEB_BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
14
- /** 默认 queryId 清单(漂移时可用 queryIds 参数整体覆盖) */
15
- exports.DEFAULT_QUERY_IDS = {
16
- UserByScreenName: 'sLVLhk0bGj3MVFEKTdax1w',
17
- UserTweets: 'HuTx74BxAnezK1gWvYY7zg',
18
- UserTweetsAndReplies: 'RIWc55YCNyUJ-U3HHGYkdg',
19
- Likes: 'nXEl0lfN_XSznVMlprThgQ',
20
- Followers: 'pd8Tt1qUz1YWrICegqZ8cw',
21
- Following: 'wjvx62Hye2dGVvnvVco0xA',
22
- };
23
- const TIMELINE_FEATURES = {
24
- blue_business_profile_image_shape_enabled: true,
25
- creator_subscriptions_tweet_preview_api_enabled: true,
26
- freedom_of_speech_not_reach_fetch_enabled: true,
27
- graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
28
- graphql_timeline_v2_bookmark_timeline: true,
29
- hidden_profile_likes_enabled: true,
30
- highlights_tweets_tab_ui_enabled: true,
31
- interactive_text_enabled: true,
32
- longform_notetweets_consumption_enabled: true,
33
- longform_notetweets_inline_media_enabled: true,
34
- longform_notetweets_rich_text_read_enabled: true,
35
- longform_notetweets_richtext_consumption_enabled: true,
36
- profile_foundations_tweet_stats_enabled: true,
37
- profile_foundations_tweet_stats_tweet_frequency: true,
38
- responsive_web_birdwatch_note_limit_enabled: true,
39
- responsive_web_edit_tweet_api_enabled: true,
40
- responsive_web_enhance_cards_enabled: false,
41
- responsive_web_graphql_exclude_directive_enabled: true,
42
- responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
43
- responsive_web_graphql_timeline_navigation_enabled: true,
44
- responsive_web_media_download_video_enabled: false,
45
- responsive_web_text_conversations_enabled: false,
46
- responsive_web_twitter_article_data_v2_enabled: true,
47
- responsive_web_twitter_article_tweet_consumption_enabled: false,
48
- responsive_web_twitter_blue_verified_badge_is_enabled: true,
49
- rweb_lists_timeline_redesign_enabled: true,
50
- spaces_2022_h2_clipping: true,
51
- spaces_2022_h2_spaces_communities: true,
52
- standardized_nudges_misinfo: true,
53
- subscriptions_verification_info_verified_since_enabled: true,
54
- tweet_awards_web_tipping_enabled: false,
55
- tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
56
- tweetypie_unmention_optimization_enabled: true,
57
- verified_phone_label_enabled: false,
58
- vibe_api_enabled: true,
59
- view_counts_everywhere_api_enabled: true,
60
- };
61
- async function gql(opName, queryId, variables, creds, get) {
62
- const url = `https://x.com/i/api/graphql/${queryId}/${opName}` +
63
- `?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(TIMELINE_FEATURES))}`;
64
- let res;
65
- try {
66
- res = await get(url, {
67
- headers: {
68
- authorization: `Bearer ${WEB_BEARER}`,
69
- 'x-csrf-token': creds.ct0,
70
- 'x-twitter-auth-type': 'OAuth2Session',
71
- 'x-twitter-active-user': 'yes',
72
- 'x-twitter-client-language': 'en',
73
- },
74
- cookies: { auth_token: creds.authToken, ct0: creds.ct0 },
75
- timeout: 30000,
76
- });
77
- }
78
- catch (e) {
79
- throw new Error(`X GraphQL 请求失败:${e?.message || e}`);
80
- }
81
- if (res.status === 403 || res.status === 429) {
82
- throw new Error(`X GraphQL 被 Cloudflare 拦截 (HTTP ${res.status}):TLS 指纹校验未通过。` +
83
- `请确认已安装可选依赖 @char46/tlsget-rs(npm i @char46/tlsget-rs),它提供浏览器级 TLS 指纹。`);
84
- }
85
- if (res.status !== 200) {
86
- 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)}`);
87
- }
88
- return res.data;
89
- }
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; } });
90
14
  async function resolveTwitterUser(screenName, creds, get = tls_client_1.tlsGet, queryIds = {}) {
91
- const ids = { ...exports.DEFAULT_QUERY_IDS, ...queryIds };
15
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...queryIds };
92
16
  const variables = {
93
17
  screen_name: screenName.replace(/^@/, ''),
94
18
  withSafetyModeUserFields: true,
95
19
  withSuperFollowsUserFields: true,
96
20
  };
97
- const data = await gql('UserByScreenName', ids.UserByScreenName, variables, creds, get);
21
+ const data = await (0, twitter_1.twitterGraphql)('UserByScreenName', ids.UserByScreenName, variables, creds, get);
98
22
  const user = data?.data?.user?.result;
99
23
  if (!user || user.__typename === 'UserUnavailable') {
100
24
  throw new Error(`用户 @${screenName} 不可访问(不存在、被封或需登录)`);
@@ -109,70 +33,16 @@ async function resolveTwitterUser(screenName, creds, get = tls_client_1.tlsGet,
109
33
  friends: Number(legacy.friends_count ?? 0) || 0,
110
34
  };
111
35
  }
112
- /** 纯函数:把单条 tweet result 归类(含转推解包) */
113
- function categorizeTweetResult(result) {
114
- const raw = (0, twitter_1.unwrapTweetResult)(result);
115
- const rtInner = raw?.legacy?.retweeted_status_result?.result;
116
- const inner = rtInner ? (0, twitter_1.unwrapTweetResult)(rtInner) : raw;
117
- const legacy = inner?.legacy || {};
118
- const media = Array.isArray(legacy.extended_entities?.media) ? legacy.extended_entities.media : [];
119
- const hasImage = media.some((m) => m?.type === 'photo');
120
- const hasVideo = media.some((m) => m?.type === 'video' || m?.type === 'animated_gif');
121
- const isRetweet = !!rtInner;
122
- const replyToId = typeof legacy.in_reply_to_status_id_str === 'string' ? legacy.in_reply_to_status_id_str : undefined;
123
- const isReply = !!replyToId;
124
- const isText = !hasImage && !hasVideo && !isRetweet;
125
- return {
126
- inner,
127
- isRetweet,
128
- isReply,
129
- hasVideo,
130
- hasImage,
131
- isText,
132
- id: String(inner?.rest_id ?? legacy.id_str ?? ''),
133
- replyToId,
134
- };
135
- }
136
- /** 纯函数:从 timeline instructions 抽取推文结果与底部游标 */
36
+ /** 兼容别名:timeline instructions → 推文结果 + 底部游标 */
137
37
  function parseTimeline(instructions) {
138
- const results = [];
139
- let bottomCursor;
140
- for (const ins of instructions || []) {
141
- const entries = ins?.entries || ins?.entry ? [].concat(ins.entry, ins.entries).filter(Boolean) : [];
142
- for (const entry of entries) {
143
- const content = entry?.content;
144
- if (!content)
145
- continue;
146
- if (content.entryType === 'TimelineTimelineCursor' && content.contentType === 'Bottom') {
147
- bottomCursor = String(content.value || bottomCursor || '');
148
- continue;
149
- }
150
- if (content.entryType === 'TimelineTimelineItem' && content.itemContent?.tweet_results?.result) {
151
- results.push(content.itemContent.tweet_results.result);
152
- continue;
153
- }
154
- if (content.entryType === 'TimelineTimelineModule') {
155
- for (const item of content.items || []) {
156
- const r = item?.itemContent?.tweet_results?.result;
157
- if (r)
158
- results.push(r);
159
- }
160
- }
161
- }
162
- }
163
- return { results, bottomCursor };
164
- }
165
- function timelineOf(data) {
166
- const user = data?.data?.user?.result;
167
- const timeline = user?.timeline_v2?.timeline || user?.timeline?.timeline;
168
- return timeline?.instructions || [];
38
+ return (0, twitter_1.walkTimelineResults)(instructions);
169
39
  }
170
40
  /** 用户时间线(tab:tweets=推文/转推;replies=含回复;likes=当前登录用户的点赞) */
171
41
  async function fetchUserTimeline(opts) {
172
42
  const tab = opts.tab || 'tweets';
173
43
  const limit = Math.max(1, Math.min(200, opts.limit ?? 20));
174
44
  const get = opts.get || tls_client_1.tlsGet;
175
- const ids = { ...exports.DEFAULT_QUERY_IDS, ...opts.queryIds };
45
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
176
46
  let userId = opts.userId;
177
47
  if (!userId) {
178
48
  const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
@@ -199,10 +69,14 @@ async function fetchUserTimeline(opts) {
199
69
  };
200
70
  if (cursor)
201
71
  variables.cursor = cursor;
202
- const data = await gql(opName, queryId, variables, opts.creds, get);
203
- const { results, bottomCursor } = parseTimeline(timelineOf(data));
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);
204
78
  for (const r of results) {
205
- const cat = categorizeTweetResult(r);
79
+ const cat = (0, twitter_1.categorizeTweetResult)(r);
206
80
  if (!cat.id || seen.has(cat.id))
207
81
  continue;
208
82
  seen.add(cat.id);
@@ -275,7 +149,7 @@ function parseConnections(instructions) {
275
149
  async function fetchUserConnections(opts) {
276
150
  const limit = Math.max(1, Math.min(1000, opts.limit ?? 50));
277
151
  const get = opts.get || tls_client_1.tlsGet;
278
- const ids = { ...exports.DEFAULT_QUERY_IDS, ...opts.queryIds };
152
+ const ids = { ...twitter_1.DEFAULT_QUERY_IDS, ...opts.queryIds };
279
153
  const u = await resolveTwitterUser(opts.screenName, opts.creds, get, opts.queryIds);
280
154
  const [opName, queryId] = opts.type === 'following'
281
155
  ? ['Following', ids.Following]
@@ -289,8 +163,12 @@ async function fetchUserConnections(opts) {
289
163
  const variables = { userId: u.userId, count: 50, includePromotedContent: false };
290
164
  if (cursor)
291
165
  variables.cursor = cursor;
292
- const data = await gql(opName, queryId, variables, opts.creds, get);
293
- const { users, bottomCursor } = parseConnections(timelineOf(data));
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);
294
172
  for (const user of users) {
295
173
  if (seen.has(user.screenName))
296
174
  continue;
@@ -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;
@@ -31,21 +62,41 @@ export declare function unwrapTweetResult(result: any): any;
31
62
  /** 鉴权 GraphQL 原始获取:返回未映射的 tweet result 节点(树构建复用) */
32
63
  export declare function fetchGraphqlRaw(id: string, creds: TwitterCreds, get: GraphqlGetter): Promise<any>;
33
64
  export declare function parseTwitter(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter): Promise<ParsedData>;
34
- /** 推文树节点:quoted = 本推引用的推文(递归);replyTo = 本推回复的目标(向根方向递归) */
65
+ /**
66
+ * 推文树节点:
67
+ * - quoted = 本推引用的推文(递归)
68
+ * - replyTo = 本推回复的目标(向根方向递归)
69
+ * - replies = 本推收到的回复(评论,向下递归;withReplies 时填充)
70
+ */
35
71
  export interface TweetTree {
36
72
  id: string;
37
73
  tweet: ParsedData;
38
74
  quoted?: TweetTree;
39
75
  replyTo?: TweetTree;
76
+ replies?: TweetTree[];
40
77
  }
41
78
  /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
42
79
  * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
43
80
  export declare function guestQuoteId(tw: any): string | undefined;
44
81
  /** 游客态(syndication)取回复目标推文 ID */
45
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[];
46
96
  /**
47
- * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)。
97
+ * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)
98
+ * + 可选会话回复树(replies,向下递归,TweetDetail 装配)。
48
99
  * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
49
100
  * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
50
101
  */
51
- export declare function fetchTweetTree(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter): Promise<TweetTree>;
102
+ export declare function fetchTweetTree(url: string, http: AxiosInstance, creds?: TwitterCreds, getGraphql?: GraphqlGetter, opts?: FetchTweetTreeOptions): Promise<TweetTree>;
@@ -1,5 +1,9 @@
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;
4
8
  exports.extractTweetId = extractTweetId;
5
9
  exports.mapGraphql = mapGraphql;
@@ -8,6 +12,7 @@ exports.fetchGraphqlRaw = fetchGraphqlRaw;
8
12
  exports.parseTwitter = parseTwitter;
9
13
  exports.guestQuoteId = guestQuoteId;
10
14
  exports.guestReplyToId = guestReplyToId;
15
+ exports.assembleReplies = assembleReplies;
11
16
  exports.fetchTweetTree = fetchTweetTree;
12
17
  const tls_client_1 = require("../utils/tls-client");
13
18
  /**
@@ -40,6 +45,138 @@ const GRAPHQL_FEATURES = {
40
45
  longform_notetweets_rich_text_read_enabled: true,
41
46
  responsive_web_graphql_timeline_navigation_enabled: true,
42
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
+ }
43
180
  /** Grok 翻译特性集(与网页端抓包一致;关键是 grok_show_grok_translated_post 开关) */
44
181
  const GROK_FEATURES = {
45
182
  creator_subscriptions_tweet_preview_api_enabled: true,
@@ -458,6 +595,7 @@ async function parseTwitter(url, http, creds, getGraphql) {
458
595
  }
459
596
  const TREE_QUOTE_DEPTH = 6;
460
597
  const TREE_REPLY_DEPTH = 12;
598
+ const TREE_REPLIES_LIMIT = 100;
461
599
  /** 游客态(syndication)取被引用推文 ID:显式字段优先;否则启发式——引用链的
462
600
  * t.co 短链由 X 追加在正文末尾,expanded_url 为推文永久链。 */
463
601
  function guestQuoteId(tw) {
@@ -498,11 +636,50 @@ async function fetchSyndicationRaw(tweetId, http) {
498
636
  return tw && tw.__typename === 'Tweet' && tw.user ? tw : null;
499
637
  }
500
638
  /**
501
- * 拉取推文树:引用链(quoted,向下递归)+ 回复链(replyTo,向根递归)。
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 装配)。
502
679
  * - 有登录态:GraphQL 单次响应自带嵌套 quoted_status_result;父级逐次拉取
503
680
  * - 无登录态:syndication 逐节点拉取(引用 ID 走启发式);不可达分支静默截断
504
681
  */
505
- async function fetchTweetTree(url, http, creds, getGraphql) {
682
+ async function fetchTweetTree(url, http, creds, getGraphql, opts = {}) {
506
683
  const id = extractTweetId(url);
507
684
  if (!id)
508
685
  throw new Error('无法从 X 链接提取推文 ID');
@@ -556,13 +733,59 @@ async function fetchTweetTree(url, http, creds, getGraphql) {
556
733
  const raw = await fetchGraphqlRaw(tweetId, creds, getGraphql || tls_client_1.tlsGet);
557
734
  return buildFromGraphqlResult(raw, depth);
558
735
  }
559
- if (creds && creds.authToken && creds.ct0) {
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) {
560
773
  try {
561
- return await buildGraphqlById(id, 0);
774
+ tree = await buildGraphqlById(id, 0);
562
775
  }
563
776
  catch {
564
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);
565
787
  }
788
+ catch { /* 会话不可达则保留无回复树 */ }
566
789
  }
567
- return buildGuest(id, 0);
790
+ return tree;
568
791
  }
@@ -28,7 +28,9 @@ export interface SeamVerdict {
28
28
  /** 结论文案 */
29
29
  reason: string;
30
30
  }
31
- /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
31
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;允许至多一条「边界容差缝」
32
+ * (比值 ∈ [3,5),其余全部通过——平台独立重压缩的典型错位幅度;平坦不可验证缝不参与容差)。
33
+ * 返回最大接缝比值与逐缝证据。 */
32
34
  export declare function verifyLayout(layout: MergeLayout, grays: Buffer[]): {
33
35
  pass: boolean;
34
36
  score: number;
@@ -37,6 +37,10 @@ const COL_LEN = 64;
37
37
  /** 接缝差异 / 内部自然差异 的判定阈值(母图切片 ≈1,无关图片 ≫1;下限 3 防平坦图除零) */
38
38
  const BOUNDARY_THRESH = 3.0;
39
39
  const MAD_FLOOR = 3.0;
40
+ /** 单缝边界容差:平台(如 X)对各分片独立重压缩会让个别接缝低频轻微错位;
41
+ * 其余接缝全部通过且仅一条落在 [BOUNDARY_THRESH, BORDERLINE_THRESH) 时放行 */
42
+ const BORDERLINE_THRESH = 5.0;
43
+ const BORDERLINE_MAX = 1;
40
44
  /** 边界带纹理能量下限:低于此值视为「不可验证接缝」(相似背景/平边照片会骗过亮度对比) */
41
45
  const TEXTURE_MIN = 4.0;
42
46
  /* ---------- 尺寸候选 ---------- */
@@ -184,7 +188,9 @@ function seamVerdictH(a, b, from, to) {
184
188
  [colOf(b, 2), colOf(b, 1)],
185
189
  ]);
186
190
  }
187
- /** 内容验证:所有接缝(趋势+纹理)均通过才成立;返回最大接缝比值与逐缝证据 */
191
+ /** 内容验证:所有接缝(趋势+纹理)均通过才成立;允许至多一条「边界容差缝」
192
+ * (比值 ∈ [3,5),其余全部通过——平台独立重压缩的典型错位幅度;平坦不可验证缝不参与容差)。
193
+ * 返回最大接缝比值与逐缝证据。 */
188
194
  function verifyLayout(layout, grays) {
189
195
  const seams = [];
190
196
  const n = grays.length;
@@ -208,7 +214,11 @@ function verifyLayout(layout, grays) {
208
214
  if (!seams.length)
209
215
  return { pass: false, score: Infinity, seams };
210
216
  const worst = Math.max(...seams.map((v) => v.score));
211
- return { pass: seams.every((v) => v.ok), score: worst, seams };
217
+ const failed = seams.filter((v) => !v.ok);
218
+ const borderline = failed.filter((v) => v.score < BORDERLINE_THRESH);
219
+ const pass = seams.every((v) => v.ok)
220
+ || (borderline.length <= BORDERLINE_MAX && failed.length === borderline.length);
221
+ return { pass, score: worst, seams };
212
222
  }
213
223
  /** 内容识别:候选布局逐一经接缝连续性验证,取通过者中比值最小的一个 */
214
224
  function detectMergeLayout(sizes, grays) {
@@ -232,8 +242,9 @@ function pickMergeLayout(sizes, grays) {
232
242
  let best = null;
233
243
  for (const c of candidates) {
234
244
  const { pass, score, seams } = verifyLayout(c, grays);
245
+ const tolerated = seams.filter((v) => !v.ok).length;
235
246
  const detail = seams.map((v) => `[${v.dir === 'V' ? '竖' : '横'}缝 ${v.from}→${v.to}] ${v.reason}`).join(';');
236
- (0, logger_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
247
+ (0, logger_1.debugLog)(`同源合并证据·${layoutName(c)}:${pass ? '通过' : '拒绝'}${pass && tolerated ? `(含 ${tolerated} 条边界容差缝)` : ''},worst=${score === Infinity ? '∞' : score.toFixed(2)} ⟶ ${detail}`);
237
248
  if (pass && (!best || score < best.score))
238
249
  best = { layout: c, score, seams };
239
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sns-parse/core",
3
- "version": "0.4.0-alpha.7+upstream.1.6.7",
3
+ "version": "0.4.0-alpha.9+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",