@webmcp-bridge/adapter-x 0.1.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.
@@ -0,0 +1,1424 @@
1
+ /**
2
+ * This module implements the X site fallback adapter with robust auth checks and compose confirmation.
3
+ * It depends on Playwright page evaluation and shared adapter contracts to execute browser-side tool actions.
4
+ */
5
+ const DEFAULT_TIMELINE_LIMIT = 10;
6
+ const MAX_TIMELINE_LIMIT = 20;
7
+ const MAX_READ_PAGE_CACHE_SIZE = 8;
8
+ const DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS = 10_000;
9
+ const DEFAULT_MAX_POST_LENGTH = 280;
10
+ const AUTH_STABILIZE_ATTEMPTS = 6;
11
+ const AUTH_STABILIZE_DELAY_MS = 750;
12
+ const AUTH_WARMUP_TIMEOUT_MS = 12_000;
13
+ const CAPTURE_INJECT_SCRIPT = String.raw `
14
+ (() => {
15
+ const globalAny = window;
16
+ if (globalAny.__WEBMCP_X_CAPTURE__) {
17
+ return;
18
+ }
19
+
20
+ const state = {
21
+ enabled: true,
22
+ entries: [],
23
+ };
24
+
25
+ const now = () => Date.now();
26
+ const isGraphQLTimelineUrl = (url) => {
27
+ if (typeof url !== "string") return false;
28
+ return (
29
+ url.includes("/i/api/graphql/") &&
30
+ (
31
+ url.includes("/HomeTimeline") ||
32
+ url.includes("/Bookmarks") ||
33
+ url.includes("/BookmarksAll") ||
34
+ url.includes("/TweetDetail") ||
35
+ url.includes("/UserTweets") ||
36
+ url.includes("/UserMedia") ||
37
+ url.includes("/UserTweetsAndReplies") ||
38
+ url.includes("/SearchTimeline")
39
+ )
40
+ );
41
+ };
42
+
43
+ const detectOperation = (url) => {
44
+ if (url.includes("/HomeTimeline")) return "HomeTimeline";
45
+ if (url.includes("/BookmarksAll")) return "BookmarksAll";
46
+ if (url.includes("/Bookmarks")) return "Bookmarks";
47
+ if (url.includes("/TweetDetail")) return "TweetDetail";
48
+ if (url.includes("/UserTweetsAndReplies")) return "UserTweetsAndReplies";
49
+ if (url.includes("/UserMedia")) return "UserMedia";
50
+ if (url.includes("/UserTweets")) return "UserTweets";
51
+ if (url.includes("/SearchTimeline")) return "SearchTimeline";
52
+ return "Unknown";
53
+ };
54
+
55
+ const pickHeaders = (headersLike) => {
56
+ const output = {};
57
+ if (!headersLike) return output;
58
+ try {
59
+ const headers = new Headers(headersLike);
60
+ headers.forEach((value, key) => {
61
+ output[String(key).toLowerCase()] = String(value);
62
+ });
63
+ return output;
64
+ } catch {
65
+ if (typeof headersLike === "object") {
66
+ for (const [k, v] of Object.entries(headersLike)) {
67
+ output[String(k).toLowerCase()] = String(v);
68
+ }
69
+ }
70
+ return output;
71
+ }
72
+ };
73
+
74
+ const appendEntry = (entry) => {
75
+ state.entries.push(entry);
76
+ if (state.entries.length > 80) {
77
+ state.entries.splice(0, state.entries.length - 80);
78
+ }
79
+ };
80
+
81
+ const originalFetch = globalAny.fetch?.bind(globalAny);
82
+ if (typeof originalFetch === "function") {
83
+ globalAny.fetch = async (...args) => {
84
+ const input = args[0];
85
+ const init = args[1] || {};
86
+ const url = typeof input === "string" ? input : input?.url || "";
87
+ const method = String(init.method || (typeof input !== "string" && input?.method) || "GET").toUpperCase();
88
+ const headers = pickHeaders(init.headers || (typeof input !== "string" ? input?.headers : undefined));
89
+ const body = typeof init.body === "string" ? init.body : undefined;
90
+ const shouldCapture = isGraphQLTimelineUrl(url);
91
+ const response = await originalFetch(...args);
92
+
93
+ if (shouldCapture) {
94
+ let responseJson;
95
+ try {
96
+ responseJson = await response.clone().json();
97
+ } catch {
98
+ responseJson = undefined;
99
+ }
100
+ appendEntry({
101
+ ts: now(),
102
+ op: detectOperation(url),
103
+ url,
104
+ method,
105
+ headers,
106
+ body,
107
+ ok: response.ok,
108
+ status: response.status,
109
+ responseJson,
110
+ });
111
+ }
112
+ return response;
113
+ };
114
+ }
115
+
116
+ const OriginalXMLHttpRequest = globalAny.XMLHttpRequest;
117
+ const xhrProto = OriginalXMLHttpRequest?.prototype;
118
+ if (xhrProto && !xhrProto.__webmcpCapturePatched) {
119
+ const originalOpen = xhrProto.open;
120
+ const originalSend = xhrProto.send;
121
+ const originalSetRequestHeader = xhrProto.setRequestHeader;
122
+
123
+ xhrProto.open = function(method, url, ...rest) {
124
+ this.__webmcpCapture = {
125
+ method: String(method || "GET").toUpperCase(),
126
+ url: String(url || ""),
127
+ headers: {},
128
+ };
129
+ return originalOpen.call(this, method, url, ...rest);
130
+ };
131
+
132
+ xhrProto.setRequestHeader = function(key, value) {
133
+ try {
134
+ const capture = this.__webmcpCapture;
135
+ if (capture && capture.headers && typeof key === "string") {
136
+ capture.headers[String(key).toLowerCase()] = String(value);
137
+ }
138
+ } catch {}
139
+ return originalSetRequestHeader.call(this, key, value);
140
+ };
141
+
142
+ xhrProto.send = function(body) {
143
+ try {
144
+ this.addEventListener("loadend", () => {
145
+ const capture = this.__webmcpCapture || {};
146
+ const url = typeof capture.url === "string" ? capture.url : "";
147
+ if (!isGraphQLTimelineUrl(url)) {
148
+ return;
149
+ }
150
+ let responseJson;
151
+ try {
152
+ const text = typeof this.responseText === "string" ? this.responseText : "";
153
+ responseJson = text ? JSON.parse(text) : undefined;
154
+ } catch {
155
+ responseJson = undefined;
156
+ }
157
+ appendEntry({
158
+ ts: now(),
159
+ op: detectOperation(url),
160
+ url,
161
+ method: typeof capture.method === "string" ? capture.method : "GET",
162
+ headers: capture.headers || {},
163
+ body: typeof body === "string" ? body : undefined,
164
+ ok: this.status >= 200 && this.status < 300,
165
+ status: Number(this.status || 0),
166
+ responseJson,
167
+ });
168
+ });
169
+ } catch {}
170
+ return originalSend.call(this, body);
171
+ };
172
+
173
+ xhrProto.__webmcpCapturePatched = true;
174
+ }
175
+
176
+ globalAny.__WEBMCP_X_CAPTURE__ = state;
177
+ })();
178
+ `;
179
+ const TOOL_DEFINITIONS = [
180
+ {
181
+ name: "auth.get",
182
+ description: "Detect login/challenge state",
183
+ inputSchema: {
184
+ type: "object",
185
+ description: "No parameters.",
186
+ additionalProperties: false,
187
+ },
188
+ annotations: {
189
+ readOnlyHint: true,
190
+ },
191
+ },
192
+ {
193
+ name: "timeline.home.list",
194
+ description: "Read home timeline tweet cards",
195
+ inputSchema: {
196
+ type: "object",
197
+ description: "List tweets from your home timeline. Supports cursor pagination.",
198
+ properties: {
199
+ limit: {
200
+ type: "integer",
201
+ description: `Maximum number of tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
202
+ minimum: 1,
203
+ maximum: MAX_TIMELINE_LIMIT,
204
+ },
205
+ cursor: {
206
+ type: "string",
207
+ description: "Pagination cursor returned by previous call as nextCursor.",
208
+ },
209
+ },
210
+ additionalProperties: false,
211
+ },
212
+ annotations: {
213
+ readOnlyHint: true,
214
+ },
215
+ },
216
+ {
217
+ name: "tweet.get",
218
+ description: "Read one tweet by url or id",
219
+ inputSchema: {
220
+ type: "object",
221
+ description: "Fetch a single tweet using full URL or tweet id.",
222
+ properties: {
223
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
224
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
225
+ },
226
+ additionalProperties: false,
227
+ },
228
+ annotations: {
229
+ readOnlyHint: true,
230
+ },
231
+ },
232
+ {
233
+ name: "favorites.list",
234
+ description: "Read bookmarks/favorites feed cards",
235
+ inputSchema: {
236
+ type: "object",
237
+ description: "List tweets from bookmarks/favorites. Supports cursor pagination.",
238
+ properties: {
239
+ limit: {
240
+ type: "integer",
241
+ description: `Maximum number of tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
242
+ minimum: 1,
243
+ maximum: MAX_TIMELINE_LIMIT,
244
+ },
245
+ cursor: {
246
+ type: "string",
247
+ description: "Pagination cursor returned by previous call as nextCursor.",
248
+ },
249
+ },
250
+ additionalProperties: false,
251
+ },
252
+ annotations: {
253
+ readOnlyHint: true,
254
+ },
255
+ },
256
+ {
257
+ name: "timeline.user.list",
258
+ description: "Read one user's timeline tweet cards",
259
+ inputSchema: {
260
+ type: "object",
261
+ description: "List tweets from a target user's profile timeline. Supports cursor pagination.",
262
+ properties: {
263
+ username: {
264
+ type: "string",
265
+ minLength: 1,
266
+ description: "X username, with or without leading @.",
267
+ },
268
+ limit: {
269
+ type: "integer",
270
+ description: `Maximum number of tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
271
+ minimum: 1,
272
+ maximum: MAX_TIMELINE_LIMIT,
273
+ },
274
+ cursor: {
275
+ type: "string",
276
+ description: "Pagination cursor returned by previous call as nextCursor.",
277
+ },
278
+ },
279
+ required: ["username"],
280
+ additionalProperties: false,
281
+ },
282
+ annotations: {
283
+ readOnlyHint: true,
284
+ },
285
+ },
286
+ {
287
+ name: "search.tweets.list",
288
+ description: "Read search tweets list",
289
+ inputSchema: {
290
+ type: "object",
291
+ description: "Search tweets by query. Supports cursor pagination.",
292
+ properties: {
293
+ query: { type: "string", minLength: 1, description: "Search query text." },
294
+ mode: {
295
+ type: "string",
296
+ description: "Search ranking mode. Use latest for reverse-chronological results.",
297
+ enum: ["top", "latest"],
298
+ },
299
+ limit: {
300
+ type: "integer",
301
+ description: `Maximum number of tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
302
+ minimum: 1,
303
+ maximum: MAX_TIMELINE_LIMIT,
304
+ },
305
+ cursor: {
306
+ type: "string",
307
+ description: "Pagination cursor returned by previous call as nextCursor.",
308
+ },
309
+ },
310
+ required: ["query"],
311
+ additionalProperties: false,
312
+ },
313
+ annotations: {
314
+ readOnlyHint: true,
315
+ },
316
+ },
317
+ {
318
+ name: "user.get",
319
+ description: "Read a user profile summary by handle",
320
+ inputSchema: {
321
+ type: "object",
322
+ description: "Read public profile information for one user.",
323
+ properties: {
324
+ handle: {
325
+ type: "string",
326
+ minLength: 1,
327
+ description: "User handle, with or without leading @.",
328
+ },
329
+ },
330
+ required: ["handle"],
331
+ additionalProperties: false,
332
+ },
333
+ annotations: {
334
+ readOnlyHint: true,
335
+ },
336
+ },
337
+ {
338
+ name: "tweet.create",
339
+ description: "Publish a short text post",
340
+ inputSchema: {
341
+ type: "object",
342
+ description: "Create a new post from the currently logged-in account.",
343
+ properties: {
344
+ text: {
345
+ type: "string",
346
+ description: `Post text content. Max length ${DEFAULT_MAX_POST_LENGTH}.`,
347
+ minLength: 1,
348
+ maxLength: DEFAULT_MAX_POST_LENGTH,
349
+ },
350
+ dryRun: {
351
+ type: "boolean",
352
+ description: "When true, validate compose path without submitting.",
353
+ },
354
+ },
355
+ required: ["text"],
356
+ additionalProperties: false,
357
+ },
358
+ },
359
+ ];
360
+ function toRecord(value) {
361
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
362
+ return value;
363
+ }
364
+ return {};
365
+ }
366
+ function errorResult(code, message, details) {
367
+ const error = {
368
+ code,
369
+ message,
370
+ };
371
+ if (details !== undefined) {
372
+ error.details = details;
373
+ }
374
+ return { error };
375
+ }
376
+ function normalizeTimelineLimit(input) {
377
+ const rawLimit = input.limit;
378
+ if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
379
+ return DEFAULT_TIMELINE_LIMIT;
380
+ }
381
+ return Math.max(1, Math.min(MAX_TIMELINE_LIMIT, Math.floor(rawLimit)));
382
+ }
383
+ async function ensureNetworkCaptureInstalled(page) {
384
+ await page.addInitScript(CAPTURE_INJECT_SCRIPT);
385
+ await page.evaluate(CAPTURE_INJECT_SCRIPT);
386
+ }
387
+ async function hasCapturedTemplate(page, mode) {
388
+ const result = await page.evaluate(({ targetMode }) => {
389
+ const globalAny = window;
390
+ const entries = Array.isArray(globalAny.__WEBMCP_X_CAPTURE__?.entries)
391
+ ? globalAny.__WEBMCP_X_CAPTURE__?.entries ?? []
392
+ : [];
393
+ const ops = targetMode === "home"
394
+ ? ["HomeTimeline", "TweetDetail"]
395
+ : targetMode === "bookmarks"
396
+ ? ["BookmarksAll", "Bookmarks"]
397
+ : targetMode === "tweet"
398
+ ? ["TweetDetail"]
399
+ : targetMode === "user_timeline"
400
+ ? ["UserTweets", "UserTweetsAndReplies", "UserMedia"]
401
+ : ["SearchTimeline"];
402
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
403
+ const entry = entries[i];
404
+ if (entry && typeof entry.op === "string" && ops.includes(entry.op)) {
405
+ return true;
406
+ }
407
+ }
408
+ return false;
409
+ }, { targetMode: mode });
410
+ return result === true;
411
+ }
412
+ async function warmupNetworkTemplate(page, mode) {
413
+ if (await hasCapturedTemplate(page, mode)) {
414
+ return;
415
+ }
416
+ await waitForTweetSurface(page);
417
+ await page
418
+ .evaluate(() => {
419
+ window.scrollTo(0, Math.max(document.body.scrollHeight * 0.8, 1200));
420
+ })
421
+ .catch(() => { });
422
+ await page.waitForTimeout(900);
423
+ if (await hasCapturedTemplate(page, mode)) {
424
+ return;
425
+ }
426
+ await page
427
+ .evaluate(() => {
428
+ window.scrollTo(0, 0);
429
+ })
430
+ .catch(() => { });
431
+ await page.waitForTimeout(700);
432
+ if (await hasCapturedTemplate(page, mode)) {
433
+ return;
434
+ }
435
+ await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
436
+ await waitForTweetSurface(page);
437
+ }
438
+ async function detectAuth(page) {
439
+ return await page.evaluate(({ op }) => {
440
+ void op;
441
+ const signals = [];
442
+ const challengeSelectors = [
443
+ "form[action*='account/access']",
444
+ "input[name='verification_string']",
445
+ "iframe[title*='challenge']",
446
+ ];
447
+ const loginSelectors = [
448
+ "input[name='text']",
449
+ "input[autocomplete='username']",
450
+ "a[href='/login']",
451
+ "a[href*='/i/flow/login']",
452
+ ];
453
+ const authenticatedSelectors = [
454
+ "[data-testid='AppTabBar_Home_Link']",
455
+ "[data-testid='SideNav_NewTweet_Button']",
456
+ "[data-testid='tweetTextarea_0']",
457
+ "nav[aria-label='Primary']",
458
+ ];
459
+ const hasSelector = (selectors) => {
460
+ return selectors.some((selector) => document.querySelector(selector) !== null);
461
+ };
462
+ const bodyText = (document.body?.innerText ?? "").toLowerCase();
463
+ const pathname = location.pathname.toLowerCase();
464
+ const hasChallengeUi = hasSelector(challengeSelectors) ||
465
+ pathname.includes("/account/access") ||
466
+ bodyText.includes("are you human") ||
467
+ bodyText.includes("unusual activity") ||
468
+ bodyText.includes("challenge");
469
+ if (hasChallengeUi) {
470
+ signals.push("challenge_ui");
471
+ return { state: "challenge_required", signals };
472
+ }
473
+ if (hasSelector(authenticatedSelectors)) {
474
+ signals.push("authenticated_ui");
475
+ return { state: "authenticated", signals };
476
+ }
477
+ if (hasSelector(loginSelectors) || pathname.includes("/login") || pathname.includes("/i/flow/login")) {
478
+ signals.push("login_ui");
479
+ return { state: "auth_required", signals };
480
+ }
481
+ signals.push("auth_unknown");
482
+ return { state: "auth_required", signals };
483
+ }, { op: "detect_auth" });
484
+ }
485
+ async function detectAuthStable(page) {
486
+ let auth = await detectAuth(page);
487
+ for (let attempt = 1; attempt < AUTH_STABILIZE_ATTEMPTS; attempt += 1) {
488
+ const shouldRetry = auth.state === "auth_required" && auth.signals.includes("auth_unknown");
489
+ if (!shouldRetry) {
490
+ return auth;
491
+ }
492
+ await page.waitForTimeout(AUTH_STABILIZE_DELAY_MS);
493
+ auth = await detectAuth(page);
494
+ }
495
+ return auth;
496
+ }
497
+ async function warmupAuthProbe(page) {
498
+ const deadline = Date.now() + AUTH_WARMUP_TIMEOUT_MS;
499
+ for (;;) {
500
+ const auth = await detectAuth(page);
501
+ const stable = !(auth.state === "auth_required" && auth.signals.includes("auth_unknown"));
502
+ if (stable || Date.now() >= deadline) {
503
+ return;
504
+ }
505
+ await page.waitForTimeout(AUTH_STABILIZE_DELAY_MS);
506
+ }
507
+ }
508
+ const READ_PAGE_CACHE = new WeakMap();
509
+ const PROCESS_TEMPLATE_CACHE = new Map();
510
+ async function readTimelineViaNetwork(page, options) {
511
+ const fallbackTemplate = PROCESS_TEMPLATE_CACHE.get(options.mode);
512
+ const response = await page.evaluate(async ({ mode, limit, cursor: inputCursor, tweetId, cachedTemplate }) => {
513
+ const globalAny = window;
514
+ const capture = globalAny.__WEBMCP_X_CAPTURE__;
515
+ const entries = Array.isArray(capture?.entries) ? capture.entries : [];
516
+ const pickTemplate = () => {
517
+ const acceptOps = mode === "home"
518
+ ? ["HomeTimeline", "TweetDetail"]
519
+ : mode === "bookmarks"
520
+ ? ["BookmarksAll", "Bookmarks"]
521
+ : mode === "tweet"
522
+ ? ["TweetDetail"]
523
+ : mode === "user_timeline"
524
+ ? ["UserTweets", "UserTweetsAndReplies", "UserMedia"]
525
+ : ["SearchTimeline"];
526
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
527
+ const entry = entries[i];
528
+ if (!entry || !entry.op || !entry.url || !entry.method) {
529
+ continue;
530
+ }
531
+ if (!acceptOps.includes(entry.op)) {
532
+ continue;
533
+ }
534
+ const output = {
535
+ url: entry.url,
536
+ method: entry.method,
537
+ headers: entry.headers ?? {},
538
+ };
539
+ if (entry.body !== undefined) {
540
+ output.body = entry.body;
541
+ }
542
+ return output;
543
+ }
544
+ return null;
545
+ };
546
+ const template = pickTemplate() ?? cachedTemplate ?? null;
547
+ if (!template) {
548
+ return { items: [], source: "dom", reason: "no_template" };
549
+ }
550
+ const parseJsonSafely = (value) => {
551
+ if (!value) {
552
+ return {};
553
+ }
554
+ try {
555
+ const parsed = JSON.parse(value);
556
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
557
+ }
558
+ catch {
559
+ return {};
560
+ }
561
+ };
562
+ const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
563
+ const collectFromResult = (input) => {
564
+ const outputItems = [];
565
+ const seen = new Set();
566
+ let nextCursor;
567
+ const visit = (value) => {
568
+ if (!value || typeof value !== "object") {
569
+ return;
570
+ }
571
+ if (Array.isArray(value)) {
572
+ for (const item of value) {
573
+ visit(item);
574
+ }
575
+ return;
576
+ }
577
+ const record = value;
578
+ const entryId = typeof record.entryId === "string" ? record.entryId : "";
579
+ const content = (record.content ?? {});
580
+ const entryType = typeof content.entryType === "string" ? content.entryType : "";
581
+ if (!nextCursor && entryType === "TimelineTimelineCursor") {
582
+ const cursorType = typeof content.cursorType === "string" ? content.cursorType : "";
583
+ const cursorValue = typeof content.value === "string" ? content.value : "";
584
+ if (cursorType.toLowerCase().includes("bottom") && cursorValue) {
585
+ nextCursor = cursorValue;
586
+ }
587
+ }
588
+ if (entryId.includes("cursor-bottom") && !nextCursor) {
589
+ const cursorValue = typeof content.value === "string" ? content.value : "";
590
+ if (cursorValue) {
591
+ nextCursor = cursorValue;
592
+ }
593
+ }
594
+ const contentItem = content.item ?? undefined;
595
+ const contentItemContent = contentItem?.itemContent ?? undefined;
596
+ const itemContent = content.itemContent ?? contentItemContent;
597
+ const tweetResults = itemContent?.tweet_results?.result;
598
+ let tweet = tweetResults;
599
+ if (tweet && typeof tweet === "object" && "tweet" in tweet) {
600
+ tweet = tweet.tweet;
601
+ }
602
+ const restId = typeof tweet?.rest_id === "string" ? tweet.rest_id : "";
603
+ const legacy = tweet?.legacy ?? {};
604
+ const fullText = typeof legacy.full_text === "string"
605
+ ? legacy.full_text
606
+ : typeof legacy.text === "string"
607
+ ? legacy.text
608
+ : "";
609
+ const noteText = tweet?.note_tweet?.note_tweet_results
610
+ ?.result?.text;
611
+ const text = normalizeText(typeof noteText === "string" && noteText ? noteText : fullText);
612
+ if (restId && text) {
613
+ const userResult = tweet?.core?.user_results
614
+ ?.result ?? {};
615
+ const userLegacy = userResult.legacy ?? {};
616
+ const screenName = typeof userLegacy.screen_name === "string" ? userLegacy.screen_name : "";
617
+ const authorName = typeof userLegacy.name === "string" ? userLegacy.name : "";
618
+ const createdAt = typeof legacy.created_at === "string" ? legacy.created_at : undefined;
619
+ const key = `${restId}:${text}`;
620
+ if (!seen.has(key)) {
621
+ seen.add(key);
622
+ const item = {
623
+ id: restId,
624
+ text,
625
+ };
626
+ if (screenName) {
627
+ item.url = `https://x.com/${screenName}/status/${restId}`;
628
+ item.author = authorName ? `${authorName}@${screenName}` : `@${screenName}`;
629
+ }
630
+ if (createdAt) {
631
+ item.createdAt = createdAt;
632
+ }
633
+ outputItems.push(item);
634
+ }
635
+ }
636
+ for (const nested of Object.values(record)) {
637
+ visit(nested);
638
+ }
639
+ };
640
+ visit(input);
641
+ const result = { items: outputItems };
642
+ if (nextCursor !== undefined) {
643
+ result.nextCursor = nextCursor;
644
+ }
645
+ return result;
646
+ };
647
+ const sanitizeHeaders = (headers) => {
648
+ const blockedPrefixes = ["sec-", ":"];
649
+ const blockedExact = new Set(["host", "content-length", "cookie", "origin", "referer", "connection"]);
650
+ const output = {};
651
+ for (const [key, value] of Object.entries(headers)) {
652
+ const k = key.toLowerCase();
653
+ if (blockedExact.has(k)) {
654
+ continue;
655
+ }
656
+ if (blockedPrefixes.some((prefix) => k.startsWith(prefix))) {
657
+ continue;
658
+ }
659
+ output[k] = value;
660
+ }
661
+ return output;
662
+ };
663
+ const templateUrl = new URL(template.url, location.origin);
664
+ const templateVariables = parseJsonSafely(templateUrl.searchParams.get("variables"));
665
+ const templateFeatures = parseJsonSafely(templateUrl.searchParams.get("features"));
666
+ const templateFieldToggles = parseJsonSafely(templateUrl.searchParams.get("fieldToggles"));
667
+ const headers = sanitizeHeaders(template.headers);
668
+ const cursor = typeof inputCursor === "string" && inputCursor ? inputCursor : undefined;
669
+ const createRequestUrl = () => {
670
+ const vars = { ...templateVariables };
671
+ if (mode === "tweet" && tweetId) {
672
+ vars.focalTweetId = tweetId;
673
+ }
674
+ vars.count = Math.max(20, limit);
675
+ if (cursor) {
676
+ vars.cursor = cursor;
677
+ }
678
+ else {
679
+ delete vars.cursor;
680
+ }
681
+ const next = new URL(template.url, location.origin);
682
+ next.searchParams.set("variables", JSON.stringify(vars));
683
+ if (Object.keys(templateFeatures).length > 0) {
684
+ next.searchParams.set("features", JSON.stringify(templateFeatures));
685
+ }
686
+ if (Object.keys(templateFieldToggles).length > 0) {
687
+ next.searchParams.set("fieldToggles", JSON.stringify(templateFieldToggles));
688
+ }
689
+ return next.toString();
690
+ };
691
+ const requestUrl = createRequestUrl();
692
+ let response;
693
+ try {
694
+ response = await fetch(requestUrl, {
695
+ method: template.method,
696
+ headers,
697
+ credentials: "include",
698
+ });
699
+ }
700
+ catch {
701
+ return { items: [], source: "dom", reason: "request_failed" };
702
+ }
703
+ if (!response.ok) {
704
+ return {
705
+ items: [],
706
+ source: "dom",
707
+ reason: `http_error_${response.status}`,
708
+ };
709
+ }
710
+ let responseJson;
711
+ try {
712
+ responseJson = await response.json();
713
+ }
714
+ catch {
715
+ return { items: [], source: "dom", reason: "response_parse_failed" };
716
+ }
717
+ const parsed = collectFromResult(responseJson);
718
+ const result = {
719
+ items: parsed.items.slice(0, limit),
720
+ source: parsed.items.length > 0 ? "network" : "dom",
721
+ selectedTemplate: template,
722
+ };
723
+ if (parsed.nextCursor) {
724
+ result.nextCursor = parsed.nextCursor;
725
+ }
726
+ if (parsed.items.length === 0) {
727
+ result.reason = "empty_result";
728
+ }
729
+ return result;
730
+ }, {
731
+ mode: options.mode,
732
+ limit: options.limit,
733
+ cursor: options.cursor,
734
+ tweetId: options.tweetId,
735
+ cachedTemplate: fallbackTemplate,
736
+ });
737
+ if (!response ||
738
+ typeof response !== "object" ||
739
+ !("items" in response) ||
740
+ !Array.isArray(response.items)) {
741
+ return { items: [], source: "dom", reason: "invalid_response" };
742
+ }
743
+ const typed = response;
744
+ const selectedTemplate = typed.selectedTemplate;
745
+ if (selectedTemplate &&
746
+ typeof selectedTemplate.url === "string" &&
747
+ typeof selectedTemplate.method === "string" &&
748
+ typeof selectedTemplate.headers === "object" &&
749
+ selectedTemplate.headers !== null &&
750
+ !Array.isArray(selectedTemplate.headers)) {
751
+ const cacheValue = {
752
+ url: selectedTemplate.url,
753
+ method: selectedTemplate.method,
754
+ headers: selectedTemplate.headers,
755
+ };
756
+ if (typeof selectedTemplate.body === "string") {
757
+ cacheValue.body = selectedTemplate.body;
758
+ }
759
+ PROCESS_TEMPLATE_CACHE.set(options.mode, cacheValue);
760
+ }
761
+ const result = {
762
+ items: typed.items,
763
+ source: typed.source,
764
+ };
765
+ if (typeof typed.nextCursor === "string" && typed.nextCursor.length > 0) {
766
+ result.nextCursor = typed.nextCursor;
767
+ }
768
+ if (typeof typed.reason === "string" && typed.reason.length > 0) {
769
+ result.reason = typed.reason;
770
+ }
771
+ return result;
772
+ }
773
+ async function extractTweetCards(page, limit) {
774
+ const cards = await page.evaluate(({ maxItems }) => {
775
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
776
+ const dedupe = new Set();
777
+ const items = [];
778
+ const pushItem = (item) => {
779
+ const dedupeKey = `${item.id}:${item.text}`;
780
+ if (!item.text || dedupe.has(dedupeKey)) {
781
+ return;
782
+ }
783
+ dedupe.add(dedupeKey);
784
+ items.push(item);
785
+ };
786
+ const articles = Array.from(document.querySelectorAll("article"));
787
+ for (const article of articles) {
788
+ const statusAnchor = article.querySelector("a[href*='/status/']");
789
+ const url = statusAnchor?.href;
790
+ const id = url?.match(/status\/(\d+)/)?.[1] ?? `article-${items.length + 1}`;
791
+ const textNodes = Array.from(article.querySelectorAll("[data-testid='tweetText'], div[lang], div[dir='auto']"));
792
+ const mergedText = normalize(textNodes.map((n) => n.textContent || "").join(" "));
793
+ const fallbackText = normalize(article.textContent || "");
794
+ const text = mergedText || fallbackText;
795
+ if (!text) {
796
+ continue;
797
+ }
798
+ const authorRaw = article.querySelector("[data-testid='User-Name']")?.textContent ?? "";
799
+ const createdAtRaw = article.querySelector("time")?.dateTime ?? "";
800
+ const item = { id, text };
801
+ if (url) {
802
+ item.url = url;
803
+ }
804
+ const author = normalize(authorRaw);
805
+ if (author) {
806
+ item.author = author;
807
+ }
808
+ if (createdAtRaw) {
809
+ item.createdAt = createdAtRaw;
810
+ }
811
+ pushItem(item);
812
+ if (items.length >= maxItems) {
813
+ break;
814
+ }
815
+ }
816
+ if (items.length < maxItems) {
817
+ const cells = Array.from(document.querySelectorAll("[data-testid='cellInnerDiv']"));
818
+ for (const cell of cells) {
819
+ if (items.length >= maxItems) {
820
+ break;
821
+ }
822
+ const text = normalize(cell.innerText || cell.textContent || "");
823
+ if (!text || text.length < 16) {
824
+ continue;
825
+ }
826
+ const statusAnchor = cell.querySelector("a[href*='/status/']");
827
+ const url = statusAnchor?.href;
828
+ const id = url?.match(/status\/(\d+)/)?.[1] ?? `cell-${items.length + 1}`;
829
+ const item = { id, text };
830
+ if (url) {
831
+ item.url = url;
832
+ }
833
+ pushItem(item);
834
+ }
835
+ }
836
+ if (items.length === 0) {
837
+ const bodyText = normalize(document.body?.innerText || "");
838
+ if (bodyText) {
839
+ const snippet = bodyText.slice(0, 280);
840
+ pushItem({
841
+ id: "fallback-body-1",
842
+ text: snippet,
843
+ });
844
+ }
845
+ }
846
+ return items;
847
+ }, { maxItems: limit });
848
+ return cards;
849
+ }
850
+ async function withEphemeralReadOnlyPage(page, url, run) {
851
+ const context = page.context();
852
+ const readPage = await context.newPage();
853
+ try {
854
+ await ensureNetworkCaptureInstalled(readPage);
855
+ await readPage.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
856
+ await waitForTweetSurface(readPage);
857
+ return await run(readPage);
858
+ }
859
+ finally {
860
+ await readPage.close().catch(() => { });
861
+ }
862
+ }
863
+ function getReadPageCacheState(page) {
864
+ let state = READ_PAGE_CACHE.get(page);
865
+ if (!state) {
866
+ state = {
867
+ pages: new Map(),
868
+ lru: [],
869
+ };
870
+ READ_PAGE_CACHE.set(page, state);
871
+ }
872
+ return state;
873
+ }
874
+ function isSameLocation(currentUrl, targetUrl) {
875
+ try {
876
+ const current = new URL(currentUrl);
877
+ const target = new URL(targetUrl);
878
+ return current.origin === target.origin && current.pathname === target.pathname && current.search === target.search;
879
+ }
880
+ catch {
881
+ return false;
882
+ }
883
+ }
884
+ function touchReadPageLru(state, key) {
885
+ const next = state.lru.filter((item) => item !== key);
886
+ next.push(key);
887
+ state.lru = next;
888
+ }
889
+ async function evictReadPagesIfNeeded(state) {
890
+ while (state.lru.length > MAX_READ_PAGE_CACHE_SIZE) {
891
+ const evictKey = state.lru.shift();
892
+ if (!evictKey) {
893
+ return;
894
+ }
895
+ const entry = state.pages.get(evictKey);
896
+ state.pages.delete(evictKey);
897
+ if (entry && !entry.page.isClosed()) {
898
+ await entry.page.close().catch(() => { });
899
+ }
900
+ }
901
+ }
902
+ async function getOrCreateCachedReadPage(ownerPage, key, url) {
903
+ const state = getReadPageCacheState(ownerPage);
904
+ const existing = state.pages.get(key);
905
+ if (existing && !existing.page.isClosed()) {
906
+ const currentUrl = existing.page.url();
907
+ if (!isSameLocation(currentUrl, url)) {
908
+ await existing.page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
909
+ await waitForTweetSurface(existing.page);
910
+ }
911
+ touchReadPageLru(state, key);
912
+ return existing.page;
913
+ }
914
+ const readPage = await ownerPage.context().newPage();
915
+ await ensureNetworkCaptureInstalled(readPage);
916
+ await readPage.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
917
+ await waitForTweetSurface(readPage);
918
+ state.pages.set(key, { key, page: readPage });
919
+ touchReadPageLru(state, key);
920
+ await evictReadPagesIfNeeded(state);
921
+ return readPage;
922
+ }
923
+ async function withCachedReadOnlyPage(ownerPage, key, url, run) {
924
+ const readPage = await getOrCreateCachedReadPage(ownerPage, key, url);
925
+ return await run(readPage);
926
+ }
927
+ async function closeCachedReadPages(ownerPage) {
928
+ const state = READ_PAGE_CACHE.get(ownerPage);
929
+ READ_PAGE_CACHE.delete(ownerPage);
930
+ if (!state) {
931
+ return;
932
+ }
933
+ for (const entry of state.pages.values()) {
934
+ if (!entry.page.isClosed()) {
935
+ await entry.page.close().catch(() => { });
936
+ }
937
+ }
938
+ }
939
+ async function waitForTweetSurface(page) {
940
+ await page
941
+ .waitForFunction(() => {
942
+ const articleCount = document.querySelectorAll("article").length;
943
+ const cellCount = document.querySelectorAll("[data-testid='cellInnerDiv']").length;
944
+ const hasTweetText = document.querySelectorAll("[data-testid='tweetText'], div[lang], div[dir='auto']").length > 0;
945
+ return articleCount > 0 || cellCount > 0 || hasTweetText;
946
+ }, undefined, { timeout: 8_000 })
947
+ .catch(() => { });
948
+ await page.waitForTimeout(1_000);
949
+ }
950
+ function mapTweetCards(items) {
951
+ return items.map((item) => {
952
+ const mapped = {
953
+ id: item.id,
954
+ text: item.text,
955
+ };
956
+ if (item.url) {
957
+ mapped.url = item.url;
958
+ }
959
+ return mapped;
960
+ });
961
+ }
962
+ function toTimelinePageFromNetwork(input) {
963
+ const result = {
964
+ items: mapTweetCards(input.items),
965
+ source: input.source,
966
+ hasMore: false,
967
+ };
968
+ if (input.nextCursor) {
969
+ result.nextCursor = input.nextCursor;
970
+ result.hasMore = true;
971
+ }
972
+ if (input.source === "dom" && input.reason) {
973
+ result.debug = { reason: input.reason };
974
+ }
975
+ return result;
976
+ }
977
+ async function readTimelineWithMode(page, mode, limit, cursor) {
978
+ await waitForTweetSurface(page);
979
+ await warmupNetworkTemplate(page, mode);
980
+ const networkRequest = {
981
+ mode,
982
+ limit,
983
+ };
984
+ if (cursor) {
985
+ networkRequest.cursor = cursor;
986
+ }
987
+ const fromNetwork = await readTimelineViaNetwork(page, networkRequest);
988
+ if (fromNetwork.items.length > 0) {
989
+ return toTimelinePageFromNetwork(fromNetwork);
990
+ }
991
+ const cards = await extractTweetCards(page, limit);
992
+ return {
993
+ items: mapTweetCards(cards),
994
+ source: "dom",
995
+ hasMore: false,
996
+ debug: {
997
+ reason: fromNetwork.reason ?? "dom_fallback",
998
+ },
999
+ };
1000
+ }
1001
+ function normalizeUsername(input) {
1002
+ if (typeof input !== "string") {
1003
+ return "";
1004
+ }
1005
+ return input.replace(/^@+/, "").trim();
1006
+ }
1007
+ function normalizeSearchMode(input) {
1008
+ return input === "top" ? "top" : "latest";
1009
+ }
1010
+ function buildSearchUrl(query, mode) {
1011
+ const url = new URL("https://x.com/search");
1012
+ url.searchParams.set("q", query);
1013
+ url.searchParams.set("src", "typed_query");
1014
+ url.searchParams.set("f", mode === "top" ? "top" : "live");
1015
+ return url.toString();
1016
+ }
1017
+ async function readTweetByUrl(page, url) {
1018
+ return await withEphemeralReadOnlyPage(page, url, async (readPage) => {
1019
+ const matchId = url.match(/status\/(\d+)/)?.[1];
1020
+ if (matchId) {
1021
+ const fromNetwork = await readTimelineViaNetwork(readPage, {
1022
+ mode: "tweet",
1023
+ limit: 1,
1024
+ tweetId: matchId,
1025
+ });
1026
+ const first = fromNetwork.items[0];
1027
+ if (first) {
1028
+ return { tweet: first };
1029
+ }
1030
+ }
1031
+ const cards = await extractTweetCards(readPage, 1);
1032
+ const tweet = cards[0];
1033
+ if (!tweet) {
1034
+ return errorResult("UPSTREAM_CHANGED", "tweet content not found");
1035
+ }
1036
+ return { tweet };
1037
+ });
1038
+ }
1039
+ async function readProfile(page, handle) {
1040
+ const normalizedHandle = handle.replace(/^@+/, "").trim();
1041
+ const profileUrl = `https://x.com/${normalizedHandle}`;
1042
+ return await withEphemeralReadOnlyPage(page, profileUrl, async (readPage) => {
1043
+ const profile = await readPage.evaluate(() => {
1044
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
1045
+ const name = document.querySelector("[data-testid='UserName'] span")?.textContent ?? "";
1046
+ const bio = document.querySelector("[data-testid='UserDescription']")?.textContent ?? "";
1047
+ const location = document.querySelector("[data-testid='UserLocation']")?.textContent ?? "";
1048
+ const website = document.querySelector("[data-testid='UserUrl'] a")?.href ?? "";
1049
+ const followingText = document.querySelector("a[href$='/following'] span")?.textContent ?? "";
1050
+ const followersText = document.querySelector("a[href$='/verified_followers'], a[href$='/followers'] span")
1051
+ ?.textContent ?? "";
1052
+ const output = {
1053
+ name: normalize(name),
1054
+ bio: normalize(bio),
1055
+ location: normalize(location),
1056
+ following: normalize(followingText),
1057
+ followers: normalize(followersText),
1058
+ };
1059
+ if (website) {
1060
+ output.website = website;
1061
+ }
1062
+ return output;
1063
+ });
1064
+ return {
1065
+ handle: `@${normalizedHandle}`,
1066
+ url: profileUrl,
1067
+ profile,
1068
+ };
1069
+ });
1070
+ }
1071
+ async function composePost(page, text, dryRun) {
1072
+ return await page.evaluate(({ content, dryRunMode }) => {
1073
+ const pickFirst = (selectors) => {
1074
+ for (const selector of selectors) {
1075
+ const element = document.querySelector(selector);
1076
+ if (element) {
1077
+ return element;
1078
+ }
1079
+ }
1080
+ return null;
1081
+ };
1082
+ const clickFirst = (selectors) => {
1083
+ const element = pickFirst(selectors);
1084
+ element?.click();
1085
+ };
1086
+ const setText = (target, value) => {
1087
+ target.focus();
1088
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
1089
+ target.value = value;
1090
+ target.dispatchEvent(new Event("input", { bubbles: true }));
1091
+ target.dispatchEvent(new Event("change", { bubbles: true }));
1092
+ return true;
1093
+ }
1094
+ if (target.isContentEditable) {
1095
+ try {
1096
+ const selection = window.getSelection();
1097
+ const range = document.createRange();
1098
+ range.selectNodeContents(target);
1099
+ selection?.removeAllRanges();
1100
+ selection?.addRange(range);
1101
+ document.execCommand("insertText", false, value);
1102
+ }
1103
+ catch {
1104
+ // Ignore and fallback to direct assignment below.
1105
+ }
1106
+ if ((target.textContent ?? "").trim() !== value) {
1107
+ target.textContent = value;
1108
+ }
1109
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, data: value }));
1110
+ return true;
1111
+ }
1112
+ return false;
1113
+ };
1114
+ const composerSelectors = [
1115
+ "div[data-testid='tweetTextarea_0']",
1116
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
1117
+ "div[role='textbox'][aria-label*='Post text']",
1118
+ "div[role='textbox'][aria-label*='What is happening']",
1119
+ ];
1120
+ const openComposerSelectors = [
1121
+ "[data-testid='SideNav_NewTweet_Button']",
1122
+ "[data-testid='tweetButton']",
1123
+ "a[href='/compose/post']",
1124
+ "a[href='/compose/tweet']",
1125
+ ];
1126
+ const submitSelectors = [
1127
+ "[data-testid='tweetButtonInline']",
1128
+ "[data-testid='tweetButton']",
1129
+ "div[data-testid='toolBar'] [data-testid='tweetButtonInline']",
1130
+ ];
1131
+ let composer = pickFirst(composerSelectors);
1132
+ if (!composer) {
1133
+ clickFirst(openComposerSelectors);
1134
+ composer = pickFirst(composerSelectors);
1135
+ }
1136
+ if (!composer) {
1137
+ return { ok: false, reason: "composer_not_found" };
1138
+ }
1139
+ const inputOk = setText(composer, content);
1140
+ if (!inputOk) {
1141
+ return { ok: false, reason: "compose_input_failed" };
1142
+ }
1143
+ const submit = pickFirst(submitSelectors);
1144
+ if (dryRunMode) {
1145
+ return {
1146
+ ok: true,
1147
+ dryRun: true,
1148
+ submitVisible: submit !== null,
1149
+ };
1150
+ }
1151
+ if (!submit) {
1152
+ return { ok: false, reason: "submit_not_found" };
1153
+ }
1154
+ submit.click();
1155
+ return { ok: true };
1156
+ }, { content: text, dryRunMode: dryRun });
1157
+ }
1158
+ async function ensureComposerReady(page) {
1159
+ const composerSelectors = [
1160
+ "div[data-testid='tweetTextarea_0']",
1161
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
1162
+ "div[role='textbox'][aria-label*='Post text']",
1163
+ "div[role='textbox'][aria-label*='What is happening']",
1164
+ ];
1165
+ const openComposerSelectors = [
1166
+ "[data-testid='SideNav_NewTweet_Button']",
1167
+ "[data-testid='tweetButton']",
1168
+ "a[href='/compose/post']",
1169
+ "a[href='/compose/tweet']",
1170
+ ];
1171
+ for (const selector of composerSelectors) {
1172
+ const handle = await page.waitForSelector(selector, { timeout: 800 }).catch(() => null);
1173
+ if (handle) {
1174
+ await handle.dispose();
1175
+ return;
1176
+ }
1177
+ }
1178
+ await page
1179
+ .evaluate((selectors) => {
1180
+ for (const selector of selectors) {
1181
+ const element = document.querySelector(selector);
1182
+ if (element) {
1183
+ element.click();
1184
+ return;
1185
+ }
1186
+ }
1187
+ }, openComposerSelectors)
1188
+ .catch(() => { });
1189
+ for (const selector of composerSelectors) {
1190
+ const handle = await page.waitForSelector(selector, { timeout: 2000 }).catch(() => null);
1191
+ if (handle) {
1192
+ await handle.dispose();
1193
+ return;
1194
+ }
1195
+ }
1196
+ }
1197
+ async function waitForComposeConfirmation(page, text, timeoutMs) {
1198
+ const snippet = text.slice(0, 24).trim();
1199
+ if (!snippet) {
1200
+ return { confirmed: false };
1201
+ }
1202
+ try {
1203
+ await page.waitForFunction((needle) => {
1204
+ const normalize = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
1205
+ const normalizedNeedle = normalize(needle);
1206
+ const nodes = Array.from(document.querySelectorAll("article [data-testid='tweetText'], article div[lang]"));
1207
+ return nodes.some((node) => normalize(node.innerText || node.textContent || "").includes(normalizedNeedle));
1208
+ }, snippet, { timeout: timeoutMs });
1209
+ }
1210
+ catch {
1211
+ return { confirmed: false };
1212
+ }
1213
+ const statusUrl = await page.evaluate(({ needle }) => {
1214
+ const normalize = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
1215
+ const normalizedNeedle = normalize(needle);
1216
+ const tweets = Array.from(document.querySelectorAll("article"));
1217
+ for (const tweet of tweets) {
1218
+ const textNodes = Array.from(tweet.querySelectorAll("[data-testid='tweetText'], div[lang]"));
1219
+ const matched = textNodes.some((node) => normalize(node.innerText || node.textContent || "").includes(normalizedNeedle));
1220
+ if (!matched) {
1221
+ continue;
1222
+ }
1223
+ const statusLink = tweet.querySelector("a[href*='/status/']");
1224
+ if (statusLink?.href) {
1225
+ return statusLink.href;
1226
+ }
1227
+ }
1228
+ return undefined;
1229
+ }, { needle: snippet });
1230
+ if (typeof statusUrl === "string" && statusUrl.length > 0) {
1231
+ return {
1232
+ confirmed: true,
1233
+ statusUrl,
1234
+ };
1235
+ }
1236
+ return { confirmed: true };
1237
+ }
1238
+ async function requireAuthenticated(page) {
1239
+ const auth = await detectAuthStable(page);
1240
+ if (auth.state === "authenticated") {
1241
+ return { ok: true, auth };
1242
+ }
1243
+ if (auth.state === "challenge_required") {
1244
+ return {
1245
+ ok: false,
1246
+ result: errorResult("CHALLENGE_REQUIRED", "x.com challenge is blocking actions", {
1247
+ state: auth.state,
1248
+ signals: auth.signals,
1249
+ }),
1250
+ };
1251
+ }
1252
+ return {
1253
+ ok: false,
1254
+ result: errorResult("AUTH_REQUIRED", "login required", {
1255
+ state: auth.state,
1256
+ signals: auth.signals,
1257
+ }),
1258
+ };
1259
+ }
1260
+ export function createXAdapter(options) {
1261
+ const composeConfirmTimeoutMs = options?.composeConfirmTimeoutMs ?? DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS;
1262
+ const maxPostLength = options?.maxPostLength ?? DEFAULT_MAX_POST_LENGTH;
1263
+ return {
1264
+ name: "adapter-x",
1265
+ start: async ({ page }) => {
1266
+ await ensureNetworkCaptureInstalled(page);
1267
+ await page.waitForLoadState("domcontentloaded").catch(() => {
1268
+ // Keep startup best-effort; auth probing will still run.
1269
+ });
1270
+ await warmupAuthProbe(page);
1271
+ },
1272
+ listTools: async () => TOOL_DEFINITIONS,
1273
+ callTool: async ({ name, input }, { page }) => {
1274
+ const args = toRecord(input);
1275
+ if (name === "auth.get") {
1276
+ const auth = await detectAuthStable(page);
1277
+ return {
1278
+ state: auth.state,
1279
+ signals: auth.signals,
1280
+ };
1281
+ }
1282
+ if (name === "timeline.home.list") {
1283
+ const authCheck = await requireAuthenticated(page);
1284
+ if (!authCheck.ok) {
1285
+ return authCheck.result;
1286
+ }
1287
+ const limit = normalizeTimelineLimit(args);
1288
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
1289
+ const result = cursor
1290
+ ? await readTimelineWithMode(page, "home", limit, cursor)
1291
+ : await readTimelineWithMode(page, "home", limit);
1292
+ if (result.source === "network" || result.items.length > 0) {
1293
+ return result;
1294
+ }
1295
+ return await withCachedReadOnlyPage(page, "home", "https://x.com/home", async (readPage) => {
1296
+ return cursor
1297
+ ? await readTimelineWithMode(readPage, "home", limit, cursor)
1298
+ : await readTimelineWithMode(readPage, "home", limit);
1299
+ });
1300
+ }
1301
+ if (name === "tweet.get") {
1302
+ const authCheck = await requireAuthenticated(page);
1303
+ if (!authCheck.ok) {
1304
+ return authCheck.result;
1305
+ }
1306
+ const url = typeof args.url === "string" ? args.url.trim() : "";
1307
+ const id = typeof args.id === "string" ? args.id.trim() : "";
1308
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
1309
+ if (!targetUrl) {
1310
+ return errorResult("VALIDATION_ERROR", "url or id is required");
1311
+ }
1312
+ return await readTweetByUrl(page, targetUrl);
1313
+ }
1314
+ if (name === "favorites.list") {
1315
+ const authCheck = await requireAuthenticated(page);
1316
+ if (!authCheck.ok) {
1317
+ return authCheck.result;
1318
+ }
1319
+ const limit = normalizeTimelineLimit(args);
1320
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
1321
+ return await withCachedReadOnlyPage(page, "bookmarks", "https://x.com/i/bookmarks", async (readPage) => {
1322
+ return cursor
1323
+ ? await readTimelineWithMode(readPage, "bookmarks", limit, cursor)
1324
+ : await readTimelineWithMode(readPage, "bookmarks", limit);
1325
+ });
1326
+ }
1327
+ if (name === "timeline.user.list") {
1328
+ const authCheck = await requireAuthenticated(page);
1329
+ if (!authCheck.ok) {
1330
+ return authCheck.result;
1331
+ }
1332
+ const username = normalizeUsername(args.username);
1333
+ if (!username) {
1334
+ return errorResult("VALIDATION_ERROR", "username is required");
1335
+ }
1336
+ const limit = normalizeTimelineLimit(args);
1337
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
1338
+ const profileUrl = `https://x.com/${username}`;
1339
+ const cacheKey = `user:${username.toLowerCase()}`;
1340
+ return await withCachedReadOnlyPage(page, cacheKey, profileUrl, async (readPage) => {
1341
+ return cursor
1342
+ ? await readTimelineWithMode(readPage, "user_timeline", limit, cursor)
1343
+ : await readTimelineWithMode(readPage, "user_timeline", limit);
1344
+ });
1345
+ }
1346
+ if (name === "search.tweets.list") {
1347
+ const authCheck = await requireAuthenticated(page);
1348
+ if (!authCheck.ok) {
1349
+ return authCheck.result;
1350
+ }
1351
+ const query = typeof args.query === "string" ? args.query.trim() : "";
1352
+ if (!query) {
1353
+ return errorResult("VALIDATION_ERROR", "query is required");
1354
+ }
1355
+ const mode = normalizeSearchMode(args.mode);
1356
+ const limit = normalizeTimelineLimit(args);
1357
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
1358
+ const searchUrl = buildSearchUrl(query, mode);
1359
+ const cacheKey = `search:${mode}:${query.toLowerCase()}`;
1360
+ return await withCachedReadOnlyPage(page, cacheKey, searchUrl, async (readPage) => {
1361
+ return cursor
1362
+ ? await readTimelineWithMode(readPage, "search", limit, cursor)
1363
+ : await readTimelineWithMode(readPage, "search", limit);
1364
+ });
1365
+ }
1366
+ if (name === "user.get") {
1367
+ const authCheck = await requireAuthenticated(page);
1368
+ if (!authCheck.ok) {
1369
+ return authCheck.result;
1370
+ }
1371
+ const handle = typeof args.handle === "string" ? args.handle.trim() : "";
1372
+ if (!handle) {
1373
+ return errorResult("VALIDATION_ERROR", "handle is required");
1374
+ }
1375
+ return await readProfile(page, handle);
1376
+ }
1377
+ if (name === "tweet.create") {
1378
+ const authCheck = await requireAuthenticated(page);
1379
+ if (!authCheck.ok) {
1380
+ return authCheck.result;
1381
+ }
1382
+ const text = typeof args.text === "string" ? args.text.trim() : "";
1383
+ if (!text) {
1384
+ return errorResult("VALIDATION_ERROR", "text is required");
1385
+ }
1386
+ if (text.length > maxPostLength) {
1387
+ return errorResult("VALIDATION_ERROR", `text exceeds max length ${maxPostLength}`);
1388
+ }
1389
+ const dryRun = args.dryRun === true;
1390
+ await ensureComposerReady(page);
1391
+ const composeResult = await composePost(page, text, dryRun);
1392
+ if (!composeResult.ok) {
1393
+ return errorResult("UPSTREAM_CHANGED", "compose controls not found", {
1394
+ reason: composeResult.reason ?? "unknown",
1395
+ });
1396
+ }
1397
+ if (composeResult.dryRun) {
1398
+ return {
1399
+ ok: true,
1400
+ dryRun: true,
1401
+ submitVisible: composeResult.submitVisible === true,
1402
+ };
1403
+ }
1404
+ const confirmation = await waitForComposeConfirmation(page, text, composeConfirmTimeoutMs);
1405
+ if (!confirmation.confirmed) {
1406
+ return errorResult("ACTION_UNCONFIRMED", "post submit was not confirmed in timeline");
1407
+ }
1408
+ const result = {
1409
+ ok: true,
1410
+ confirmed: true,
1411
+ };
1412
+ if (confirmation.statusUrl !== undefined) {
1413
+ result.statusUrl = confirmation.statusUrl;
1414
+ }
1415
+ return result;
1416
+ }
1417
+ return errorResult("TOOL_NOT_FOUND", `unknown tool: ${name}`);
1418
+ },
1419
+ stop: async ({ page }) => {
1420
+ await closeCachedReadPages(page);
1421
+ },
1422
+ };
1423
+ }
1424
+ //# sourceMappingURL=adapter.js.map