@cm-growth-hacking/twitter-client 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/bin/twitter.js +1129 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +71 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +196 -0
- package/dist/client/TwitterClient.d.ts +47 -0
- package/dist/client/TwitterClient.d.ts.map +1 -0
- package/dist/client/TwitterClient.js +472 -0
- package/dist/client/auth.d.ts +21 -0
- package/dist/client/auth.d.ts.map +1 -0
- package/dist/client/auth.js +42 -0
- package/dist/client/graphql.d.ts +49 -0
- package/dist/client/graphql.d.ts.map +1 -0
- package/dist/client/graphql.js +191 -0
- package/dist/client/parsers.d.ts +14 -0
- package/dist/client/parsers.d.ts.map +1 -0
- package/dist/client/parsers.js +147 -0
- package/dist/client/query-ids.d.ts +22 -0
- package/dist/client/query-ids.d.ts.map +1 -0
- package/dist/client/query-ids.js +157 -0
- package/dist/client/rest.d.ts +10 -0
- package/dist/client/rest.d.ts.map +1 -0
- package/dist/client/rest.js +41 -0
- package/dist/client/types.d.ts +80 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
2
|
+
import { queryIdManager } from './query-ids.js';
|
|
3
|
+
export class GraphQLClient {
|
|
4
|
+
authToken;
|
|
5
|
+
ct0;
|
|
6
|
+
userAgent;
|
|
7
|
+
timeout;
|
|
8
|
+
clientUuid;
|
|
9
|
+
clientDeviceId;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.authToken = options.authToken;
|
|
12
|
+
this.ct0 = options.ct0;
|
|
13
|
+
this.timeout = options.timeout;
|
|
14
|
+
this.userAgent =
|
|
15
|
+
options.userAgent ||
|
|
16
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
17
|
+
this.clientUuid = randomUUID();
|
|
18
|
+
this.clientDeviceId = randomUUID();
|
|
19
|
+
}
|
|
20
|
+
createTransactionId() {
|
|
21
|
+
return randomBytes(16).toString('hex');
|
|
22
|
+
}
|
|
23
|
+
getHeaders() {
|
|
24
|
+
return {
|
|
25
|
+
accept: '*/*',
|
|
26
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
27
|
+
authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
|
28
|
+
'content-type': 'application/json',
|
|
29
|
+
'x-csrf-token': this.ct0,
|
|
30
|
+
'x-twitter-auth-type': 'OAuth2Session',
|
|
31
|
+
'x-twitter-active-user': 'yes',
|
|
32
|
+
'x-twitter-client-language': 'en',
|
|
33
|
+
'x-client-uuid': this.clientUuid,
|
|
34
|
+
'x-twitter-client-deviceid': this.clientDeviceId,
|
|
35
|
+
'x-client-transaction-id': this.createTransactionId(),
|
|
36
|
+
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
|
37
|
+
'user-agent': this.userAgent,
|
|
38
|
+
origin: 'https://x.com',
|
|
39
|
+
referer: 'https://x.com/',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async fetchWithTimeout(url, init) {
|
|
43
|
+
if (!this.timeout || this.timeout <= 0) {
|
|
44
|
+
return fetch(url, init);
|
|
45
|
+
}
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
48
|
+
try {
|
|
49
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timeoutId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
getErrorCode(status, errors) {
|
|
56
|
+
if (status === 401 || status === 403) {
|
|
57
|
+
return 'AUTH_FAILED';
|
|
58
|
+
}
|
|
59
|
+
if (status === 429) {
|
|
60
|
+
return 'RATE_LIMITED';
|
|
61
|
+
}
|
|
62
|
+
if (status === 404) {
|
|
63
|
+
return 'STALE_QUERY_ID';
|
|
64
|
+
}
|
|
65
|
+
if (errors?.some((e) => e.code === 226)) {
|
|
66
|
+
return 'RATE_LIMITED';
|
|
67
|
+
}
|
|
68
|
+
return 'NETWORK_ERROR';
|
|
69
|
+
}
|
|
70
|
+
async validateResponse(response) {
|
|
71
|
+
const data = await response.json();
|
|
72
|
+
if (data.errors && data.errors.length > 0) {
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
error: data.errors.map((e) => e.message).join(', '),
|
|
76
|
+
code: this.getErrorCode(500, data.errors),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!data.data) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: 'No data returned from API',
|
|
83
|
+
code: 'NETWORK_ERROR',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
data: data.data,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async request(operation, variables, features) {
|
|
92
|
+
let queryId = queryIdManager.getQueryId(operation);
|
|
93
|
+
let queryIds = Array.isArray(queryId) ? queryId : [queryId];
|
|
94
|
+
let hasRefreshed = false; // Track if we've already attempted refresh
|
|
95
|
+
// Try each query ID in order
|
|
96
|
+
for (let i = 0; i < queryIds.length; i++) {
|
|
97
|
+
const id = queryIds[i];
|
|
98
|
+
const url = `https://x.com/i/api/graphql/${id}/${operation}`;
|
|
99
|
+
try {
|
|
100
|
+
const body = JSON.stringify({
|
|
101
|
+
variables,
|
|
102
|
+
features,
|
|
103
|
+
queryId: id,
|
|
104
|
+
});
|
|
105
|
+
const response = await this.fetchWithTimeout(url, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: this.getHeaders(),
|
|
108
|
+
body,
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
const code = this.getErrorCode(response.status);
|
|
112
|
+
// Auto-refresh on 404 (except for last query ID)
|
|
113
|
+
if (response.status === 404 && i < queryIds.length - 1) {
|
|
114
|
+
continue; // Try next fallback ID
|
|
115
|
+
}
|
|
116
|
+
// If 404 and we have no more fallbacks, try refreshing once (but only once per request)
|
|
117
|
+
if (response.status === 404 && i === queryIds.length - 1 && !hasRefreshed) {
|
|
118
|
+
console.info(`Query ID stale for ${operation}, refreshing...`);
|
|
119
|
+
hasRefreshed = true; // Mark that we've attempted refresh
|
|
120
|
+
try {
|
|
121
|
+
await queryIdManager.updateQueryIds({ force: true });
|
|
122
|
+
// Get fresh query IDs and continue the loop with them
|
|
123
|
+
queryId = queryIdManager.getQueryId(operation);
|
|
124
|
+
queryIds = Array.isArray(queryId) ? queryId : [queryId];
|
|
125
|
+
// Reset index to try fresh IDs from the beginning
|
|
126
|
+
i = -1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
catch (refreshError) {
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
error: `Failed to refresh query IDs: ${refreshError instanceof Error ? refreshError.message : 'Unknown error'}`,
|
|
133
|
+
code: 'NETWORK_ERROR',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// For other errors or if we've already refreshed, return error
|
|
138
|
+
const text = await response.text();
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
|
142
|
+
code,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return await this.validateResponse(response);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
error: 'Request timeout',
|
|
152
|
+
code: 'NETWORK_ERROR',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
158
|
+
code: 'NETWORK_ERROR',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
error: 'All query IDs failed',
|
|
165
|
+
code: 'STALE_QUERY_ID',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch the authenticated user's information using GraphQL
|
|
170
|
+
*/
|
|
171
|
+
async getCurrentUser() {
|
|
172
|
+
return await this.request('AboutAccountQuery', {}, {
|
|
173
|
+
hidden_profile_subscriptions_enabled: true,
|
|
174
|
+
hidden_profile_likes_enabled: true,
|
|
175
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
176
|
+
verified_phone_label_enabled: false,
|
|
177
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
178
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
179
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
180
|
+
responsive_web_grok_analyze_post_followups_enabled: false,
|
|
181
|
+
responsive_web_grok_annotations_enabled: false,
|
|
182
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
183
|
+
responsive_web_jetfuel_frame: true,
|
|
184
|
+
longform_notetweets_consumption_enabled: true,
|
|
185
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
186
|
+
tweet_awards_web_tipping_enabled: false,
|
|
187
|
+
responsive_web_media_download_video_enabled: false,
|
|
188
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Tweet, User } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse tweet object from GraphQL response
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseTweet(tweetData: any): Tweet | null;
|
|
6
|
+
/**
|
|
7
|
+
* Parse user object from GraphQL response
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseUser(userData: any): User | null;
|
|
10
|
+
/**
|
|
11
|
+
* Extract tweets from timeline instructions
|
|
12
|
+
*/
|
|
13
|
+
export declare function extractTweetsFromInstructions(instructions: any[]): Tweet[];
|
|
14
|
+
//# sourceMappingURL=parsers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parsers.d.ts","sourceRoot":"","sources":["../../src/client/parsers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAa,MAAM,YAAY,CAAC;AAEzD;;GAEG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,GAAG,GAAG,KAAK,GAAG,IAAI,CAoGvD;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI,CAkBpD;AAED;;GAEG;AACH,wBAAgB,6BAA6B,CAAC,YAAY,EAAE,GAAG,EAAE,GAAG,KAAK,EAAE,CAoC1E"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse tweet object from GraphQL response
|
|
3
|
+
*/
|
|
4
|
+
export function parseTweet(tweetData) {
|
|
5
|
+
if (!tweetData)
|
|
6
|
+
return null;
|
|
7
|
+
const legacy = tweetData.legacy || tweetData;
|
|
8
|
+
const core = tweetData.core?.user_results?.result;
|
|
9
|
+
const userLegacy = core?.legacy;
|
|
10
|
+
const userCore = core?.core;
|
|
11
|
+
if (!legacy)
|
|
12
|
+
return null;
|
|
13
|
+
// Extract username from either legacy or core (Twitter uses different locations)
|
|
14
|
+
const username = userLegacy?.screen_name || userCore?.screen_name || '';
|
|
15
|
+
const name = userLegacy?.name || userCore?.name || username;
|
|
16
|
+
if (!username) {
|
|
17
|
+
// Skip tweets without identifiable author
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const id = legacy.id_str || legacy.rest_id || tweetData.rest_id || '';
|
|
21
|
+
const tweet = {
|
|
22
|
+
id,
|
|
23
|
+
url: `https://x.com/${username}/status/${id}`,
|
|
24
|
+
text: legacy.full_text || legacy.text || '',
|
|
25
|
+
author: {
|
|
26
|
+
id: core?.rest_id || userLegacy?.id_str || '',
|
|
27
|
+
username,
|
|
28
|
+
name,
|
|
29
|
+
},
|
|
30
|
+
createdAt: legacy.created_at || '',
|
|
31
|
+
replyCount: legacy.reply_count || 0,
|
|
32
|
+
retweetCount: legacy.retweet_count || 0,
|
|
33
|
+
likeCount: legacy.favorite_count || 0,
|
|
34
|
+
viewCount: tweetData.views?.count ? parseInt(tweetData.views.count, 10) : undefined,
|
|
35
|
+
bookmarkCount: legacy.bookmark_count,
|
|
36
|
+
};
|
|
37
|
+
// Optional fields
|
|
38
|
+
if (legacy.conversation_id_str) {
|
|
39
|
+
tweet.conversationId = legacy.conversation_id_str;
|
|
40
|
+
}
|
|
41
|
+
if (legacy.in_reply_to_status_id_str) {
|
|
42
|
+
tweet.inReplyToStatusId = legacy.in_reply_to_status_id_str;
|
|
43
|
+
}
|
|
44
|
+
// Parse media with enhanced fields (width, height, preview, video info)
|
|
45
|
+
if (legacy.extended_entities?.media) {
|
|
46
|
+
tweet.media = legacy.extended_entities.media.map((m) => {
|
|
47
|
+
const type = m.type === 'video' || m.type === 'animated_gif' ? (m.type === 'animated_gif' ? 'gif' : 'video') : 'photo';
|
|
48
|
+
const mediaItem = {
|
|
49
|
+
type,
|
|
50
|
+
url: m.media_url_https || '',
|
|
51
|
+
altText: m.alt_text,
|
|
52
|
+
};
|
|
53
|
+
// Add dimensions from largest available size
|
|
54
|
+
const sizes = m.sizes;
|
|
55
|
+
if (sizes?.large) {
|
|
56
|
+
mediaItem.width = sizes.large.w;
|
|
57
|
+
mediaItem.height = sizes.large.h;
|
|
58
|
+
}
|
|
59
|
+
else if (sizes?.medium) {
|
|
60
|
+
mediaItem.width = sizes.medium.w;
|
|
61
|
+
mediaItem.height = sizes.medium.h;
|
|
62
|
+
}
|
|
63
|
+
// Add thumbnail/preview URL
|
|
64
|
+
if (sizes?.small) {
|
|
65
|
+
mediaItem.previewUrl = `${m.media_url_https}:small`;
|
|
66
|
+
}
|
|
67
|
+
// Extract video URL and duration for videos/gifs
|
|
68
|
+
if ((type === 'video' || type === 'gif') && m.video_info?.variants) {
|
|
69
|
+
// Prefer highest bitrate MP4, fall back to first MP4
|
|
70
|
+
const mp4Variants = m.video_info.variants.filter((v) => v.content_type === 'video/mp4' && typeof v.url === 'string');
|
|
71
|
+
const mp4WithBitrate = mp4Variants
|
|
72
|
+
.filter((v) => typeof v.bitrate === 'number')
|
|
73
|
+
.sort((a, b) => b.bitrate - a.bitrate);
|
|
74
|
+
const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0];
|
|
75
|
+
if (selectedVariant) {
|
|
76
|
+
mediaItem.videoUrl = selectedVariant.url;
|
|
77
|
+
}
|
|
78
|
+
// Add video duration
|
|
79
|
+
if (typeof m.video_info.duration_millis === 'number') {
|
|
80
|
+
mediaItem.durationMs = m.video_info.duration_millis;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return mediaItem;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Parse quoted tweet
|
|
87
|
+
if (legacy.quoted_status_result?.result) {
|
|
88
|
+
const quoted = parseTweet(legacy.quoted_status_result.result);
|
|
89
|
+
if (quoted)
|
|
90
|
+
tweet.quotedTweet = quoted;
|
|
91
|
+
}
|
|
92
|
+
return tweet;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse user object from GraphQL response
|
|
96
|
+
*/
|
|
97
|
+
export function parseUser(userData) {
|
|
98
|
+
if (!userData)
|
|
99
|
+
return null;
|
|
100
|
+
const legacy = userData.legacy || userData;
|
|
101
|
+
const user = {
|
|
102
|
+
id: userData.rest_id || legacy.id_str || '',
|
|
103
|
+
username: legacy.screen_name || '',
|
|
104
|
+
name: legacy.name || '',
|
|
105
|
+
description: legacy.description,
|
|
106
|
+
followersCount: legacy.followers_count,
|
|
107
|
+
followingCount: legacy.friends_count,
|
|
108
|
+
isBlueVerified: legacy.verified || legacy.is_blue_verified,
|
|
109
|
+
profileImageUrl: legacy.profile_image_url_https,
|
|
110
|
+
createdAt: legacy.created_at,
|
|
111
|
+
};
|
|
112
|
+
return user;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Extract tweets from timeline instructions
|
|
116
|
+
*/
|
|
117
|
+
export function extractTweetsFromInstructions(instructions) {
|
|
118
|
+
const tweets = [];
|
|
119
|
+
const seenIds = new Set();
|
|
120
|
+
for (const instruction of instructions) {
|
|
121
|
+
if (!instruction)
|
|
122
|
+
continue;
|
|
123
|
+
// Handle different instruction types
|
|
124
|
+
const entries = instruction.entries ||
|
|
125
|
+
instruction.moduleItems ||
|
|
126
|
+
[];
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry)
|
|
129
|
+
continue;
|
|
130
|
+
const content = entry.content || entry.item?.content;
|
|
131
|
+
if (!content)
|
|
132
|
+
continue;
|
|
133
|
+
// Try to find tweet data in various possible locations
|
|
134
|
+
const tweetData = content.tweet_results?.result ||
|
|
135
|
+
content.tweet?.tweet_results?.result ||
|
|
136
|
+
content.itemContent?.tweet_results?.result;
|
|
137
|
+
if (tweetData) {
|
|
138
|
+
const tweet = parseTweet(tweetData);
|
|
139
|
+
if (tweet && !seenIds.has(tweet.id)) {
|
|
140
|
+
tweets.push(tweet);
|
|
141
|
+
seenIds.add(tweet.id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return tweets;
|
|
147
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare class QueryIdManager {
|
|
2
|
+
private cache;
|
|
3
|
+
constructor();
|
|
4
|
+
private loadCache;
|
|
5
|
+
private saveCache;
|
|
6
|
+
getQueryId(operation: string): string | string[];
|
|
7
|
+
/**
|
|
8
|
+
* Fetch fresh query IDs from Twitter's web client bundle
|
|
9
|
+
*/
|
|
10
|
+
scrapeQueryIds(): Promise<Record<string, string>>;
|
|
11
|
+
/**
|
|
12
|
+
* Update query IDs by scraping Twitter's web client
|
|
13
|
+
*/
|
|
14
|
+
updateQueryIds(options?: {
|
|
15
|
+
force?: boolean;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
updated: number;
|
|
18
|
+
changes: string[];
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare const queryIdManager: QueryIdManager;
|
|
22
|
+
//# sourceMappingURL=query-ids.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-ids.d.ts","sourceRoot":"","sources":["../../src/client/query-ids.ts"],"names":[],"mappings":"AAkBA,qBAAa,cAAc;IACzB,OAAO,CAAC,KAAK,CAAyC;;IAMtD,OAAO,CAAC,SAAS;IAoBjB,OAAO,CAAC,SAAS;IAiBjB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE;IAYhD;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IA+CvD;;OAEG;IACG,cAAc,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CAkDrG;AAED,eAAO,MAAM,cAAc,gBAAuB,CAAC"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const BAKED_IDS = {
|
|
5
|
+
CreateTweet: 'nmdAQXJDxw6-0KKF2on7eA',
|
|
6
|
+
TweetDetail: ['_NvJCnIjOW__EP5-RF197A', '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'],
|
|
7
|
+
SearchTimeline: ['6AAys3t42mosm_yTI_QENg', 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA'],
|
|
8
|
+
UserByScreenName: ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w'],
|
|
9
|
+
UserTweets: 'Wms1GvIiHXAPBaCr9KblaA',
|
|
10
|
+
AboutAccountQuery: ['zs_jFPFT78rBpXv9Z3U2YQ', 'cY6EpQYqLfc7LGIJCevPqA'],
|
|
11
|
+
MentionsTimeline: 'Wms1GvIiHXAPBaCr9KblaA',
|
|
12
|
+
};
|
|
13
|
+
const CACHE_PATH = join(homedir(), '.config', 'twitter-client', 'query-ids.json');
|
|
14
|
+
const CACHE_VERSION = 'v1';
|
|
15
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
16
|
+
export class QueryIdManager {
|
|
17
|
+
cache = {};
|
|
18
|
+
constructor() {
|
|
19
|
+
this.loadCache();
|
|
20
|
+
}
|
|
21
|
+
loadCache() {
|
|
22
|
+
try {
|
|
23
|
+
const content = readFileSync(CACHE_PATH, 'utf-8');
|
|
24
|
+
const data = JSON.parse(content);
|
|
25
|
+
// Check cache version and TTL
|
|
26
|
+
if (data.version !== CACHE_VERSION) {
|
|
27
|
+
return; // Version mismatch, use baked-in
|
|
28
|
+
}
|
|
29
|
+
if (data.timestamp && Date.now() - data.timestamp > CACHE_TTL) {
|
|
30
|
+
return; // Cache expired, use baked-in
|
|
31
|
+
}
|
|
32
|
+
this.cache = data.queryIds || {};
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Cache doesn't exist or is invalid, use baked-in
|
|
36
|
+
this.cache = {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
saveCache(queryIds) {
|
|
40
|
+
try {
|
|
41
|
+
const dir = dirname(CACHE_PATH);
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const data = {
|
|
44
|
+
version: CACHE_VERSION,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
queryIds,
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.warn(`Failed to save query ID cache: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
getQueryId(operation) {
|
|
55
|
+
// Check cache first
|
|
56
|
+
if (this.cache[operation]) {
|
|
57
|
+
return this.cache[operation];
|
|
58
|
+
}
|
|
59
|
+
// Fall back to baked-in
|
|
60
|
+
if (BAKED_IDS[operation]) {
|
|
61
|
+
return BAKED_IDS[operation];
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Unknown operation: ${operation}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fetch fresh query IDs from Twitter's web client bundle
|
|
67
|
+
*/
|
|
68
|
+
async scrapeQueryIds() {
|
|
69
|
+
const operations = Object.keys(BAKED_IDS);
|
|
70
|
+
const queryIds = {};
|
|
71
|
+
// Fetch Twitter's main client JS bundle
|
|
72
|
+
const bundleUrl = 'https://abs.twimg.com/responsive-web/client-web/main.' + Date.now() + '.js';
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetch(bundleUrl);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Failed to fetch bundle: ${response.status}`);
|
|
77
|
+
}
|
|
78
|
+
const js = await response.text();
|
|
79
|
+
// Extract query IDs using regex
|
|
80
|
+
// Pattern: operationName:"queryId"
|
|
81
|
+
for (const op of operations) {
|
|
82
|
+
// Try multiple regex patterns
|
|
83
|
+
const patterns = [
|
|
84
|
+
new RegExp(`"${op}":"([A-Za-z0-9]+)"`, 'g'),
|
|
85
|
+
new RegExp(`queryId:\\s*"([A-Za-z0-9]+)"\\s*,\\s*\\w*:\\s*\\w*:\\s*\\{\\s*"name":\\s*"${op}"`, 'g'),
|
|
86
|
+
new RegExp(`"queryId":\\s*"([A-Za-z0-9]+)"`, 'g'),
|
|
87
|
+
];
|
|
88
|
+
for (const pattern of patterns) {
|
|
89
|
+
const matches = js.matchAll(pattern);
|
|
90
|
+
const ids = Array.from(matches).map(m => m[1]).filter(id => id.length >= 15 && id.length <= 25);
|
|
91
|
+
if (ids.length > 0) {
|
|
92
|
+
queryIds[op] = ids[0]; // Use first match
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
console.warn(`Scraping failed, using baked-in IDs: ${error}`);
|
|
100
|
+
// Return baked-in IDs as fallback
|
|
101
|
+
for (const op of operations) {
|
|
102
|
+
const id = BAKED_IDS[op];
|
|
103
|
+
queryIds[op] = Array.isArray(id) ? id[0] : id;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return queryIds;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Update query IDs by scraping Twitter's web client
|
|
110
|
+
*/
|
|
111
|
+
async updateQueryIds(options) {
|
|
112
|
+
const changes = [];
|
|
113
|
+
// Check if cache is still fresh (unless forced)
|
|
114
|
+
if (!options?.force) {
|
|
115
|
+
try {
|
|
116
|
+
const content = readFileSync(CACHE_PATH, 'utf-8');
|
|
117
|
+
const data = JSON.parse(content);
|
|
118
|
+
const age = Date.now() - (data.timestamp || 0);
|
|
119
|
+
if (age < CACHE_TTL) {
|
|
120
|
+
return { updated: 0, changes: [] };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Cache doesn't exist, proceed with update
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Scrape fresh IDs
|
|
128
|
+
const freshIds = await this.scrapeQueryIds();
|
|
129
|
+
// Build new cache with fresh IDs
|
|
130
|
+
const newCache = {};
|
|
131
|
+
for (const [op, freshId] of Object.entries(freshIds)) {
|
|
132
|
+
const oldId = this.cache[op] || BAKED_IDS[op];
|
|
133
|
+
const oldIdStr = Array.isArray(oldId) ? oldId[0] : oldId;
|
|
134
|
+
if (freshId !== oldIdStr) {
|
|
135
|
+
changes.push(`${op}: ${oldIdStr} → ${freshId}`);
|
|
136
|
+
}
|
|
137
|
+
// For operations with fallbacks, include multiple IDs
|
|
138
|
+
if (op === 'TweetDetail' || op === 'SearchTimeline') {
|
|
139
|
+
const bakedFallbacks = BAKED_IDS[op];
|
|
140
|
+
if (Array.isArray(bakedFallbacks)) {
|
|
141
|
+
newCache[op] = [freshId, ...bakedFallbacks.slice(1)];
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
newCache[op] = freshId;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
newCache[op] = freshId;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Save to cache
|
|
152
|
+
this.saveCache(newCache);
|
|
153
|
+
this.cache = newCache;
|
|
154
|
+
return { updated: changes.length, changes };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export const queryIdManager = new QueryIdManager();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class RestClient {
|
|
2
|
+
private authToken;
|
|
3
|
+
private ct0;
|
|
4
|
+
private userAgent;
|
|
5
|
+
private timeout;
|
|
6
|
+
constructor(authToken: string, ct0: string, userAgent: string, timeout?: number);
|
|
7
|
+
private fetchWithTimeout;
|
|
8
|
+
private getHeaders;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=rest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/client/rest.ts"],"names":[],"mappings":"AAEA,qBAAa,UAAU;IAInB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,GAAG;IACX,OAAO,CAAC,SAAS;IALnB,OAAO,CAAC,OAAO,CAAqB;gBAG1B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;YAKJ,gBAAgB;IAe9B,OAAO,CAAC,UAAU;CAgBnB"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class RestClient {
|
|
2
|
+
authToken;
|
|
3
|
+
ct0;
|
|
4
|
+
userAgent;
|
|
5
|
+
timeout;
|
|
6
|
+
constructor(authToken, ct0, userAgent, timeout) {
|
|
7
|
+
this.authToken = authToken;
|
|
8
|
+
this.ct0 = ct0;
|
|
9
|
+
this.userAgent = userAgent;
|
|
10
|
+
this.timeout = timeout;
|
|
11
|
+
}
|
|
12
|
+
async fetchWithTimeout(url, init) {
|
|
13
|
+
if (!this.timeout || this.timeout <= 0) {
|
|
14
|
+
return fetch(url, init);
|
|
15
|
+
}
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
18
|
+
try {
|
|
19
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
clearTimeout(timeoutId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
getHeaders() {
|
|
26
|
+
return {
|
|
27
|
+
'accept': '*/*',
|
|
28
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
29
|
+
'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
'x-csrf-token': this.ct0,
|
|
32
|
+
'x-twitter-auth-type': 'OAuth2Session',
|
|
33
|
+
'x-twitter-active-user': 'yes',
|
|
34
|
+
'x-twitter-client-language': 'en',
|
|
35
|
+
'cookie': `auth_token=${this.authToken}; ct0=${this.ct0}`,
|
|
36
|
+
'user-agent': this.userAgent,
|
|
37
|
+
'origin': 'https://x.com',
|
|
38
|
+
'referer': 'https://x.com/',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface Tweet {
|
|
2
|
+
id: string;
|
|
3
|
+
url: string;
|
|
4
|
+
text: string;
|
|
5
|
+
author: {
|
|
6
|
+
username: string;
|
|
7
|
+
name: string;
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
createdAt: string;
|
|
11
|
+
replyCount: number;
|
|
12
|
+
retweetCount: number;
|
|
13
|
+
likeCount: number;
|
|
14
|
+
viewCount?: number;
|
|
15
|
+
bookmarkCount?: number;
|
|
16
|
+
conversationId?: string;
|
|
17
|
+
inReplyToStatusId?: string;
|
|
18
|
+
quotedTweet?: Tweet;
|
|
19
|
+
media?: MediaItem[];
|
|
20
|
+
}
|
|
21
|
+
export interface MediaItem {
|
|
22
|
+
type: 'photo' | 'video' | 'gif';
|
|
23
|
+
url: string;
|
|
24
|
+
altText?: string;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
previewUrl?: string;
|
|
28
|
+
videoUrl?: string;
|
|
29
|
+
durationMs?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface User {
|
|
32
|
+
id: string;
|
|
33
|
+
username: string;
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
followersCount?: number;
|
|
37
|
+
followingCount?: number;
|
|
38
|
+
isBlueVerified?: boolean;
|
|
39
|
+
profileImageUrl?: string;
|
|
40
|
+
createdAt?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface NewsItem {
|
|
43
|
+
id: string;
|
|
44
|
+
headline: string;
|
|
45
|
+
category?: string;
|
|
46
|
+
timeAgo?: string;
|
|
47
|
+
postCount?: number;
|
|
48
|
+
description?: string;
|
|
49
|
+
url?: string;
|
|
50
|
+
tweets?: Tweet[];
|
|
51
|
+
}
|
|
52
|
+
export type SuccessResult<T> = {
|
|
53
|
+
success: true;
|
|
54
|
+
} & T;
|
|
55
|
+
export type ErrorResult = {
|
|
56
|
+
success: false;
|
|
57
|
+
error: string;
|
|
58
|
+
code?: ErrorCode;
|
|
59
|
+
};
|
|
60
|
+
export type Result<T> = SuccessResult<T> | ErrorResult;
|
|
61
|
+
export type ErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'INVALID_INPUT' | 'NETWORK_ERROR' | 'STALE_QUERY_ID';
|
|
62
|
+
export type TweetResult = Result<{
|
|
63
|
+
tweet: Tweet;
|
|
64
|
+
}>;
|
|
65
|
+
export type TweetsResult = Result<{
|
|
66
|
+
tweets: Tweet[];
|
|
67
|
+
nextCursor?: string;
|
|
68
|
+
}>;
|
|
69
|
+
export type PostResult = Result<{
|
|
70
|
+
tweetId: string;
|
|
71
|
+
}>;
|
|
72
|
+
export type UserResult = Result<{
|
|
73
|
+
user: User;
|
|
74
|
+
}>;
|
|
75
|
+
export type NewsResult = Result<{
|
|
76
|
+
items: NewsItem[];
|
|
77
|
+
}>;
|
|
78
|
+
export type SearchResult = TweetsResult;
|
|
79
|
+
export type ThreadResult = TweetsResult;
|
|
80
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/client/types.ts"],"names":[],"mappings":"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"}
|