x-relay-mcp 1.0.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/.claude/skills/x-relay/SKILL.md +130 -0
- package/README.md +21 -0
- package/dist/cli-DZkaLoUg.d.ts +232 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1958 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +1977 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-shim.d.ts +11 -0
- package/dist/mcp-shim.js +1956 -0
- package/dist/mcp-shim.js.map +1 -0
- package/package.json +116 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1977 @@
|
|
|
1
|
+
// src/output.ts
|
|
2
|
+
function ok(command, data) {
|
|
3
|
+
return { ok: true, command, data };
|
|
4
|
+
}
|
|
5
|
+
function err(command, code, message, hint) {
|
|
6
|
+
const error = hint !== void 0 ? { code, message, hint } : { code, message };
|
|
7
|
+
return { ok: false, command, error };
|
|
8
|
+
}
|
|
9
|
+
function toJson(envelope) {
|
|
10
|
+
return JSON.stringify(envelope, null, 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/ids.ts
|
|
14
|
+
var BARE_TWEET_ID_RE = /^\d{6,20}$/;
|
|
15
|
+
var PATH_TWEET_ID_RE = /^\d{1,20}$/;
|
|
16
|
+
var HANDLE_RE = /^[A-Za-z0-9_]{1,15}$/;
|
|
17
|
+
var X_HOSTS = /* @__PURE__ */ new Set([
|
|
18
|
+
"x.com",
|
|
19
|
+
"www.x.com",
|
|
20
|
+
"twitter.com",
|
|
21
|
+
"www.twitter.com",
|
|
22
|
+
"mobile.twitter.com",
|
|
23
|
+
"mobile.x.com"
|
|
24
|
+
]);
|
|
25
|
+
var RESERVED = /* @__PURE__ */ new Set([
|
|
26
|
+
"i",
|
|
27
|
+
"home",
|
|
28
|
+
"search",
|
|
29
|
+
"explore",
|
|
30
|
+
"notifications",
|
|
31
|
+
"messages",
|
|
32
|
+
"settings",
|
|
33
|
+
"compose",
|
|
34
|
+
"hashtag",
|
|
35
|
+
"status",
|
|
36
|
+
"intent",
|
|
37
|
+
"share",
|
|
38
|
+
"login",
|
|
39
|
+
"signup",
|
|
40
|
+
"about"
|
|
41
|
+
]);
|
|
42
|
+
function parseUrl(input) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(input);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function extractTweetId(input) {
|
|
50
|
+
if (!input) return null;
|
|
51
|
+
if (BARE_TWEET_ID_RE.test(input)) return input;
|
|
52
|
+
const url = parseUrl(input);
|
|
53
|
+
if (!url || !X_HOSTS.has(url.hostname)) return null;
|
|
54
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
55
|
+
const statusIdx = segments.indexOf("status");
|
|
56
|
+
if (statusIdx === -1) return null;
|
|
57
|
+
const id = segments[statusIdx + 1];
|
|
58
|
+
return id !== void 0 && PATH_TWEET_ID_RE.test(id) ? id : null;
|
|
59
|
+
}
|
|
60
|
+
function extractHandle(input) {
|
|
61
|
+
if (!input) return null;
|
|
62
|
+
const bare = input.startsWith("@") ? input.slice(1) : input;
|
|
63
|
+
if (HANDLE_RE.test(bare)) return bare;
|
|
64
|
+
const url = parseUrl(input);
|
|
65
|
+
if (!url || !X_HOSTS.has(url.hostname)) return null;
|
|
66
|
+
const first2 = url.pathname.split("/").filter(Boolean)[0];
|
|
67
|
+
if (first2 === void 0 || RESERVED.has(first2.toLowerCase())) return null;
|
|
68
|
+
return HANDLE_RE.test(first2) ? first2 : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/engine/auth.ts
|
|
72
|
+
var BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
|
|
73
|
+
var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
74
|
+
function parseJsonForm(input) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(input);
|
|
77
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
80
|
+
out[key] = String(value);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function parsePairForm(input) {
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const part of input.split(";")) {
|
|
90
|
+
const eq = part.indexOf("=");
|
|
91
|
+
if (eq === -1) continue;
|
|
92
|
+
const key = part.slice(0, eq).trim();
|
|
93
|
+
const value = part.slice(eq + 1).trim();
|
|
94
|
+
if (key.length > 0) out[key] = value;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function parseCookies(input) {
|
|
99
|
+
const jar = parseJsonForm(input.trim()) ?? parsePairForm(input);
|
|
100
|
+
const authToken = jar.auth_token;
|
|
101
|
+
const ct0 = jar.ct0;
|
|
102
|
+
if (authToken === void 0) throw new Error("Missing cookie: auth_token");
|
|
103
|
+
if (ct0 === void 0) throw new Error("Missing cookie: ct0");
|
|
104
|
+
const extra = {};
|
|
105
|
+
for (const [key, value] of Object.entries(jar)) {
|
|
106
|
+
if (key !== "auth_token" && key !== "ct0") extra[key] = value;
|
|
107
|
+
}
|
|
108
|
+
const cookies = { authToken, ct0 };
|
|
109
|
+
if (Object.keys(extra).length > 0) cookies.extra = extra;
|
|
110
|
+
return cookies;
|
|
111
|
+
}
|
|
112
|
+
function cookieString(cookies) {
|
|
113
|
+
const parts = [`auth_token=${cookies.authToken}`, `ct0=${cookies.ct0}`];
|
|
114
|
+
for (const [key, value] of Object.entries(cookies.extra ?? {})) {
|
|
115
|
+
parts.push(`${key}=${value}`);
|
|
116
|
+
}
|
|
117
|
+
return parts.join("; ");
|
|
118
|
+
}
|
|
119
|
+
function buildHeaders(args) {
|
|
120
|
+
const { cookies, transactionId, clientLanguage = "en" } = args;
|
|
121
|
+
return {
|
|
122
|
+
authorization: `Bearer ${BEARER_TOKEN}`,
|
|
123
|
+
"x-csrf-token": cookies.ct0,
|
|
124
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
125
|
+
"x-twitter-active-user": "yes",
|
|
126
|
+
"x-twitter-client-language": clientLanguage,
|
|
127
|
+
"content-type": "application/json",
|
|
128
|
+
cookie: cookieString(cookies),
|
|
129
|
+
"x-client-transaction-id": transactionId,
|
|
130
|
+
referer: "https://x.com/",
|
|
131
|
+
origin: "https://x.com",
|
|
132
|
+
"sec-fetch-site": "same-site",
|
|
133
|
+
"sec-fetch-mode": "cors",
|
|
134
|
+
"sec-fetch-dest": "empty",
|
|
135
|
+
"user-agent": USER_AGENT,
|
|
136
|
+
accept: "*/*",
|
|
137
|
+
"accept-language": "en-US,en;q=0.9"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/engine/ops.ts
|
|
142
|
+
var OPS = {
|
|
143
|
+
SearchTimeline: { queryId: "Yw6L66Pw54NHKuq4Dp7b4Q", operationName: "SearchTimeline" },
|
|
144
|
+
UserByScreenName: { queryId: "IGgvgiOx4QZndDHuD3x9TQ", operationName: "UserByScreenName" },
|
|
145
|
+
UserByRestId: { queryId: "VQfQ9wwYdk6j_u2O4vt64Q", operationName: "UserByRestId" },
|
|
146
|
+
UserTweets: { queryId: "36rb3Xj3iJ64Q-9wKDjCcQ", operationName: "UserTweets" },
|
|
147
|
+
UserTweetsAndReplies: {
|
|
148
|
+
queryId: "D5eKzDa5ZoJuC1TCeAXbWA",
|
|
149
|
+
operationName: "UserTweetsAndReplies"
|
|
150
|
+
},
|
|
151
|
+
UserMedia: { queryId: "9EovraBTXJYGSEQXZqlLmQ", operationName: "UserMedia" },
|
|
152
|
+
Bookmarks: { queryId: "XD0ViOeSOW4YoeNTGjVaYw", operationName: "Bookmarks" },
|
|
153
|
+
TweetDetail: { queryId: "oCon7R-cgWRFy6EfZjaKfg", operationName: "TweetDetail" },
|
|
154
|
+
Followers: { queryId: "_orfRBQae57vylFPH0Huhg", operationName: "Followers" },
|
|
155
|
+
Following: { queryId: "F42cDX8PDFxkbjjq6JrM2w", operationName: "Following" },
|
|
156
|
+
ListLatestTweetsTimeline: {
|
|
157
|
+
queryId: "7UuJsFvnWuZo0HmxrzU42Q",
|
|
158
|
+
operationName: "ListLatestTweetsTimeline"
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var FEATURES = {
|
|
162
|
+
rweb_video_screen_enabled: false,
|
|
163
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
164
|
+
rweb_tipjar_consumption_enabled: true,
|
|
165
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
166
|
+
verified_phone_label_enabled: false,
|
|
167
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
168
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
169
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
170
|
+
premium_content_api_read_enabled: false,
|
|
171
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
172
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
173
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
174
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
175
|
+
responsive_web_jetfuel_frame: false,
|
|
176
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
177
|
+
articles_preview_enabled: true,
|
|
178
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
179
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
180
|
+
view_counts_everywhere_api_enabled: true,
|
|
181
|
+
longform_notetweets_consumption_enabled: true,
|
|
182
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
183
|
+
tweet_awards_web_tipping_enabled: false,
|
|
184
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
185
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
186
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
187
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
188
|
+
standardized_nudges_misinfo: true,
|
|
189
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
190
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
191
|
+
longform_notetweets_inline_media_enabled: true,
|
|
192
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
193
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
194
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
195
|
+
payments_enabled: false,
|
|
196
|
+
hidden_profile_subscriptions_enabled: true,
|
|
197
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
198
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
199
|
+
responsive_web_enhance_cards_enabled: false
|
|
200
|
+
};
|
|
201
|
+
function graphqlUrl(op) {
|
|
202
|
+
const { queryId, operationName } = OPS[op];
|
|
203
|
+
return `https://x.com/i/api/graphql/${queryId}/${operationName}`;
|
|
204
|
+
}
|
|
205
|
+
function withCursor(base, cursor) {
|
|
206
|
+
return cursor === void 0 ? base : { ...base, cursor };
|
|
207
|
+
}
|
|
208
|
+
function searchRequest(params) {
|
|
209
|
+
const { query, count = 20, product = "Latest", cursor, kv, ft } = params;
|
|
210
|
+
const variables = withCursor(
|
|
211
|
+
{ rawQuery: query, count, querySource: "typed_query", product },
|
|
212
|
+
cursor
|
|
213
|
+
);
|
|
214
|
+
return {
|
|
215
|
+
variables: { ...variables, ...kv },
|
|
216
|
+
features: { ...FEATURES, ...ft },
|
|
217
|
+
fieldToggles: { withArticleRichContentState: false }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function bookmarksRequest(params) {
|
|
221
|
+
const { count = 20, cursor, kv, ft } = params;
|
|
222
|
+
const variables = withCursor({ count, includePromotedContent: true }, cursor);
|
|
223
|
+
return {
|
|
224
|
+
variables: { ...variables, ...kv },
|
|
225
|
+
features: { ...FEATURES, graphql_timeline_v2_bookmark_timeline: true, ...ft }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function userTweetsRequest(params) {
|
|
229
|
+
const { userId, count = 40, cursor, replies = false, kv, ft } = params;
|
|
230
|
+
const op = replies ? "UserTweetsAndReplies" : "UserTweets";
|
|
231
|
+
const variables = withCursor(
|
|
232
|
+
{
|
|
233
|
+
userId,
|
|
234
|
+
count,
|
|
235
|
+
includePromotedContent: false,
|
|
236
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
237
|
+
withVoice: true,
|
|
238
|
+
withV2Timeline: true
|
|
239
|
+
},
|
|
240
|
+
cursor
|
|
241
|
+
);
|
|
242
|
+
return {
|
|
243
|
+
op,
|
|
244
|
+
variables: { ...variables, ...kv },
|
|
245
|
+
features: { ...FEATURES, ...ft },
|
|
246
|
+
fieldToggles: { withArticlePlainText: false }
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function userByScreenNameRequest(params) {
|
|
250
|
+
const { screenName, kv, ft } = params;
|
|
251
|
+
return {
|
|
252
|
+
variables: { screen_name: screenName, withGrokTranslatedBio: false, ...kv },
|
|
253
|
+
features: { ...FEATURES, ...ft },
|
|
254
|
+
fieldToggles: { withPayments: false, withAuxiliaryUserLabels: true }
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function tweetDetailRequest(params) {
|
|
258
|
+
const { focalTweetId, cursor, kv, ft } = params;
|
|
259
|
+
const variables = withCursor(
|
|
260
|
+
{
|
|
261
|
+
focalTweetId,
|
|
262
|
+
with_rux_injections: false,
|
|
263
|
+
rankingMode: "Relevance",
|
|
264
|
+
includePromotedContent: true,
|
|
265
|
+
withCommunity: true,
|
|
266
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
267
|
+
withBirdwatchNotes: true,
|
|
268
|
+
withVoice: true
|
|
269
|
+
},
|
|
270
|
+
cursor
|
|
271
|
+
);
|
|
272
|
+
return {
|
|
273
|
+
variables: { ...variables, ...kv },
|
|
274
|
+
features: { ...FEATURES, ...ft },
|
|
275
|
+
fieldToggles: { withArticleRichContentState: false, withArticlePlainText: false }
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function stableStringify(value) {
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
281
|
+
}
|
|
282
|
+
if (value !== null && typeof value === "object") {
|
|
283
|
+
const obj = value;
|
|
284
|
+
const keys = Object.keys(obj).sort();
|
|
285
|
+
const body = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",");
|
|
286
|
+
return `{${body}}`;
|
|
287
|
+
}
|
|
288
|
+
return JSON.stringify(value) ?? "null";
|
|
289
|
+
}
|
|
290
|
+
function encodeParams(req) {
|
|
291
|
+
const params = new URLSearchParams();
|
|
292
|
+
params.set("variables", stableStringify(req.variables));
|
|
293
|
+
params.set("features", stableStringify(req.features));
|
|
294
|
+
if (req.fieldToggles !== void 0) {
|
|
295
|
+
params.set("fieldToggles", stableStringify(req.fieldToggles));
|
|
296
|
+
}
|
|
297
|
+
return params.toString();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/engine/client.ts
|
|
301
|
+
var DEFAULT_BACKOFF_MS = 1e3;
|
|
302
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
303
|
+
function isFeatureDrift(body) {
|
|
304
|
+
if (body === null || typeof body !== "object") return false;
|
|
305
|
+
const errors = body.errors;
|
|
306
|
+
if (!Array.isArray(errors)) return false;
|
|
307
|
+
return errors.some((err2) => {
|
|
308
|
+
if (err2 === null || typeof err2 !== "object") return false;
|
|
309
|
+
const code = err2.code;
|
|
310
|
+
const message = err2.message;
|
|
311
|
+
if (code === 336) return true;
|
|
312
|
+
return typeof message === "string" && /features cannot be null/i.test(message);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function featureDrift(op) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: {
|
|
319
|
+
code: "FEATURE_DRIFT",
|
|
320
|
+
message: `Feature drift on ${op}: X rejected the features blob \u2014 refresh src/engine/ops.ts features/query-ids.`
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function safeJson(res) {
|
|
325
|
+
try {
|
|
326
|
+
return await res.json();
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function backoffMs(res) {
|
|
332
|
+
const resetHeader = res.headers.get("x-rate-limit-reset");
|
|
333
|
+
const reset = resetHeader === null ? null : Number(resetHeader);
|
|
334
|
+
if (reset === null || !Number.isFinite(reset)) return DEFAULT_BACKOFF_MS;
|
|
335
|
+
const until = reset * 1e3 - Date.now();
|
|
336
|
+
return until > 0 ? until : DEFAULT_BACKOFF_MS;
|
|
337
|
+
}
|
|
338
|
+
var RETRY_NOW = { retry: true, waitMs: 0 };
|
|
339
|
+
function isRetry(value) {
|
|
340
|
+
return "retry" in value;
|
|
341
|
+
}
|
|
342
|
+
function terminalError(op, status, body) {
|
|
343
|
+
if (status === 400) {
|
|
344
|
+
if (isFeatureDrift(body)) return featureDrift(op);
|
|
345
|
+
return { ok: false, error: { code: "BAD_REQUEST", status: 400, message: "Bad request." } };
|
|
346
|
+
}
|
|
347
|
+
if (status === 401 || status === 403) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: {
|
|
351
|
+
code: "AUTH_FAILED",
|
|
352
|
+
status,
|
|
353
|
+
message: "Auth failed \u2014 session cookies expired or invalid."
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: { code: "FETCH_FAILED", status, message: `Request failed with status ${status}.` }
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function createClient(args) {
|
|
363
|
+
const {
|
|
364
|
+
cookies,
|
|
365
|
+
transaction,
|
|
366
|
+
fetchImpl = fetch,
|
|
367
|
+
sleep = defaultSleep,
|
|
368
|
+
maxRetries = 3,
|
|
369
|
+
clientLanguage
|
|
370
|
+
} = args;
|
|
371
|
+
async function fetchOnce(op, request) {
|
|
372
|
+
const url = `${graphqlUrl(op)}?${encodeParams(request)}`;
|
|
373
|
+
const path = new URL(url).pathname;
|
|
374
|
+
const txid = await transaction("GET", path);
|
|
375
|
+
const headers = buildHeaders({ cookies, transactionId: txid, clientLanguage });
|
|
376
|
+
return fetchImpl(url, { method: "GET", headers });
|
|
377
|
+
}
|
|
378
|
+
async function classify(op, res, retries) {
|
|
379
|
+
const { status } = res;
|
|
380
|
+
if (status === 200) {
|
|
381
|
+
const body2 = await safeJson(res);
|
|
382
|
+
return isFeatureDrift(body2) ? featureDrift(op) : { ok: true, value: body2 };
|
|
383
|
+
}
|
|
384
|
+
if (status === 429) {
|
|
385
|
+
if (retries.rateLimit >= maxRetries) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
error: { code: "RATE_LIMITED", status: 429, message: "Rate limited; retries exhausted." }
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
retries.rateLimit += 1;
|
|
392
|
+
return { retry: true, waitMs: backoffMs(res) };
|
|
393
|
+
}
|
|
394
|
+
if (status === 404) {
|
|
395
|
+
if (retries.notFound >= maxRetries) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: {
|
|
399
|
+
code: "NOT_FOUND",
|
|
400
|
+
status: 404,
|
|
401
|
+
message: "Not found; retries exhausted (stale txid?)."
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
retries.notFound += 1;
|
|
406
|
+
return RETRY_NOW;
|
|
407
|
+
}
|
|
408
|
+
const body = status === 400 ? await safeJson(res) : null;
|
|
409
|
+
return terminalError(op, status, body);
|
|
410
|
+
}
|
|
411
|
+
async function get(op, request) {
|
|
412
|
+
const retries = { rateLimit: 0, notFound: 0 };
|
|
413
|
+
for (; ; ) {
|
|
414
|
+
let res;
|
|
415
|
+
try {
|
|
416
|
+
res = await fetchOnce(op, request);
|
|
417
|
+
} catch (err2) {
|
|
418
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
419
|
+
return { ok: false, error: { code: "FETCH_FAILED", message } };
|
|
420
|
+
}
|
|
421
|
+
const outcome = await classify(op, res, retries);
|
|
422
|
+
if (!isRetry(outcome)) return outcome;
|
|
423
|
+
if (outcome.waitMs > 0) await sleep(outcome.waitMs);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { get };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/engine/cookies.ts
|
|
430
|
+
import { execFileSync } from "child_process";
|
|
431
|
+
import { createDecipheriv, pbkdf2Sync } from "crypto";
|
|
432
|
+
import { copyFileSync, existsSync, mkdtempSync, readdirSync } from "fs";
|
|
433
|
+
import { homedir, tmpdir } from "os";
|
|
434
|
+
import { join } from "path";
|
|
435
|
+
function deriveKey(keychainSecret2) {
|
|
436
|
+
return pbkdf2Sync(keychainSecret2, "saltysalt", 1003, 16, "sha1");
|
|
437
|
+
}
|
|
438
|
+
function isPrintableAscii(s) {
|
|
439
|
+
return /^[\x20-\x7e]*$/.test(s);
|
|
440
|
+
}
|
|
441
|
+
function decryptCookieValue(encrypted, key) {
|
|
442
|
+
if (encrypted.length === 0) return "";
|
|
443
|
+
const prefix = encrypted.subarray(0, 3).toString("latin1");
|
|
444
|
+
if (prefix !== "v10" && prefix !== "v11") return encrypted.toString("utf8");
|
|
445
|
+
const iv = Buffer.alloc(16, " ");
|
|
446
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
447
|
+
decipher.setAutoPadding(false);
|
|
448
|
+
let out;
|
|
449
|
+
try {
|
|
450
|
+
out = Buffer.concat([decipher.update(encrypted.subarray(3)), decipher.final()]);
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const pad = out[out.length - 1] ?? 0;
|
|
455
|
+
if (pad > 0 && pad <= 16) out = out.subarray(0, out.length - pad);
|
|
456
|
+
const direct = out.toString("utf8");
|
|
457
|
+
if (isPrintableAscii(direct)) return direct;
|
|
458
|
+
const stripped = out.subarray(32).toString("utf8");
|
|
459
|
+
if (isPrintableAscii(stripped)) return stripped;
|
|
460
|
+
return direct;
|
|
461
|
+
}
|
|
462
|
+
function pickAuthCookies(rows) {
|
|
463
|
+
let authToken;
|
|
464
|
+
let ct0;
|
|
465
|
+
const extra = {};
|
|
466
|
+
for (const row of rows) {
|
|
467
|
+
if (!row.name || !row.value) continue;
|
|
468
|
+
if (row.name === "auth_token") authToken = row.value;
|
|
469
|
+
else if (row.name === "ct0") ct0 = row.value;
|
|
470
|
+
else extra[row.name] = row.value;
|
|
471
|
+
}
|
|
472
|
+
if (authToken === void 0 || ct0 === void 0) return null;
|
|
473
|
+
const cookies = { authToken, ct0 };
|
|
474
|
+
if (Object.keys(extra).length > 0) cookies.extra = extra;
|
|
475
|
+
return cookies;
|
|
476
|
+
}
|
|
477
|
+
var BROWSERS = [
|
|
478
|
+
{ name: "arc", keychain: "Arc", base: "Arc/User Data" },
|
|
479
|
+
{ name: "chrome", keychain: "Chrome", base: "Google/Chrome" },
|
|
480
|
+
{ name: "brave", keychain: "Brave", base: "BraveSoftware/Brave-Browser" },
|
|
481
|
+
{ name: "edge", keychain: "Microsoft Edge", base: "Microsoft Edge" }
|
|
482
|
+
];
|
|
483
|
+
function browserOrder() {
|
|
484
|
+
const pref = (process.env.XRELAY_BROWSER ?? "").trim().toLowerCase();
|
|
485
|
+
if (!pref) return BROWSERS;
|
|
486
|
+
const first2 = BROWSERS.filter((b) => b.name === pref);
|
|
487
|
+
return first2.length ? [...first2, ...BROWSERS.filter((b) => b.name !== pref)] : BROWSERS;
|
|
488
|
+
}
|
|
489
|
+
function keychainSecret(browser) {
|
|
490
|
+
try {
|
|
491
|
+
return execFileSync(
|
|
492
|
+
"security",
|
|
493
|
+
["find-generic-password", "-w", "-s", `${browser.keychain} Safe Storage`],
|
|
494
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
|
495
|
+
).trim();
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function profileCookieDbs(browser) {
|
|
501
|
+
const root = join(homedir(), "Library", "Application Support", browser.base);
|
|
502
|
+
if (!existsSync(root)) return [];
|
|
503
|
+
const dbs = [];
|
|
504
|
+
const defaultDb = join(root, "Default", "Cookies");
|
|
505
|
+
if (existsSync(defaultDb)) dbs.push(defaultDb);
|
|
506
|
+
for (const entry of readdirSync(root)) {
|
|
507
|
+
if (entry.startsWith("Profile ")) {
|
|
508
|
+
const db = join(root, entry, "Cookies");
|
|
509
|
+
if (existsSync(db)) dbs.push(db);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return dbs;
|
|
513
|
+
}
|
|
514
|
+
function readCookieRows(dbPath) {
|
|
515
|
+
const tmp = join(mkdtempSync(join(tmpdir(), "xrelay-")), "Cookies");
|
|
516
|
+
copyFileSync(dbPath, tmp);
|
|
517
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
518
|
+
if (existsSync(dbPath + suffix)) copyFileSync(dbPath + suffix, tmp + suffix);
|
|
519
|
+
}
|
|
520
|
+
const sql = "SELECT host_key AS hostKey, name, hex(encrypted_value) AS hexValue FROM cookies WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'";
|
|
521
|
+
try {
|
|
522
|
+
const out = execFileSync("sqlite3", ["-readonly", "-json", tmp, sql], {
|
|
523
|
+
encoding: "utf8",
|
|
524
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
525
|
+
}).trim();
|
|
526
|
+
if (!out) return [];
|
|
527
|
+
return JSON.parse(out);
|
|
528
|
+
} catch {
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function extractCookies() {
|
|
533
|
+
if (process.platform !== "darwin") return null;
|
|
534
|
+
for (const browser of browserOrder()) {
|
|
535
|
+
const dbs = profileCookieDbs(browser);
|
|
536
|
+
if (dbs.length === 0) continue;
|
|
537
|
+
const secret = keychainSecret(browser);
|
|
538
|
+
if (!secret) continue;
|
|
539
|
+
const key = deriveKey(secret);
|
|
540
|
+
for (const db of dbs) {
|
|
541
|
+
const rows = readCookieRows(db).map((r) => ({
|
|
542
|
+
hostKey: r.hostKey,
|
|
543
|
+
name: r.name,
|
|
544
|
+
value: decryptCookieValue(Buffer.from(r.hexValue, "hex"), key) ?? ""
|
|
545
|
+
}));
|
|
546
|
+
const cookies = pickAuthCookies(rows);
|
|
547
|
+
if (cookies) return cookies;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
function getCookies() {
|
|
553
|
+
const env = process.env.XRELAY_COOKIES;
|
|
554
|
+
if (env) return parseCookies(env);
|
|
555
|
+
const cookies = extractCookies();
|
|
556
|
+
if (cookies) return cookies;
|
|
557
|
+
throw new Error(
|
|
558
|
+
'No X cookies found. Log into x.com in Arc/Chrome/Brave/Edge (macOS), or set XRELAY_COOKIES="auth_token=...; ct0=...". If a Keychain prompt appeared, click "Always Allow".'
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/engine/parse.ts
|
|
563
|
+
function isRecord(value) {
|
|
564
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
565
|
+
}
|
|
566
|
+
function child(node, key) {
|
|
567
|
+
const value = node[key];
|
|
568
|
+
return isRecord(value) ? value : void 0;
|
|
569
|
+
}
|
|
570
|
+
function asString(value) {
|
|
571
|
+
return typeof value === "string" ? value : void 0;
|
|
572
|
+
}
|
|
573
|
+
function asNumber(value) {
|
|
574
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
575
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
576
|
+
const parsed = Number(value);
|
|
577
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
578
|
+
}
|
|
579
|
+
return void 0;
|
|
580
|
+
}
|
|
581
|
+
function asBool(value) {
|
|
582
|
+
return typeof value === "boolean" ? value : void 0;
|
|
583
|
+
}
|
|
584
|
+
function findDict(obj, key, findFirst = false) {
|
|
585
|
+
const out = [];
|
|
586
|
+
const walk = (node) => {
|
|
587
|
+
if (Array.isArray(node)) {
|
|
588
|
+
return node.some(walk);
|
|
589
|
+
}
|
|
590
|
+
if (!isRecord(node)) return false;
|
|
591
|
+
for (const [k, value] of Object.entries(node)) {
|
|
592
|
+
if (k === key) {
|
|
593
|
+
out.push(value);
|
|
594
|
+
if (findFirst) return true;
|
|
595
|
+
}
|
|
596
|
+
if (walk(value)) return true;
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
};
|
|
600
|
+
walk(obj);
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
function parseUserResult(result) {
|
|
604
|
+
if (!isRecord(result)) return null;
|
|
605
|
+
if (result.__typename !== void 0 && result.__typename !== "User") return null;
|
|
606
|
+
const legacy = child(result, "legacy");
|
|
607
|
+
const core = child(result, "core");
|
|
608
|
+
const handle = asString(core?.screen_name) ?? asString(legacy?.screen_name);
|
|
609
|
+
const id = asString(result.rest_id) ?? asString(legacy?.id_str);
|
|
610
|
+
if (handle === void 0 || id === void 0) return null;
|
|
611
|
+
const name = asString(core?.name) ?? asString(legacy?.name) ?? handle;
|
|
612
|
+
const verification = child(result, "verification");
|
|
613
|
+
const verified = asBool(result.is_blue_verified) === true || asBool(verification?.verified) === true || asBool(legacy?.verified) === true;
|
|
614
|
+
const followers = asNumber(legacy?.followers_count) ?? asNumber(result.followers_count) ?? 0;
|
|
615
|
+
const following = asNumber(legacy?.friends_count) ?? asNumber(result.friends_count) ?? 0;
|
|
616
|
+
const tweets = asNumber(legacy?.statuses_count) ?? asNumber(result.statuses_count) ?? 0;
|
|
617
|
+
const profile = {
|
|
618
|
+
id,
|
|
619
|
+
handle,
|
|
620
|
+
name,
|
|
621
|
+
verified,
|
|
622
|
+
followers,
|
|
623
|
+
following,
|
|
624
|
+
tweets,
|
|
625
|
+
url: `https://x.com/${handle}`
|
|
626
|
+
};
|
|
627
|
+
applyUserOptionals(profile, result, legacy, core);
|
|
628
|
+
return profile;
|
|
629
|
+
}
|
|
630
|
+
function applyUserOptionals(profile, result, legacy, core) {
|
|
631
|
+
const profileBio = child(result, "profile_bio");
|
|
632
|
+
const bio = asString(legacy?.description) ?? asString(profileBio?.description);
|
|
633
|
+
if (bio !== void 0) profile.bio = bio;
|
|
634
|
+
const createdAt = asString(core?.created_at) ?? asString(legacy?.created_at);
|
|
635
|
+
if (createdAt !== void 0) profile.createdAt = createdAt;
|
|
636
|
+
const locationObj = child(result, "location");
|
|
637
|
+
const location = asString(legacy?.location) ?? asString(locationObj?.location);
|
|
638
|
+
if (location !== void 0) profile.location = location;
|
|
639
|
+
const avatarObj = child(result, "avatar");
|
|
640
|
+
const avatar = asString(legacy?.profile_image_url_https) ?? asString(avatarObj?.image_url);
|
|
641
|
+
if (avatar !== void 0) profile.avatar = avatar;
|
|
642
|
+
}
|
|
643
|
+
function authorFromProfile(profile) {
|
|
644
|
+
const author = {
|
|
645
|
+
id: profile.id,
|
|
646
|
+
handle: profile.handle,
|
|
647
|
+
name: profile.name,
|
|
648
|
+
verified: profile.verified,
|
|
649
|
+
followers: profile.followers
|
|
650
|
+
};
|
|
651
|
+
if (profile.avatar !== void 0) author.avatar = profile.avatar;
|
|
652
|
+
return author;
|
|
653
|
+
}
|
|
654
|
+
function parseAuthor(result) {
|
|
655
|
+
const core = child(result, "core");
|
|
656
|
+
const userResults = core ? child(core, "user_results") : void 0;
|
|
657
|
+
const userNode = userResults ? userResults.result : void 0;
|
|
658
|
+
const profile = parseUserResult(userNode);
|
|
659
|
+
return profile ? authorFromProfile(profile) : null;
|
|
660
|
+
}
|
|
661
|
+
function tweetText(result, legacy) {
|
|
662
|
+
const note = child(result, "note_tweet");
|
|
663
|
+
const noteResults = note ? child(note, "note_tweet_results") : void 0;
|
|
664
|
+
const noteResult = noteResults ? child(noteResults, "result") : void 0;
|
|
665
|
+
const noteText = noteResult ? asString(noteResult.text) : void 0;
|
|
666
|
+
if (noteText !== void 0) return noteText;
|
|
667
|
+
return asString(legacy?.full_text) ?? asString(result.full_text) ?? "";
|
|
668
|
+
}
|
|
669
|
+
function tweetViews(result, legacy) {
|
|
670
|
+
const views = child(result, "views") ?? child(result, "ext_views");
|
|
671
|
+
const extFromLegacy = legacy ? child(legacy, "ext_views") : void 0;
|
|
672
|
+
return asNumber(views?.count) ?? asNumber(extFromLegacy?.count);
|
|
673
|
+
}
|
|
674
|
+
function tweetMetrics(result, legacy) {
|
|
675
|
+
const pick = (key) => asNumber(legacy?.[key]) ?? asNumber(result[key]);
|
|
676
|
+
const metrics = {};
|
|
677
|
+
const likes = pick("favorite_count");
|
|
678
|
+
const retweets = pick("retweet_count");
|
|
679
|
+
const replies = pick("reply_count");
|
|
680
|
+
const quotes = pick("quote_count");
|
|
681
|
+
const bookmarks = pick("bookmark_count");
|
|
682
|
+
const views = tweetViews(result, legacy);
|
|
683
|
+
if (likes !== void 0) metrics.likes = likes;
|
|
684
|
+
if (retweets !== void 0) metrics.retweets = retweets;
|
|
685
|
+
if (replies !== void 0) metrics.replies = replies;
|
|
686
|
+
if (quotes !== void 0) metrics.quotes = quotes;
|
|
687
|
+
if (bookmarks !== void 0) metrics.bookmarks = bookmarks;
|
|
688
|
+
if (views !== void 0) metrics.views = views;
|
|
689
|
+
return metrics;
|
|
690
|
+
}
|
|
691
|
+
function entityStrings(entities, arrayKey, field) {
|
|
692
|
+
const arr = entities?.[arrayKey];
|
|
693
|
+
if (!Array.isArray(arr)) return void 0;
|
|
694
|
+
const out = [];
|
|
695
|
+
for (const item of arr) {
|
|
696
|
+
if (isRecord(item)) {
|
|
697
|
+
const value = asString(item[field]);
|
|
698
|
+
if (value !== void 0) out.push(value);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return out.length > 0 ? out : void 0;
|
|
702
|
+
}
|
|
703
|
+
var MEDIA_MAP = {
|
|
704
|
+
photo: "photo",
|
|
705
|
+
video: "video",
|
|
706
|
+
animated_gif: "gif"
|
|
707
|
+
};
|
|
708
|
+
function tweetMedia(extended) {
|
|
709
|
+
const arr = extended?.media;
|
|
710
|
+
if (!Array.isArray(arr)) return void 0;
|
|
711
|
+
const out = [];
|
|
712
|
+
for (const item of arr) {
|
|
713
|
+
if (isRecord(item)) {
|
|
714
|
+
const kind = asString(item.type);
|
|
715
|
+
if (kind !== void 0 && kind in MEDIA_MAP) {
|
|
716
|
+
const mapped = MEDIA_MAP[kind];
|
|
717
|
+
if (mapped !== void 0) out.push(mapped);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return out.length > 0 ? out : void 0;
|
|
722
|
+
}
|
|
723
|
+
function dualString(result, legacy, key) {
|
|
724
|
+
return asString(legacy?.[key]) ?? asString(result[key]);
|
|
725
|
+
}
|
|
726
|
+
function applyTweetDetails(tweet, result, legacy) {
|
|
727
|
+
const lang = dualString(result, legacy, "lang");
|
|
728
|
+
if (lang !== void 0) tweet.lang = lang;
|
|
729
|
+
const createdAt = dualString(result, legacy, "created_at");
|
|
730
|
+
if (createdAt !== void 0) tweet.createdAt = createdAt;
|
|
731
|
+
const conversationId = dualString(result, legacy, "conversation_id_str");
|
|
732
|
+
if (conversationId !== void 0) tweet.conversationId = conversationId;
|
|
733
|
+
const entities = child(result, "entities") ?? (legacy ? child(legacy, "entities") : void 0);
|
|
734
|
+
const extended = child(result, "extended_entities") ?? (legacy ? child(legacy, "extended_entities") : void 0);
|
|
735
|
+
const hashtags = entityStrings(entities, "hashtags", "text");
|
|
736
|
+
if (hashtags !== void 0) tweet.hashtags = hashtags;
|
|
737
|
+
const mentions = entityStrings(entities, "user_mentions", "screen_name");
|
|
738
|
+
if (mentions !== void 0) tweet.mentions = mentions;
|
|
739
|
+
const urls = entityStrings(entities, "urls", "expanded_url");
|
|
740
|
+
if (urls !== void 0) tweet.urls = urls;
|
|
741
|
+
const media = tweetMedia(extended);
|
|
742
|
+
if (media !== void 0) tweet.media = media;
|
|
743
|
+
}
|
|
744
|
+
function applyTweetRelations(tweet, result, legacy) {
|
|
745
|
+
if (dualString(result, legacy, "in_reply_to_status_id_str") !== void 0) tweet.isReply = true;
|
|
746
|
+
const isRetweet = child(result, "retweeted_status_result") !== void 0 || (legacy ? child(legacy, "retweeted_status_result") !== void 0 : false);
|
|
747
|
+
if (isRetweet) tweet.isRetweet = true;
|
|
748
|
+
const quoteId = dualString(result, legacy, "quoted_status_id_str");
|
|
749
|
+
const quotedNode = child(result, "quoted_status_result") ?? (legacy ? child(legacy, "quoted_status_result") : void 0);
|
|
750
|
+
if (quoteId !== void 0 || quotedNode !== void 0) tweet.isQuote = true;
|
|
751
|
+
if (quotedNode !== void 0) {
|
|
752
|
+
const quoted = parseTweetResult(quotedNode.result);
|
|
753
|
+
if (quoted !== null) tweet.quoted = quoted;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function parseTweetResult(result) {
|
|
757
|
+
if (!isRecord(result)) return null;
|
|
758
|
+
if (result.__typename === "TweetWithVisibilityResults") {
|
|
759
|
+
return parseTweetResult(result.tweet);
|
|
760
|
+
}
|
|
761
|
+
if (result.__typename === "TweetTombstone") return null;
|
|
762
|
+
const legacy = child(result, "legacy");
|
|
763
|
+
const id = asString(result.rest_id) ?? asString(legacy?.id_str);
|
|
764
|
+
if (id === void 0) return null;
|
|
765
|
+
const author = parseAuthor(result);
|
|
766
|
+
if (author === null) return null;
|
|
767
|
+
const tweet = {
|
|
768
|
+
id,
|
|
769
|
+
url: `https://x.com/${author.handle}/status/${id}`,
|
|
770
|
+
text: tweetText(result, legacy),
|
|
771
|
+
author,
|
|
772
|
+
metrics: tweetMetrics(result, legacy)
|
|
773
|
+
};
|
|
774
|
+
applyTweetDetails(tweet, result, legacy);
|
|
775
|
+
applyTweetRelations(tweet, result, legacy);
|
|
776
|
+
return tweet;
|
|
777
|
+
}
|
|
778
|
+
var TWEET_ENTRY_PREFIXES = ["tweet", "search-grid", "profile-conversation"];
|
|
779
|
+
var DROP_ENTRY_PREFIXES = ["cursor-", "promoted", "who-to-follow", "module-"];
|
|
780
|
+
function startsWithAny(value, prefixes) {
|
|
781
|
+
return prefixes.some((prefix) => value.startsWith(prefix));
|
|
782
|
+
}
|
|
783
|
+
function locateInstructions(json) {
|
|
784
|
+
const found = findDict(json, "instructions", true)[0];
|
|
785
|
+
return Array.isArray(found) ? found : [];
|
|
786
|
+
}
|
|
787
|
+
function collectEntries(instructions) {
|
|
788
|
+
const entries = [];
|
|
789
|
+
for (const instruction of instructions) {
|
|
790
|
+
if (isRecord(instruction) && Array.isArray(instruction.entries)) {
|
|
791
|
+
for (const entry of instruction.entries) {
|
|
792
|
+
if (isRecord(entry)) entries.push(entry);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return entries;
|
|
797
|
+
}
|
|
798
|
+
function entryBottomCursor(entry, entryId) {
|
|
799
|
+
const content = child(entry, "content");
|
|
800
|
+
const itemContent = content ? child(content, "itemContent") : void 0;
|
|
801
|
+
const cursorType = asString(content?.cursorType) ?? asString(itemContent?.cursorType);
|
|
802
|
+
const isBottom = cursorType === "Bottom" || cursorType === "ShowMoreThreads" || entryId.startsWith("cursor-bottom") || entryId.startsWith("cursor-showmore");
|
|
803
|
+
if (!isBottom) return void 0;
|
|
804
|
+
return asString(content?.value) ?? asString(itemContent?.value);
|
|
805
|
+
}
|
|
806
|
+
function entryTweetNodes(entry) {
|
|
807
|
+
return findDict(entry, "tweet_results").map((node) => isRecord(node) ? node.result : void 0);
|
|
808
|
+
}
|
|
809
|
+
function parseTimeline(json, _opts) {
|
|
810
|
+
const entries = collectEntries(locateInstructions(json));
|
|
811
|
+
const tweets = [];
|
|
812
|
+
const seen = /* @__PURE__ */ new Set();
|
|
813
|
+
let nextCursor;
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
const entryId = asString(entry.entryId) ?? "";
|
|
816
|
+
const cursor = entryBottomCursor(entry, entryId);
|
|
817
|
+
if (cursor !== void 0) nextCursor = cursor;
|
|
818
|
+
if (startsWithAny(entryId, DROP_ENTRY_PREFIXES)) continue;
|
|
819
|
+
if (!startsWithAny(entryId, TWEET_ENTRY_PREFIXES)) continue;
|
|
820
|
+
for (const node of entryTweetNodes(entry)) {
|
|
821
|
+
try {
|
|
822
|
+
const tweet = parseTweetResult(node);
|
|
823
|
+
if (tweet !== null && !seen.has(tweet.id)) {
|
|
824
|
+
seen.add(tweet.id);
|
|
825
|
+
tweets.push(tweet);
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const page = { tweets };
|
|
832
|
+
if (nextCursor !== void 0) page.nextCursor = nextCursor;
|
|
833
|
+
return page;
|
|
834
|
+
}
|
|
835
|
+
function parseThread(json, focalTweetId) {
|
|
836
|
+
const page = parseTimeline(json);
|
|
837
|
+
const root = page.tweets.find((tweet) => tweet.id === focalTweetId);
|
|
838
|
+
const replies = page.tweets.filter((tweet) => tweet.id !== focalTweetId);
|
|
839
|
+
const result = {
|
|
840
|
+
root: root ?? {
|
|
841
|
+
id: focalTweetId,
|
|
842
|
+
url: `https://x.com/i/status/${focalTweetId}`,
|
|
843
|
+
text: "",
|
|
844
|
+
author: { id: "", handle: "", name: "", verified: false },
|
|
845
|
+
metrics: {}
|
|
846
|
+
},
|
|
847
|
+
replies
|
|
848
|
+
};
|
|
849
|
+
if (page.nextCursor !== void 0) result.nextCursor = page.nextCursor;
|
|
850
|
+
return result;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/engine/xctid/transaction.ts
|
|
854
|
+
import { createHash } from "crypto";
|
|
855
|
+
|
|
856
|
+
// src/engine/xctid/cubic.ts
|
|
857
|
+
var Cubic = class {
|
|
858
|
+
curves;
|
|
859
|
+
constructor(curves) {
|
|
860
|
+
this.curves = curves;
|
|
861
|
+
}
|
|
862
|
+
getValue(time) {
|
|
863
|
+
const [c0 = 0, c1 = 0, c2 = 0, c3 = 0] = this.curves;
|
|
864
|
+
let startGradient = 0;
|
|
865
|
+
let endGradient = 0;
|
|
866
|
+
let start = 0;
|
|
867
|
+
let mid = 0;
|
|
868
|
+
const endInit = 1;
|
|
869
|
+
let end = endInit;
|
|
870
|
+
if (time <= 0) {
|
|
871
|
+
if (c0 > 0) {
|
|
872
|
+
startGradient = c1 / c0;
|
|
873
|
+
} else if (c1 === 0 && c2 > 0) {
|
|
874
|
+
startGradient = c3 / c2;
|
|
875
|
+
}
|
|
876
|
+
return startGradient * time;
|
|
877
|
+
}
|
|
878
|
+
if (time >= 1) {
|
|
879
|
+
if (c2 < 1) {
|
|
880
|
+
endGradient = (c3 - 1) / (c2 - 1);
|
|
881
|
+
} else if (c2 === 1 && c0 < 1) {
|
|
882
|
+
endGradient = (c1 - 1) / (c0 - 1);
|
|
883
|
+
}
|
|
884
|
+
return 1 + endGradient * (time - 1);
|
|
885
|
+
}
|
|
886
|
+
while (start < end) {
|
|
887
|
+
mid = (start + end) / 2;
|
|
888
|
+
const xEst = this.calculate(c0, c2, mid);
|
|
889
|
+
if (Math.abs(time - xEst) < 1e-5) {
|
|
890
|
+
return this.calculate(c1, c3, mid);
|
|
891
|
+
}
|
|
892
|
+
if (xEst < time) {
|
|
893
|
+
start = mid;
|
|
894
|
+
} else {
|
|
895
|
+
end = mid;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return this.calculate(c1, c3, mid);
|
|
899
|
+
}
|
|
900
|
+
calculate(a, b, m) {
|
|
901
|
+
return 3 * a * (1 - m) * (1 - m) * m + 3 * b * (1 - m) * m * m + m * m * m;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// src/engine/xctid/errors.ts
|
|
906
|
+
var ClientTransactionError = class extends Error {
|
|
907
|
+
code;
|
|
908
|
+
constructor(message, options = {}) {
|
|
909
|
+
super(message, options.cause ? { cause: options.cause } : void 0);
|
|
910
|
+
this.name = new.target.name;
|
|
911
|
+
this.code = options.code ?? "CLIENT_TRANSACTION_ERROR";
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
var ClientTransactionInitializationError = class extends ClientTransactionError {
|
|
915
|
+
constructor(message, options = {}) {
|
|
916
|
+
super(message, {
|
|
917
|
+
code: options.code ?? "CLIENT_TRANSACTION_INITIALIZATION_ERROR",
|
|
918
|
+
cause: options.cause
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
var OnDemandFileUrlResolutionError = class extends ClientTransactionInitializationError {
|
|
923
|
+
constructor() {
|
|
924
|
+
super("Unable to resolve the X ondemand chunk URL from the homepage runtime.", {
|
|
925
|
+
code: "ONDEMAND_FILE_URL_RESOLUTION_ERROR"
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
var OnDemandFileFetchError = class extends ClientTransactionInitializationError {
|
|
930
|
+
url;
|
|
931
|
+
status;
|
|
932
|
+
statusText;
|
|
933
|
+
constructor(url, status, statusText) {
|
|
934
|
+
super(`Unable to fetch the X ondemand chunk from "${url}": ${status} ${statusText}.`, {
|
|
935
|
+
code: "ONDEMAND_FILE_FETCH_ERROR"
|
|
936
|
+
});
|
|
937
|
+
this.url = url;
|
|
938
|
+
this.status = status;
|
|
939
|
+
this.statusText = statusText;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
var KeyByteIndicesExtractionError = class extends ClientTransactionInitializationError {
|
|
943
|
+
constructor() {
|
|
944
|
+
super("Unable to extract key byte indices from the X ondemand chunk.", {
|
|
945
|
+
code: "KEY_BYTE_INDICES_EXTRACTION_ERROR"
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
var SiteVerificationKeyNotFoundError = class extends ClientTransactionInitializationError {
|
|
950
|
+
constructor() {
|
|
951
|
+
super("Unable to find the twitter-site-verification meta tag in the homepage document.", {
|
|
952
|
+
code: "SITE_VERIFICATION_KEY_NOT_FOUND_ERROR"
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
var IndicesNotInitializedError = class extends ClientTransactionError {
|
|
957
|
+
constructor() {
|
|
958
|
+
super(
|
|
959
|
+
"ClientTransaction indices are not initialized. Call initialize() before generating animation data.",
|
|
960
|
+
{ code: "INDICES_NOT_INITIALIZED_ERROR" }
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
var AnimationFrameDataError = class extends ClientTransactionInitializationError {
|
|
965
|
+
rowIndex;
|
|
966
|
+
constructor(rowIndex) {
|
|
967
|
+
super(
|
|
968
|
+
`Unable to build animation data for row ${rowIndex}. The homepage animation markup may have changed.`,
|
|
969
|
+
{ code: "ANIMATION_FRAME_DATA_ERROR" }
|
|
970
|
+
);
|
|
971
|
+
this.rowIndex = rowIndex;
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
var ClientTransactionNotInitializedError = class extends ClientTransactionError {
|
|
975
|
+
constructor() {
|
|
976
|
+
super(
|
|
977
|
+
"ClientTransaction has not been initialized. Call initialize() or use ClientTransaction.create() first.",
|
|
978
|
+
{ code: "CLIENT_TRANSACTION_NOT_INITIALIZED_ERROR" }
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
var HandleXMigrationError = class extends ClientTransactionError {
|
|
983
|
+
constructor(message, options = {}) {
|
|
984
|
+
super(message, { code: options.code ?? "HANDLE_X_MIGRATION_ERROR", cause: options.cause });
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
var XHomePageFetchError = class extends HandleXMigrationError {
|
|
988
|
+
status;
|
|
989
|
+
statusText;
|
|
990
|
+
constructor(status, statusText) {
|
|
991
|
+
super(`Unable to fetch the X homepage: ${status} ${statusText}.`, {
|
|
992
|
+
code: "X_HOMEPAGE_FETCH_ERROR"
|
|
993
|
+
});
|
|
994
|
+
this.status = status;
|
|
995
|
+
this.statusText = statusText;
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
var XMigrationRedirectionError = class extends HandleXMigrationError {
|
|
999
|
+
status;
|
|
1000
|
+
statusText;
|
|
1001
|
+
constructor(status, statusText) {
|
|
1002
|
+
super(`Unable to follow the X migration redirect: ${status} ${statusText}.`, {
|
|
1003
|
+
code: "X_MIGRATION_REDIRECTION_ERROR"
|
|
1004
|
+
});
|
|
1005
|
+
this.status = status;
|
|
1006
|
+
this.statusText = statusText;
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
var XMigrationFormError = class extends HandleXMigrationError {
|
|
1010
|
+
status;
|
|
1011
|
+
statusText;
|
|
1012
|
+
constructor(status, statusText) {
|
|
1013
|
+
super(`Unable to submit the X migration form: ${status} ${statusText}.`, {
|
|
1014
|
+
code: "X_MIGRATION_FORM_ERROR"
|
|
1015
|
+
});
|
|
1016
|
+
this.status = status;
|
|
1017
|
+
this.statusText = statusText;
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
var InterpolationInputError = class extends ClientTransactionError {
|
|
1021
|
+
fromLength;
|
|
1022
|
+
toLength;
|
|
1023
|
+
constructor(fromLength, toLength) {
|
|
1024
|
+
super(
|
|
1025
|
+
`Interpolation requires arrays of the same length, but received ${fromLength} and ${toLength}.`,
|
|
1026
|
+
{ code: "INTERPOLATION_INPUT_ERROR" }
|
|
1027
|
+
);
|
|
1028
|
+
this.fromLength = fromLength;
|
|
1029
|
+
this.toLength = toLength;
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
// src/engine/xctid/interpolate.ts
|
|
1034
|
+
function interpolate(fromList, toList, f) {
|
|
1035
|
+
if (fromList.length !== toList.length) {
|
|
1036
|
+
throw new InterpolationInputError(fromList.length, toList.length);
|
|
1037
|
+
}
|
|
1038
|
+
const out = [];
|
|
1039
|
+
for (let i = 0; i < fromList.length; i++) {
|
|
1040
|
+
out.push(interpolateNum(fromList[i] ?? 0, toList[i] ?? 0, f));
|
|
1041
|
+
}
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
1044
|
+
function interpolateNum(fromVal, toVal, f) {
|
|
1045
|
+
if (typeof fromVal === "number" && typeof toVal === "number") {
|
|
1046
|
+
return fromVal * (1 - f) + toVal * f;
|
|
1047
|
+
}
|
|
1048
|
+
if (typeof fromVal === "boolean" && typeof toVal === "boolean") {
|
|
1049
|
+
return f < 0.5 ? fromVal ? 1 : 0 : toVal ? 1 : 0;
|
|
1050
|
+
}
|
|
1051
|
+
return 0;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/engine/xctid/rotation.ts
|
|
1055
|
+
function convertRotationToMatrix(rotation) {
|
|
1056
|
+
const rad = rotation * Math.PI / 180;
|
|
1057
|
+
return [Math.cos(rad), -Math.sin(rad), Math.sin(rad), Math.cos(rad)];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/engine/xctid/utils.ts
|
|
1061
|
+
import { parseHTML } from "linkedom";
|
|
1062
|
+
var BROWSER_HEADERS = {
|
|
1063
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
1064
|
+
"accept-language": "en-US,en;q=0.9",
|
|
1065
|
+
"cache-control": "no-cache",
|
|
1066
|
+
pragma: "no-cache",
|
|
1067
|
+
priority: "u=0, i",
|
|
1068
|
+
"sec-ch-ua": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
|
|
1069
|
+
"sec-ch-ua-mobile": "?0",
|
|
1070
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
1071
|
+
"sec-fetch-dest": "document",
|
|
1072
|
+
"sec-fetch-mode": "navigate",
|
|
1073
|
+
"sec-fetch-site": "none",
|
|
1074
|
+
"sec-fetch-user": "?1",
|
|
1075
|
+
"upgrade-insecure-requests": "1",
|
|
1076
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
|
1077
|
+
};
|
|
1078
|
+
async function handleXMigration(fetchImpl = fetch) {
|
|
1079
|
+
const response = await fetchImpl("https://x.com", { headers: BROWSER_HEADERS });
|
|
1080
|
+
if (!response.ok) {
|
|
1081
|
+
throw new XHomePageFetchError(response.status, response.statusText);
|
|
1082
|
+
}
|
|
1083
|
+
const htmlText = await response.text();
|
|
1084
|
+
let document = parseHTML(htmlText).window.document;
|
|
1085
|
+
const migrationRedirectionRegex = /(http(?:s)?:\/\/(?:www\.)?(twitter|x){1}\.com(\/x)?\/migrate([/?])?tok=[a-zA-Z0-9%\-_]+)+/i;
|
|
1086
|
+
const metaRefresh = document.querySelector("meta[http-equiv='refresh']");
|
|
1087
|
+
const metaContent = metaRefresh ? metaRefresh.getAttribute("content") || "" : "";
|
|
1088
|
+
const migrationRedirectionUrl = migrationRedirectionRegex.exec(metaContent) || migrationRedirectionRegex.exec(htmlText);
|
|
1089
|
+
if (migrationRedirectionUrl) {
|
|
1090
|
+
const redirectResponse = await fetchImpl(migrationRedirectionUrl[0]);
|
|
1091
|
+
if (!redirectResponse.ok) {
|
|
1092
|
+
throw new XMigrationRedirectionError(redirectResponse.status, redirectResponse.statusText);
|
|
1093
|
+
}
|
|
1094
|
+
document = parseHTML(await redirectResponse.text()).window.document;
|
|
1095
|
+
}
|
|
1096
|
+
const migrationForm = document.querySelector("form[name='f']") || document.querySelector("form[action='https://x.com/x/migrate']");
|
|
1097
|
+
if (migrationForm) {
|
|
1098
|
+
const url = migrationForm.getAttribute("action") || "https://x.com/x/migrate";
|
|
1099
|
+
const method = migrationForm.getAttribute("method") || "POST";
|
|
1100
|
+
const requestPayload = new FormData();
|
|
1101
|
+
for (const element of Array.from(migrationForm.querySelectorAll("input"))) {
|
|
1102
|
+
const name = element.getAttribute("name");
|
|
1103
|
+
const value = element.getAttribute("value");
|
|
1104
|
+
if (name && value) requestPayload.append(name, value);
|
|
1105
|
+
}
|
|
1106
|
+
const formResponse = await fetchImpl(url, { method, body: requestPayload });
|
|
1107
|
+
if (!formResponse.ok) {
|
|
1108
|
+
throw new XMigrationFormError(formResponse.status, formResponse.statusText);
|
|
1109
|
+
}
|
|
1110
|
+
document = parseHTML(await formResponse.text()).window.document;
|
|
1111
|
+
}
|
|
1112
|
+
return document;
|
|
1113
|
+
}
|
|
1114
|
+
function floatToHex(x) {
|
|
1115
|
+
const result = [];
|
|
1116
|
+
let n = x;
|
|
1117
|
+
let quotient = Math.floor(n);
|
|
1118
|
+
const fraction = n - quotient;
|
|
1119
|
+
while (quotient > 0) {
|
|
1120
|
+
quotient = Math.floor(n / 16);
|
|
1121
|
+
const remainder = Math.floor(n - quotient * 16);
|
|
1122
|
+
if (remainder > 9) {
|
|
1123
|
+
result.unshift(String.fromCharCode(remainder + 55));
|
|
1124
|
+
} else {
|
|
1125
|
+
result.unshift(remainder.toString());
|
|
1126
|
+
}
|
|
1127
|
+
n = quotient;
|
|
1128
|
+
}
|
|
1129
|
+
if (fraction === 0) return result.join("");
|
|
1130
|
+
result.push(".");
|
|
1131
|
+
let frac = fraction;
|
|
1132
|
+
while (frac > 0) {
|
|
1133
|
+
frac *= 16;
|
|
1134
|
+
const integer = Math.floor(frac);
|
|
1135
|
+
frac -= integer;
|
|
1136
|
+
if (integer > 9) {
|
|
1137
|
+
result.push(String.fromCharCode(integer + 55));
|
|
1138
|
+
} else {
|
|
1139
|
+
result.push(integer.toString());
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return result.join("");
|
|
1143
|
+
}
|
|
1144
|
+
function isOdd(num2) {
|
|
1145
|
+
return num2 % 2 ? -1 : 0;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/engine/xctid/transaction.ts
|
|
1149
|
+
var ON_DEMAND_CHUNK_NAME = "ondemand.s";
|
|
1150
|
+
var INDICES_REGEX = /\(\w\[(\d{1,2})\],\s*16\)/g;
|
|
1151
|
+
var ON_DEMAND_FILE_HASH_REGEX = /(\d+):\s*["']ondemand\.s["'][\s\S]*?\}\)\[e\]\s*\|\|\s*e\)\s*\+\s*["']\.["']\s*\+\s*\(\{[\s\S]*?\b\1:\s*["']([a-zA-Z0-9_-]+)["']/s;
|
|
1152
|
+
var DEFAULT_KEYWORD = "obfiowerehiring";
|
|
1153
|
+
var ADDITIONAL_RANDOM_NUMBER = 3;
|
|
1154
|
+
var EPOCH_SECONDS = 1682924400;
|
|
1155
|
+
function resolveOnDemandFileUrlFromRuntime(runtimeSource) {
|
|
1156
|
+
const match = ON_DEMAND_FILE_HASH_REGEX.exec(runtimeSource);
|
|
1157
|
+
if (!match) return null;
|
|
1158
|
+
return `https://abs.twimg.com/responsive-web/client-web/${ON_DEMAND_CHUNK_NAME}.${match[2]}a.js`;
|
|
1159
|
+
}
|
|
1160
|
+
async function assembleTransactionId(keyBytes, animationKey, method, path, timeNow, randomNum) {
|
|
1161
|
+
const timeNowBytes = [
|
|
1162
|
+
timeNow & 255,
|
|
1163
|
+
timeNow >> 8 & 255,
|
|
1164
|
+
timeNow >> 16 & 255,
|
|
1165
|
+
timeNow >> 24 & 255
|
|
1166
|
+
];
|
|
1167
|
+
const data = `${method}!${path}!${timeNow}${DEFAULT_KEYWORD}${animationKey}`;
|
|
1168
|
+
const hashBytes = [...createHash("sha256").update(data, "utf8").digest()];
|
|
1169
|
+
const rnd = randomNum ?? Math.floor(Math.random() * 256);
|
|
1170
|
+
const bytesArr = [
|
|
1171
|
+
...keyBytes,
|
|
1172
|
+
...timeNowBytes,
|
|
1173
|
+
...hashBytes.slice(0, 16),
|
|
1174
|
+
ADDITIONAL_RANDOM_NUMBER
|
|
1175
|
+
];
|
|
1176
|
+
const out = Uint8Array.from([rnd, ...bytesArr.map((b) => b ^ rnd)]);
|
|
1177
|
+
return Buffer.from(out).toString("base64").replace(/=/g, "");
|
|
1178
|
+
}
|
|
1179
|
+
var ClientTransaction = class _ClientTransaction {
|
|
1180
|
+
homePageDocument;
|
|
1181
|
+
rowIndex = null;
|
|
1182
|
+
keyByteIndices = null;
|
|
1183
|
+
key = null;
|
|
1184
|
+
keyBytes = null;
|
|
1185
|
+
animationKey = null;
|
|
1186
|
+
isInitialized = false;
|
|
1187
|
+
constructor(homePageDocument) {
|
|
1188
|
+
this.homePageDocument = homePageDocument;
|
|
1189
|
+
}
|
|
1190
|
+
static async create(homePageDocument) {
|
|
1191
|
+
const instance = new _ClientTransaction(homePageDocument);
|
|
1192
|
+
await instance.initialize();
|
|
1193
|
+
return instance;
|
|
1194
|
+
}
|
|
1195
|
+
async initialize() {
|
|
1196
|
+
if (this.isInitialized) return;
|
|
1197
|
+
[this.rowIndex, this.keyByteIndices] = await this.getIndices();
|
|
1198
|
+
this.key = this.getKey();
|
|
1199
|
+
this.keyBytes = getKeyBytes(this.key);
|
|
1200
|
+
this.animationKey = this.getAnimationKey(this.keyBytes);
|
|
1201
|
+
this.isInitialized = true;
|
|
1202
|
+
}
|
|
1203
|
+
async getIndices() {
|
|
1204
|
+
const onDemandFileUrl = this.getOnDemandFileUrl();
|
|
1205
|
+
const onDemandFileResponse = await fetch(onDemandFileUrl);
|
|
1206
|
+
if (!onDemandFileResponse.ok) {
|
|
1207
|
+
throw new OnDemandFileFetchError(
|
|
1208
|
+
onDemandFileUrl,
|
|
1209
|
+
onDemandFileResponse.status,
|
|
1210
|
+
onDemandFileResponse.statusText
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
const responseText = await onDemandFileResponse.text();
|
|
1214
|
+
const indices = [];
|
|
1215
|
+
INDICES_REGEX.lastIndex = 0;
|
|
1216
|
+
let match = INDICES_REGEX.exec(responseText);
|
|
1217
|
+
while (match !== null) {
|
|
1218
|
+
if (match[1] !== void 0) indices.push(Number.parseInt(match[1], 10));
|
|
1219
|
+
match = INDICES_REGEX.exec(responseText);
|
|
1220
|
+
}
|
|
1221
|
+
if (!indices.length) throw new KeyByteIndicesExtractionError();
|
|
1222
|
+
return [indices[0] ?? 0, indices.slice(1)];
|
|
1223
|
+
}
|
|
1224
|
+
getOnDemandFileUrl() {
|
|
1225
|
+
const doc = this.homePageDocument;
|
|
1226
|
+
const runtimeSources = Array.from(doc.querySelectorAll("script")).map((script) => script.textContent || "").filter((text) => text.includes(ON_DEMAND_CHUNK_NAME));
|
|
1227
|
+
runtimeSources.push(doc.documentElement.outerHTML);
|
|
1228
|
+
for (const runtimeSource of runtimeSources) {
|
|
1229
|
+
const url = resolveOnDemandFileUrlFromRuntime(runtimeSource);
|
|
1230
|
+
if (url) return url;
|
|
1231
|
+
}
|
|
1232
|
+
throw new OnDemandFileUrlResolutionError();
|
|
1233
|
+
}
|
|
1234
|
+
getKey() {
|
|
1235
|
+
const element = this.homePageDocument.querySelector("[name='twitter-site-verification']");
|
|
1236
|
+
const content = element ? element.getAttribute("content") ?? "" : "";
|
|
1237
|
+
if (!content) throw new SiteVerificationKeyNotFoundError();
|
|
1238
|
+
return content;
|
|
1239
|
+
}
|
|
1240
|
+
getFrames() {
|
|
1241
|
+
return Array.from(this.homePageDocument.querySelectorAll("[id^='loading-x-anim']"));
|
|
1242
|
+
}
|
|
1243
|
+
get2dArray(keyBytes) {
|
|
1244
|
+
const frames = this.getFrames();
|
|
1245
|
+
if (!frames.length) return [[]];
|
|
1246
|
+
const frame = frames[(keyBytes[5] ?? 0) % 4];
|
|
1247
|
+
const firstChild = frame?.children[0];
|
|
1248
|
+
const targetChild = firstChild?.children[1];
|
|
1249
|
+
const dAttr = targetChild?.getAttribute("d") ?? null;
|
|
1250
|
+
if (dAttr === null) return [];
|
|
1251
|
+
const items = dAttr.substring(9).split("C");
|
|
1252
|
+
return items.map((item) => {
|
|
1253
|
+
const cleaned = item.replace(/[^\d]+/g, " ").trim();
|
|
1254
|
+
const parts = cleaned === "" ? [] : cleaned.split(/\s+/);
|
|
1255
|
+
return parts.map((str) => Number.parseInt(str, 10));
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
solve(value, minVal, maxVal, rounding) {
|
|
1259
|
+
const result = value * (maxVal - minVal) / 255 + minVal;
|
|
1260
|
+
return rounding ? Math.floor(result) : Math.round(result * 100) / 100;
|
|
1261
|
+
}
|
|
1262
|
+
animate(frames, targetTime) {
|
|
1263
|
+
const fromColor = frames.slice(0, 3).concat(1).map(Number);
|
|
1264
|
+
const toColor = frames.slice(3, 6).concat(1).map(Number);
|
|
1265
|
+
const fromRotation = [0];
|
|
1266
|
+
const toRotation = [this.solve(frames[6] ?? 0, 60, 360, true)];
|
|
1267
|
+
const curves = frames.slice(7).map((item, counter) => this.solve(item, isOdd(counter), 1, false));
|
|
1268
|
+
const val = new Cubic(curves).getValue(targetTime);
|
|
1269
|
+
const color = interpolate(fromColor, toColor, val).map((value) => value > 0 ? value : 0);
|
|
1270
|
+
const rotation = interpolate(fromRotation, toRotation, val);
|
|
1271
|
+
const matrix = convertRotationToMatrix(rotation[0] ?? 0);
|
|
1272
|
+
const strArr = color.slice(0, -1).map((value) => Math.round(value).toString(16));
|
|
1273
|
+
for (const value of matrix) {
|
|
1274
|
+
let rounded = Math.round(value * 100) / 100;
|
|
1275
|
+
if (rounded < 0) rounded = -rounded;
|
|
1276
|
+
const hexValue = floatToHex(rounded);
|
|
1277
|
+
strArr.push(hexValue.startsWith(".") ? `0${hexValue}`.toLowerCase() : hexValue || "0");
|
|
1278
|
+
}
|
|
1279
|
+
strArr.push("0", "0");
|
|
1280
|
+
return strArr.join("").replace(/[.-]/g, "");
|
|
1281
|
+
}
|
|
1282
|
+
getAnimationKey(keyBytes) {
|
|
1283
|
+
const totalTime = 4096;
|
|
1284
|
+
if (this.rowIndex == null || this.keyByteIndices == null) {
|
|
1285
|
+
throw new IndicesNotInitializedError();
|
|
1286
|
+
}
|
|
1287
|
+
const rowIndex = (keyBytes[this.rowIndex] ?? 0) % 16;
|
|
1288
|
+
let frameTime = this.keyByteIndices.reduce((acc, idx) => acc * ((keyBytes[idx] ?? 0) % 16), 1);
|
|
1289
|
+
frameTime = Math.round(frameTime / 10) * 10;
|
|
1290
|
+
const arr = this.get2dArray(keyBytes);
|
|
1291
|
+
const frameRow = arr[rowIndex];
|
|
1292
|
+
if (!frameRow) throw new AnimationFrameDataError(rowIndex);
|
|
1293
|
+
return this.animate(frameRow, frameTime / totalTime);
|
|
1294
|
+
}
|
|
1295
|
+
/** Generates a transaction id for the given (method, path). Requires initialize(). */
|
|
1296
|
+
async generateTransactionId(method, path, timeNow) {
|
|
1297
|
+
if (!this.isInitialized || this.keyBytes == null || this.animationKey == null) {
|
|
1298
|
+
throw new ClientTransactionNotInitializedError();
|
|
1299
|
+
}
|
|
1300
|
+
const t = timeNow ?? Math.floor((Date.now() - EPOCH_SECONDS * 1e3) / 1e3);
|
|
1301
|
+
return assembleTransactionId(this.keyBytes, this.animationKey, method, path, t);
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
function getKeyBytes(key) {
|
|
1305
|
+
return Array.from(Buffer.from(key, "base64"));
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/engine/index.ts
|
|
1309
|
+
var DEFAULT_LIMIT = 40;
|
|
1310
|
+
var EMPTY_PAGE_TOLERANCE = 3;
|
|
1311
|
+
var EngineError = class extends Error {
|
|
1312
|
+
code;
|
|
1313
|
+
status;
|
|
1314
|
+
constructor(code, message, status) {
|
|
1315
|
+
super(message);
|
|
1316
|
+
this.name = "EngineError";
|
|
1317
|
+
this.code = code;
|
|
1318
|
+
if (status !== void 0) this.status = status;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
function idLte(a, b) {
|
|
1322
|
+
try {
|
|
1323
|
+
return BigInt(a) <= BigInt(b);
|
|
1324
|
+
} catch {
|
|
1325
|
+
return a.length === b.length ? a <= b : a.length < b.length;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function createTransactionProvider(fetchImpl) {
|
|
1329
|
+
let ctPromise;
|
|
1330
|
+
const init = async () => ClientTransaction.create(await handleXMigration(fetchImpl));
|
|
1331
|
+
const get = () => {
|
|
1332
|
+
if (ctPromise === void 0) ctPromise = init();
|
|
1333
|
+
return ctPromise;
|
|
1334
|
+
};
|
|
1335
|
+
return {
|
|
1336
|
+
provider: async (method, path) => (await get()).generateTransactionId(method, path),
|
|
1337
|
+
refresh: async () => {
|
|
1338
|
+
ctPromise = init();
|
|
1339
|
+
await ctPromise;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
async function paginate(fetchPage, limit, stopAtId) {
|
|
1344
|
+
const tweets = [];
|
|
1345
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1346
|
+
let cursor;
|
|
1347
|
+
let emptyStreak = 0;
|
|
1348
|
+
let reachedWatermark = false;
|
|
1349
|
+
while (tweets.length < limit && !reachedWatermark) {
|
|
1350
|
+
const page = await fetchPage(cursor);
|
|
1351
|
+
let fresh = 0;
|
|
1352
|
+
for (const t of page.tweets) {
|
|
1353
|
+
if (stopAtId !== void 0 && idLte(t.id, stopAtId)) {
|
|
1354
|
+
reachedWatermark = true;
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
if (seen.has(t.id)) continue;
|
|
1358
|
+
seen.add(t.id);
|
|
1359
|
+
tweets.push(t);
|
|
1360
|
+
fresh += 1;
|
|
1361
|
+
}
|
|
1362
|
+
if (reachedWatermark) break;
|
|
1363
|
+
if (fresh === 0) {
|
|
1364
|
+
emptyStreak += 1;
|
|
1365
|
+
if (emptyStreak >= EMPTY_PAGE_TOLERANCE) break;
|
|
1366
|
+
} else {
|
|
1367
|
+
emptyStreak = 0;
|
|
1368
|
+
}
|
|
1369
|
+
if (page.nextCursor === void 0 || page.nextCursor === cursor) break;
|
|
1370
|
+
cursor = page.nextCursor;
|
|
1371
|
+
}
|
|
1372
|
+
const out = { tweets: tweets.slice(0, limit) };
|
|
1373
|
+
if (cursor !== void 0 && !reachedWatermark) out.nextCursor = cursor;
|
|
1374
|
+
return out;
|
|
1375
|
+
}
|
|
1376
|
+
function createEngine(deps) {
|
|
1377
|
+
const txn = deps.transaction ? { provider: deps.transaction, refresh: async () => {
|
|
1378
|
+
} } : createTransactionProvider(deps.fetchImpl);
|
|
1379
|
+
const client = deps.client ?? createClient({
|
|
1380
|
+
cookies: deps.cookies ?? getCookies(),
|
|
1381
|
+
transaction: txn.provider,
|
|
1382
|
+
...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
|
|
1383
|
+
});
|
|
1384
|
+
async function call(op, request, retried = false) {
|
|
1385
|
+
const res = await client.get(op, request);
|
|
1386
|
+
if (res.ok) return res.value;
|
|
1387
|
+
if (res.error.code === "NOT_FOUND" && !retried) {
|
|
1388
|
+
await txn.refresh();
|
|
1389
|
+
return call(op, request, true);
|
|
1390
|
+
}
|
|
1391
|
+
throw new EngineError(res.error.code, res.error.message, res.error.status);
|
|
1392
|
+
}
|
|
1393
|
+
async function getUser(handle) {
|
|
1394
|
+
const value = await call("UserByScreenName", userByScreenNameRequest({ screenName: handle }));
|
|
1395
|
+
const result = findDict(value, "result", true)[0];
|
|
1396
|
+
return parseUserResult(result);
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
async search(query, opts) {
|
|
1400
|
+
const product = opts?.product ?? "Top";
|
|
1401
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT;
|
|
1402
|
+
const page = await paginate(async (cursor) => {
|
|
1403
|
+
const value = await call("SearchTimeline", searchRequest({ query, product, cursor }));
|
|
1404
|
+
return parseTimeline(value);
|
|
1405
|
+
}, limit);
|
|
1406
|
+
const out = { query, product, tweets: page.tweets };
|
|
1407
|
+
if (page.nextCursor !== void 0) out.nextCursor = page.nextCursor;
|
|
1408
|
+
return out;
|
|
1409
|
+
},
|
|
1410
|
+
user: getUser,
|
|
1411
|
+
async userTweets(handle, opts) {
|
|
1412
|
+
const profile = await getUser(handle);
|
|
1413
|
+
if (!profile) throw new EngineError("NOT_FOUND", `user @${handle} not found`);
|
|
1414
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT;
|
|
1415
|
+
return paginate(
|
|
1416
|
+
async (cursor) => {
|
|
1417
|
+
const req = userTweetsRequest({ userId: profile.id, replies: opts?.replies, cursor });
|
|
1418
|
+
const value = await call(req.op, req);
|
|
1419
|
+
return parseTimeline(value);
|
|
1420
|
+
},
|
|
1421
|
+
limit,
|
|
1422
|
+
opts?.stopAtId
|
|
1423
|
+
);
|
|
1424
|
+
},
|
|
1425
|
+
async bookmarks(opts) {
|
|
1426
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT;
|
|
1427
|
+
return paginate(
|
|
1428
|
+
async (cursor) => {
|
|
1429
|
+
const value = await call("Bookmarks", bookmarksRequest({ cursor }));
|
|
1430
|
+
return parseTimeline(value);
|
|
1431
|
+
},
|
|
1432
|
+
limit,
|
|
1433
|
+
opts?.stopAtId
|
|
1434
|
+
);
|
|
1435
|
+
},
|
|
1436
|
+
async thread(id) {
|
|
1437
|
+
const value = await call("TweetDetail", tweetDetailRequest({ focalTweetId: id }));
|
|
1438
|
+
return parseThread(value, id);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/commands/registry.ts
|
|
1444
|
+
var COMMANDS = [
|
|
1445
|
+
{
|
|
1446
|
+
name: "search",
|
|
1447
|
+
cost: "cheap \u2014 the net",
|
|
1448
|
+
summary: "Live X search. Cast a wide net; rank on engagement/recency metadata.",
|
|
1449
|
+
usage: 'xrelay search "<query>" [--limit N] [--product Top|Latest|Media|People]\n [--from <h>] [--to <h>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]\n [--lang xx] [--min-faves N] [--min-retweets N] [--filter media|links|replies|-replies ...]'
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
name: "user",
|
|
1453
|
+
cost: "1 call",
|
|
1454
|
+
summary: "Profile lookup: bio, followers, verified, counts, joined.",
|
|
1455
|
+
usage: "xrelay user <handle|url>"
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
name: "user-posts",
|
|
1459
|
+
cost: "medium",
|
|
1460
|
+
summary: "A user's timeline (optionally including replies).",
|
|
1461
|
+
usage: "xrelay user-posts <handle|url> [--replies] [--limit N]"
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
name: "thread",
|
|
1465
|
+
cost: "expensive \u2014 full read",
|
|
1466
|
+
summary: "A tweet plus its reply thread. Read only the finalists.",
|
|
1467
|
+
usage: "xrelay thread <id|url>"
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
name: "bookmarks",
|
|
1471
|
+
cost: "cheap \u2014 local cache",
|
|
1472
|
+
summary: "Search your saved posts in the local cache. --sync to refresh, --live to hit X.",
|
|
1473
|
+
usage: 'xrelay bookmarks [-q "<query>"] [--limit N] [--sort relevance|newest|likes|views|bookmarks]\n [--sync] [--repair] [--live]'
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
name: "my-posts",
|
|
1477
|
+
cost: "cheap \u2014 local cache",
|
|
1478
|
+
summary: "Search your own posts in the local cache. --sync to refresh, --live to hit X.",
|
|
1479
|
+
usage: 'xrelay my-posts [-q "<query>"] [--limit N] [--sort ...] [--handle <you>] [--sync] [--live]'
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
name: "sync",
|
|
1483
|
+
cost: "medium \u2014 incremental",
|
|
1484
|
+
summary: "Pull only NEW bookmarks/posts since the last sync into the local cache.",
|
|
1485
|
+
usage: "xrelay sync bookmarks|posts|all [--handle <you>] [--repair]"
|
|
1486
|
+
}
|
|
1487
|
+
];
|
|
1488
|
+
var commandNames = COMMANDS.map((c) => c.name);
|
|
1489
|
+
|
|
1490
|
+
// src/cache/store.ts
|
|
1491
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
1492
|
+
import { homedir as homedir2 } from "os";
|
|
1493
|
+
import { join as join2 } from "path";
|
|
1494
|
+
function cacheDir() {
|
|
1495
|
+
return process.env.XRELAY_CACHE_DIR ?? join2(homedir2(), ".xrelay");
|
|
1496
|
+
}
|
|
1497
|
+
function cachePath(source, dir) {
|
|
1498
|
+
return join2(dir ?? cacheDir(), `${source}.json`);
|
|
1499
|
+
}
|
|
1500
|
+
function loadCache(source, dir) {
|
|
1501
|
+
try {
|
|
1502
|
+
const raw = readFileSync(cachePath(source, dir), "utf8");
|
|
1503
|
+
return JSON.parse(raw);
|
|
1504
|
+
} catch {
|
|
1505
|
+
return { source, tweets: {} };
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
function saveCache(file, dir) {
|
|
1509
|
+
const target = cachePath(file.source, dir);
|
|
1510
|
+
mkdirSync(dir ?? cacheDir(), { recursive: true });
|
|
1511
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
1512
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}
|
|
1513
|
+
`);
|
|
1514
|
+
renameSync(tmp, target);
|
|
1515
|
+
}
|
|
1516
|
+
function isHigherId(a, b) {
|
|
1517
|
+
try {
|
|
1518
|
+
return BigInt(a) > BigInt(b);
|
|
1519
|
+
} catch {
|
|
1520
|
+
if (a.length !== b.length) return a.length > b.length;
|
|
1521
|
+
return a > b;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
function mergeTweets(file, fresh) {
|
|
1525
|
+
let added = 0;
|
|
1526
|
+
for (const t of fresh) {
|
|
1527
|
+
if (!(t.id in file.tweets)) added += 1;
|
|
1528
|
+
file.tweets[t.id] = t;
|
|
1529
|
+
}
|
|
1530
|
+
let watermark;
|
|
1531
|
+
for (const id of Object.keys(file.tweets)) {
|
|
1532
|
+
if (watermark === void 0 || isHigherId(id, watermark)) watermark = id;
|
|
1533
|
+
}
|
|
1534
|
+
if (watermark !== void 0) file.watermark = watermark;
|
|
1535
|
+
return { added };
|
|
1536
|
+
}
|
|
1537
|
+
function allTweets(file) {
|
|
1538
|
+
return Object.values(file.tweets);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// src/cache/search.ts
|
|
1542
|
+
var DEFAULT_LIMIT2 = 20;
|
|
1543
|
+
function tokenize(query) {
|
|
1544
|
+
return query.toLowerCase().split(/\s+/).filter((token) => token.length > 0);
|
|
1545
|
+
}
|
|
1546
|
+
function countOccurrences(haystack, needle) {
|
|
1547
|
+
if (needle.length === 0) return 0;
|
|
1548
|
+
let count = 0;
|
|
1549
|
+
let from = 0;
|
|
1550
|
+
for (; ; ) {
|
|
1551
|
+
const idx = haystack.indexOf(needle, from);
|
|
1552
|
+
if (idx === -1) break;
|
|
1553
|
+
count += 1;
|
|
1554
|
+
from = idx + needle.length;
|
|
1555
|
+
}
|
|
1556
|
+
return count;
|
|
1557
|
+
}
|
|
1558
|
+
function scoreTweet(tweet, tokens) {
|
|
1559
|
+
const haystack = `${tweet.text} ${tweet.author.handle} ${tweet.author.name}`.toLowerCase();
|
|
1560
|
+
let score = 0;
|
|
1561
|
+
for (const token of tokens) {
|
|
1562
|
+
score += countOccurrences(haystack, token);
|
|
1563
|
+
}
|
|
1564
|
+
return score;
|
|
1565
|
+
}
|
|
1566
|
+
function compareIds(a, b) {
|
|
1567
|
+
const ai = BigInt(a);
|
|
1568
|
+
const bi = BigInt(b);
|
|
1569
|
+
if (ai < bi) return -1;
|
|
1570
|
+
if (ai > bi) return 1;
|
|
1571
|
+
return 0;
|
|
1572
|
+
}
|
|
1573
|
+
function metric(tweet, key) {
|
|
1574
|
+
return tweet.metrics[key] ?? 0;
|
|
1575
|
+
}
|
|
1576
|
+
function comparator(sort) {
|
|
1577
|
+
switch (sort) {
|
|
1578
|
+
case "newest":
|
|
1579
|
+
return (a, b) => compareIds(b.tweet.id, a.tweet.id);
|
|
1580
|
+
case "oldest":
|
|
1581
|
+
return (a, b) => compareIds(a.tweet.id, b.tweet.id);
|
|
1582
|
+
case "likes":
|
|
1583
|
+
return (a, b) => metric(b.tweet, "likes") - metric(a.tweet, "likes");
|
|
1584
|
+
case "views":
|
|
1585
|
+
return (a, b) => metric(b.tweet, "views") - metric(a.tweet, "views");
|
|
1586
|
+
case "bookmarks":
|
|
1587
|
+
return (a, b) => metric(b.tweet, "bookmarks") - metric(a.tweet, "bookmarks");
|
|
1588
|
+
default:
|
|
1589
|
+
return (a, b) => b.score - a.score || compareIds(b.tweet.id, a.tweet.id);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
function searchCache(tweets, query, opts = {}) {
|
|
1593
|
+
const tokens = tokenize(query);
|
|
1594
|
+
const sort = opts.sort ?? "relevance";
|
|
1595
|
+
const limit = opts.limit ?? DEFAULT_LIMIT2;
|
|
1596
|
+
const scored = [];
|
|
1597
|
+
for (const tweet of tweets) {
|
|
1598
|
+
const score = scoreTweet(tweet, tokens);
|
|
1599
|
+
if (tokens.length > 0 && score === 0) continue;
|
|
1600
|
+
scored.push({ tweet, score });
|
|
1601
|
+
}
|
|
1602
|
+
scored.sort(comparator(sort));
|
|
1603
|
+
return scored.slice(0, limit).map((s) => s.tweet);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// src/cache/sync.ts
|
|
1607
|
+
var DEFAULT_MAX = 1e5;
|
|
1608
|
+
async function syncInto(file, fetchPage, opts) {
|
|
1609
|
+
const max = opts.max ?? DEFAULT_MAX;
|
|
1610
|
+
const stopAtId = opts.repair ? void 0 : file.watermark;
|
|
1611
|
+
const page = await fetchPage(max, stopAtId);
|
|
1612
|
+
const { added } = mergeTweets(file, page.tweets);
|
|
1613
|
+
file.syncedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1614
|
+
saveCache(file, opts.dir);
|
|
1615
|
+
const result = {
|
|
1616
|
+
source: file.source,
|
|
1617
|
+
added,
|
|
1618
|
+
total: Object.keys(file.tweets).length
|
|
1619
|
+
};
|
|
1620
|
+
if (file.handle !== void 0) result.handle = file.handle;
|
|
1621
|
+
if (file.watermark !== void 0) result.watermark = file.watermark;
|
|
1622
|
+
return result;
|
|
1623
|
+
}
|
|
1624
|
+
function syncBookmarks(engine, opts = {}) {
|
|
1625
|
+
const file = loadCache("bookmarks", opts.dir);
|
|
1626
|
+
return syncInto(
|
|
1627
|
+
file,
|
|
1628
|
+
(limit, stopAtId) => engine.bookmarks({ limit, ...stopAtId !== void 0 ? { stopAtId } : {} }),
|
|
1629
|
+
opts
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
function syncPosts(engine, handle, opts = {}) {
|
|
1633
|
+
const file = loadCache("posts", opts.dir);
|
|
1634
|
+
if (handle) file.handle = handle;
|
|
1635
|
+
const h = file.handle;
|
|
1636
|
+
if (!h) {
|
|
1637
|
+
return Promise.reject(
|
|
1638
|
+
new Error("posts sync needs your handle once: `xrelay sync posts --handle <you>`")
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
return syncInto(
|
|
1642
|
+
file,
|
|
1643
|
+
(limit, stopAtId) => engine.userTweets(h, { limit, ...stopAtId !== void 0 ? { stopAtId } : {} }),
|
|
1644
|
+
opts
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/commands/query.ts
|
|
1649
|
+
function buildSearchQuery(flags) {
|
|
1650
|
+
const parts = [];
|
|
1651
|
+
const base = flags.query.trim();
|
|
1652
|
+
if (base) parts.push(base);
|
|
1653
|
+
if (flags.from) parts.push(`from:${flags.from}`);
|
|
1654
|
+
if (flags.to) parts.push(`to:${flags.to}`);
|
|
1655
|
+
if (flags.since) parts.push(`since:${flags.since}`);
|
|
1656
|
+
if (flags.until) parts.push(`until:${flags.until}`);
|
|
1657
|
+
if (flags.lang) parts.push(`lang:${flags.lang}`);
|
|
1658
|
+
if (flags.minFaves !== void 0) parts.push(`min_faves:${flags.minFaves}`);
|
|
1659
|
+
if (flags.minRetweets !== void 0) parts.push(`min_retweets:${flags.minRetweets}`);
|
|
1660
|
+
for (const f of flags.filter ?? []) {
|
|
1661
|
+
const v = f.trim();
|
|
1662
|
+
if (!v) continue;
|
|
1663
|
+
parts.push(v.startsWith("-") ? `-filter:${v.slice(1)}` : `filter:${v}`);
|
|
1664
|
+
}
|
|
1665
|
+
return parts.join(" ");
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/commands/runners.ts
|
|
1669
|
+
async function guard(command, fn) {
|
|
1670
|
+
try {
|
|
1671
|
+
return ok(command, await fn());
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
if (e instanceof EngineError) {
|
|
1674
|
+
const hint = e.code === "AUTH_FAILED" ? "Re-log into x.com in your browser, or set XRELAY_COOKIES." : e.code === "FEATURE_DRIFT" ? "X rotated its API; refresh the query-ids/features in src/engine/ops.ts." : void 0;
|
|
1675
|
+
return err(command, e.code, e.message, hint);
|
|
1676
|
+
}
|
|
1677
|
+
return err(command, "FETCH_FAILED", e instanceof Error ? e.message : String(e));
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function runSearch(engine, opts) {
|
|
1681
|
+
const raw = buildSearchQuery(opts);
|
|
1682
|
+
if (!raw) return Promise.resolve(err("search", "INVALID_INPUT", "empty query"));
|
|
1683
|
+
return guard(
|
|
1684
|
+
"search",
|
|
1685
|
+
() => engine.search(raw, {
|
|
1686
|
+
...opts.product ? { product: opts.product } : {},
|
|
1687
|
+
...opts.limit !== void 0 ? { limit: opts.limit } : {}
|
|
1688
|
+
})
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
function runUser(engine, handle) {
|
|
1692
|
+
if (!handle) return Promise.resolve(err("user", "INVALID_INPUT", "missing handle"));
|
|
1693
|
+
return guard("user", () => engine.user(handle));
|
|
1694
|
+
}
|
|
1695
|
+
function runUserPosts(engine, opts) {
|
|
1696
|
+
if (!opts.handle) return Promise.resolve(err("user-posts", "INVALID_INPUT", "missing handle"));
|
|
1697
|
+
return guard(
|
|
1698
|
+
"user-posts",
|
|
1699
|
+
() => engine.userTweets(opts.handle, {
|
|
1700
|
+
...opts.replies ? { replies: true } : {},
|
|
1701
|
+
...opts.limit !== void 0 ? { limit: opts.limit } : {}
|
|
1702
|
+
})
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
function runThread(engine, id) {
|
|
1706
|
+
if (!id) return Promise.resolve(err("thread", "INVALID_INPUT", "missing tweet id/url"));
|
|
1707
|
+
return guard("thread", () => engine.thread(id));
|
|
1708
|
+
}
|
|
1709
|
+
function syncOpts(opts) {
|
|
1710
|
+
return {
|
|
1711
|
+
...opts.repair ? { repair: true } : {},
|
|
1712
|
+
...opts.max !== void 0 ? { max: opts.max } : {}
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
function runBookmarks(engine, opts = {}) {
|
|
1716
|
+
if (opts.live) {
|
|
1717
|
+
return guard(
|
|
1718
|
+
"bookmarks",
|
|
1719
|
+
() => engine.bookmarks({ ...opts.limit !== void 0 ? { limit: opts.limit } : {} })
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
return guard("bookmarks", async () => {
|
|
1723
|
+
let added;
|
|
1724
|
+
if (opts.sync || opts.repair) {
|
|
1725
|
+
const r = await syncBookmarks(engine, syncOpts(opts));
|
|
1726
|
+
added = r.added;
|
|
1727
|
+
}
|
|
1728
|
+
return viewCache("bookmarks", opts, added);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
function runMyPosts(engine, opts = {}) {
|
|
1732
|
+
if (opts.live) {
|
|
1733
|
+
if (!opts.handle) {
|
|
1734
|
+
return Promise.resolve(err("my-posts", "INVALID_INPUT", "--live needs --handle <you>"));
|
|
1735
|
+
}
|
|
1736
|
+
return guard(
|
|
1737
|
+
"my-posts",
|
|
1738
|
+
() => engine.userTweets(opts.handle, {
|
|
1739
|
+
...opts.limit !== void 0 ? { limit: opts.limit } : {}
|
|
1740
|
+
})
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
return guard("my-posts", async () => {
|
|
1744
|
+
let added;
|
|
1745
|
+
if (opts.sync || opts.repair) {
|
|
1746
|
+
const r = await syncPosts(engine, opts.handle, syncOpts(opts));
|
|
1747
|
+
added = r.added;
|
|
1748
|
+
}
|
|
1749
|
+
return viewCache("posts", opts, added);
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
function viewCache(source, opts, added) {
|
|
1753
|
+
const file = loadCache(source);
|
|
1754
|
+
const tweets = searchCache(allTweets(file), opts.query ?? "", {
|
|
1755
|
+
...opts.limit !== void 0 ? { limit: opts.limit } : {},
|
|
1756
|
+
...opts.sort ? { sort: opts.sort } : {}
|
|
1757
|
+
});
|
|
1758
|
+
const total = Object.keys(file.tweets).length;
|
|
1759
|
+
const view = { source, cached: true, total, tweets };
|
|
1760
|
+
if (file.syncedAt !== void 0) view.syncedAt = file.syncedAt;
|
|
1761
|
+
if (added !== void 0) view.added = added;
|
|
1762
|
+
if (total === 0) {
|
|
1763
|
+
view.hint = `cache is empty \u2014 run \`xrelay sync ${source}${source === "posts" ? " --handle <you>" : ""}\` first (or pass --live).`;
|
|
1764
|
+
}
|
|
1765
|
+
return view;
|
|
1766
|
+
}
|
|
1767
|
+
function runSync(engine, opts) {
|
|
1768
|
+
const so = syncOpts(opts);
|
|
1769
|
+
return guard("sync", async () => {
|
|
1770
|
+
const results = [];
|
|
1771
|
+
if (opts.source === "bookmarks" || opts.source === "all") {
|
|
1772
|
+
results.push(await syncBookmarks(engine, so));
|
|
1773
|
+
}
|
|
1774
|
+
if (opts.source === "posts" || opts.source === "all") {
|
|
1775
|
+
results.push(await syncPosts(engine, opts.handle, so));
|
|
1776
|
+
}
|
|
1777
|
+
return results;
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// src/cli.ts
|
|
1782
|
+
import { fileURLToPath } from "url";
|
|
1783
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
1784
|
+
"limit",
|
|
1785
|
+
"product",
|
|
1786
|
+
"from",
|
|
1787
|
+
"to",
|
|
1788
|
+
"since",
|
|
1789
|
+
"until",
|
|
1790
|
+
"lang",
|
|
1791
|
+
"min-faves",
|
|
1792
|
+
"min-retweets",
|
|
1793
|
+
"filter",
|
|
1794
|
+
"query",
|
|
1795
|
+
"sort",
|
|
1796
|
+
"handle",
|
|
1797
|
+
"max"
|
|
1798
|
+
]);
|
|
1799
|
+
var BOOL_FLAGS = /* @__PURE__ */ new Set(["replies", "sync", "live", "repair"]);
|
|
1800
|
+
var SHORT_FLAGS = { q: "query" };
|
|
1801
|
+
function parseArgs(argv) {
|
|
1802
|
+
const positionals = [];
|
|
1803
|
+
const flags = {};
|
|
1804
|
+
const bools = /* @__PURE__ */ new Set();
|
|
1805
|
+
let command;
|
|
1806
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1807
|
+
const token = argv[i];
|
|
1808
|
+
if (token === void 0) continue;
|
|
1809
|
+
const name = token.startsWith("--") ? token.slice(2) : token.startsWith("-") && token.length > 1 ? SHORT_FLAGS[token.slice(1)] : void 0;
|
|
1810
|
+
if (name !== void 0) {
|
|
1811
|
+
if (BOOL_FLAGS.has(name)) {
|
|
1812
|
+
bools.add(name);
|
|
1813
|
+
} else if (VALUE_FLAGS.has(name)) {
|
|
1814
|
+
const value = argv[i + 1];
|
|
1815
|
+
if (value !== void 0) {
|
|
1816
|
+
const existing = flags[name] ?? [];
|
|
1817
|
+
existing.push(value);
|
|
1818
|
+
flags[name] = existing;
|
|
1819
|
+
i += 1;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
if (command === void 0) command = token;
|
|
1825
|
+
else positionals.push(token);
|
|
1826
|
+
}
|
|
1827
|
+
return { command, positionals, flags, bools };
|
|
1828
|
+
}
|
|
1829
|
+
var first = (p, name) => p.flags[name]?.[0];
|
|
1830
|
+
var num = (p, name) => {
|
|
1831
|
+
const v = first(p, name);
|
|
1832
|
+
if (v === void 0) return void 0;
|
|
1833
|
+
const n = Number(v);
|
|
1834
|
+
return Number.isFinite(n) ? n : void 0;
|
|
1835
|
+
};
|
|
1836
|
+
var PRODUCTS = /* @__PURE__ */ new Set(["Top", "Latest", "Media", "People"]);
|
|
1837
|
+
var SORTS = /* @__PURE__ */ new Set(["relevance", "newest", "oldest", "likes", "views", "bookmarks"]);
|
|
1838
|
+
function buildCacheOpts(parsed) {
|
|
1839
|
+
const limit = num(parsed, "limit");
|
|
1840
|
+
const sort = first(parsed, "sort");
|
|
1841
|
+
return {
|
|
1842
|
+
...first(parsed, "query") ? { query: first(parsed, "query") } : {},
|
|
1843
|
+
...limit !== void 0 ? { limit } : {},
|
|
1844
|
+
...sort && SORTS.has(sort) ? { sort } : {},
|
|
1845
|
+
...parsed.bools.has("live") ? { live: true } : {},
|
|
1846
|
+
...parsed.bools.has("sync") ? { sync: true } : {},
|
|
1847
|
+
...parsed.bools.has("repair") ? { repair: true } : {},
|
|
1848
|
+
...num(parsed, "max") !== void 0 ? { max: num(parsed, "max") } : {}
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
function helpText() {
|
|
1852
|
+
const lines = ["xrelay \u2014 deep-research over X/Twitter", "", "Commands:"];
|
|
1853
|
+
for (const c of COMMANDS) {
|
|
1854
|
+
lines.push(` ${c.name.padEnd(11)} ${c.summary} [${c.cost}]`);
|
|
1855
|
+
}
|
|
1856
|
+
lines.push("", "Run `xrelay <command>` with --help-style usage in SKILL.md.");
|
|
1857
|
+
return lines.join("\n");
|
|
1858
|
+
}
|
|
1859
|
+
function buildSearchOpts(parsed) {
|
|
1860
|
+
const product = first(parsed, "product");
|
|
1861
|
+
const limit = num(parsed, "limit");
|
|
1862
|
+
const minFaves = num(parsed, "min-faves");
|
|
1863
|
+
const minRetweets = num(parsed, "min-retweets");
|
|
1864
|
+
return {
|
|
1865
|
+
query: parsed.positionals.join(" "),
|
|
1866
|
+
...limit !== void 0 ? { limit } : {},
|
|
1867
|
+
...product && PRODUCTS.has(product) ? { product } : {},
|
|
1868
|
+
...first(parsed, "from") ? { from: first(parsed, "from") } : {},
|
|
1869
|
+
...first(parsed, "to") ? { to: first(parsed, "to") } : {},
|
|
1870
|
+
...first(parsed, "since") ? { since: first(parsed, "since") } : {},
|
|
1871
|
+
...first(parsed, "until") ? { until: first(parsed, "until") } : {},
|
|
1872
|
+
...first(parsed, "lang") ? { lang: first(parsed, "lang") } : {},
|
|
1873
|
+
...minFaves !== void 0 ? { minFaves } : {},
|
|
1874
|
+
...minRetweets !== void 0 ? { minRetweets } : {},
|
|
1875
|
+
...parsed.flags.filter ? { filter: parsed.flags.filter } : {}
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
async function dispatch(parsed, engine) {
|
|
1879
|
+
const { command } = parsed;
|
|
1880
|
+
const target = parsed.positionals[0] ?? "";
|
|
1881
|
+
switch (command) {
|
|
1882
|
+
case "search":
|
|
1883
|
+
return runSearch(engine, buildSearchOpts(parsed));
|
|
1884
|
+
case "user":
|
|
1885
|
+
return runUser(engine, extractHandle(target) ?? target);
|
|
1886
|
+
case "user-posts":
|
|
1887
|
+
return runUserPosts(engine, {
|
|
1888
|
+
handle: extractHandle(target) ?? target,
|
|
1889
|
+
replies: parsed.bools.has("replies"),
|
|
1890
|
+
...num(parsed, "limit") !== void 0 ? { limit: num(parsed, "limit") } : {}
|
|
1891
|
+
});
|
|
1892
|
+
case "thread":
|
|
1893
|
+
return runThread(engine, extractTweetId(target) ?? target);
|
|
1894
|
+
case "bookmarks":
|
|
1895
|
+
return runBookmarks(engine, buildCacheOpts(parsed));
|
|
1896
|
+
case "my-posts":
|
|
1897
|
+
return runMyPosts(engine, {
|
|
1898
|
+
...buildCacheOpts(parsed),
|
|
1899
|
+
...first(parsed, "handle") ? { handle: first(parsed, "handle") } : {}
|
|
1900
|
+
});
|
|
1901
|
+
case "sync": {
|
|
1902
|
+
const source = target === "posts" || target === "all" ? target : "bookmarks";
|
|
1903
|
+
return runSync(engine, {
|
|
1904
|
+
source,
|
|
1905
|
+
...first(parsed, "handle") ? { handle: first(parsed, "handle") } : {},
|
|
1906
|
+
repair: parsed.bools.has("repair"),
|
|
1907
|
+
...num(parsed, "max") !== void 0 ? { max: num(parsed, "max") } : {}
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
default:
|
|
1911
|
+
return err("cli", "UNKNOWN_COMMAND", `unknown command: ${command ?? "(none)"}`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
async function run(argv, engine) {
|
|
1915
|
+
const parsed = parseArgs(argv);
|
|
1916
|
+
if (parsed.command === void 0 || parsed.command === "help") {
|
|
1917
|
+
process.stdout.write(`${helpText()}
|
|
1918
|
+
`);
|
|
1919
|
+
return 0;
|
|
1920
|
+
}
|
|
1921
|
+
if (!commandNames.includes(parsed.command)) {
|
|
1922
|
+
process.stdout.write(
|
|
1923
|
+
`${toJson(err("cli", "UNKNOWN_COMMAND", `unknown command: ${parsed.command}`))}
|
|
1924
|
+
`
|
|
1925
|
+
);
|
|
1926
|
+
return 2;
|
|
1927
|
+
}
|
|
1928
|
+
const eng = engine ?? createEngine({});
|
|
1929
|
+
const envelope = await dispatch(parsed, eng);
|
|
1930
|
+
process.stdout.write(`${toJson(envelope)}
|
|
1931
|
+
`);
|
|
1932
|
+
return envelope.ok ? 0 : 1;
|
|
1933
|
+
}
|
|
1934
|
+
var isEntry = import.meta.main === true || process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
|
|
1935
|
+
if (isEntry) {
|
|
1936
|
+
run(process.argv.slice(2)).then(
|
|
1937
|
+
(code) => {
|
|
1938
|
+
process.exitCode = code;
|
|
1939
|
+
},
|
|
1940
|
+
(e) => {
|
|
1941
|
+
process.stdout.write(
|
|
1942
|
+
`${toJson(err("cli", "FATAL", e instanceof Error ? e.message : String(e)))}
|
|
1943
|
+
`
|
|
1944
|
+
);
|
|
1945
|
+
process.exitCode = 1;
|
|
1946
|
+
}
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
export {
|
|
1950
|
+
COMMANDS,
|
|
1951
|
+
EngineError,
|
|
1952
|
+
buildSearchQuery,
|
|
1953
|
+
commandNames,
|
|
1954
|
+
createEngine,
|
|
1955
|
+
dispatch,
|
|
1956
|
+
err,
|
|
1957
|
+
extractCookies,
|
|
1958
|
+
extractHandle,
|
|
1959
|
+
extractTweetId,
|
|
1960
|
+
getCookies,
|
|
1961
|
+
loadCache,
|
|
1962
|
+
ok,
|
|
1963
|
+
parseArgs,
|
|
1964
|
+
parseCookies,
|
|
1965
|
+
run,
|
|
1966
|
+
runBookmarks,
|
|
1967
|
+
runSearch,
|
|
1968
|
+
runThread,
|
|
1969
|
+
runUser,
|
|
1970
|
+
runUserPosts,
|
|
1971
|
+
saveCache,
|
|
1972
|
+
searchCache,
|
|
1973
|
+
syncBookmarks,
|
|
1974
|
+
syncPosts,
|
|
1975
|
+
toJson
|
|
1976
|
+
};
|
|
1977
|
+
//# sourceMappingURL=index.js.map
|