@webmcp-bridge/adapter-x 0.4.2 → 0.5.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/dist/adapter.js CHANGED
@@ -2,28 +2,25 @@
2
2
  * This module implements the X site fallback adapter with robust auth checks and compose confirmation.
3
3
  * It depends on Playwright page evaluation and shared adapter contracts to execute browser-side tool actions.
4
4
  */
5
+ import { buildRequestCaptureInitScript, captureRoutedResponseText, collectTextByTag, joinTextParts, parseNdjsonLines, TemplateCache, } from "@webmcp-bridge/adapter-utils";
6
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { basename, extname, isAbsolute, join } from "node:path";
5
9
  const DEFAULT_TIMELINE_LIMIT = 10;
6
10
  const MAX_TIMELINE_LIMIT = 20;
7
11
  const MAX_READ_PAGE_CACHE_SIZE = 8;
8
12
  const DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS = 10_000;
13
+ const DEFAULT_GROK_RESPONSE_TIMEOUT_MS = 90_000;
9
14
  const DEFAULT_MAX_POST_LENGTH = 280;
10
15
  const AUTH_STABILIZE_ATTEMPTS = 6;
11
16
  const AUTH_STABILIZE_DELAY_MS = 750;
12
17
  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) => {
18
+ const GROK_ARTIFACT_DIR_PREFIX = "webmcp-bridge-grok-";
19
+ const TWEET_MEDIA_ARTIFACT_DIR_PREFIX = "webmcp-bridge-x-media-";
20
+ const ALLOWED_TWEET_MEDIA_HOSTS = new Set(["pbs.twimg.com", "video.twimg.com"]);
21
+ const CAPTURE_INJECT_SCRIPT = buildRequestCaptureInitScript({
22
+ globalKey: "__WEBMCP_X_CAPTURE__",
23
+ shouldCaptureSource: String.raw `((url) => {
27
24
  if (typeof url !== "string") return false;
28
25
  return (
29
26
  url.includes("/i/api/graphql/") &&
@@ -38,144 +35,22 @@ const CAPTURE_INJECT_SCRIPT = String.raw `
38
35
  url.includes("/SearchTimeline")
39
36
  )
40
37
  );
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
- `;
38
+ })`,
39
+ enrichEntrySource: String.raw `((entry) => {
40
+ const url = typeof entry?.url === "string" ? entry.url : "";
41
+ let op = "Unknown";
42
+ if (url.includes("/HomeTimeline")) op = "HomeTimeline";
43
+ else if (url.includes("/BookmarksAll")) op = "BookmarksAll";
44
+ else if (url.includes("/Bookmarks")) op = "Bookmarks";
45
+ else if (url.includes("/TweetDetail")) op = "TweetDetail";
46
+ else if (url.includes("/UserTweetsAndReplies")) op = "UserTweetsAndReplies";
47
+ else if (url.includes("/UserMedia")) op = "UserMedia";
48
+ else if (url.includes("/UserTweets")) op = "UserTweets";
49
+ else if (url.includes("/SearchTimeline")) op = "SearchTimeline";
50
+ return { ...entry, op };
51
+ })`,
52
+ maxEntries: 80,
53
+ });
179
54
  const TOOL_DEFINITIONS = [
180
55
  {
181
56
  name: "auth.get",
@@ -229,6 +104,101 @@ const TOOL_DEFINITIONS = [
229
104
  readOnlyHint: true,
230
105
  },
231
106
  },
107
+ {
108
+ name: "tweet.conversation.get",
109
+ description: "Read one tweet conversation by url or id",
110
+ inputSchema: {
111
+ type: "object",
112
+ description: "Fetch the focal tweet with its visible ancestors and replies from a tweet detail conversation.",
113
+ properties: {
114
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
115
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
116
+ limit: {
117
+ type: "integer",
118
+ description: `Maximum number of reply tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
119
+ minimum: 1,
120
+ maximum: MAX_TIMELINE_LIMIT,
121
+ },
122
+ cursor: {
123
+ type: "string",
124
+ description: "Pagination cursor returned by previous call as nextCursor.",
125
+ },
126
+ },
127
+ additionalProperties: false,
128
+ },
129
+ annotations: {
130
+ readOnlyHint: true,
131
+ },
132
+ },
133
+ {
134
+ name: "tweet.replies.list",
135
+ description: "List replies for one tweet by url or id",
136
+ inputSchema: {
137
+ type: "object",
138
+ description: "Fetch reply tweets for one focal tweet. Supports cursor pagination when the upstream detail response exposes a reply cursor.",
139
+ properties: {
140
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
141
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
142
+ limit: {
143
+ type: "integer",
144
+ description: `Maximum number of replies to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
145
+ minimum: 1,
146
+ maximum: MAX_TIMELINE_LIMIT,
147
+ },
148
+ cursor: {
149
+ type: "string",
150
+ description: "Pagination cursor returned by previous call as nextCursor.",
151
+ },
152
+ },
153
+ additionalProperties: false,
154
+ },
155
+ annotations: {
156
+ readOnlyHint: true,
157
+ },
158
+ },
159
+ {
160
+ name: "tweet.thread.get",
161
+ description: "Read one tweet thread by url or id",
162
+ inputSchema: {
163
+ type: "object",
164
+ description: "Fetch the same-author thread chain around one focal tweet.",
165
+ properties: {
166
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
167
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
168
+ limit: {
169
+ type: "integer",
170
+ description: `Maximum number of thread tweets to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
171
+ minimum: 1,
172
+ maximum: MAX_TIMELINE_LIMIT,
173
+ },
174
+ },
175
+ additionalProperties: false,
176
+ },
177
+ annotations: {
178
+ readOnlyHint: true,
179
+ },
180
+ },
181
+ {
182
+ name: "tweet.media.download",
183
+ description: "Download media for one tweet by url or id",
184
+ inputSchema: {
185
+ type: "object",
186
+ description: "Download one tweet's media to local artifact paths. Defaults to all media when mediaIndex is omitted.",
187
+ properties: {
188
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
189
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
190
+ mediaIndex: {
191
+ type: "integer",
192
+ description: "Optional zero-based media index to download. Omit to download all media items.",
193
+ minimum: 0,
194
+ },
195
+ },
196
+ additionalProperties: false,
197
+ },
198
+ annotations: {
199
+ readOnlyHint: true,
200
+ },
201
+ },
232
202
  {
233
203
  name: "favorites.list",
234
204
  description: "Read bookmarks/favorites feed cards",
@@ -253,6 +223,46 @@ const TOOL_DEFINITIONS = [
253
223
  readOnlyHint: true,
254
224
  },
255
225
  },
226
+ {
227
+ name: "notifications.list",
228
+ description: "Read the main notifications feed",
229
+ inputSchema: {
230
+ type: "object",
231
+ description: "List recent notifications from the authenticated account.",
232
+ properties: {
233
+ limit: {
234
+ type: "integer",
235
+ description: `Maximum number of notifications to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
236
+ minimum: 1,
237
+ maximum: MAX_TIMELINE_LIMIT,
238
+ },
239
+ },
240
+ additionalProperties: false,
241
+ },
242
+ annotations: {
243
+ readOnlyHint: true,
244
+ },
245
+ },
246
+ {
247
+ name: "mentions.list",
248
+ description: "Read the mentions tab from notifications",
249
+ inputSchema: {
250
+ type: "object",
251
+ description: "List recent mention notifications where the account is referenced.",
252
+ properties: {
253
+ limit: {
254
+ type: "integer",
255
+ description: `Maximum number of mentions to return. Default ${DEFAULT_TIMELINE_LIMIT}, max ${MAX_TIMELINE_LIMIT}.`,
256
+ minimum: 1,
257
+ maximum: MAX_TIMELINE_LIMIT,
258
+ },
259
+ },
260
+ additionalProperties: false,
261
+ },
262
+ annotations: {
263
+ readOnlyHint: true,
264
+ },
265
+ },
256
266
  {
257
267
  name: "timeline.user.list",
258
268
  description: "Read one user's timeline tweet cards",
@@ -356,6 +366,62 @@ const TOOL_DEFINITIONS = [
356
366
  additionalProperties: false,
357
367
  },
358
368
  },
369
+ {
370
+ name: "tweet.reply",
371
+ description: "Reply to one tweet by url or id",
372
+ inputSchema: {
373
+ type: "object",
374
+ description: "Open a tweet detail page, compose one reply, and optionally skip final submit with dryRun.",
375
+ properties: {
376
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
377
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
378
+ text: {
379
+ type: "string",
380
+ description: `Reply text content. Max length ${DEFAULT_MAX_POST_LENGTH}.`,
381
+ minLength: 1,
382
+ maxLength: DEFAULT_MAX_POST_LENGTH,
383
+ },
384
+ dryRun: {
385
+ type: "boolean",
386
+ description: "When true, validate the reply compose path without submitting.",
387
+ },
388
+ },
389
+ required: ["text"],
390
+ additionalProperties: false,
391
+ },
392
+ },
393
+ {
394
+ name: "grok.chat",
395
+ description: "Send one prompt to Grok from the authenticated X session",
396
+ inputSchema: {
397
+ type: "object",
398
+ description: "Ask Grok from the authenticated X session. Starts a new chat by default; pass conversationId to continue an existing conversation.",
399
+ properties: {
400
+ prompt: {
401
+ type: "string",
402
+ description: "Prompt text to send to Grok.",
403
+ minLength: 1,
404
+ },
405
+ conversationId: {
406
+ type: "string",
407
+ description: "Existing Grok conversation id. When omitted, the adapter starts a new chat before asking.",
408
+ minLength: 1,
409
+ },
410
+ attachmentPaths: {
411
+ type: "array",
412
+ description: "Optional local files to upload with the prompt.",
413
+ items: {
414
+ type: "string",
415
+ description: "Absolute local file path to upload through the browser session.",
416
+ minLength: 1,
417
+ "x-uxc-kind": "file-path",
418
+ },
419
+ },
420
+ },
421
+ required: ["prompt"],
422
+ additionalProperties: false,
423
+ },
424
+ },
359
425
  ];
360
426
  function toRecord(value) {
361
427
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
@@ -373,104 +439,441 @@ function errorResult(code, message, details) {
373
439
  }
374
440
  return { error };
375
441
  }
376
- function normalizeTimelineLimit(input) {
377
- const rawLimit = input.limit;
378
- if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
379
- return DEFAULT_TIMELINE_LIMIT;
442
+ function sanitizeArtifactName(name) {
443
+ const trimmed = name.trim();
444
+ const normalized = trimmed.replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ");
445
+ return normalized.length > 0 ? normalized : "artifact.bin";
446
+ }
447
+ function inferArtifactExtension(mimeType) {
448
+ switch ((mimeType ?? "").toLowerCase()) {
449
+ case "text/csv":
450
+ return ".csv";
451
+ case "application/json":
452
+ return ".json";
453
+ case "text/plain":
454
+ return ".txt";
455
+ case "text/markdown":
456
+ return ".md";
457
+ case "application/pdf":
458
+ return ".pdf";
459
+ case "image/png":
460
+ return ".png";
461
+ case "image/jpeg":
462
+ return ".jpg";
463
+ default:
464
+ return ".bin";
380
465
  }
381
- return Math.max(1, Math.min(MAX_TIMELINE_LIMIT, Math.floor(rawLimit)));
382
466
  }
383
- async function ensureNetworkCaptureInstalled(page) {
384
- await page.addInitScript(CAPTURE_INJECT_SCRIPT);
385
- await page.evaluate(CAPTURE_INJECT_SCRIPT);
467
+ function inferArtifactNameFromLabel(label, mimeType) {
468
+ const match = label.match(/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)\b/);
469
+ const matchedName = match?.[1];
470
+ if (matchedName) {
471
+ return sanitizeArtifactName(matchedName);
472
+ }
473
+ const base = sanitizeArtifactName(label.replace(/\s+/g, " ").trim() || "artifact");
474
+ const extension = extname(base) || inferArtifactExtension(mimeType);
475
+ return extname(base) ? base : `${base}${extension}`;
386
476
  }
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;
477
+ function inferArtifactNameFromUrl(url, fallbackBase, mimeType) {
478
+ try {
479
+ const parsed = new URL(url);
480
+ const pathnameName = basename(parsed.pathname);
481
+ const format = parsed.searchParams.get("format")?.trim();
482
+ if (pathnameName) {
483
+ const safePathName = sanitizeArtifactName(pathnameName);
484
+ if (extname(safePathName)) {
485
+ return safePathName;
486
+ }
487
+ if (format) {
488
+ return `${safePathName}.${sanitizeArtifactName(format).replace(/^\.+/, "")}`;
406
489
  }
407
490
  }
408
- return false;
409
- }, { targetMode: mode });
410
- return result === true;
491
+ if (format) {
492
+ return `${sanitizeArtifactName(fallbackBase)}.${sanitizeArtifactName(format).replace(/^\.+/, "")}`;
493
+ }
494
+ }
495
+ catch {
496
+ // Fall through to MIME-based fallback.
497
+ }
498
+ return inferArtifactNameFromLabel(fallbackBase, mimeType);
411
499
  }
412
- async function warmupNetworkTemplate(page, mode) {
413
- if (await hasCapturedTemplate(page, mode)) {
414
- return;
500
+ function parseDataUri(uri) {
501
+ if (!uri.startsWith("data:")) {
502
+ return undefined;
415
503
  }
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;
504
+ const commaIndex = uri.indexOf(",");
505
+ if (commaIndex < 0) {
506
+ return undefined;
425
507
  }
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;
508
+ const meta = uri.slice(5, commaIndex);
509
+ const payload = uri.slice(commaIndex + 1);
510
+ const parts = meta.split(";").filter((part) => part.length > 0);
511
+ const mimeType = parts[0] && !parts[0].includes("=") ? parts[0] : undefined;
512
+ const isBase64 = parts.includes("base64");
513
+ try {
514
+ const buffer = isBase64
515
+ ? Buffer.from(payload, "base64")
516
+ : Buffer.from(decodeURIComponent(payload), "utf8");
517
+ return mimeType ? { mimeType, buffer } : { buffer };
518
+ }
519
+ catch {
520
+ return undefined;
434
521
  }
435
- await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
436
- await waitForTweetSurface(page);
437
522
  }
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 };
523
+ function toTweetMediaArray(value) {
524
+ if (!Array.isArray(value)) {
525
+ return [];
526
+ }
527
+ const output = [];
528
+ for (const rawEntry of value) {
529
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
530
+ continue;
472
531
  }
473
- if (hasSelector(authenticatedSelectors)) {
532
+ const entry = rawEntry;
533
+ const type = entry.type;
534
+ const url = entry.url;
535
+ if ((type !== "photo" && type !== "video" && type !== "animated_gif")
536
+ || typeof url !== "string"
537
+ || url.trim().length === 0) {
538
+ continue;
539
+ }
540
+ const media = {
541
+ type,
542
+ url,
543
+ };
544
+ if (typeof entry.previewUrl === "string" && entry.previewUrl.trim()) {
545
+ media.previewUrl = entry.previewUrl;
546
+ }
547
+ if (typeof entry.width === "number" && Number.isFinite(entry.width)) {
548
+ media.width = entry.width;
549
+ }
550
+ if (typeof entry.height === "number" && Number.isFinite(entry.height)) {
551
+ media.height = entry.height;
552
+ }
553
+ if (typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs)) {
554
+ media.durationMs = entry.durationMs;
555
+ }
556
+ if (Array.isArray(entry.variants)) {
557
+ const variants = entry.variants.flatMap((rawVariant) => {
558
+ if (!rawVariant || typeof rawVariant !== "object" || Array.isArray(rawVariant)) {
559
+ return [];
560
+ }
561
+ const variantRecord = rawVariant;
562
+ if (typeof variantRecord.url !== "string" || !variantRecord.url.trim()) {
563
+ return [];
564
+ }
565
+ const variant = { url: variantRecord.url };
566
+ if (typeof variantRecord.contentType === "string" && variantRecord.contentType.trim()) {
567
+ variant.contentType = variantRecord.contentType;
568
+ }
569
+ if (typeof variantRecord.bitrate === "number" && Number.isFinite(variantRecord.bitrate)) {
570
+ variant.bitrate = variantRecord.bitrate;
571
+ }
572
+ return [variant];
573
+ });
574
+ if (variants.length > 0) {
575
+ media.variants = variants;
576
+ }
577
+ }
578
+ output.push(media);
579
+ }
580
+ return output;
581
+ }
582
+ function normalizeTweetMediaForDownload(media) {
583
+ if (media.type !== "photo") {
584
+ return media;
585
+ }
586
+ try {
587
+ const parsed = new URL(media.url);
588
+ if (parsed.hostname.includes("pbs.twimg.com") && !parsed.searchParams.has("name")) {
589
+ parsed.searchParams.set("name", "orig");
590
+ return {
591
+ ...media,
592
+ url: parsed.toString(),
593
+ };
594
+ }
595
+ }
596
+ catch {
597
+ return media;
598
+ }
599
+ return media;
600
+ }
601
+ async function materializeGrokArtifacts(response) {
602
+ const matches = Array.from(response.matchAll(/\[([^\]]+)\]\((data:[^)]+)\)/g));
603
+ if (matches.length === 0) {
604
+ return { response };
605
+ }
606
+ const artifacts = [];
607
+ const reservedNames = new Set();
608
+ let artifactDir;
609
+ let cleanedResponse = response;
610
+ for (const match of matches) {
611
+ const label = match[1] ?? "artifact";
612
+ const dataUri = match[2] ?? "";
613
+ const parsed = parseDataUri(dataUri);
614
+ if (!parsed) {
615
+ continue;
616
+ }
617
+ let name = inferArtifactNameFromLabel(label, parsed.mimeType);
618
+ if (reservedNames.has(name)) {
619
+ const extension = extname(name);
620
+ const stem = extension ? name.slice(0, -extension.length) : name;
621
+ let suffix = 2;
622
+ do {
623
+ name = `${stem}-${suffix}${extension}`;
624
+ suffix += 1;
625
+ } while (reservedNames.has(name));
626
+ }
627
+ reservedNames.add(name);
628
+ if (!artifactDir) {
629
+ artifactDir = await mkdtemp(join(tmpdir(), GROK_ARTIFACT_DIR_PREFIX));
630
+ }
631
+ const path = join(artifactDir, name);
632
+ await writeFile(path, parsed.buffer);
633
+ const artifact = {
634
+ kind: "file",
635
+ name,
636
+ path,
637
+ };
638
+ if (parsed.mimeType) {
639
+ artifact.mimeType = parsed.mimeType;
640
+ }
641
+ artifacts.push(artifact);
642
+ cleanedResponse = cleanedResponse.replace(match[0], `${label.trim() || name} `);
643
+ }
644
+ const output = {
645
+ response: cleanedResponse.replace(/[ \t]+\n/g, "\n").replace(/\s{2,}/g, " ").replace(/\n{3,}/g, "\n\n").trim(),
646
+ };
647
+ if (artifacts.length > 0) {
648
+ output.artifacts = artifacts;
649
+ }
650
+ return output;
651
+ }
652
+ async function materializeTweetMediaArtifacts(tweet, mediaEntries) {
653
+ let artifactDir;
654
+ const reservedNames = new Set();
655
+ const artifacts = [];
656
+ try {
657
+ for (const entry of mediaEntries) {
658
+ const normalizedMedia = normalizeTweetMediaForDownload(entry.media);
659
+ let mediaUrl;
660
+ try {
661
+ mediaUrl = new URL(normalizedMedia.url);
662
+ }
663
+ catch {
664
+ throw new Error("invalid_media_url");
665
+ }
666
+ if (mediaUrl.protocol !== "https:" || !ALLOWED_TWEET_MEDIA_HOSTS.has(mediaUrl.hostname)) {
667
+ throw new Error(`unsupported_media_url:${mediaUrl.toString()}`);
668
+ }
669
+ const response = await fetch(mediaUrl.toString());
670
+ if (!response.ok) {
671
+ throw new Error(`media_download_http_${response.status}|${mediaUrl.toString()}`);
672
+ }
673
+ const arrayBuffer = await response.arrayBuffer();
674
+ const contentTypeHeader = response.headers.get("content-type")?.split(";")[0]?.trim() || undefined;
675
+ const fallbackBase = `${tweet.id}-media-${entry.mediaIndex + 1}`;
676
+ let name = inferArtifactNameFromUrl(normalizedMedia.url, fallbackBase, contentTypeHeader);
677
+ if (reservedNames.has(name)) {
678
+ const extension = extname(name);
679
+ const stem = extension ? name.slice(0, -extension.length) : name;
680
+ let suffix = 2;
681
+ do {
682
+ name = `${stem}-${suffix}${extension}`;
683
+ suffix += 1;
684
+ } while (reservedNames.has(name));
685
+ }
686
+ reservedNames.add(name);
687
+ if (!artifactDir) {
688
+ artifactDir = await mkdtemp(join(tmpdir(), TWEET_MEDIA_ARTIFACT_DIR_PREFIX));
689
+ }
690
+ const path = join(artifactDir, name);
691
+ await writeFile(path, Buffer.from(arrayBuffer));
692
+ const artifact = {
693
+ kind: "file",
694
+ name,
695
+ path,
696
+ mediaIndex: entry.mediaIndex,
697
+ sourceUrl: normalizedMedia.url,
698
+ };
699
+ if (contentTypeHeader) {
700
+ artifact.mimeType = contentTypeHeader;
701
+ }
702
+ artifacts.push(artifact);
703
+ }
704
+ }
705
+ catch (error) {
706
+ if (artifactDir) {
707
+ await rm(artifactDir, { recursive: true, force: true }).catch(() => { });
708
+ }
709
+ throw error;
710
+ }
711
+ return artifacts;
712
+ }
713
+ function mapTweetMediaDownloadError(error) {
714
+ const message = error instanceof Error && error.message ? error.message : "media download failed";
715
+ if (message.startsWith("media_download_http_")) {
716
+ const [statusPart = "", urlPart = ""] = message.replace("media_download_http_", "").split("|", 2);
717
+ const status = Number.parseInt(statusPart, 10);
718
+ return errorResult("HTTP_ERROR", Number.isFinite(status)
719
+ ? `media download returned HTTP ${status}`
720
+ : "media download returned an HTTP error", typeof urlPart === "string" && urlPart ? { url: urlPart } : undefined);
721
+ }
722
+ if (message.startsWith("unsupported_media_url:")) {
723
+ const url = message.slice("unsupported_media_url:".length);
724
+ return errorResult("VALIDATION_ERROR", "media URL is not on an allowed host", { url });
725
+ }
726
+ if (message === "invalid_media_url") {
727
+ return errorResult("UPSTREAM_CHANGED", "tweet media URL is invalid");
728
+ }
729
+ return errorResult("UPSTREAM_CHANGED", message);
730
+ }
731
+ async function resolveGrokAttachments(input) {
732
+ if (input === undefined) {
733
+ return { ok: true, attachments: [] };
734
+ }
735
+ if (!Array.isArray(input)) {
736
+ return {
737
+ ok: false,
738
+ result: errorResult("VALIDATION_ERROR", "attachmentPaths must be an array"),
739
+ };
740
+ }
741
+ const attachments = [];
742
+ for (const [index, rawPath] of input.entries()) {
743
+ const path = typeof rawPath === "string" ? rawPath.trim() : "";
744
+ if (!path) {
745
+ return {
746
+ ok: false,
747
+ result: errorResult("VALIDATION_ERROR", `attachmentPaths[${index}] must be a non-empty string`),
748
+ };
749
+ }
750
+ if (!isAbsolute(path)) {
751
+ return {
752
+ ok: false,
753
+ result: errorResult("VALIDATION_ERROR", `attachmentPaths[${index}] must be an absolute file path`),
754
+ };
755
+ }
756
+ try {
757
+ const fileStat = await stat(path);
758
+ if (!fileStat.isFile()) {
759
+ return {
760
+ ok: false,
761
+ result: errorResult("VALIDATION_ERROR", `attachmentPaths[${index}] must point to a file`),
762
+ };
763
+ }
764
+ }
765
+ catch {
766
+ return {
767
+ ok: false,
768
+ result: errorResult("VALIDATION_ERROR", `attachmentPaths[${index}] was not found`),
769
+ };
770
+ }
771
+ const resolvedAttachment = {
772
+ path,
773
+ name: basename(path),
774
+ };
775
+ attachments.push(resolvedAttachment);
776
+ }
777
+ return { ok: true, attachments };
778
+ }
779
+ function normalizeTimelineLimit(input) {
780
+ const rawLimit = input.limit;
781
+ if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
782
+ return DEFAULT_TIMELINE_LIMIT;
783
+ }
784
+ return Math.max(1, Math.min(MAX_TIMELINE_LIMIT, Math.floor(rawLimit)));
785
+ }
786
+ async function ensureNetworkCaptureInstalled(page) {
787
+ await page.addInitScript(CAPTURE_INJECT_SCRIPT);
788
+ await page.evaluate(CAPTURE_INJECT_SCRIPT);
789
+ }
790
+ async function hasCapturedTemplate(page, mode) {
791
+ const result = await page.evaluate(({ targetMode }) => {
792
+ const globalAny = window;
793
+ const entries = Array.isArray(globalAny.__WEBMCP_X_CAPTURE__?.entries)
794
+ ? globalAny.__WEBMCP_X_CAPTURE__?.entries ?? []
795
+ : [];
796
+ const ops = targetMode === "home"
797
+ ? ["HomeTimeline", "TweetDetail"]
798
+ : targetMode === "bookmarks"
799
+ ? ["BookmarksAll", "Bookmarks"]
800
+ : targetMode === "tweet"
801
+ ? ["TweetDetail"]
802
+ : targetMode === "user_timeline"
803
+ ? ["UserTweets", "UserTweetsAndReplies", "UserMedia"]
804
+ : ["SearchTimeline"];
805
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
806
+ const entry = entries[i];
807
+ if (entry && typeof entry.op === "string" && ops.includes(entry.op)) {
808
+ return true;
809
+ }
810
+ }
811
+ return false;
812
+ }, { targetMode: mode });
813
+ return result === true;
814
+ }
815
+ async function warmupNetworkTemplate(page, mode) {
816
+ if (await hasCapturedTemplate(page, mode)) {
817
+ return;
818
+ }
819
+ await waitForTweetSurface(page);
820
+ await page
821
+ .evaluate(() => {
822
+ window.scrollTo(0, Math.max(document.body.scrollHeight * 0.8, 1200));
823
+ })
824
+ .catch(() => { });
825
+ await page.waitForTimeout(900);
826
+ if (await hasCapturedTemplate(page, mode)) {
827
+ return;
828
+ }
829
+ await page
830
+ .evaluate(() => {
831
+ window.scrollTo(0, 0);
832
+ })
833
+ .catch(() => { });
834
+ await page.waitForTimeout(700);
835
+ if (await hasCapturedTemplate(page, mode)) {
836
+ return;
837
+ }
838
+ await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
839
+ await waitForTweetSurface(page);
840
+ }
841
+ async function detectAuth(page) {
842
+ return await page.evaluate(({ op }) => {
843
+ void op;
844
+ const signals = [];
845
+ const challengeSelectors = [
846
+ "form[action*='account/access']",
847
+ "input[name='verification_string']",
848
+ "iframe[title*='challenge']",
849
+ ];
850
+ const loginSelectors = [
851
+ "input[name='text']",
852
+ "input[autocomplete='username']",
853
+ "a[href='/login']",
854
+ "a[href*='/i/flow/login']",
855
+ ];
856
+ const authenticatedSelectors = [
857
+ "[data-testid='AppTabBar_Home_Link']",
858
+ "[data-testid='SideNav_NewTweet_Button']",
859
+ "[data-testid='tweetTextarea_0']",
860
+ "nav[aria-label='Primary']",
861
+ ];
862
+ const hasSelector = (selectors) => {
863
+ return selectors.some((selector) => document.querySelector(selector) !== null);
864
+ };
865
+ const bodyText = (document.body?.innerText ?? "").toLowerCase();
866
+ const pathname = location.pathname.toLowerCase();
867
+ const hasChallengeUi = hasSelector(challengeSelectors) ||
868
+ pathname.includes("/account/access") ||
869
+ bodyText.includes("are you human") ||
870
+ bodyText.includes("unusual activity") ||
871
+ bodyText.includes("challenge");
872
+ if (hasChallengeUi) {
873
+ signals.push("challenge_ui");
874
+ return { state: "challenge_required", signals };
875
+ }
876
+ if (hasSelector(authenticatedSelectors)) {
474
877
  signals.push("authenticated_ui");
475
878
  return { state: "authenticated", signals };
476
879
  }
@@ -505,8 +908,97 @@ async function warmupAuthProbe(page) {
505
908
  await page.waitForTimeout(AUTH_STABILIZE_DELAY_MS);
506
909
  }
507
910
  }
911
+ function normalizeInlineText(value) {
912
+ return value.replace(/\s+/g, " ").trim();
913
+ }
914
+ function canonicalizeStatusUrl(input, fallbackId) {
915
+ if (!input) {
916
+ return undefined;
917
+ }
918
+ try {
919
+ const url = new URL(input);
920
+ const segments = url.pathname.split("/").filter(Boolean);
921
+ const statusIndex = segments.findIndex((segment) => segment === "status");
922
+ if (statusIndex < 0) {
923
+ return input;
924
+ }
925
+ const statusId = segments[statusIndex + 1] ?? fallbackId;
926
+ if (!statusId) {
927
+ return input;
928
+ }
929
+ if (segments[0] === "i" && segments[1] === "web") {
930
+ return `${url.origin}/i/web/status/${statusId}`;
931
+ }
932
+ const handle = segments[0];
933
+ if (!handle) {
934
+ return `${url.origin}/i/web/status/${statusId}`;
935
+ }
936
+ return `${url.origin}/${handle}/status/${statusId}`;
937
+ }
938
+ catch {
939
+ return input;
940
+ }
941
+ }
942
+ function enrichNotificationItem(item) {
943
+ const text = normalizeInlineText(item.text);
944
+ const summary = item.summary ? normalizeInlineText(item.summary) : undefined;
945
+ const tweetText = item.tweetText ? normalizeInlineText(item.tweetText) : undefined;
946
+ const next = {
947
+ id: item.id,
948
+ text,
949
+ };
950
+ if (item.url) {
951
+ next.url = item.url;
952
+ }
953
+ if (summary) {
954
+ next.summary = summary;
955
+ }
956
+ if (tweetText) {
957
+ next.tweetText = tweetText;
958
+ }
959
+ if (item.media && item.media.length > 0) {
960
+ next.media = item.media;
961
+ }
962
+ if (item.kind) {
963
+ next.kind = item.kind;
964
+ }
965
+ const likeMatch = text.match(/^(.+?liked your post·\s*\S+)\s+(?:Article\s+)?(.+)$/i);
966
+ if (likeMatch?.[1] && likeMatch[2]) {
967
+ next.kind = next.kind ?? "like";
968
+ next.summary = next.summary ?? normalizeInlineText(likeMatch[1]);
969
+ next.tweetText = next.tweetText ?? normalizeInlineText(likeMatch[2]);
970
+ next.text = next.tweetText;
971
+ return next;
972
+ }
973
+ const repostMatch = text.match(/^(.+?reposted(?: your post)?·\s*\S+)\s+(.+)$/i);
974
+ if (repostMatch?.[1] && repostMatch[2]) {
975
+ next.kind = next.kind ?? "repost";
976
+ next.summary = next.summary ?? normalizeInlineText(repostMatch[1]);
977
+ next.tweetText = next.tweetText ?? normalizeInlineText(repostMatch[2]);
978
+ next.text = next.tweetText;
979
+ return next;
980
+ }
981
+ const replyMatch = text.match(/^(.+?Replying to @\w+(?:.*?·\s*\S+)?)\s+(.+)$/i);
982
+ if (replyMatch?.[1] && replyMatch[2]) {
983
+ next.kind = next.kind ?? "reply";
984
+ next.summary = next.summary ?? normalizeInlineText(replyMatch[1]);
985
+ next.tweetText = next.tweetText ?? normalizeInlineText(replyMatch[2]);
986
+ next.text = next.tweetText;
987
+ return next;
988
+ }
989
+ const followMatch = text.match(/^(.+?followed you(?:·\s*\S+)?)$/i);
990
+ if (followMatch?.[1]) {
991
+ next.kind = next.kind ?? "follow";
992
+ next.summary = next.summary ?? normalizeInlineText(followMatch[1]);
993
+ return next;
994
+ }
995
+ if (!next.kind && next.url) {
996
+ next.kind = "mention";
997
+ }
998
+ return next;
999
+ }
508
1000
  const READ_PAGE_CACHE = new WeakMap();
509
- const PROCESS_TEMPLATE_CACHE = new Map();
1001
+ const PROCESS_TEMPLATE_CACHE = new TemplateCache();
510
1002
  async function readTimelineViaNetwork(page, options) {
511
1003
  const fallbackTemplate = PROCESS_TEMPLATE_CACHE.get(options.mode);
512
1004
  const response = await page.evaluate(async ({ mode, limit, cursor: inputCursor, tweetId, cachedTemplate }) => {
@@ -560,6 +1052,82 @@ async function readTimelineViaNetwork(page, options) {
560
1052
  }
561
1053
  };
562
1054
  const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
1055
+ const collectMedia = (value) => {
1056
+ if (!Array.isArray(value)) {
1057
+ return [];
1058
+ }
1059
+ const output = [];
1060
+ for (const rawEntry of value) {
1061
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
1062
+ continue;
1063
+ }
1064
+ const entry = rawEntry;
1065
+ const type = entry.type;
1066
+ const mediaUrlHttps = typeof entry.media_url_https === "string" ? entry.media_url_https : "";
1067
+ const mediaUrl = typeof entry.media_url === "string" ? entry.media_url : "";
1068
+ const previewUrl = mediaUrlHttps || mediaUrl;
1069
+ if (type !== "photo" && type !== "video" && type !== "animated_gif") {
1070
+ continue;
1071
+ }
1072
+ const originalInfo = entry.original_info ?? {};
1073
+ const nextMedia = {
1074
+ type,
1075
+ url: previewUrl,
1076
+ };
1077
+ if (previewUrl) {
1078
+ nextMedia.previewUrl = previewUrl;
1079
+ }
1080
+ if (typeof originalInfo.w === "number" && Number.isFinite(originalInfo.w)) {
1081
+ nextMedia.width = originalInfo.w;
1082
+ }
1083
+ if (typeof originalInfo.h === "number" && Number.isFinite(originalInfo.h)) {
1084
+ nextMedia.height = originalInfo.h;
1085
+ }
1086
+ if (type === "video" || type === "animated_gif") {
1087
+ const videoInfo = entry.video_info ?? {};
1088
+ if (typeof videoInfo.duration_millis === "number" && Number.isFinite(videoInfo.duration_millis)) {
1089
+ nextMedia.durationMs = videoInfo.duration_millis;
1090
+ }
1091
+ const variants = Array.isArray(videoInfo.variants) ? videoInfo.variants : [];
1092
+ const normalizedVariants = variants.flatMap((rawVariant) => {
1093
+ if (!rawVariant || typeof rawVariant !== "object" || Array.isArray(rawVariant)) {
1094
+ return [];
1095
+ }
1096
+ const variant = rawVariant;
1097
+ const variantUrl = typeof variant.url === "string" ? variant.url : "";
1098
+ if (!variantUrl) {
1099
+ return [];
1100
+ }
1101
+ const nextVariant = { url: variantUrl };
1102
+ if (typeof variant.content_type === "string" && variant.content_type) {
1103
+ nextVariant.contentType = variant.content_type;
1104
+ }
1105
+ if (typeof variant.bitrate === "number" && Number.isFinite(variant.bitrate)) {
1106
+ nextVariant.bitrate = variant.bitrate;
1107
+ }
1108
+ return [nextVariant];
1109
+ });
1110
+ if (normalizedVariants.length > 0) {
1111
+ nextMedia.variants = normalizedVariants;
1112
+ const mp4Variants = normalizedVariants.filter((variant) => variant.contentType === "video/mp4");
1113
+ const preferredVariant = (mp4Variants.length > 0 ? mp4Variants : normalizedVariants)
1114
+ .slice()
1115
+ .sort((left, right) => {
1116
+ const leftBitrate = typeof left.bitrate === "number" ? left.bitrate : -1;
1117
+ const rightBitrate = typeof right.bitrate === "number" ? right.bitrate : -1;
1118
+ return rightBitrate - leftBitrate;
1119
+ })[0];
1120
+ if (preferredVariant && typeof preferredVariant.url === "string" && preferredVariant.url) {
1121
+ nextMedia.url = preferredVariant.url;
1122
+ }
1123
+ }
1124
+ }
1125
+ if (typeof nextMedia.url === "string" && nextMedia.url) {
1126
+ output.push(nextMedia);
1127
+ }
1128
+ }
1129
+ return output;
1130
+ };
563
1131
  const collectFromResult = (input) => {
564
1132
  const outputItems = [];
565
1133
  const seen = new Set();
@@ -601,6 +1169,8 @@ async function readTimelineViaNetwork(page, options) {
601
1169
  }
602
1170
  const restId = typeof tweet?.rest_id === "string" ? tweet.rest_id : "";
603
1171
  const legacy = tweet?.legacy ?? {};
1172
+ const entities = legacy.entities ?? {};
1173
+ const extendedEntities = legacy.extended_entities ?? entities;
604
1174
  const fullText = typeof legacy.full_text === "string"
605
1175
  ? legacy.full_text
606
1176
  : typeof legacy.text === "string"
@@ -609,6 +1179,7 @@ async function readTimelineViaNetwork(page, options) {
609
1179
  const noteText = tweet?.note_tweet?.note_tweet_results
610
1180
  ?.result?.text;
611
1181
  const text = normalizeText(typeof noteText === "string" && noteText ? noteText : fullText);
1182
+ const media = collectMedia(extendedEntities.media);
612
1183
  if (restId && text) {
613
1184
  const userResult = tweet?.core?.user_results
614
1185
  ?.result ?? {};
@@ -630,6 +1201,9 @@ async function readTimelineViaNetwork(page, options) {
630
1201
  if (createdAt) {
631
1202
  item.createdAt = createdAt;
632
1203
  }
1204
+ if (media.length > 0) {
1205
+ item.media = toTweetMediaArray(media);
1206
+ }
633
1207
  outputItems.push(item);
634
1208
  }
635
1209
  }
@@ -648,6 +1222,9 @@ async function readTimelineViaNetwork(page, options) {
648
1222
  const blockedPrefixes = ["sec-", ":"];
649
1223
  const blockedExact = new Set(["host", "content-length", "cookie", "origin", "referer", "connection"]);
650
1224
  const output = {};
1225
+ if (!headers) {
1226
+ return output;
1227
+ }
651
1228
  for (const [key, value] of Object.entries(headers)) {
652
1229
  const k = key.toLowerCase();
653
1230
  if (blockedExact.has(k)) {
@@ -744,15 +1321,16 @@ async function readTimelineViaNetwork(page, options) {
744
1321
  const selectedTemplate = typed.selectedTemplate;
745
1322
  if (selectedTemplate &&
746
1323
  typeof selectedTemplate.url === "string" &&
747
- typeof selectedTemplate.method === "string" &&
748
- typeof selectedTemplate.headers === "object" &&
749
- selectedTemplate.headers !== null &&
750
- !Array.isArray(selectedTemplate.headers)) {
1324
+ typeof selectedTemplate.method === "string") {
751
1325
  const cacheValue = {
752
1326
  url: selectedTemplate.url,
753
1327
  method: selectedTemplate.method,
754
- headers: selectedTemplate.headers,
755
1328
  };
1329
+ if (typeof selectedTemplate.headers === "object" &&
1330
+ selectedTemplate.headers !== null &&
1331
+ !Array.isArray(selectedTemplate.headers)) {
1332
+ cacheValue.headers = selectedTemplate.headers;
1333
+ }
756
1334
  if (typeof selectedTemplate.body === "string") {
757
1335
  cacheValue.body = selectedTemplate.body;
758
1336
  }
@@ -773,8 +1351,87 @@ async function readTimelineViaNetwork(page, options) {
773
1351
  async function extractTweetCards(page, limit) {
774
1352
  const cards = await page.evaluate(({ maxItems }) => {
775
1353
  const normalize = (value) => value.replace(/\s+/g, " ").trim();
1354
+ const cleanText = (value) => {
1355
+ return normalize(value
1356
+ .replace(/\bPromote\b/gi, " ")
1357
+ .replace(/\bShow translation\b/gi, " ")
1358
+ .replace(/\bRelevant View activity\b/gi, " ")
1359
+ .replace(/\bPost your reply\b/gi, " ")
1360
+ .replace(/\bReply\b$/gi, " ")
1361
+ .replace(/\bShow more replies\b/gi, " "));
1362
+ };
1363
+ const canonicalizeStatusUrl = (input, fallbackId) => {
1364
+ if (!input) {
1365
+ return undefined;
1366
+ }
1367
+ try {
1368
+ const url = new URL(input);
1369
+ const segments = url.pathname.split("/").filter(Boolean);
1370
+ const statusIndex = segments.findIndex((segment) => segment === "status");
1371
+ if (statusIndex < 0) {
1372
+ return input;
1373
+ }
1374
+ const statusId = segments[statusIndex + 1] ?? fallbackId;
1375
+ if (!statusId) {
1376
+ return input;
1377
+ }
1378
+ if (segments[0] === "i" && segments[1] === "web") {
1379
+ return `${url.origin}/i/web/status/${statusId}`;
1380
+ }
1381
+ const handle = segments[0];
1382
+ if (!handle) {
1383
+ return `${url.origin}/i/web/status/${statusId}`;
1384
+ }
1385
+ return `${url.origin}/${handle}/status/${statusId}`;
1386
+ }
1387
+ catch {
1388
+ return input;
1389
+ }
1390
+ };
776
1391
  const dedupe = new Set();
777
1392
  const items = [];
1393
+ const collectDomMedia = (root) => {
1394
+ const output = [];
1395
+ const seen = new Set();
1396
+ const pushMedia = (entry) => {
1397
+ const key = `${String(entry.type ?? "")}:${String(entry.url ?? "")}`;
1398
+ if (!entry.url || seen.has(key)) {
1399
+ return;
1400
+ }
1401
+ seen.add(key);
1402
+ output.push(entry);
1403
+ };
1404
+ const imageNodes = Array.from(root.querySelectorAll("img[src*='pbs.twimg.com/media']"));
1405
+ for (const image of imageNodes) {
1406
+ const src = image.currentSrc || image.src || "";
1407
+ if (!src) {
1408
+ continue;
1409
+ }
1410
+ const media = {
1411
+ type: "photo",
1412
+ url: src,
1413
+ };
1414
+ if (Number.isFinite(image.naturalWidth) && image.naturalWidth > 0) {
1415
+ media.width = image.naturalWidth;
1416
+ }
1417
+ if (Number.isFinite(image.naturalHeight) && image.naturalHeight > 0) {
1418
+ media.height = image.naturalHeight;
1419
+ }
1420
+ pushMedia(media);
1421
+ }
1422
+ const videoNodes = Array.from(root.querySelectorAll("video[src], video source[src]"));
1423
+ for (const node of videoNodes) {
1424
+ const src = (node instanceof HTMLSourceElement ? node.src : node.currentSrc || node.src) || "";
1425
+ if (!src) {
1426
+ continue;
1427
+ }
1428
+ pushMedia({
1429
+ type: "video",
1430
+ url: src,
1431
+ });
1432
+ }
1433
+ return output;
1434
+ };
778
1435
  const pushItem = (item) => {
779
1436
  const dedupeKey = `${item.id}:${item.text}`;
780
1437
  if (!item.text || dedupe.has(dedupeKey)) {
@@ -786,11 +1443,12 @@ async function extractTweetCards(page, limit) {
786
1443
  const articles = Array.from(document.querySelectorAll("article"));
787
1444
  for (const article of articles) {
788
1445
  const statusAnchor = article.querySelector("a[href*='/status/']");
789
- const url = statusAnchor?.href;
1446
+ const matchedId = statusAnchor?.href?.match(/status\/(\d+)/)?.[1];
1447
+ const url = canonicalizeStatusUrl(statusAnchor?.href, matchedId);
790
1448
  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 || "");
1449
+ const tweetTextNode = article.querySelector("[data-testid='tweetText']");
1450
+ const mergedText = cleanText(tweetTextNode?.innerText || tweetTextNode?.textContent || "");
1451
+ const fallbackText = cleanText(article.innerText || article.textContent || "");
794
1452
  const text = mergedText || fallbackText;
795
1453
  if (!text) {
796
1454
  continue;
@@ -808,6 +1466,10 @@ async function extractTweetCards(page, limit) {
808
1466
  if (createdAtRaw) {
809
1467
  item.createdAt = createdAtRaw;
810
1468
  }
1469
+ const media = collectDomMedia(article);
1470
+ if (media.length > 0) {
1471
+ item.media = toTweetMediaArray(media);
1472
+ }
811
1473
  pushItem(item);
812
1474
  if (items.length >= maxItems) {
813
1475
  break;
@@ -819,22 +1481,28 @@ async function extractTweetCards(page, limit) {
819
1481
  if (items.length >= maxItems) {
820
1482
  break;
821
1483
  }
822
- const text = normalize(cell.innerText || cell.textContent || "");
1484
+ const tweetTextNode = cell.querySelector("[data-testid='tweetText']");
1485
+ const text = cleanText(tweetTextNode?.innerText || tweetTextNode?.textContent || cell.innerText || cell.textContent || "");
823
1486
  if (!text || text.length < 16) {
824
1487
  continue;
825
1488
  }
826
1489
  const statusAnchor = cell.querySelector("a[href*='/status/']");
827
- const url = statusAnchor?.href;
1490
+ const matchedId = statusAnchor?.href?.match(/status\/(\d+)/)?.[1];
1491
+ const url = canonicalizeStatusUrl(statusAnchor?.href, matchedId);
828
1492
  const id = url?.match(/status\/(\d+)/)?.[1] ?? `cell-${items.length + 1}`;
829
1493
  const item = { id, text };
830
1494
  if (url) {
831
1495
  item.url = url;
832
1496
  }
1497
+ const media = collectDomMedia(cell);
1498
+ if (media.length > 0) {
1499
+ item.media = toTweetMediaArray(media);
1500
+ }
833
1501
  pushItem(item);
834
1502
  }
835
1503
  }
836
1504
  if (items.length === 0) {
837
- const bodyText = normalize(document.body?.innerText || "");
1505
+ const bodyText = cleanText(document.body?.innerText || "");
838
1506
  if (bodyText) {
839
1507
  const snippet = bodyText.slice(0, 280);
840
1508
  pushItem({
@@ -847,6 +1515,181 @@ async function extractTweetCards(page, limit) {
847
1515
  }, { maxItems: limit });
848
1516
  return cards;
849
1517
  }
1518
+ async function scrollTweetDetailSurface(page) {
1519
+ return await page.evaluate(({ op }) => {
1520
+ void op;
1521
+ const viewportHeight = window.innerHeight || 0;
1522
+ const scrollHeight = Math.max(document.documentElement?.scrollHeight ?? 0, document.body?.scrollHeight ?? 0);
1523
+ const beforeY = window.scrollY;
1524
+ const maxScrollY = Math.max(0, scrollHeight - viewportHeight);
1525
+ const delta = Math.max(Math.floor(viewportHeight * 0.9), 900);
1526
+ const targetY = Math.min(beforeY + delta, maxScrollY);
1527
+ window.scrollTo({ top: targetY, behavior: "instant" });
1528
+ return targetY > beforeY;
1529
+ }, { op: "scroll_tweet_detail_surface" });
1530
+ }
1531
+ async function extractTweetCardsAcrossScroll(page, limit) {
1532
+ let merged = mergeTimelineItems(mapTweetCards(await extractTweetCards(page, limit)));
1533
+ let stagnantIterations = 0;
1534
+ for (let attempt = 0; attempt < 6 && merged.length < limit; attempt += 1) {
1535
+ const didScroll = await scrollTweetDetailSurface(page);
1536
+ if (!didScroll) {
1537
+ break;
1538
+ }
1539
+ await page.waitForTimeout(1_000);
1540
+ const nextCards = mergeTimelineItems([...merged, ...mapTweetCards(await extractTweetCards(page, limit))]);
1541
+ if (nextCards.length <= merged.length) {
1542
+ stagnantIterations += 1;
1543
+ if (stagnantIterations >= 2) {
1544
+ break;
1545
+ }
1546
+ }
1547
+ else {
1548
+ stagnantIterations = 0;
1549
+ }
1550
+ merged = nextCards;
1551
+ }
1552
+ const hasCanonicalStatusItem = merged.some((item) => Boolean(item.url?.includes("/status/")));
1553
+ if (hasCanonicalStatusItem) {
1554
+ merged = merged.filter((item) => !item.id.startsWith("fallback-body-"));
1555
+ }
1556
+ return merged.slice(0, limit).map((item) => {
1557
+ const nextItem = {
1558
+ id: item.id,
1559
+ text: item.text,
1560
+ };
1561
+ if (item.url) {
1562
+ nextItem.url = item.url;
1563
+ }
1564
+ if (item.media && item.media.length > 0) {
1565
+ nextItem.media = item.media;
1566
+ }
1567
+ return nextItem;
1568
+ });
1569
+ }
1570
+ async function extractNotificationCards(page, limit) {
1571
+ return await page.evaluate(({ op, maxItems }) => {
1572
+ if (op !== "extract_notifications") {
1573
+ return [];
1574
+ }
1575
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
1576
+ const classifySummary = (value) => {
1577
+ const text = value.toLowerCase();
1578
+ if (text.includes("followed you"))
1579
+ return "follow";
1580
+ if (text.includes("liked your post"))
1581
+ return "like";
1582
+ if (text.includes("reposted your post") || text.includes("reposted"))
1583
+ return "repost";
1584
+ if (text.includes("replying to @") || text.includes("replied"))
1585
+ return "reply";
1586
+ if (text.includes("@"))
1587
+ return "mention";
1588
+ return "notification";
1589
+ };
1590
+ const isGenericHelperText = (value) => {
1591
+ const text = value.toLowerCase();
1592
+ return (text.includes("control which conversations you're mentioned in") ||
1593
+ text.includes("control which conversations you’re mentioned in") ||
1594
+ (text.includes("learn more") && text.includes("mentioned")));
1595
+ };
1596
+ const scoreItem = (value) => {
1597
+ const text = normalize(value.text);
1598
+ let score = text.length;
1599
+ if (value.summary) {
1600
+ score += 100;
1601
+ }
1602
+ if (value.tweetText) {
1603
+ score += 60;
1604
+ }
1605
+ return score;
1606
+ };
1607
+ const pickPreferred = (current, next) => {
1608
+ if (!current) {
1609
+ return next;
1610
+ }
1611
+ return scoreItem(next) > scoreItem(current) ? next : current;
1612
+ };
1613
+ const byKey = new Map();
1614
+ const pushItem = (item) => {
1615
+ const text = normalize(item.text);
1616
+ if (!text || isGenericHelperText(text)) {
1617
+ return;
1618
+ }
1619
+ const normalizedItem = {
1620
+ id: item.id,
1621
+ text,
1622
+ };
1623
+ if (item.url) {
1624
+ normalizedItem.url = item.url;
1625
+ }
1626
+ if (item.kind) {
1627
+ normalizedItem.kind = item.kind;
1628
+ }
1629
+ if (item.summary) {
1630
+ normalizedItem.summary = normalize(item.summary);
1631
+ }
1632
+ if (item.tweetText) {
1633
+ normalizedItem.tweetText = normalize(item.tweetText);
1634
+ }
1635
+ const dedupeKey = item.url ? `url:${item.url}` : `text:${text.toLowerCase()}`;
1636
+ byKey.set(dedupeKey, pickPreferred(byKey.get(dedupeKey), normalizedItem));
1637
+ };
1638
+ const articles = Array.from(document.querySelectorAll("article"));
1639
+ for (const article of articles) {
1640
+ const statusAnchor = article.querySelector("a[href*='/status/']");
1641
+ const url = statusAnchor?.href;
1642
+ const id = url?.match(/status\/(\d+)/)?.[1] ?? `article-${byKey.size + 1}`;
1643
+ const textNodes = Array.from(article.querySelectorAll("[data-testid='tweetText'], div[lang], div[dir='auto']"));
1644
+ const tweetText = normalize(textNodes.map((node) => node.textContent || "").join(" "));
1645
+ const fallbackText = normalize(article.innerText || article.textContent || "");
1646
+ const summary = tweetText ? normalize(fallbackText.replace(tweetText, "")) : "";
1647
+ const item = {
1648
+ id,
1649
+ text: tweetText || fallbackText,
1650
+ };
1651
+ if (url) {
1652
+ item.url = url;
1653
+ }
1654
+ if (summary) {
1655
+ item.summary = summary;
1656
+ item.kind = classifySummary(summary);
1657
+ }
1658
+ if (tweetText) {
1659
+ item.tweetText = tweetText;
1660
+ }
1661
+ pushItem(item);
1662
+ }
1663
+ if (byKey.size < maxItems) {
1664
+ const cells = Array.from(document.querySelectorAll("[data-testid='cellInnerDiv']"));
1665
+ for (const cell of cells) {
1666
+ const fallbackText = normalize(cell.innerText || cell.textContent || "");
1667
+ const tweetNode = cell.querySelector("[data-testid='tweetText'], div[lang], div[dir='auto']");
1668
+ const tweetText = normalize(tweetNode?.innerText || tweetNode?.textContent || "");
1669
+ const summary = tweetText ? normalize(fallbackText.replace(tweetText, "")) : "";
1670
+ const statusAnchor = cell.querySelector("a[href*='/status/']");
1671
+ const url = statusAnchor?.href;
1672
+ const id = url?.match(/status\/(\d+)/)?.[1] ?? `cell-${byKey.size + 1}`;
1673
+ const item = {
1674
+ id,
1675
+ text: tweetText || fallbackText,
1676
+ };
1677
+ if (url) {
1678
+ item.url = url;
1679
+ }
1680
+ if (summary) {
1681
+ item.summary = summary;
1682
+ item.kind = classifySummary(summary);
1683
+ }
1684
+ if (tweetText) {
1685
+ item.tweetText = tweetText;
1686
+ }
1687
+ pushItem(item);
1688
+ }
1689
+ }
1690
+ return Array.from(byKey.values()).slice(0, maxItems);
1691
+ }, { op: "extract_notifications", maxItems: limit });
1692
+ }
850
1693
  async function withEphemeralReadOnlyPage(page, url, run) {
851
1694
  const context = page.context();
852
1695
  const readPage = await context.newPage();
@@ -860,6 +1703,18 @@ async function withEphemeralReadOnlyPage(page, url, run) {
860
1703
  await readPage.close().catch(() => { });
861
1704
  }
862
1705
  }
1706
+ async function withEphemeralPage(page, url, run) {
1707
+ const context = page.context();
1708
+ const ephemeralPage = await context.newPage();
1709
+ try {
1710
+ await ensureNetworkCaptureInstalled(ephemeralPage);
1711
+ await ephemeralPage.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
1712
+ return await run(ephemeralPage);
1713
+ }
1714
+ finally {
1715
+ await ephemeralPage.close().catch(() => { });
1716
+ }
1717
+ }
863
1718
  function getReadPageCacheState(page) {
864
1719
  let state = READ_PAGE_CACHE.get(page);
865
1720
  if (!state) {
@@ -956,9 +1811,109 @@ function mapTweetCards(items) {
956
1811
  if (item.url) {
957
1812
  mapped.url = item.url;
958
1813
  }
1814
+ if (item.media && item.media.length > 0) {
1815
+ mapped.media = item.media;
1816
+ }
959
1817
  return mapped;
960
1818
  });
961
1819
  }
1820
+ function getTimelineStatusId(item) {
1821
+ if (item.id && !item.id.startsWith("article-") && !item.id.startsWith("cell-")) {
1822
+ return item.id;
1823
+ }
1824
+ return item.url?.match(/status\/(\d+)/)?.[1] ?? "";
1825
+ }
1826
+ function getTimelineDedupeKey(item) {
1827
+ const statusId = getTimelineStatusId(item);
1828
+ if (statusId) {
1829
+ return `id:${statusId}`;
1830
+ }
1831
+ if (item.url) {
1832
+ return `url:${item.url}`;
1833
+ }
1834
+ return `text:${item.text}`;
1835
+ }
1836
+ function pickPreferredTimelineItem(current, next) {
1837
+ if (!current) {
1838
+ return next;
1839
+ }
1840
+ const score = (item) => {
1841
+ let total = item.text.length;
1842
+ if (item.url) {
1843
+ total += 10;
1844
+ if (canonicalizeStatusUrl(item.url, item.id) === item.url) {
1845
+ total += 20;
1846
+ }
1847
+ }
1848
+ if (item.media && item.media.length > 0) {
1849
+ total += item.media.length * 15;
1850
+ }
1851
+ if (item.text.includes("Post your reply") || item.text.includes("Relevant View activity")) {
1852
+ total -= 80;
1853
+ }
1854
+ return total;
1855
+ };
1856
+ const currentScore = score(current);
1857
+ const nextScore = score(next);
1858
+ return nextScore > currentScore ? next : current;
1859
+ }
1860
+ function mergeTimelineItems(items) {
1861
+ const order = [];
1862
+ const merged = new Map();
1863
+ for (const item of items) {
1864
+ const key = getTimelineDedupeKey(item);
1865
+ if (!merged.has(key)) {
1866
+ order.push(key);
1867
+ }
1868
+ merged.set(key, pickPreferredTimelineItem(merged.get(key), item));
1869
+ }
1870
+ return order.map((key) => merged.get(key)).filter((item) => Boolean(item));
1871
+ }
1872
+ function extractHandleFromStatusUrl(url) {
1873
+ if (!url) {
1874
+ return "";
1875
+ }
1876
+ try {
1877
+ const parsed = new URL(url);
1878
+ const segments = parsed.pathname.split("/").filter(Boolean);
1879
+ if (segments[0] === "i" && segments[1] === "web") {
1880
+ return "";
1881
+ }
1882
+ const handle = segments[0] ?? "";
1883
+ return handle.replace(/^@+/, "").trim().toLowerCase();
1884
+ }
1885
+ catch {
1886
+ return "";
1887
+ }
1888
+ }
1889
+ function buildConversationPayload(items, focalStatusId, replyLimit, source, nextCursor, debugReason) {
1890
+ const focalIndex = items.findIndex((item) => getTimelineStatusId(item) === focalStatusId);
1891
+ if (focalIndex < 0) {
1892
+ return errorResult("UPSTREAM_CHANGED", "focal tweet not found in conversation");
1893
+ }
1894
+ const focal = items[focalIndex];
1895
+ if (!focal) {
1896
+ return errorResult("UPSTREAM_CHANGED", "focal tweet not found in conversation");
1897
+ }
1898
+ const ancestors = items.slice(0, focalIndex);
1899
+ const allReplies = items.slice(focalIndex + 1);
1900
+ const replies = allReplies.slice(0, replyLimit);
1901
+ const output = {
1902
+ focal,
1903
+ ancestors,
1904
+ replies,
1905
+ source,
1906
+ hasMore: allReplies.length > replyLimit,
1907
+ };
1908
+ if (nextCursor) {
1909
+ output.nextCursor = nextCursor;
1910
+ output.hasMore = true;
1911
+ }
1912
+ if (source === "dom" && debugReason) {
1913
+ output.debug = { reason: debugReason };
1914
+ }
1915
+ return output;
1916
+ }
962
1917
  function toTimelinePageFromNetwork(input) {
963
1918
  const result = {
964
1919
  items: mapTweetCards(input.items),
@@ -1020,22 +1975,157 @@ async function readTweetByUrl(page, url) {
1020
1975
  if (matchId) {
1021
1976
  const fromNetwork = await readTimelineViaNetwork(readPage, {
1022
1977
  mode: "tweet",
1023
- limit: 1,
1978
+ limit: Math.max(DEFAULT_TIMELINE_LIMIT, 20),
1024
1979
  tweetId: matchId,
1025
1980
  });
1026
- const first = fromNetwork.items[0];
1027
- if (first) {
1028
- return { tweet: first };
1981
+ const matched = fromNetwork.items.find((item) => item.id === matchId) ?? fromNetwork.items[0];
1982
+ if (matched) {
1983
+ return { tweet: matched };
1029
1984
  }
1030
1985
  }
1031
- const cards = await extractTweetCards(readPage, 1);
1032
- const tweet = cards[0];
1986
+ const cards = await extractTweetCardsAcrossScroll(readPage, 20);
1987
+ const tweet = (matchId ? cards.find((item) => item.id === matchId) : undefined) ?? cards[0];
1033
1988
  if (!tweet) {
1034
1989
  return errorResult("UPSTREAM_CHANGED", "tweet content not found");
1035
1990
  }
1036
1991
  return { tweet };
1037
1992
  });
1038
1993
  }
1994
+ async function downloadTweetMediaByUrl(page, url, mediaIndex) {
1995
+ const tweetResult = await readTweetByUrl(page, url);
1996
+ if (!tweetResult || typeof tweetResult !== "object" || !("tweet" in tweetResult)) {
1997
+ return tweetResult;
1998
+ }
1999
+ const tweet = tweetResult.tweet;
2000
+ const media = Array.isArray(tweet.media) ? tweet.media : [];
2001
+ if (media.length === 0) {
2002
+ return errorResult("NO_MEDIA", "tweet has no downloadable media");
2003
+ }
2004
+ if (mediaIndex !== undefined && (!Number.isInteger(mediaIndex) || mediaIndex < 0)) {
2005
+ return errorResult("VALIDATION_ERROR", "mediaIndex must be a non-negative integer");
2006
+ }
2007
+ if (mediaIndex !== undefined && mediaIndex >= media.length) {
2008
+ return errorResult("VALIDATION_ERROR", "mediaIndex is out of range");
2009
+ }
2010
+ const selectedEntries = mediaIndex === undefined
2011
+ ? media.map((entry, index) => ({ mediaIndex: index, media: entry }))
2012
+ : [{ mediaIndex, media: media[mediaIndex] }];
2013
+ try {
2014
+ const artifacts = await materializeTweetMediaArtifacts(tweet, selectedEntries);
2015
+ return {
2016
+ tweet,
2017
+ items: selectedEntries.map((entry, index) => ({
2018
+ mediaIndex: entry.mediaIndex,
2019
+ media: entry.media,
2020
+ artifact: artifacts[index],
2021
+ })),
2022
+ };
2023
+ }
2024
+ catch (error) {
2025
+ return mapTweetMediaDownloadError(error);
2026
+ }
2027
+ }
2028
+ async function readTweetConversationByUrl(page, url, limit, cursor) {
2029
+ return await withEphemeralReadOnlyPage(page, url, async (readPage) => {
2030
+ const matchId = url.match(/status\/(\d+)/)?.[1];
2031
+ if (!matchId) {
2032
+ return errorResult("VALIDATION_ERROR", "tweet id could not be derived from url");
2033
+ }
2034
+ const merged = [];
2035
+ let source = "dom";
2036
+ const request = {
2037
+ mode: "tweet",
2038
+ limit,
2039
+ tweetId: matchId,
2040
+ };
2041
+ if (cursor) {
2042
+ request.cursor = cursor;
2043
+ }
2044
+ const fromNetwork = await readTimelineViaNetwork(readPage, request);
2045
+ if (fromNetwork.items.length > 0) {
2046
+ merged.push(...mapTweetCards(fromNetwork.items));
2047
+ source = "network";
2048
+ }
2049
+ if (!cursor) {
2050
+ const domCards = await extractTweetCardsAcrossScroll(readPage, Math.max(limit + 1, 20));
2051
+ merged.push(...mapTweetCards(domCards));
2052
+ }
2053
+ const conversationItems = mergeTimelineItems(merged);
2054
+ if (conversationItems.length === 0) {
2055
+ return errorResult("UPSTREAM_CHANGED", "tweet conversation content not found");
2056
+ }
2057
+ return buildConversationPayload(conversationItems, matchId, limit, source, fromNetwork.nextCursor, fromNetwork.reason ?? (source === "dom" ? "dom_fallback" : undefined));
2058
+ });
2059
+ }
2060
+ async function readTweetRepliesByUrl(page, url, limit, cursor) {
2061
+ const conversation = await readTweetConversationByUrl(page, url, limit, cursor);
2062
+ if (!conversation || typeof conversation !== "object" || !("focal" in conversation) || !("replies" in conversation)) {
2063
+ return conversation;
2064
+ }
2065
+ const typed = conversation;
2066
+ const output = {
2067
+ focal: typed.focal,
2068
+ items: typed.replies.slice(0, limit),
2069
+ source: typed.source,
2070
+ hasMore: typed.hasMore,
2071
+ };
2072
+ if (typed.nextCursor) {
2073
+ output.nextCursor = typed.nextCursor;
2074
+ }
2075
+ if (typed.debug) {
2076
+ output.debug = typed.debug;
2077
+ }
2078
+ return output;
2079
+ }
2080
+ async function readTweetThreadByUrl(page, url, limit) {
2081
+ const conversation = await readTweetConversationByUrl(page, url, Math.max(limit, 20));
2082
+ if (!conversation || typeof conversation !== "object" || !("focal" in conversation)) {
2083
+ return conversation;
2084
+ }
2085
+ const typed = conversation;
2086
+ const focalHandle = extractHandleFromStatusUrl(typed.focal.url);
2087
+ const threadItems = mergeTimelineItems([...typed.ancestors, typed.focal, ...typed.replies].filter((item) => {
2088
+ if (!focalHandle) {
2089
+ return true;
2090
+ }
2091
+ return extractHandleFromStatusUrl(item.url) === focalHandle;
2092
+ })).slice(0, limit);
2093
+ if (threadItems.length === 0) {
2094
+ return errorResult("UPSTREAM_CHANGED", "tweet conversation content not found");
2095
+ }
2096
+ const root = threadItems[0];
2097
+ if (!root) {
2098
+ return errorResult("UPSTREAM_CHANGED", "tweet thread content not found");
2099
+ }
2100
+ const output = {
2101
+ root,
2102
+ focal: typed.focal,
2103
+ tweets: threadItems,
2104
+ source: typed.source,
2105
+ };
2106
+ if (typed.hasMore) {
2107
+ output.incomplete = true;
2108
+ }
2109
+ if (typed.nextCursor) {
2110
+ output.nextCursor = typed.nextCursor;
2111
+ }
2112
+ if (typed.debug) {
2113
+ output.debug = typed.debug;
2114
+ }
2115
+ return output;
2116
+ }
2117
+ async function readNotifications(page, limit) {
2118
+ await waitForTweetSurface(page);
2119
+ const cards = await extractNotificationCards(page, limit);
2120
+ return {
2121
+ items: cards.map(enrichNotificationItem),
2122
+ source: "dom",
2123
+ hasMore: false,
2124
+ debug: {
2125
+ reason: "notifications_dom",
2126
+ },
2127
+ };
2128
+ }
1039
2129
  async function readProfile(page, handle) {
1040
2130
  const normalizedHandle = handle.replace(/^@+/, "").trim();
1041
2131
  const profileUrl = `https://x.com/${normalizedHandle}`;
@@ -1235,82 +2325,1179 @@ async function waitForComposeConfirmation(page, text, timeoutMs) {
1235
2325
  }
1236
2326
  return { confirmed: true };
1237
2327
  }
1238
- async function requireAuthenticated(page) {
1239
- const auth = await detectAuthStable(page);
1240
- if (auth.state === "authenticated") {
1241
- return { ok: true, auth };
2328
+ async function ensureReplyComposerReady(page) {
2329
+ const composerSelectors = [
2330
+ "div[data-testid='tweetTextarea_0']",
2331
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
2332
+ "div[role='textbox'][aria-label*='Post text']",
2333
+ "div[role='textbox'][aria-label*='Reply']",
2334
+ "div[role='textbox'][aria-label*='Post your reply']",
2335
+ ];
2336
+ const openReplySelectors = [
2337
+ "[data-testid='reply']",
2338
+ "[data-testid='replyButton']",
2339
+ "button[aria-label*='Reply']",
2340
+ "div[role='button'][aria-label*='Reply']",
2341
+ ];
2342
+ for (const selector of composerSelectors) {
2343
+ const handle = await page.waitForSelector(selector, { timeout: 800 }).catch(() => null);
2344
+ if (handle) {
2345
+ await handle.dispose();
2346
+ return;
2347
+ }
1242
2348
  }
1243
- if (auth.state === "challenge_required") {
2349
+ await page
2350
+ .evaluate((selectors) => {
2351
+ for (const selector of selectors) {
2352
+ const element = document.querySelector(selector);
2353
+ if (element) {
2354
+ element.click();
2355
+ return;
2356
+ }
2357
+ }
2358
+ }, openReplySelectors)
2359
+ .catch(() => { });
2360
+ for (const selector of composerSelectors) {
2361
+ const handle = await page.waitForSelector(selector, { timeout: 2500 }).catch(() => null);
2362
+ if (handle) {
2363
+ await handle.dispose();
2364
+ return;
2365
+ }
2366
+ }
2367
+ }
2368
+ async function composeReply(page, text, dryRun) {
2369
+ const composerSelectors = [
2370
+ "div[data-testid='tweetTextarea_0']",
2371
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
2372
+ "div[role='textbox'][aria-label*='Reply']",
2373
+ "div[role='textbox'][aria-label*='Post your reply']",
2374
+ "div[role='textbox'][aria-label*='Post text']",
2375
+ ];
2376
+ const selectAllShortcut = process.platform === "darwin" ? "Meta+A" : "Control+A";
2377
+ let composerSelector;
2378
+ for (const selector of composerSelectors) {
2379
+ const handle = await page.waitForSelector(selector, { timeout: 800 }).catch(() => null);
2380
+ if (!handle) {
2381
+ continue;
2382
+ }
2383
+ await handle.dispose().catch(() => { });
2384
+ composerSelector = selector;
2385
+ break;
2386
+ }
2387
+ if (!composerSelector) {
2388
+ return { ok: false, reason: "composer_not_found" };
2389
+ }
2390
+ try {
2391
+ await page.click(composerSelector);
2392
+ await page.keyboard.press(selectAllShortcut).catch(() => { });
2393
+ await page.keyboard.press("Backspace").catch(() => { });
2394
+ await page.type(composerSelector, text, { delay: 12 });
2395
+ }
2396
+ catch {
2397
+ return { ok: false, reason: "compose_input_failed" };
2398
+ }
2399
+ const submitVisible = await waitForReplySubmitReady(page, 2_000);
2400
+ if (dryRun) {
1244
2401
  return {
1245
- ok: false,
1246
- result: errorResult("CHALLENGE_REQUIRED", "x.com challenge is blocking actions", {
1247
- state: auth.state,
1248
- signals: auth.signals,
1249
- }),
2402
+ ok: true,
2403
+ dryRun: true,
2404
+ submitVisible,
1250
2405
  };
1251
2406
  }
2407
+ if (!submitVisible) {
2408
+ return { ok: false, reason: "submit_not_found" };
2409
+ }
1252
2410
  return {
1253
- ok: false,
1254
- result: errorResult("AUTH_REQUIRED", "login required", {
1255
- state: auth.state,
1256
- signals: auth.signals,
1257
- }),
2411
+ ok: true,
2412
+ submitVisible: true,
1258
2413
  };
1259
2414
  }
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
- };
2415
+ async function waitForReplySubmitReady(page, timeoutMs) {
2416
+ try {
2417
+ await page.waitForFunction(({ op }) => {
2418
+ if (op !== "reply_submit_ready") {
2419
+ return false;
1281
2420
  }
1282
- if (name === "timeline.home.list") {
1283
- const authCheck = await requireAuthenticated(page);
1284
- if (!authCheck.ok) {
1285
- return authCheck.result;
2421
+ const selectors = [
2422
+ "[data-testid='tweetButtonInline']",
2423
+ "[data-testid='tweetButton']",
2424
+ "div[data-testid='toolBar'] [data-testid='tweetButtonInline']",
2425
+ ];
2426
+ const isEnabled = (element) => {
2427
+ if (!element) {
2428
+ return false;
1286
2429
  }
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;
2430
+ if (element instanceof HTMLButtonElement) {
2431
+ return !element.disabled;
1294
2432
  }
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;
2433
+ const ariaDisabled = (element.getAttribute("aria-disabled") ?? "").toLowerCase();
2434
+ return ariaDisabled !== "true";
2435
+ };
2436
+ const composerSelectors = [
2437
+ "div[data-testid='tweetTextarea_0']",
2438
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
2439
+ "div[role='textbox'][aria-label*='Reply']",
2440
+ "div[role='textbox'][aria-label*='Post your reply']",
2441
+ "div[role='textbox'][aria-label*='Post text']",
2442
+ ];
2443
+ const findNearestSubmit = (composer) => {
2444
+ if (!composer) {
2445
+ return null;
1305
2446
  }
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");
2447
+ const roots = [
2448
+ composer.closest("[role='dialog']"),
2449
+ composer.closest("form"),
2450
+ composer.closest("article"),
2451
+ composer.parentElement,
2452
+ composer.parentElement?.parentElement,
2453
+ document.body,
2454
+ ];
2455
+ for (const root of roots) {
2456
+ if (!root) {
2457
+ continue;
2458
+ }
2459
+ for (const selector of selectors) {
2460
+ const element = root.querySelector(selector);
2461
+ if (isEnabled(element)) {
2462
+ return element;
2463
+ }
2464
+ }
2465
+ }
2466
+ return null;
2467
+ };
2468
+ let composer = null;
2469
+ for (const selector of composerSelectors) {
2470
+ composer = document.querySelector(selector);
2471
+ if (composer) {
2472
+ break;
2473
+ }
2474
+ }
2475
+ if (findNearestSubmit(composer)) {
2476
+ return true;
2477
+ }
2478
+ for (const selector of selectors) {
2479
+ const element = document.querySelector(selector);
2480
+ if (isEnabled(element)) {
2481
+ return true;
2482
+ }
2483
+ }
2484
+ return false;
2485
+ }, { op: "reply_submit_ready" }, { timeout: Math.max(1_500, Math.min(timeoutMs, 6_000)) });
2486
+ return true;
2487
+ }
2488
+ catch {
2489
+ return false;
2490
+ }
2491
+ }
2492
+ async function submitReply(page) {
2493
+ return await page.evaluate(({ op }) => {
2494
+ if (op !== "reply_submit") {
2495
+ return { ok: false, reason: "invalid_operation" };
2496
+ }
2497
+ const composerSelectors = [
2498
+ "div[data-testid='tweetTextarea_0']",
2499
+ "div[role='textbox'][data-testid='tweetTextarea_0']",
2500
+ "div[role='textbox'][aria-label*='Reply']",
2501
+ "div[role='textbox'][aria-label*='Post your reply']",
2502
+ "div[role='textbox'][aria-label*='Post text']",
2503
+ ];
2504
+ const selectors = [
2505
+ "[data-testid='tweetButtonInline']",
2506
+ "[data-testid='tweetButton']",
2507
+ "div[data-testid='toolBar'] [data-testid='tweetButtonInline']",
2508
+ ];
2509
+ const isEnabled = (element) => {
2510
+ if (!element) {
2511
+ return false;
2512
+ }
2513
+ if (element instanceof HTMLButtonElement) {
2514
+ return !element.disabled;
2515
+ }
2516
+ const ariaDisabled = (element.getAttribute("aria-disabled") ?? "").toLowerCase();
2517
+ return ariaDisabled !== "true";
2518
+ };
2519
+ const findNearestSubmit = (composer) => {
2520
+ if (!composer) {
2521
+ return null;
2522
+ }
2523
+ const roots = [
2524
+ composer.closest("[role='dialog']"),
2525
+ composer.closest("form"),
2526
+ composer.closest("article"),
2527
+ composer.parentElement,
2528
+ composer.parentElement?.parentElement,
2529
+ document.body,
2530
+ ];
2531
+ for (const root of roots) {
2532
+ if (!root) {
2533
+ continue;
2534
+ }
2535
+ for (const selector of selectors) {
2536
+ const element = root.querySelector(selector);
2537
+ if (isEnabled(element)) {
2538
+ return element;
2539
+ }
2540
+ }
2541
+ }
2542
+ return null;
2543
+ };
2544
+ let composer = null;
2545
+ for (const selector of composerSelectors) {
2546
+ composer = document.querySelector(selector);
2547
+ if (composer) {
2548
+ break;
2549
+ }
2550
+ }
2551
+ const nearestSubmit = findNearestSubmit(composer);
2552
+ if (nearestSubmit) {
2553
+ nearestSubmit.click();
2554
+ return { ok: true };
2555
+ }
2556
+ for (const selector of selectors) {
2557
+ const element = document.querySelector(selector);
2558
+ if (!isEnabled(element)) {
2559
+ continue;
2560
+ }
2561
+ element?.click();
2562
+ return { ok: true };
2563
+ }
2564
+ return { ok: false, reason: "submit_not_found" };
2565
+ }, { op: "reply_submit" });
2566
+ }
2567
+ async function waitForReplyConfirmation(page, targetUrl, text, timeoutMs) {
2568
+ const firstPassTimeoutMs = Math.max(2_500, Math.min(timeoutMs, 5_000));
2569
+ const firstPass = await waitForComposeConfirmation(page, text, firstPassTimeoutMs);
2570
+ if (firstPass.confirmed) {
2571
+ return firstPass;
2572
+ }
2573
+ await page.waitForTimeout(1_000);
2574
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
2575
+ await waitForTweetSurface(page);
2576
+ const secondPassTimeoutMs = Math.max(2_500, timeoutMs - firstPassTimeoutMs);
2577
+ return await waitForComposeConfirmation(page, text, secondPassTimeoutMs);
2578
+ }
2579
+ async function waitForGrokSurface(page) {
2580
+ await page
2581
+ .waitForFunction(() => {
2582
+ const composer = document.querySelector("textarea") ||
2583
+ document.querySelector("[contenteditable='true'][role='textbox']") ||
2584
+ document.querySelector("[role='textbox'][contenteditable='true']");
2585
+ const messages = document.querySelector("[data-message-author-role='assistant']") ||
2586
+ document.querySelector("[data-testid*='assistant']") ||
2587
+ document.querySelector("article");
2588
+ return composer !== null || messages !== null;
2589
+ }, undefined, { timeout: 12_000 })
2590
+ .catch(() => { });
2591
+ await page.waitForTimeout(800);
2592
+ }
2593
+ async function submitGrokPrompt(page, prompt) {
2594
+ const composerSelectors = [
2595
+ "textarea",
2596
+ "[contenteditable='true'][role='textbox']",
2597
+ "[role='textbox'][contenteditable='true']",
2598
+ ];
2599
+ const submitSelectors = [
2600
+ "button[aria-label*='Grok something']",
2601
+ "button[aria-label*='Send']",
2602
+ "button[aria-label*='send']",
2603
+ "button[data-testid*='send']",
2604
+ "button[type='submit']",
2605
+ ];
2606
+ const selectAllShortcut = process.platform === "darwin" ? "Meta+A" : "Control+A";
2607
+ let composerSelector;
2608
+ for (const selector of composerSelectors) {
2609
+ const handle = await page.waitForSelector(selector, { timeout: 1_200 }).catch(() => null);
2610
+ if (!handle) {
2611
+ continue;
2612
+ }
2613
+ await handle.dispose().catch(() => { });
2614
+ composerSelector = selector;
2615
+ break;
2616
+ }
2617
+ if (!composerSelector) {
2618
+ return { ok: false, reason: "composer_not_found" };
2619
+ }
2620
+ try {
2621
+ await page.click(composerSelector);
2622
+ await page.keyboard.press(selectAllShortcut).catch(() => { });
2623
+ await page.keyboard.press("Backspace").catch(() => { });
2624
+ await page.type(composerSelector, prompt, { delay: 12 });
2625
+ }
2626
+ catch {
2627
+ return { ok: false, reason: "compose_input_failed" };
2628
+ }
2629
+ const submitSelector = await page.evaluate((selectors) => {
2630
+ const normalize = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
2631
+ for (const selector of selectors) {
2632
+ const element = document.querySelector(selector);
2633
+ if (!element) {
2634
+ continue;
2635
+ }
2636
+ if (element instanceof HTMLButtonElement && element.disabled) {
2637
+ continue;
2638
+ }
2639
+ const ariaDisabled = (element.getAttribute("aria-disabled") ?? "").toLowerCase();
2640
+ if (ariaDisabled === "true") {
2641
+ continue;
2642
+ }
2643
+ const label = normalize(element.getAttribute("aria-label") ?? element.textContent ?? "");
2644
+ if (label.includes("stop")) {
2645
+ continue;
2646
+ }
2647
+ return selector;
2648
+ }
2649
+ return undefined;
2650
+ }, submitSelectors);
2651
+ if (!submitSelector) {
2652
+ return { ok: false, reason: "submit_not_found" };
2653
+ }
2654
+ try {
2655
+ await page.click(submitSelector);
2656
+ }
2657
+ catch {
2658
+ return { ok: false, reason: "submit_click_failed" };
2659
+ }
2660
+ return { ok: true };
2661
+ }
2662
+ async function prepareGrokSession(page, conversationId) {
2663
+ const targetUrl = typeof conversationId === "string" && conversationId.trim().length > 0
2664
+ ? `https://x.com/i/grok?conversation=${encodeURIComponent(conversationId.trim())}`
2665
+ : "https://x.com/i/grok";
2666
+ if (!isSameLocation(page.url(), targetUrl)) {
2667
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
2668
+ }
2669
+ await waitForGrokSurface(page);
2670
+ if (conversationId && conversationId.trim().length > 0) {
2671
+ return;
2672
+ }
2673
+ const currentConversationId = (() => {
2674
+ try {
2675
+ return new URL(page.url()).searchParams.get("conversation") ?? undefined;
2676
+ }
2677
+ catch {
2678
+ return undefined;
2679
+ }
2680
+ })();
2681
+ if (typeof page.locator !== "function") {
2682
+ return;
2683
+ }
2684
+ const newChatButton = page
2685
+ .locator("button[aria-label*='New Chat'], button:has-text('New Chat')")
2686
+ .first();
2687
+ if ((await newChatButton.count().catch(() => 0)) === 0) {
2688
+ return;
2689
+ }
2690
+ await newChatButton.click({ timeout: 2_000 }).catch(() => { });
2691
+ await page
2692
+ .waitForFunction(({ previousConversationId }) => {
2693
+ const currentUrl = window.location.href;
2694
+ try {
2695
+ const conversation = new URL(currentUrl).searchParams.get("conversation") ?? undefined;
2696
+ if (!previousConversationId) {
2697
+ return conversation === undefined || conversation.length === 0;
2698
+ }
2699
+ return conversation !== previousConversationId;
2700
+ }
2701
+ catch {
2702
+ return false;
2703
+ }
2704
+ }, { previousConversationId: currentConversationId }, { timeout: 5_000 })
2705
+ .catch(() => { });
2706
+ await page.waitForTimeout(600);
2707
+ await waitForGrokSurface(page);
2708
+ }
2709
+ async function uploadGrokAttachments(page, attachments) {
2710
+ if (attachments.length === 0) {
2711
+ return { ok: true };
2712
+ }
2713
+ const uploadSelectors = [
2714
+ "input[type='file'][accept*='application/pdf']",
2715
+ "input[type='file'][accept*='text/csv']",
2716
+ "input[type='file'][accept*='text/plain']",
2717
+ "input[type='file']",
2718
+ ];
2719
+ let uploadSelector;
2720
+ for (const selector of uploadSelectors) {
2721
+ const handle = await page.waitForSelector(selector, { timeout: 1_200 }).catch(() => null);
2722
+ if (!handle) {
2723
+ continue;
2724
+ }
2725
+ await handle.dispose().catch(() => { });
2726
+ uploadSelector = selector;
2727
+ break;
2728
+ }
2729
+ if (!uploadSelector) {
2730
+ return { ok: false, reason: "attachment_input_not_found" };
2731
+ }
2732
+ try {
2733
+ await page.setInputFiles(uploadSelector, attachments.map((attachment) => attachment.path));
2734
+ }
2735
+ catch {
2736
+ return { ok: false, reason: "attachment_upload_failed" };
2737
+ }
2738
+ const attachmentNames = attachments.map((attachment) => attachment.name);
2739
+ await page
2740
+ .waitForFunction(({ names }) => {
2741
+ const bodyText = document.body?.innerText ?? "";
2742
+ return names.every((name) => bodyText.includes(name));
2743
+ }, { names: attachmentNames }, { timeout: 10_000 })
2744
+ .catch(() => { });
2745
+ await page.waitForTimeout(600);
2746
+ return { ok: true };
2747
+ }
2748
+ async function askGrokViaNetwork(page, prompt, timeoutMs) {
2749
+ const captured = await captureRoutedResponseText(page, "https://grok.x.com/2/grok/add_response.json*", async () => {
2750
+ const submitResult = await submitGrokPrompt(page, prompt);
2751
+ return submitResult.ok;
2752
+ }, {
2753
+ timeoutMs,
2754
+ });
2755
+ if (!captured || captured.status < 200 || captured.status >= 300) {
2756
+ return undefined;
2757
+ }
2758
+ const responseText = captured.text;
2759
+ const entries = parseNdjsonLines(responseText);
2760
+ const finalParts = collectTextByTag(entries.map((entry) => {
2761
+ const output = {};
2762
+ if (typeof entry.result?.message === "string") {
2763
+ output.message = entry.result.message;
2764
+ }
2765
+ if (typeof entry.result?.messageTag === "string") {
2766
+ output.messageTag = entry.result.messageTag;
2767
+ }
2768
+ return output;
2769
+ }), "final");
2770
+ let conversationId;
2771
+ for (const entry of entries) {
2772
+ if (!conversationId && typeof entry.conversationId === "string") {
2773
+ conversationId = entry.conversationId;
2774
+ }
2775
+ }
2776
+ const finalResponse = joinTextParts(finalParts).trim();
2777
+ if (!finalResponse) {
2778
+ return undefined;
2779
+ }
2780
+ const artifactResult = await materializeGrokArtifacts(finalResponse);
2781
+ const output = {
2782
+ ok: true,
2783
+ response: artifactResult.response,
2784
+ url: typeof conversationId === "string" ? `https://x.com/i/grok?conversation=${conversationId}` : page.url(),
2785
+ };
2786
+ if (typeof conversationId === "string") {
2787
+ output.conversationId = conversationId;
2788
+ }
2789
+ if (artifactResult.artifacts) {
2790
+ output.artifacts = artifactResult.artifacts;
2791
+ }
2792
+ return output;
2793
+ }
2794
+ async function waitForGrokResponse(page, previousResponse, prompt, timeoutMs) {
2795
+ try {
2796
+ await page.waitForFunction(({ op, previous, promptText }) => {
2797
+ if (op !== "grok_wait") {
2798
+ return false;
2799
+ }
2800
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
2801
+ const previousText = normalize(previous);
2802
+ const normalizedPrompt = normalize(promptText);
2803
+ const isIgnoredResponse = (value) => {
2804
+ const lower = value.toLowerCase();
2805
+ return (lower.length < 3 ||
2806
+ lower.startsWith("see new posts") ||
2807
+ lower.startsWith("thought for ") ||
2808
+ lower.startsWith("agents thinking") ||
2809
+ lower.startsWith("ask anything") ||
2810
+ lower === "agents" ||
2811
+ lower === "thinking" ||
2812
+ lower === "expert" ||
2813
+ lower.startsWith("grok") ||
2814
+ lower.includes("explore ") ||
2815
+ lower.includes("discuss ") ||
2816
+ lower.includes("create images") ||
2817
+ lower.includes("edit image") ||
2818
+ lower.includes("latest news") ||
2819
+ lower === normalizedPrompt.toLowerCase() ||
2820
+ lower === previousText.toLowerCase());
2821
+ };
2822
+ const scope = document.querySelector("div[aria-label='Grok']") ??
2823
+ document.querySelector("main");
2824
+ if (!scope) {
2825
+ return false;
2826
+ }
2827
+ const lines = (scope.innerText || scope.textContent || "")
2828
+ .split(/\n+/)
2829
+ .map((line) => normalize(line))
2830
+ .filter((line) => line.length > 0);
2831
+ const linePromptIndex = lines.lastIndexOf(normalizedPrompt);
2832
+ if (linePromptIndex >= 0) {
2833
+ const hasStopControl = Array.from(document.querySelectorAll("button")).some((button) => {
2834
+ const label = normalize(button.getAttribute("aria-label") ?? button.textContent ?? "").toLowerCase();
2835
+ return label.includes("stop");
2836
+ });
2837
+ let hasLineCandidate = false;
2838
+ for (let index = linePromptIndex + 1; index < lines.length; index += 1) {
2839
+ const candidate = lines[index];
2840
+ if (!candidate || isIgnoredResponse(candidate)) {
2841
+ continue;
2842
+ }
2843
+ hasLineCandidate = true;
2844
+ }
2845
+ if (!hasStopControl && hasLineCandidate) {
2846
+ return true;
2847
+ }
2848
+ }
2849
+ const entries = [];
2850
+ for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
2851
+ if (node.closest("button, a, textarea, nav")) {
2852
+ continue;
2853
+ }
2854
+ const text = normalize(node.innerText || node.textContent || "");
2855
+ if (!text) {
2856
+ continue;
2857
+ }
2858
+ const childWithSameText = Array.from(node.children).some((child) => {
2859
+ if (!(child instanceof HTMLElement)) {
2860
+ return false;
2861
+ }
2862
+ return normalize(child.innerText || child.textContent || "") === text;
2863
+ });
2864
+ if (childWithSameText) {
2865
+ continue;
2866
+ }
2867
+ if (entries[entries.length - 1] !== text) {
2868
+ entries.push(text);
2869
+ }
2870
+ }
2871
+ const hasStopControl = Array.from(document.querySelectorAll("button")).some((button) => {
2872
+ const label = normalize(button.getAttribute("aria-label") ?? button.textContent ?? "").toLowerCase();
2873
+ return label.includes("stop");
2874
+ });
2875
+ const promptIndex = entries.lastIndexOf(normalizedPrompt);
2876
+ if (promptIndex < 0) {
2877
+ return false;
2878
+ }
2879
+ let hasCandidate = false;
2880
+ for (let index = promptIndex + 1; index < entries.length; index += 1) {
2881
+ const candidate = entries[index];
2882
+ if (!candidate || isIgnoredResponse(candidate)) {
2883
+ continue;
2884
+ }
2885
+ hasCandidate = true;
2886
+ }
2887
+ return !hasStopControl && hasCandidate;
2888
+ }, {
2889
+ op: "grok_wait",
2890
+ previous: (previousResponse ?? "").replace(/\s+/g, " ").trim(),
2891
+ promptText: prompt,
2892
+ }, { timeout: timeoutMs });
2893
+ }
2894
+ catch {
2895
+ return { confirmed: false };
2896
+ }
2897
+ const state = await page.evaluate(({ op, promptText, previousText }) => {
2898
+ if (op !== "grok_extract_state") {
2899
+ return undefined;
2900
+ }
2901
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
2902
+ const normalizedPrompt = normalize(promptText);
2903
+ const normalizedPrevious = normalize(previousText);
2904
+ const isIgnoredResponse = (value) => {
2905
+ const lower = value.toLowerCase();
2906
+ return (lower.length < 3 ||
2907
+ lower.startsWith("see new posts") ||
2908
+ lower.startsWith("thought for ") ||
2909
+ lower.startsWith("agents thinking") ||
2910
+ lower.startsWith("ask anything") ||
2911
+ lower === "agents" ||
2912
+ lower === "thinking" ||
2913
+ lower === "expert" ||
2914
+ lower.startsWith("grok") ||
2915
+ lower.includes("explore ") ||
2916
+ lower.includes("discuss ") ||
2917
+ lower.includes("create images") ||
2918
+ lower.includes("edit image") ||
2919
+ lower.includes("latest news") ||
2920
+ lower === normalizedPrompt.toLowerCase() ||
2921
+ lower === normalizedPrevious.toLowerCase());
2922
+ };
2923
+ const scope = document.querySelector("div[aria-label='Grok']") ??
2924
+ document.querySelector("main");
2925
+ if (!scope) {
2926
+ return undefined;
2927
+ }
2928
+ const lines = (scope.innerText || scope.textContent || "")
2929
+ .split(/\n+/)
2930
+ .map((line) => normalize(line))
2931
+ .filter((line) => line.length > 0);
2932
+ const lineResponseCandidates = [];
2933
+ const linePromptIndex = lines.lastIndexOf(normalizedPrompt);
2934
+ if (linePromptIndex >= 0) {
2935
+ for (let index = linePromptIndex + 1; index < lines.length; index += 1) {
2936
+ const candidate = lines[index];
2937
+ if (!candidate || isIgnoredResponse(candidate)) {
2938
+ continue;
2939
+ }
2940
+ lineResponseCandidates.push(candidate);
2941
+ }
2942
+ }
2943
+ let responseForPrompt;
2944
+ if (lineResponseCandidates.length > 0) {
2945
+ responseForPrompt = lineResponseCandidates.sort((left, right) => right.length - left.length)[0];
2946
+ }
2947
+ if (responseForPrompt) {
2948
+ let latestResponse = responseForPrompt;
2949
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2950
+ const candidate = lines[index];
2951
+ if (!candidate || isIgnoredResponse(candidate)) {
2952
+ continue;
2953
+ }
2954
+ latestResponse = candidate;
2955
+ break;
2956
+ }
2957
+ return {
2958
+ responseForPrompt,
2959
+ latestResponse,
2960
+ };
2961
+ }
2962
+ const entries = [];
2963
+ for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
2964
+ if (node.closest("button, a, textarea, nav")) {
2965
+ continue;
2966
+ }
2967
+ const text = normalize(node.innerText || node.textContent || "");
2968
+ if (!text) {
2969
+ continue;
2970
+ }
2971
+ const childWithSameText = Array.from(node.children).some((child) => {
2972
+ if (!(child instanceof HTMLElement)) {
2973
+ return false;
2974
+ }
2975
+ return normalize(child.innerText || child.textContent || "") === text;
2976
+ });
2977
+ if (childWithSameText) {
2978
+ continue;
2979
+ }
2980
+ if (entries[entries.length - 1] !== text) {
2981
+ entries.push(text);
2982
+ }
2983
+ }
2984
+ const responseCandidates = [];
2985
+ const promptIndex = entries.lastIndexOf(normalizedPrompt);
2986
+ if (promptIndex >= 0) {
2987
+ for (let index = promptIndex + 1; index < entries.length; index += 1) {
2988
+ const candidate = entries[index];
2989
+ if (!candidate || isIgnoredResponse(candidate)) {
2990
+ continue;
2991
+ }
2992
+ responseCandidates.push(candidate);
2993
+ }
2994
+ }
2995
+ responseForPrompt = undefined;
2996
+ if (responseCandidates.length > 0) {
2997
+ responseForPrompt = responseCandidates.sort((left, right) => right.length - left.length)[0];
2998
+ }
2999
+ let latestResponse;
3000
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
3001
+ const candidate = entries[index];
3002
+ if (!candidate || isIgnoredResponse(candidate)) {
3003
+ continue;
3004
+ }
3005
+ latestResponse = candidate;
3006
+ break;
3007
+ }
3008
+ return {
3009
+ responseForPrompt,
3010
+ latestResponse,
3011
+ };
3012
+ }, {
3013
+ op: "grok_extract_state",
3014
+ promptText: prompt,
3015
+ previousText: previousResponse ?? "",
3016
+ });
3017
+ if (state && typeof state === "object" && typeof state.responseForPrompt === "string" && state.responseForPrompt.length > 0) {
3018
+ await page.waitForTimeout(600);
3019
+ const settledState = await page.evaluate(({ op, promptText, previousText }) => {
3020
+ if (op !== "grok_extract_state") {
3021
+ return undefined;
3022
+ }
3023
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
3024
+ const normalizedPrompt = normalize(promptText);
3025
+ const normalizedPrevious = normalize(previousText);
3026
+ const isIgnoredResponse = (value) => {
3027
+ const lower = value.toLowerCase();
3028
+ return (lower.length < 3 ||
3029
+ lower.startsWith("see new posts") ||
3030
+ lower.startsWith("thought for ") ||
3031
+ lower.startsWith("agents thinking") ||
3032
+ lower.startsWith("ask anything") ||
3033
+ lower === "agents" ||
3034
+ lower === "thinking" ||
3035
+ lower === "expert" ||
3036
+ lower.startsWith("grok") ||
3037
+ lower.includes("explore ") ||
3038
+ lower.includes("discuss ") ||
3039
+ lower.includes("create images") ||
3040
+ lower.includes("edit image") ||
3041
+ lower.includes("latest news") ||
3042
+ lower === normalizedPrompt.toLowerCase() ||
3043
+ lower === normalizedPrevious.toLowerCase());
3044
+ };
3045
+ const scope = document.querySelector("div[aria-label='Grok']") ??
3046
+ document.querySelector("main");
3047
+ if (!scope) {
3048
+ return undefined;
3049
+ }
3050
+ const lines = (scope.innerText || scope.textContent || "")
3051
+ .split(/\n+/)
3052
+ .map((line) => normalize(line))
3053
+ .filter((line) => line.length > 0);
3054
+ const lineResponseCandidates = [];
3055
+ const linePromptIndex = lines.lastIndexOf(normalizedPrompt);
3056
+ if (linePromptIndex >= 0) {
3057
+ for (let index = linePromptIndex + 1; index < lines.length; index += 1) {
3058
+ const candidate = lines[index];
3059
+ if (!candidate || isIgnoredResponse(candidate)) {
3060
+ continue;
3061
+ }
3062
+ lineResponseCandidates.push(candidate);
3063
+ }
3064
+ }
3065
+ if (lineResponseCandidates.length > 0) {
3066
+ return {
3067
+ responseForPrompt: lineResponseCandidates.sort((left, right) => right.length - left.length)[0],
3068
+ };
3069
+ }
3070
+ const entries = [];
3071
+ for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
3072
+ if (node.closest("button, a, textarea, nav")) {
3073
+ continue;
3074
+ }
3075
+ const text = normalize(node.innerText || node.textContent || "");
3076
+ if (!text) {
3077
+ continue;
3078
+ }
3079
+ const childWithSameText = Array.from(node.children).some((child) => {
3080
+ if (!(child instanceof HTMLElement)) {
3081
+ return false;
3082
+ }
3083
+ return normalize(child.innerText || child.textContent || "") === text;
3084
+ });
3085
+ if (childWithSameText) {
3086
+ continue;
3087
+ }
3088
+ if (entries[entries.length - 1] !== text) {
3089
+ entries.push(text);
3090
+ }
3091
+ }
3092
+ const responseCandidates = [];
3093
+ const promptIndex = entries.lastIndexOf(normalizedPrompt);
3094
+ if (promptIndex >= 0) {
3095
+ for (let index = promptIndex + 1; index < entries.length; index += 1) {
3096
+ const candidate = entries[index];
3097
+ if (!candidate || isIgnoredResponse(candidate)) {
3098
+ continue;
3099
+ }
3100
+ responseCandidates.push(candidate);
3101
+ }
3102
+ }
3103
+ let responseForPrompt;
3104
+ if (responseCandidates.length > 0) {
3105
+ responseForPrompt = responseCandidates.sort((left, right) => right.length - left.length)[0];
3106
+ }
3107
+ return {
3108
+ responseForPrompt,
3109
+ };
3110
+ }, {
3111
+ op: "grok_extract_state",
3112
+ promptText: prompt,
3113
+ previousText: previousResponse ?? "",
3114
+ });
3115
+ if (settledState &&
3116
+ typeof settledState === "object" &&
3117
+ typeof settledState.responseForPrompt === "string" &&
3118
+ settledState.responseForPrompt.length > 0) {
3119
+ return {
3120
+ confirmed: true,
3121
+ response: settledState.responseForPrompt,
3122
+ };
3123
+ }
3124
+ }
3125
+ if (state && typeof state === "object" && typeof state.responseForPrompt === "string" && state.responseForPrompt.length > 0) {
3126
+ return {
3127
+ confirmed: true,
3128
+ response: state.responseForPrompt,
3129
+ };
3130
+ }
3131
+ return { confirmed: false };
3132
+ }
3133
+ async function readLatestGrokResponse(page) {
3134
+ const state = await page.evaluate(({ op }) => {
3135
+ if (op !== "grok_extract_state") {
3136
+ return undefined;
3137
+ }
3138
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
3139
+ const isIgnoredResponse = (value) => {
3140
+ const lower = value.toLowerCase();
3141
+ return (lower.length < 3 ||
3142
+ lower.startsWith("see new posts") ||
3143
+ lower.startsWith("thought for ") ||
3144
+ lower.startsWith("agents thinking") ||
3145
+ lower.startsWith("ask anything") ||
3146
+ lower === "agents" ||
3147
+ lower === "thinking" ||
3148
+ lower === "expert" ||
3149
+ lower.startsWith("grok") ||
3150
+ lower.includes("explore "));
3151
+ };
3152
+ const scope = document.querySelector("div[aria-label='Grok']") ??
3153
+ document.querySelector("main");
3154
+ if (!scope) {
3155
+ return undefined;
3156
+ }
3157
+ const lines = (scope.innerText || scope.textContent || "")
3158
+ .split(/\n+/)
3159
+ .map((line) => normalize(line))
3160
+ .filter((line) => line.length > 0);
3161
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
3162
+ const candidate = lines[index];
3163
+ if (!candidate || isIgnoredResponse(candidate)) {
3164
+ continue;
3165
+ }
3166
+ return { latestResponse: candidate };
3167
+ }
3168
+ const entries = [];
3169
+ for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
3170
+ if (node.closest("button, a, textarea, nav")) {
3171
+ continue;
3172
+ }
3173
+ const text = normalize(node.innerText || node.textContent || "");
3174
+ if (!text || isIgnoredResponse(text)) {
3175
+ continue;
3176
+ }
3177
+ const childWithSameText = Array.from(node.children).some((child) => {
3178
+ if (!(child instanceof HTMLElement)) {
3179
+ return false;
3180
+ }
3181
+ return normalize(child.innerText || child.textContent || "") === text;
3182
+ });
3183
+ if (childWithSameText) {
3184
+ continue;
3185
+ }
3186
+ if (entries[entries.length - 1] !== text) {
3187
+ entries.push(text);
3188
+ }
3189
+ }
3190
+ let latestResponse;
3191
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
3192
+ const candidate = entries[index];
3193
+ if (!candidate || isIgnoredResponse(candidate)) {
3194
+ continue;
3195
+ }
3196
+ latestResponse = candidate;
3197
+ break;
3198
+ }
3199
+ return { latestResponse };
3200
+ }, { op: "grok_extract_state" });
3201
+ if (state && typeof state === "object" && typeof state.latestResponse === "string") {
3202
+ return state.latestResponse;
3203
+ }
3204
+ return undefined;
3205
+ }
3206
+ function logGrokPhase(phase, details) {
3207
+ const payload = {
3208
+ phase,
3209
+ };
3210
+ if (details) {
3211
+ for (const [key, value] of Object.entries(details)) {
3212
+ if (value !== undefined) {
3213
+ payload[key] = value;
3214
+ }
3215
+ }
3216
+ }
3217
+ process.stderr.write(`[adapter-x grok] ${JSON.stringify(payload)}\n`);
3218
+ }
3219
+ async function askGrok(page, prompt, timeoutMs, attachments, conversationId) {
3220
+ logGrokPhase("start", { timeoutMs, promptLength: prompt.length });
3221
+ try {
3222
+ const targetUrl = typeof conversationId === "string" && conversationId.trim().length > 0
3223
+ ? `https://x.com/i/grok?conversation=${encodeURIComponent(conversationId.trim())}`
3224
+ : "https://x.com/i/grok";
3225
+ return await withEphemeralPage(page, targetUrl, async (grokPage) => {
3226
+ logGrokPhase("page_opened", { url: grokPage.url() });
3227
+ await prepareGrokSession(grokPage, conversationId);
3228
+ logGrokPhase("surface_ready");
3229
+ const uploadResult = await uploadGrokAttachments(grokPage, attachments);
3230
+ const attachmentLogDetails = {
3231
+ ok: uploadResult.ok,
3232
+ attachmentCount: attachments.length,
3233
+ };
3234
+ if (!uploadResult.ok) {
3235
+ attachmentLogDetails.reason = uploadResult.reason;
3236
+ }
3237
+ logGrokPhase("attachments_ready", attachmentLogDetails);
3238
+ if (!uploadResult.ok) {
3239
+ return errorResult("UPSTREAM_CHANGED", "grok attachment controls not found", {
3240
+ reason: uploadResult.reason,
3241
+ });
3242
+ }
3243
+ const networkResult = await askGrokViaNetwork(grokPage, prompt, timeoutMs);
3244
+ logGrokPhase("network_result", {
3245
+ ok: networkResult?.ok === true,
3246
+ responseLength: networkResult?.response.length ?? 0,
3247
+ });
3248
+ if (networkResult?.ok) {
3249
+ const output = {
3250
+ ok: true,
3251
+ response: networkResult.response,
3252
+ url: networkResult.url,
3253
+ };
3254
+ if (networkResult.conversationId) {
3255
+ output.conversationId = networkResult.conversationId;
3256
+ }
3257
+ if (networkResult.artifacts) {
3258
+ output.artifacts = networkResult.artifacts;
3259
+ }
3260
+ return output;
3261
+ }
3262
+ await grokPage.goto("https://x.com/i/grok", { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
3263
+ await waitForGrokSurface(grokPage);
3264
+ const previousResponse = await readLatestGrokResponse(grokPage);
3265
+ logGrokPhase("previous_response_read", {
3266
+ hasPreviousResponse: previousResponse !== undefined,
3267
+ previousResponseLength: previousResponse?.length ?? 0,
3268
+ });
3269
+ const submitResult = await submitGrokPrompt(grokPage, prompt);
3270
+ const submitLogDetails = {
3271
+ ok: submitResult.ok,
3272
+ };
3273
+ if (submitResult.reason !== undefined) {
3274
+ submitLogDetails.reason = submitResult.reason;
3275
+ }
3276
+ logGrokPhase("submit_result", submitLogDetails);
3277
+ if (!submitResult.ok) {
3278
+ return errorResult("UPSTREAM_CHANGED", "grok controls not found", {
3279
+ reason: submitResult.reason ?? "unknown",
3280
+ });
3281
+ }
3282
+ logGrokPhase("wait_start", { timeoutMs });
3283
+ const confirmation = await waitForGrokResponse(grokPage, previousResponse, prompt, timeoutMs);
3284
+ logGrokPhase("wait_result", {
3285
+ confirmed: confirmation.confirmed,
3286
+ responseLength: confirmation.response?.length ?? 0,
3287
+ });
3288
+ if (!confirmation.confirmed || !confirmation.response) {
3289
+ return errorResult("ACTION_UNCONFIRMED", "grok response was not confirmed");
3290
+ }
3291
+ logGrokPhase("success", {
3292
+ responseLength: confirmation.response.length,
3293
+ url: grokPage.url(),
3294
+ });
3295
+ const output = {
3296
+ ok: true,
3297
+ response: confirmation.response,
3298
+ url: grokPage.url(),
3299
+ };
3300
+ if (conversationId) {
3301
+ output.conversationId = conversationId;
3302
+ }
3303
+ return output;
3304
+ });
3305
+ }
3306
+ catch (error) {
3307
+ const details = {};
3308
+ if (error instanceof Error) {
3309
+ details.name = error.name;
3310
+ details.message = error.message;
3311
+ }
3312
+ else if (error !== undefined) {
3313
+ details.message = String(error);
3314
+ }
3315
+ logGrokPhase("error", details);
3316
+ return errorResult("UPSTREAM_CHANGED", "grok execution threw", details);
3317
+ }
3318
+ }
3319
+ async function replyToTweet(page, targetUrl, text, dryRun, timeoutMs) {
3320
+ return await withEphemeralPage(page, targetUrl, async (replyPage) => {
3321
+ await waitForTweetSurface(replyPage);
3322
+ await ensureReplyComposerReady(replyPage);
3323
+ const composeResult = await composeReply(replyPage, text, dryRun);
3324
+ if (!composeResult.ok) {
3325
+ return errorResult("UPSTREAM_CHANGED", "reply controls not found", {
3326
+ reason: composeResult.reason ?? "unknown",
3327
+ });
3328
+ }
3329
+ if (composeResult.dryRun) {
3330
+ return {
3331
+ ok: true,
3332
+ dryRun: true,
3333
+ submitVisible: composeResult.submitVisible === true,
3334
+ replyToUrl: targetUrl,
3335
+ };
3336
+ }
3337
+ const submitReady = await waitForReplySubmitReady(replyPage, timeoutMs);
3338
+ if (!submitReady) {
3339
+ return errorResult("UPSTREAM_CHANGED", "reply controls not ready", {
3340
+ reason: "submit_not_ready",
3341
+ });
3342
+ }
3343
+ const submitResult = await submitReply(replyPage);
3344
+ if (!submitResult.ok) {
3345
+ return errorResult("UPSTREAM_CHANGED", "reply controls not found", {
3346
+ reason: submitResult.reason ?? "unknown",
3347
+ });
3348
+ }
3349
+ const confirmation = await waitForReplyConfirmation(replyPage, targetUrl, text, timeoutMs);
3350
+ if (!confirmation.confirmed) {
3351
+ return errorResult("ACTION_UNCONFIRMED", "reply submit was not confirmed in timeline");
3352
+ }
3353
+ const result = {
3354
+ ok: true,
3355
+ confirmed: true,
3356
+ replyToUrl: targetUrl,
3357
+ };
3358
+ if (confirmation.statusUrl !== undefined) {
3359
+ result.statusUrl = confirmation.statusUrl;
3360
+ }
3361
+ return result;
3362
+ });
3363
+ }
3364
+ async function requireAuthenticated(page) {
3365
+ const auth = await detectAuthStable(page);
3366
+ if (auth.state === "authenticated") {
3367
+ return { ok: true, auth };
3368
+ }
3369
+ if (auth.state === "challenge_required") {
3370
+ return {
3371
+ ok: false,
3372
+ result: errorResult("CHALLENGE_REQUIRED", "x.com challenge is blocking actions", {
3373
+ state: auth.state,
3374
+ signals: auth.signals,
3375
+ }),
3376
+ };
3377
+ }
3378
+ return {
3379
+ ok: false,
3380
+ result: errorResult("AUTH_REQUIRED", "login required", {
3381
+ state: auth.state,
3382
+ signals: auth.signals,
3383
+ }),
3384
+ };
3385
+ }
3386
+ export function createXAdapter(options) {
3387
+ const composeConfirmTimeoutMs = options?.composeConfirmTimeoutMs ?? DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS;
3388
+ const grokResponseTimeoutMs = options?.grokResponseTimeoutMs ?? DEFAULT_GROK_RESPONSE_TIMEOUT_MS;
3389
+ const maxPostLength = options?.maxPostLength ?? DEFAULT_MAX_POST_LENGTH;
3390
+ return {
3391
+ name: "adapter-x",
3392
+ start: async ({ page }) => {
3393
+ await ensureNetworkCaptureInstalled(page);
3394
+ await page.waitForLoadState("domcontentloaded").catch(() => {
3395
+ // Keep startup best-effort; auth probing will still run.
3396
+ });
3397
+ await warmupAuthProbe(page);
3398
+ },
3399
+ listTools: async () => TOOL_DEFINITIONS,
3400
+ callTool: async ({ name, input }, { page }) => {
3401
+ const args = toRecord(input);
3402
+ if (name === "auth.get") {
3403
+ const auth = await detectAuthStable(page);
3404
+ return {
3405
+ state: auth.state,
3406
+ signals: auth.signals,
3407
+ };
3408
+ }
3409
+ if (name === "timeline.home.list") {
3410
+ const authCheck = await requireAuthenticated(page);
3411
+ if (!authCheck.ok) {
3412
+ return authCheck.result;
3413
+ }
3414
+ const limit = normalizeTimelineLimit(args);
3415
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
3416
+ const result = cursor
3417
+ ? await readTimelineWithMode(page, "home", limit, cursor)
3418
+ : await readTimelineWithMode(page, "home", limit);
3419
+ if (result.source === "network" || result.items.length > 0) {
3420
+ return result;
3421
+ }
3422
+ return await withCachedReadOnlyPage(page, "home", "https://x.com/home", async (readPage) => {
3423
+ return cursor
3424
+ ? await readTimelineWithMode(readPage, "home", limit, cursor)
3425
+ : await readTimelineWithMode(readPage, "home", limit);
3426
+ });
3427
+ }
3428
+ if (name === "tweet.get") {
3429
+ const authCheck = await requireAuthenticated(page);
3430
+ if (!authCheck.ok) {
3431
+ return authCheck.result;
3432
+ }
3433
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3434
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3435
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3436
+ if (!targetUrl) {
3437
+ return errorResult("VALIDATION_ERROR", "url or id is required");
1311
3438
  }
1312
3439
  return await readTweetByUrl(page, targetUrl);
1313
3440
  }
3441
+ if (name === "tweet.thread.get") {
3442
+ const authCheck = await requireAuthenticated(page);
3443
+ if (!authCheck.ok) {
3444
+ return authCheck.result;
3445
+ }
3446
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3447
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3448
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3449
+ if (!targetUrl) {
3450
+ return errorResult("VALIDATION_ERROR", "url or id is required");
3451
+ }
3452
+ const limit = normalizeTimelineLimit(args);
3453
+ return await readTweetThreadByUrl(page, targetUrl, limit);
3454
+ }
3455
+ if (name === "tweet.media.download") {
3456
+ const authCheck = await requireAuthenticated(page);
3457
+ if (!authCheck.ok) {
3458
+ return authCheck.result;
3459
+ }
3460
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3461
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3462
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3463
+ if (!targetUrl) {
3464
+ return errorResult("VALIDATION_ERROR", "url or id is required");
3465
+ }
3466
+ const mediaIndex = typeof args.mediaIndex === "number" && Number.isFinite(args.mediaIndex)
3467
+ ? Math.floor(args.mediaIndex)
3468
+ : undefined;
3469
+ return await downloadTweetMediaByUrl(page, targetUrl, mediaIndex);
3470
+ }
3471
+ if (name === "tweet.conversation.get") {
3472
+ const authCheck = await requireAuthenticated(page);
3473
+ if (!authCheck.ok) {
3474
+ return authCheck.result;
3475
+ }
3476
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3477
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3478
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3479
+ if (!targetUrl) {
3480
+ return errorResult("VALIDATION_ERROR", "url or id is required");
3481
+ }
3482
+ const limit = normalizeTimelineLimit(args);
3483
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
3484
+ return await readTweetConversationByUrl(page, targetUrl, limit, cursor || undefined);
3485
+ }
3486
+ if (name === "tweet.replies.list") {
3487
+ const authCheck = await requireAuthenticated(page);
3488
+ if (!authCheck.ok) {
3489
+ return authCheck.result;
3490
+ }
3491
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3492
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3493
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3494
+ if (!targetUrl) {
3495
+ return errorResult("VALIDATION_ERROR", "url or id is required");
3496
+ }
3497
+ const limit = normalizeTimelineLimit(args);
3498
+ const cursor = typeof args.cursor === "string" ? args.cursor.trim() : "";
3499
+ return await readTweetRepliesByUrl(page, targetUrl, limit, cursor || undefined);
3500
+ }
1314
3501
  if (name === "favorites.list") {
1315
3502
  const authCheck = await requireAuthenticated(page);
1316
3503
  if (!authCheck.ok) {
@@ -1324,6 +3511,26 @@ export function createXAdapter(options) {
1324
3511
  : await readTimelineWithMode(readPage, "bookmarks", limit);
1325
3512
  });
1326
3513
  }
3514
+ if (name === "notifications.list") {
3515
+ const authCheck = await requireAuthenticated(page);
3516
+ if (!authCheck.ok) {
3517
+ return authCheck.result;
3518
+ }
3519
+ const limit = normalizeTimelineLimit(args);
3520
+ return await withCachedReadOnlyPage(page, "notifications", "https://x.com/notifications", async (readPage) => {
3521
+ return await readNotifications(readPage, limit);
3522
+ });
3523
+ }
3524
+ if (name === "mentions.list") {
3525
+ const authCheck = await requireAuthenticated(page);
3526
+ if (!authCheck.ok) {
3527
+ return authCheck.result;
3528
+ }
3529
+ const limit = normalizeTimelineLimit(args);
3530
+ return await withCachedReadOnlyPage(page, "notifications:mentions", "https://x.com/notifications/mentions", async (readPage) => {
3531
+ return await readNotifications(readPage, limit);
3532
+ });
3533
+ }
1327
3534
  if (name === "timeline.user.list") {
1328
3535
  const authCheck = await requireAuthenticated(page);
1329
3536
  if (!authCheck.ok) {
@@ -1414,6 +3621,43 @@ export function createXAdapter(options) {
1414
3621
  }
1415
3622
  return result;
1416
3623
  }
3624
+ if (name === "tweet.reply") {
3625
+ const authCheck = await requireAuthenticated(page);
3626
+ if (!authCheck.ok) {
3627
+ return authCheck.result;
3628
+ }
3629
+ const text = typeof args.text === "string" ? args.text.trim() : "";
3630
+ if (!text) {
3631
+ return errorResult("VALIDATION_ERROR", "text is required");
3632
+ }
3633
+ if (text.length > maxPostLength) {
3634
+ return errorResult("VALIDATION_ERROR", `text exceeds max length ${maxPostLength}`);
3635
+ }
3636
+ const url = typeof args.url === "string" ? args.url.trim() : "";
3637
+ const id = typeof args.id === "string" ? args.id.trim() : "";
3638
+ const targetUrl = url || (id ? `https://x.com/i/web/status/${id}` : "");
3639
+ if (!targetUrl) {
3640
+ return errorResult("VALIDATION_ERROR", "url or id is required");
3641
+ }
3642
+ const dryRun = args.dryRun === true;
3643
+ return await replyToTweet(page, targetUrl, text, dryRun, composeConfirmTimeoutMs);
3644
+ }
3645
+ if (name === "grok.chat") {
3646
+ const authCheck = await requireAuthenticated(page);
3647
+ if (!authCheck.ok) {
3648
+ return authCheck.result;
3649
+ }
3650
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
3651
+ if (!prompt) {
3652
+ return errorResult("VALIDATION_ERROR", "prompt is required");
3653
+ }
3654
+ const resolvedAttachments = await resolveGrokAttachments(args.attachmentPaths);
3655
+ if (!resolvedAttachments.ok) {
3656
+ return resolvedAttachments.result;
3657
+ }
3658
+ const conversationId = typeof args.conversationId === "string" ? args.conversationId.trim() : "";
3659
+ return await askGrok(page, prompt, grokResponseTimeoutMs, resolvedAttachments.attachments, conversationId || undefined);
3660
+ }
1417
3661
  return errorResult("TOOL_NOT_FOUND", `unknown tool: ${name}`);
1418
3662
  },
1419
3663
  stop: async ({ page }) => {