viruagent-cli 0.6.2 → 0.7.1
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/README.ko.md +17 -1
- package/README.md +17 -1
- package/bin/index.js +24 -1
- package/package.json +3 -2
- package/src/providers/insta/apiClient.js +4 -2
- package/src/providers/reddit/apiClient.js +584 -0
- package/src/providers/reddit/auth.js +454 -0
- package/src/providers/reddit/index.js +233 -0
- package/src/providers/reddit/session.js +174 -0
- package/src/providers/reddit/utils.js +49 -0
- package/src/providers/tistory/auth.js +11 -0
- package/src/providers/tistory/selectors.js +9 -0
- package/src/providers/x/apiClient.js +674 -0
- package/src/providers/x/auth.js +87 -0
- package/src/providers/x/graphqlSync.js +140 -0
- package/src/providers/x/index.js +251 -0
- package/src/providers/x/session.js +114 -0
- package/src/providers/x/utils.js +32 -0
- package/src/runner.js +50 -1
- package/src/services/providerManager.js +6 -2
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { loadXSession, cookiesToHeader, loadRateLimits, saveRateLimits } = require('./session');
|
|
3
|
+
const { USER_AGENT, BEARER_TOKEN } = require('./auth');
|
|
4
|
+
const { getOperation, invalidateCache } = require('./graphqlSync');
|
|
5
|
+
const { ClientTransaction, handleXMigration } = require('x-client-transaction-id');
|
|
6
|
+
|
|
7
|
+
// ── x-client-transaction-id singleton (cached, auto-refreshes on error) ──
|
|
8
|
+
let _ctInstance = null;
|
|
9
|
+
let _ctInitTime = 0;
|
|
10
|
+
const CT_TTL_MS = 3600000; // 1 hour
|
|
11
|
+
|
|
12
|
+
const getClientTransaction = async () => {
|
|
13
|
+
if (_ctInstance && Date.now() - _ctInitTime < CT_TTL_MS) return _ctInstance;
|
|
14
|
+
const document = await handleXMigration();
|
|
15
|
+
_ctInstance = await ClientTransaction.create(document);
|
|
16
|
+
_ctInitTime = Date.now();
|
|
17
|
+
return _ctInstance;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const generateTransactionId = async (method, path) => {
|
|
21
|
+
try {
|
|
22
|
+
const ct = await getClientTransaction();
|
|
23
|
+
return await ct.generateTransactionId(method, path);
|
|
24
|
+
} catch {
|
|
25
|
+
// If generation fails, invalidate and retry once
|
|
26
|
+
_ctInstance = null;
|
|
27
|
+
try {
|
|
28
|
+
const ct = await getClientTransaction();
|
|
29
|
+
return await ct.generateTransactionId(method, path);
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined; // Proceed without the header if generation fails
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const randomDelay = (minSec, maxSec) => {
|
|
37
|
+
const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ──────────────────────────────────────────────────────────────
|
|
42
|
+
// X (Twitter) Safe Action Rules (2026, community research)
|
|
43
|
+
//
|
|
44
|
+
// Account age matters significantly:
|
|
45
|
+
// New (0~30 days): use limits below (conservative)
|
|
46
|
+
// Mature (90+ days): can roughly double these
|
|
47
|
+
//
|
|
48
|
+
// [Minimum action intervals + random jitter ±30%]
|
|
49
|
+
// Tweet: 120~300s (2~5min)
|
|
50
|
+
// Like: 30~60s
|
|
51
|
+
// Retweet: 60~120s
|
|
52
|
+
// Follow: 120~180s
|
|
53
|
+
// Unfollow: 120~180s
|
|
54
|
+
//
|
|
55
|
+
// [Hourly / Daily limits (new account safe zone)]
|
|
56
|
+
// Tweet: 10/h, 50/day (hard cap 2,400/day including replies)
|
|
57
|
+
// Like: 15/h, 200/day (hard cap ~500-1,000)
|
|
58
|
+
// Retweet: 10/h, 50/day (counts toward tweet cap)
|
|
59
|
+
// Follow: 10/h, 100/day (hard cap 400/day)
|
|
60
|
+
// Unfollow: 8/h, 80/day
|
|
61
|
+
//
|
|
62
|
+
// [226 error triggers]
|
|
63
|
+
// - Burst patterns / fixed intervals
|
|
64
|
+
// - Repetitive content
|
|
65
|
+
// - Write-only (no read behavior)
|
|
66
|
+
// - New account + high volume
|
|
67
|
+
// - Cooldown: 12~48h after 226, don't resume immediately
|
|
68
|
+
// ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const DELAY = {
|
|
71
|
+
tweet: [120, 300], // 2~5min
|
|
72
|
+
like: [30, 60], // 30~60s
|
|
73
|
+
retweet: [60, 120], // 1~2min
|
|
74
|
+
follow: [120, 180], // 2~3min
|
|
75
|
+
unfollow: [120, 180], // 2~3min
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const HOURLY_LIMIT = {
|
|
79
|
+
tweet: 10,
|
|
80
|
+
like: 15,
|
|
81
|
+
retweet: 10,
|
|
82
|
+
follow: 10,
|
|
83
|
+
unfollow: 8,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const DAILY_LIMIT = {
|
|
87
|
+
tweet: 50,
|
|
88
|
+
like: 200,
|
|
89
|
+
retweet: 50,
|
|
90
|
+
follow: 100,
|
|
91
|
+
unfollow: 80,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let lastActionTime = 0;
|
|
95
|
+
|
|
96
|
+
const createXApiClient = ({ sessionPath }) => {
|
|
97
|
+
let cachedCookies = null;
|
|
98
|
+
let countersCache = null;
|
|
99
|
+
|
|
100
|
+
// ── Cookie helpers ──
|
|
101
|
+
|
|
102
|
+
const getCookies = () => {
|
|
103
|
+
if (cachedCookies) return cachedCookies;
|
|
104
|
+
const cookies = loadXSession(sessionPath);
|
|
105
|
+
if (!cookies) {
|
|
106
|
+
throw new Error('No session file found. Please log in first.');
|
|
107
|
+
}
|
|
108
|
+
const authToken = cookies.find((c) => c.name === 'auth_token');
|
|
109
|
+
if (!authToken?.value) {
|
|
110
|
+
throw new Error('No valid cookies in session. Please log in again.');
|
|
111
|
+
}
|
|
112
|
+
cachedCookies = cookies;
|
|
113
|
+
return cookies;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const getCt0 = () => {
|
|
117
|
+
const cookies = getCookies();
|
|
118
|
+
const ct0 = cookies.find((c) => c.name === 'ct0');
|
|
119
|
+
return ct0?.value || '';
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ── Rate limit counters ──
|
|
123
|
+
|
|
124
|
+
const loadCounters = () => {
|
|
125
|
+
if (countersCache) return countersCache;
|
|
126
|
+
try {
|
|
127
|
+
const saved = loadRateLimits(sessionPath);
|
|
128
|
+
countersCache = saved || {};
|
|
129
|
+
} catch {
|
|
130
|
+
countersCache = {};
|
|
131
|
+
}
|
|
132
|
+
return countersCache;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const persistCounters = () => {
|
|
136
|
+
try {
|
|
137
|
+
if (!countersCache) return;
|
|
138
|
+
saveRateLimits(sessionPath, countersCache);
|
|
139
|
+
} catch {
|
|
140
|
+
// silent
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const getCounter = (type) => {
|
|
145
|
+
const counters = loadCounters();
|
|
146
|
+
if (!counters[type]) {
|
|
147
|
+
counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
|
|
148
|
+
}
|
|
149
|
+
const c = counters[type];
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
|
|
152
|
+
if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
|
|
153
|
+
return c;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const checkLimit = (type) => {
|
|
157
|
+
const c = getCounter(type);
|
|
158
|
+
const hourlyMax = HOURLY_LIMIT[type];
|
|
159
|
+
const dailyMax = DAILY_LIMIT[type];
|
|
160
|
+
if (hourlyMax && c.hourly >= hourlyMax) {
|
|
161
|
+
const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
|
|
162
|
+
throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
|
|
163
|
+
}
|
|
164
|
+
if (dailyMax && c.daily >= dailyMax) {
|
|
165
|
+
throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const incrementCounter = (type) => {
|
|
170
|
+
const c = getCounter(type);
|
|
171
|
+
c.hourly++;
|
|
172
|
+
c.daily++;
|
|
173
|
+
persistCounters();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const withDelay = async (type, fn) => {
|
|
177
|
+
checkLimit(type);
|
|
178
|
+
const [min, max] = DELAY[type] || [10, 20];
|
|
179
|
+
const elapsed = (Date.now() - lastActionTime) / 1000;
|
|
180
|
+
if (lastActionTime > 0 && elapsed < min) {
|
|
181
|
+
await randomDelay(min - elapsed, max - elapsed);
|
|
182
|
+
}
|
|
183
|
+
const result = await fn();
|
|
184
|
+
lastActionTime = Date.now();
|
|
185
|
+
incrementCounter(type);
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// ── Core request layer ──
|
|
190
|
+
|
|
191
|
+
const buildHeaders = () => ({
|
|
192
|
+
'User-Agent': USER_AGENT,
|
|
193
|
+
Authorization: `Bearer ${BEARER_TOKEN}`,
|
|
194
|
+
'x-csrf-token': getCt0(),
|
|
195
|
+
'x-twitter-auth-type': 'OAuth2Session',
|
|
196
|
+
'x-twitter-active-user': 'yes',
|
|
197
|
+
'x-twitter-client-language': 'ko',
|
|
198
|
+
Cookie: cookiesToHeader(getCookies()),
|
|
199
|
+
// Browser fingerprint headers
|
|
200
|
+
Origin: 'https://x.com',
|
|
201
|
+
Referer: 'https://x.com/',
|
|
202
|
+
'sec-ch-ua': '"Chromium";v="146", "Google Chrome";v="146", "Not=A?Brand";v="99"',
|
|
203
|
+
'sec-ch-ua-mobile': '?0',
|
|
204
|
+
'sec-ch-ua-platform': '"macOS"',
|
|
205
|
+
'sec-fetch-dest': 'empty',
|
|
206
|
+
'sec-fetch-mode': 'cors',
|
|
207
|
+
'sec-fetch-site': 'same-origin',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const request = async (url, options = {}) => {
|
|
211
|
+
const headers = { ...buildHeaders(), ...options.headers };
|
|
212
|
+
|
|
213
|
+
// Generate x-client-transaction-id for the request
|
|
214
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
215
|
+
const urlPath = new URL(url).pathname;
|
|
216
|
+
const transactionId = await generateTransactionId(method, urlPath);
|
|
217
|
+
if (transactionId) {
|
|
218
|
+
headers['x-client-transaction-id'] = transactionId;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const res = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
222
|
+
|
|
223
|
+
if (res.status === 401 || res.status === 403) {
|
|
224
|
+
throw new Error(`Authentication error (${res.status}). Session expired.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (res.status === 429) {
|
|
228
|
+
throw new Error('rate_limit: X API rate limit exceeded. Please wait and try again.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!res.ok && !options.allowError) {
|
|
232
|
+
throw new Error(`X API error: ${res.status} ${res.statusText}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return res;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// ── GraphQL helpers ──
|
|
239
|
+
|
|
240
|
+
const buildFeatures = (featureSwitches) => {
|
|
241
|
+
const features = {};
|
|
242
|
+
for (const f of featureSwitches) features[f] = true;
|
|
243
|
+
return features;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const buildFieldToggles = (fieldToggles) => {
|
|
247
|
+
const toggles = {};
|
|
248
|
+
for (const f of fieldToggles) toggles[f] = true;
|
|
249
|
+
return toggles;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const graphqlQuery = async (operationName, variables = {}) => {
|
|
253
|
+
const op = await getOperation(operationName);
|
|
254
|
+
const features = buildFeatures(op.featureSwitches);
|
|
255
|
+
const fieldToggles = buildFieldToggles(op.fieldToggles);
|
|
256
|
+
|
|
257
|
+
const params = new URLSearchParams({
|
|
258
|
+
variables: JSON.stringify(variables),
|
|
259
|
+
features: JSON.stringify(features),
|
|
260
|
+
fieldToggles: JSON.stringify(fieldToggles),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const url = `https://x.com/i/api/graphql/${op.queryId}/${operationName}?${params}`;
|
|
264
|
+
|
|
265
|
+
// Use POST if URL is too long (>2000 chars) to avoid 404
|
|
266
|
+
let res;
|
|
267
|
+
if (url.length > 2000) {
|
|
268
|
+
res = await request(`https://x.com/i/api/graphql/${op.queryId}/${operationName}`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
271
|
+
body: JSON.stringify({ variables, features, fieldToggles, queryId: op.queryId }),
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
res = await request(url);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const data = await res.json();
|
|
278
|
+
|
|
279
|
+
// If we get errors indicating stale queryId, re-sync and retry once
|
|
280
|
+
if (data.errors?.some((e) => e.message?.includes('Could not resolve'))) {
|
|
281
|
+
invalidateCache();
|
|
282
|
+
const retryOp = await getOperation(operationName);
|
|
283
|
+
const retryFeatures = buildFeatures(retryOp.featureSwitches);
|
|
284
|
+
const retryFieldToggles = buildFieldToggles(retryOp.fieldToggles);
|
|
285
|
+
const retryRes = await request(`https://x.com/i/api/graphql/${retryOp.queryId}/${operationName}`, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers: { 'Content-Type': 'application/json' },
|
|
288
|
+
body: JSON.stringify({ variables, features: retryFeatures, fieldToggles: retryFieldToggles, queryId: retryOp.queryId }),
|
|
289
|
+
});
|
|
290
|
+
return retryRes.json();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return data;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const graphqlMutation = async (operationName, variables = {}) => {
|
|
297
|
+
const op = await getOperation(operationName);
|
|
298
|
+
const body = JSON.stringify({
|
|
299
|
+
variables,
|
|
300
|
+
features: buildFeatures(op.featureSwitches),
|
|
301
|
+
fieldToggles: buildFieldToggles(op.fieldToggles),
|
|
302
|
+
queryId: op.queryId,
|
|
303
|
+
});
|
|
304
|
+
const res = await request(`https://x.com/i/api/graphql/${op.queryId}/${operationName}`, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'Content-Type': 'application/json' },
|
|
307
|
+
body,
|
|
308
|
+
});
|
|
309
|
+
const data = await res.json();
|
|
310
|
+
|
|
311
|
+
if (data.errors?.some((e) => e.message?.includes('Could not resolve'))) {
|
|
312
|
+
invalidateCache();
|
|
313
|
+
const retryOp = await getOperation(operationName);
|
|
314
|
+
const retryBody = JSON.stringify({
|
|
315
|
+
variables,
|
|
316
|
+
features: buildFeatures(retryOp.featureSwitches),
|
|
317
|
+
fieldToggles: buildFieldToggles(retryOp.fieldToggles),
|
|
318
|
+
queryId: retryOp.queryId,
|
|
319
|
+
});
|
|
320
|
+
const retryRes = await request(`https://x.com/i/api/graphql/${retryOp.queryId}/${operationName}`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: { 'Content-Type': 'application/json' },
|
|
323
|
+
body: retryBody,
|
|
324
|
+
});
|
|
325
|
+
return retryRes.json();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return data;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// ── API Methods ──
|
|
332
|
+
|
|
333
|
+
const getViewer = async () => {
|
|
334
|
+
const data = await graphqlQuery('Viewer');
|
|
335
|
+
const viewer = data?.data?.viewer?.user_results?.result;
|
|
336
|
+
if (!viewer) throw new Error('Failed to fetch viewer info.');
|
|
337
|
+
return {
|
|
338
|
+
id: viewer.rest_id,
|
|
339
|
+
username: viewer.core?.screen_name || viewer.legacy?.screen_name,
|
|
340
|
+
name: viewer.core?.name || viewer.legacy?.name,
|
|
341
|
+
description: viewer.legacy?.description,
|
|
342
|
+
followerCount: viewer.legacy?.followers_count || viewer.legacy?.normal_followers_count,
|
|
343
|
+
followingCount: viewer.legacy?.friends_count,
|
|
344
|
+
tweetCount: viewer.legacy?.statuses_count,
|
|
345
|
+
isVerified: viewer.is_blue_verified,
|
|
346
|
+
profileImageUrl: viewer.legacy?.profile_image_url_https || viewer.avatar?.image_url,
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const getUserByScreenName = async (screenName) => {
|
|
351
|
+
const data = await graphqlQuery('UserByScreenName', { screen_name: screenName });
|
|
352
|
+
const user = data?.data?.user?.result;
|
|
353
|
+
if (!user) throw new Error(`User not found: ${screenName}`);
|
|
354
|
+
return {
|
|
355
|
+
id: user.rest_id,
|
|
356
|
+
username: user.core?.screen_name || user.legacy?.screen_name,
|
|
357
|
+
name: user.core?.name || user.legacy?.name,
|
|
358
|
+
description: user.legacy?.description,
|
|
359
|
+
followerCount: user.legacy?.followers_count || user.legacy?.normal_followers_count,
|
|
360
|
+
followingCount: user.legacy?.friends_count,
|
|
361
|
+
tweetCount: user.legacy?.statuses_count,
|
|
362
|
+
isVerified: user.is_blue_verified,
|
|
363
|
+
profileImageUrl: user.legacy?.profile_image_url_https || user.avatar?.image_url,
|
|
364
|
+
bannerUrl: user.legacy?.profile_banner_url,
|
|
365
|
+
location: user.legacy?.location,
|
|
366
|
+
url: user.legacy?.url,
|
|
367
|
+
createdAt: user.legacy?.created_at || user.core?.created_at,
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const parseTweet = (entry) => {
|
|
372
|
+
const result = entry?.content?.itemContent?.tweet_results?.result
|
|
373
|
+
|| entry?.tweet_results?.result
|
|
374
|
+
|| entry;
|
|
375
|
+
const tweet = result?.tweet || result;
|
|
376
|
+
const legacy = tweet?.legacy;
|
|
377
|
+
if (!legacy) return null;
|
|
378
|
+
|
|
379
|
+
const userResult = tweet?.core?.user_results?.result;
|
|
380
|
+
const username = userResult?.core?.screen_name || userResult?.legacy?.screen_name;
|
|
381
|
+
const name = userResult?.core?.name || userResult?.legacy?.name;
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
id: legacy.id_str || tweet.rest_id,
|
|
385
|
+
text: legacy.full_text,
|
|
386
|
+
username,
|
|
387
|
+
name,
|
|
388
|
+
likeCount: legacy.favorite_count,
|
|
389
|
+
retweetCount: legacy.retweet_count,
|
|
390
|
+
replyCount: legacy.reply_count,
|
|
391
|
+
quoteCount: legacy.quote_count,
|
|
392
|
+
viewCount: tweet.views?.count ? Number(tweet.views.count) : null,
|
|
393
|
+
createdAt: legacy.created_at,
|
|
394
|
+
url: legacy.id_str ? `https://x.com/i/status/${legacy.id_str}` : null,
|
|
395
|
+
isRetweet: Boolean(legacy.retweeted_status_result),
|
|
396
|
+
isReply: Boolean(legacy.in_reply_to_status_id_str),
|
|
397
|
+
mediaUrls: legacy.entities?.media?.map((m) => m.media_url_https) || [],
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const getUserTweets = async (userId, count = 20) => {
|
|
402
|
+
const data = await graphqlQuery('UserTweets', {
|
|
403
|
+
userId,
|
|
404
|
+
count,
|
|
405
|
+
includePromotedContent: false,
|
|
406
|
+
withQuickPromoteEligibilityTweetFields: false,
|
|
407
|
+
withVoice: false,
|
|
408
|
+
withV2Timeline: true,
|
|
409
|
+
});
|
|
410
|
+
const timeline = data?.data?.user?.result?.timeline_v2?.timeline
|
|
411
|
+
|| data?.data?.user?.result?.timeline?.timeline;
|
|
412
|
+
const instructions = timeline?.instructions || [];
|
|
413
|
+
|
|
414
|
+
return collectEntries(instructions)
|
|
415
|
+
.map(parseTweet)
|
|
416
|
+
.filter(Boolean);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const getTweetDetail = async (tweetId) => {
|
|
420
|
+
const data = await graphqlQuery('TweetDetail', {
|
|
421
|
+
focalTweetId: tweetId,
|
|
422
|
+
with_rux_injections: false,
|
|
423
|
+
rankingMode: 'Relevance',
|
|
424
|
+
includePromotedContent: false,
|
|
425
|
+
withCommunity: true,
|
|
426
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
427
|
+
withBirdwatchNotes: true,
|
|
428
|
+
withVoice: true,
|
|
429
|
+
withV2Timeline: true,
|
|
430
|
+
});
|
|
431
|
+
const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions || [];
|
|
432
|
+
const entries = instructions
|
|
433
|
+
.find((i) => i.type === 'TimelineAddEntries')?.entries || [];
|
|
434
|
+
const focal = entries.find((e) => e.entryId?.startsWith('tweet-'));
|
|
435
|
+
if (!focal) throw new Error(`Tweet not found: ${tweetId}`);
|
|
436
|
+
return parseTweet(focal);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const collectEntries = (instructions) => {
|
|
440
|
+
const entries = [];
|
|
441
|
+
for (const inst of instructions) {
|
|
442
|
+
if (inst.type === 'TimelineAddEntries' && inst.entries) {
|
|
443
|
+
entries.push(...inst.entries);
|
|
444
|
+
}
|
|
445
|
+
if (inst.entry) entries.push(inst.entry);
|
|
446
|
+
}
|
|
447
|
+
return entries;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const getHomeTimeline = async (count = 20) => {
|
|
451
|
+
const data = await graphqlQuery('HomeLatestTimeline', {
|
|
452
|
+
count,
|
|
453
|
+
includePromotedContent: false,
|
|
454
|
+
latestControlAvailable: true,
|
|
455
|
+
requestContext: 'launch',
|
|
456
|
+
withCommunity: true,
|
|
457
|
+
});
|
|
458
|
+
const instructions = data?.data?.home?.home_timeline_urt?.instructions || [];
|
|
459
|
+
return collectEntries(instructions)
|
|
460
|
+
.map(parseTweet)
|
|
461
|
+
.filter(Boolean);
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const searchTimeline = async (query, count = 20) => {
|
|
465
|
+
const data = await graphqlQuery('SearchTimeline', {
|
|
466
|
+
rawQuery: query,
|
|
467
|
+
count,
|
|
468
|
+
querySource: 'typed_query',
|
|
469
|
+
product: 'Latest',
|
|
470
|
+
});
|
|
471
|
+
const instructions = data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
|
|
472
|
+
return collectEntries(instructions)
|
|
473
|
+
.map(parseTweet)
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// ── Write operations (rate-limited) ──
|
|
478
|
+
|
|
479
|
+
const createTweet = (text, options = {}) => withDelay('tweet', async () => {
|
|
480
|
+
const variables = {
|
|
481
|
+
tweet_text: text,
|
|
482
|
+
dark_request: false,
|
|
483
|
+
media: {
|
|
484
|
+
media_entities: options.mediaIds?.map((id) => ({ media_id: id, tagged_users: [] })) || [],
|
|
485
|
+
possibly_sensitive: false,
|
|
486
|
+
},
|
|
487
|
+
semantic_annotation_ids: [],
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
if (options.replyTo) {
|
|
491
|
+
variables.reply = {
|
|
492
|
+
in_reply_to_tweet_id: options.replyTo,
|
|
493
|
+
exclude_reply_user_ids: [],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const data = await graphqlMutation('CreateTweet', variables);
|
|
498
|
+
|
|
499
|
+
// Check for errors (e.g., 226 automated request detection)
|
|
500
|
+
if (data.errors?.length) {
|
|
501
|
+
const err = data.errors[0];
|
|
502
|
+
throw new Error(`CreateTweet failed: ${err.message || JSON.stringify(err)}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const result = data?.data?.create_tweet?.tweet_results?.result;
|
|
506
|
+
const tweetId = result?.rest_id || result?.tweet?.rest_id;
|
|
507
|
+
|
|
508
|
+
if (!tweetId) {
|
|
509
|
+
throw new Error('CreateTweet: No tweet ID in response. The tweet may not have been created.');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
id: tweetId,
|
|
514
|
+
text,
|
|
515
|
+
url: `https://x.com/i/status/${tweetId}`,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const deleteTweet = async (tweetId) => {
|
|
520
|
+
const data = await graphqlMutation('DeleteTweet', { tweet_id: tweetId, dark_request: false });
|
|
521
|
+
return { status: data?.data?.delete_tweet?.tweet_results ? 'ok' : 'fail' };
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const likeTweet = (tweetId) => withDelay('like', async () => {
|
|
525
|
+
const data = await graphqlMutation('FavoriteTweet', { tweet_id: tweetId });
|
|
526
|
+
return { status: data?.data?.favorite_tweet ? 'ok' : 'fail' };
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const unlikeTweet = (tweetId) => withDelay('like', async () => {
|
|
530
|
+
const data = await graphqlMutation('UnfavoriteTweet', { tweet_id: tweetId });
|
|
531
|
+
return { status: data?.data?.unfavorite_tweet ? 'ok' : 'fail' };
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const retweet = (tweetId) => withDelay('retweet', async () => {
|
|
535
|
+
const data = await graphqlMutation('CreateRetweet', { tweet_id: tweetId, dark_request: false });
|
|
536
|
+
return { status: data?.data?.create_retweet?.retweet_results ? 'ok' : 'fail' };
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const unretweet = async (tweetId) => {
|
|
540
|
+
const data = await graphqlMutation('DeleteRetweet', { source_tweet_id: tweetId, dark_request: false });
|
|
541
|
+
return { status: data?.data?.unretweet ? 'ok' : 'fail' };
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// ── Follow / Unfollow (v1.1 REST API) ──
|
|
545
|
+
|
|
546
|
+
const followUser = (userId) => withDelay('follow', async () => {
|
|
547
|
+
const body = new URLSearchParams({ user_id: userId });
|
|
548
|
+
const res = await request('https://x.com/i/api/1.1/friendships/create.json', {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
551
|
+
body: body.toString(),
|
|
552
|
+
});
|
|
553
|
+
const data = await res.json();
|
|
554
|
+
return { status: data?.id_str ? 'ok' : 'fail', following: true, username: data?.screen_name };
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const unfollowUser = (userId) => withDelay('unfollow', async () => {
|
|
558
|
+
const body = new URLSearchParams({ user_id: userId });
|
|
559
|
+
const res = await request('https://x.com/i/api/1.1/friendships/destroy.json', {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
562
|
+
body: body.toString(),
|
|
563
|
+
});
|
|
564
|
+
const data = await res.json();
|
|
565
|
+
return { status: data?.id_str ? 'ok' : 'fail', following: false, username: data?.screen_name };
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// ── Media upload (chunked, v1.1 REST API) ──
|
|
569
|
+
|
|
570
|
+
const uploadMedia = async (buffer, mediaType = 'image/jpeg') => {
|
|
571
|
+
const totalBytes = buffer.length;
|
|
572
|
+
const mediaCategory = mediaType.startsWith('video/') ? 'tweet_video'
|
|
573
|
+
: mediaType === 'image/gif' ? 'tweet_gif' : 'tweet_image';
|
|
574
|
+
|
|
575
|
+
// INIT
|
|
576
|
+
const initBody = new URLSearchParams({
|
|
577
|
+
command: 'INIT',
|
|
578
|
+
total_bytes: totalBytes.toString(),
|
|
579
|
+
media_type: mediaType,
|
|
580
|
+
media_category: mediaCategory,
|
|
581
|
+
});
|
|
582
|
+
const initRes = await request('https://upload.x.com/i/media/upload.json', {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
585
|
+
body: initBody.toString(),
|
|
586
|
+
});
|
|
587
|
+
const initData = await initRes.json();
|
|
588
|
+
const mediaId = initData.media_id_string;
|
|
589
|
+
|
|
590
|
+
// APPEND (single chunk for images, could extend for video)
|
|
591
|
+
const formData = new FormData();
|
|
592
|
+
formData.append('command', 'APPEND');
|
|
593
|
+
formData.append('media_id', mediaId);
|
|
594
|
+
formData.append('segment_index', '0');
|
|
595
|
+
formData.append('media_data', buffer.toString('base64'));
|
|
596
|
+
|
|
597
|
+
await request('https://upload.x.com/i/media/upload.json', {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
body: formData,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// FINALIZE
|
|
603
|
+
const finalizeBody = new URLSearchParams({
|
|
604
|
+
command: 'FINALIZE',
|
|
605
|
+
media_id: mediaId,
|
|
606
|
+
});
|
|
607
|
+
const finalizeRes = await request('https://upload.x.com/i/media/upload.json', {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
610
|
+
body: finalizeBody.toString(),
|
|
611
|
+
});
|
|
612
|
+
const finalizeData = await finalizeRes.json();
|
|
613
|
+
|
|
614
|
+
// Wait for processing if needed (video)
|
|
615
|
+
if (finalizeData.processing_info) {
|
|
616
|
+
let processing = finalizeData.processing_info;
|
|
617
|
+
while (processing.state === 'pending' || processing.state === 'in_progress') {
|
|
618
|
+
const waitSec = processing.check_after_secs || 5;
|
|
619
|
+
await new Promise((r) => setTimeout(r, waitSec * 1000));
|
|
620
|
+
const statusRes = await request(
|
|
621
|
+
`https://upload.x.com/i/media/upload.json?command=STATUS&media_id=${mediaId}`,
|
|
622
|
+
);
|
|
623
|
+
const statusData = await statusRes.json();
|
|
624
|
+
processing = statusData.processing_info;
|
|
625
|
+
if (!processing) break;
|
|
626
|
+
if (processing.state === 'failed') {
|
|
627
|
+
throw new Error(`Media processing failed: ${processing.error?.message || 'unknown'}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return mediaId;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const resetState = () => {
|
|
636
|
+
cachedCookies = null;
|
|
637
|
+
countersCache = null;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
getCookies,
|
|
642
|
+
getCt0,
|
|
643
|
+
getViewer,
|
|
644
|
+
getUserByScreenName,
|
|
645
|
+
getUserTweets,
|
|
646
|
+
getTweetDetail,
|
|
647
|
+
getHomeTimeline,
|
|
648
|
+
searchTimeline,
|
|
649
|
+
createTweet,
|
|
650
|
+
deleteTweet,
|
|
651
|
+
likeTweet,
|
|
652
|
+
unlikeTweet,
|
|
653
|
+
retweet,
|
|
654
|
+
unretweet,
|
|
655
|
+
followUser,
|
|
656
|
+
unfollowUser,
|
|
657
|
+
uploadMedia,
|
|
658
|
+
resetState,
|
|
659
|
+
getRateLimitStatus: () => {
|
|
660
|
+
const status = {};
|
|
661
|
+
for (const type of Object.keys(HOURLY_LIMIT)) {
|
|
662
|
+
const c = getCounter(type);
|
|
663
|
+
status[type] = {
|
|
664
|
+
hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
|
|
665
|
+
daily: `${c.daily}/${DAILY_LIMIT[type]}`,
|
|
666
|
+
delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
return status;
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
module.exports = createXApiClient;
|