@webmcp-bridge/adapter-x 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.js CHANGED
@@ -3,21 +3,24 @@
3
3
  * It depends on Playwright page evaluation and shared adapter contracts to execute browser-side tool actions.
4
4
  */
5
5
  import { buildRequestCaptureInitScript, captureRoutedResponseText, collectTextByTag, joinTextParts, parseNdjsonLines, TemplateCache, } from "@webmcp-bridge/adapter-utils";
6
- import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
6
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { basename, extname, isAbsolute, join } from "node:path";
8
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
9
9
  const DEFAULT_TIMELINE_LIMIT = 10;
10
10
  const MAX_TIMELINE_LIMIT = 20;
11
11
  const MAX_READ_PAGE_CACHE_SIZE = 8;
12
12
  const DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS = 10_000;
13
13
  const DEFAULT_GROK_RESPONSE_TIMEOUT_MS = 90_000;
14
+ const DEFAULT_ARTICLE_PUBLISH_TIMEOUT_MS = 30_000;
14
15
  const DEFAULT_MAX_POST_LENGTH = 280;
15
16
  const AUTH_STABILIZE_ATTEMPTS = 6;
16
17
  const AUTH_STABILIZE_DELAY_MS = 750;
17
18
  const AUTH_WARMUP_TIMEOUT_MS = 12_000;
18
19
  const GROK_ARTIFACT_DIR_PREFIX = "webmcp-bridge-grok-";
19
20
  const TWEET_MEDIA_ARTIFACT_DIR_PREFIX = "webmcp-bridge-x-media-";
21
+ const ARTICLE_INLINE_IMAGE_MARKER_PREFIX = "[[WEBMCP_INLINE_IMAGE_";
20
22
  const ALLOWED_TWEET_MEDIA_HOSTS = new Set(["pbs.twimg.com", "video.twimg.com"]);
23
+ const ALLOWED_X_HOSTS = new Set(["x.com", "www.x.com", "twitter.com", "www.twitter.com"]);
21
24
  const CAPTURE_INJECT_SCRIPT = buildRequestCaptureInitScript({
22
25
  globalKey: "__WEBMCP_X_CAPTURE__",
23
26
  shouldCaptureSource: String.raw `((url) => {
@@ -32,7 +35,10 @@ const CAPTURE_INJECT_SCRIPT = buildRequestCaptureInitScript({
32
35
  url.includes("/UserTweets") ||
33
36
  url.includes("/UserMedia") ||
34
37
  url.includes("/UserTweetsAndReplies") ||
35
- url.includes("/SearchTimeline")
38
+ url.includes("/SearchTimeline") ||
39
+ url.includes("/ArticleEntitiesSlice") ||
40
+ url.includes("/ArticleRedirectScreenQuery") ||
41
+ url.includes("/UserArticlesTweets")
36
42
  )
37
43
  );
38
44
  })`,
@@ -47,6 +53,9 @@ const CAPTURE_INJECT_SCRIPT = buildRequestCaptureInitScript({
47
53
  else if (url.includes("/UserMedia")) op = "UserMedia";
48
54
  else if (url.includes("/UserTweets")) op = "UserTweets";
49
55
  else if (url.includes("/SearchTimeline")) op = "SearchTimeline";
56
+ else if (url.includes("/ArticleEntitiesSlice")) op = "ArticleEntitiesSlice";
57
+ else if (url.includes("/ArticleRedirectScreenQuery")) op = "ArticleRedirectScreenQuery";
58
+ else if (url.includes("/UserArticlesTweets")) op = "UserArticlesTweets";
50
59
  return { ...entry, op };
51
60
  })`,
52
61
  maxEntries: 80,
@@ -390,6 +399,26 @@ const TOOL_DEFINITIONS = [
390
399
  additionalProperties: false,
391
400
  },
392
401
  },
402
+ {
403
+ name: "tweet.delete",
404
+ description: "Delete one tweet by url or id",
405
+ annotations: {
406
+ readOnlyHint: false,
407
+ },
408
+ inputSchema: {
409
+ type: "object",
410
+ description: "Open a tweet detail page and delete one tweet owned by the authenticated account.",
411
+ properties: {
412
+ url: { type: "string", description: "Tweet URL, for example https://x.com/<user>/status/<id>." },
413
+ id: { type: "string", description: "Tweet id. Used when url is not provided." },
414
+ dryRun: {
415
+ type: "boolean",
416
+ description: "When true, validate delete controls without confirming the destructive action.",
417
+ },
418
+ },
419
+ additionalProperties: false,
420
+ },
421
+ },
393
422
  {
394
423
  name: "grok.chat",
395
424
  description: "Send one prompt to Grok from the authenticated X session",
@@ -422,6 +451,204 @@ const TOOL_DEFINITIONS = [
422
451
  additionalProperties: false,
423
452
  },
424
453
  },
454
+ {
455
+ name: "article.get",
456
+ description: "Read one X article by public url, edit url, or id",
457
+ inputSchema: {
458
+ type: "object",
459
+ description: "Fetch one X article using public article URL, edit URL, or article id.",
460
+ properties: {
461
+ url: {
462
+ type: "string",
463
+ description: "Public article URL or edit URL.",
464
+ minLength: 1,
465
+ },
466
+ id: {
467
+ type: "string",
468
+ description: "Article id. Used when url is not provided.",
469
+ minLength: 1,
470
+ },
471
+ authorHandle: {
472
+ type: "string",
473
+ description: "Optional article author handle, for example jolestar or @jolestar. Used for profile articles fallback when public article route is unavailable.",
474
+ minLength: 1,
475
+ },
476
+ },
477
+ additionalProperties: false,
478
+ },
479
+ annotations: {
480
+ readOnlyHint: true,
481
+ },
482
+ },
483
+ {
484
+ name: "article.draftMarkdown",
485
+ description: "Create one X article draft from a local markdown file",
486
+ inputSchema: {
487
+ type: "object",
488
+ description: "Create one X article draft from a local markdown file. The adapter derives the title from the first markdown heading when title is omitted.",
489
+ properties: {
490
+ markdownPath: {
491
+ type: "string",
492
+ description: "Absolute local file path to the markdown file to draft.",
493
+ minLength: 1,
494
+ "x-uxc-kind": "file-path",
495
+ },
496
+ title: {
497
+ type: "string",
498
+ description: "Optional title override. When omitted, the first markdown heading becomes the article title.",
499
+ minLength: 1,
500
+ },
501
+ coverImagePath: {
502
+ type: "string",
503
+ description: "Optional absolute local image path for the article cover image.",
504
+ minLength: 1,
505
+ "x-uxc-kind": "file-path",
506
+ },
507
+ },
508
+ required: ["markdownPath"],
509
+ additionalProperties: false,
510
+ },
511
+ },
512
+ {
513
+ name: "article.publishMarkdown",
514
+ description: "Publish one X article from a local markdown file",
515
+ inputSchema: {
516
+ type: "object",
517
+ description: "Create and publish one X article from a local markdown file. The adapter derives the title from the first markdown heading when title is omitted.",
518
+ properties: {
519
+ markdownPath: {
520
+ type: "string",
521
+ description: "Absolute local file path to the markdown file to publish.",
522
+ minLength: 1,
523
+ "x-uxc-kind": "file-path",
524
+ },
525
+ title: {
526
+ type: "string",
527
+ description: "Optional title override. When omitted, the first markdown heading becomes the article title.",
528
+ minLength: 1,
529
+ },
530
+ coverImagePath: {
531
+ type: "string",
532
+ description: "Optional absolute local image path for the article cover image.",
533
+ minLength: 1,
534
+ "x-uxc-kind": "file-path",
535
+ },
536
+ dryRun: {
537
+ type: "boolean",
538
+ description: "When true, validate article creation and editor population without publishing.",
539
+ },
540
+ },
541
+ required: ["markdownPath"],
542
+ additionalProperties: false,
543
+ },
544
+ },
545
+ {
546
+ name: "article.publish",
547
+ description: "Publish one existing X article draft by edit url, public url, or id",
548
+ inputSchema: {
549
+ type: "object",
550
+ description: "Open one article editor page and publish the current draft.",
551
+ properties: {
552
+ url: {
553
+ type: "string",
554
+ description: "Article edit URL or public article URL.",
555
+ minLength: 1,
556
+ },
557
+ id: {
558
+ type: "string",
559
+ description: "Article id. Used when url is not provided.",
560
+ minLength: 1,
561
+ },
562
+ },
563
+ additionalProperties: false,
564
+ },
565
+ },
566
+ {
567
+ name: "article.setCoverImage",
568
+ description: "Set or replace the cover image for one existing X article draft",
569
+ inputSchema: {
570
+ type: "object",
571
+ description: "Open one article editor page and set the cover image for the current draft.",
572
+ properties: {
573
+ url: {
574
+ type: "string",
575
+ description: "Article edit URL or public article URL.",
576
+ minLength: 1,
577
+ },
578
+ id: {
579
+ type: "string",
580
+ description: "Article id. Used when url is not provided.",
581
+ minLength: 1,
582
+ },
583
+ coverImagePath: {
584
+ type: "string",
585
+ description: "Absolute local image path for the article cover image.",
586
+ minLength: 1,
587
+ "x-uxc-kind": "file-path",
588
+ },
589
+ },
590
+ required: ["coverImagePath"],
591
+ additionalProperties: false,
592
+ },
593
+ },
594
+ {
595
+ name: "article.updateMarkdown",
596
+ description: "Replace the title and body of one existing X article draft from a local markdown file",
597
+ inputSchema: {
598
+ type: "object",
599
+ description: "Open one article editor page, replace the current title and body from a local markdown file, and upload any local inline images referenced by markdown image syntax.",
600
+ properties: {
601
+ url: {
602
+ type: "string",
603
+ description: "Article edit URL or public article URL.",
604
+ minLength: 1,
605
+ },
606
+ id: {
607
+ type: "string",
608
+ description: "Article id. Used when url is not provided.",
609
+ minLength: 1,
610
+ },
611
+ markdownPath: {
612
+ type: "string",
613
+ description: "Absolute local file path to the markdown file to apply.",
614
+ minLength: 1,
615
+ "x-uxc-kind": "file-path",
616
+ },
617
+ title: {
618
+ type: "string",
619
+ description: "Optional title override. When omitted, the first markdown heading becomes the article title.",
620
+ minLength: 1,
621
+ },
622
+ },
623
+ required: ["markdownPath"],
624
+ additionalProperties: false,
625
+ },
626
+ },
627
+ {
628
+ name: "article.delete",
629
+ description: "Delete one X article draft or published article by edit url, public url, or id",
630
+ inputSchema: {
631
+ type: "object",
632
+ description: "Open one article editor page and delete the article after confirmation.",
633
+ properties: {
634
+ url: {
635
+ type: "string",
636
+ description: "Article edit URL or public article URL.",
637
+ minLength: 1,
638
+ },
639
+ id: {
640
+ type: "string",
641
+ description: "Article id. Used when url is not provided.",
642
+ minLength: 1,
643
+ },
644
+ dryRun: {
645
+ type: "boolean",
646
+ description: "When true, validate delete controls without confirming the destructive action.",
647
+ },
648
+ },
649
+ additionalProperties: false,
650
+ },
651
+ },
425
652
  ];
426
653
  function toRecord(value) {
427
654
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
@@ -520,65 +747,6 @@ function parseDataUri(uri) {
520
747
  return undefined;
521
748
  }
522
749
  }
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;
531
- }
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
750
  function normalizeTweetMediaForDownload(media) {
583
751
  if (media.type !== "photo") {
584
752
  return media;
@@ -776,6 +944,91 @@ async function resolveGrokAttachments(input) {
776
944
  }
777
945
  return { ok: true, attachments };
778
946
  }
947
+ async function resolveArticleAttachment(value, fieldName) {
948
+ if (value === undefined) {
949
+ return { ok: true };
950
+ }
951
+ const path = typeof value === "string" ? value.trim() : "";
952
+ if (!path) {
953
+ return {
954
+ ok: false,
955
+ result: errorResult("VALIDATION_ERROR", `${fieldName} must be a non-empty string`),
956
+ };
957
+ }
958
+ if (!isAbsolute(path)) {
959
+ return {
960
+ ok: false,
961
+ result: errorResult("VALIDATION_ERROR", `${fieldName} must be an absolute file path`),
962
+ };
963
+ }
964
+ try {
965
+ const fileStat = await stat(path);
966
+ if (!fileStat.isFile()) {
967
+ return {
968
+ ok: false,
969
+ result: errorResult("VALIDATION_ERROR", `${fieldName} must point to a file`),
970
+ };
971
+ }
972
+ }
973
+ catch {
974
+ return {
975
+ ok: false,
976
+ result: errorResult("VALIDATION_ERROR", `${fieldName} was not found`),
977
+ };
978
+ }
979
+ return {
980
+ ok: true,
981
+ attachment: {
982
+ path,
983
+ name: basename(path),
984
+ },
985
+ };
986
+ }
987
+ function stripMarkdownImageDestination(rawDestination) {
988
+ const trimmed = rawDestination.trim();
989
+ const withoutAngle = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
990
+ const titleMatch = withoutAngle.match(/^(.+?)(?:\s+["'(].*)?$/);
991
+ return (titleMatch?.[1] ?? withoutAngle).trim();
992
+ }
993
+ function extractArticleTitle(markdown, markdownPath, explicitTitle) {
994
+ const title = typeof explicitTitle === "string" ? explicitTitle.trim() : "";
995
+ if (title) {
996
+ return title;
997
+ }
998
+ const headingMatch = markdown.match(/^\s*#\s+(.+?)\s*$/m);
999
+ if (headingMatch?.[1]) {
1000
+ return headingMatch[1].trim();
1001
+ }
1002
+ return basename(markdownPath, extname(markdownPath)).trim() || "Untitled";
1003
+ }
1004
+ function prepareArticleMarkdown(markdown, markdownPath) {
1005
+ const inlineImages = [];
1006
+ let nextIndex = 1;
1007
+ const prepared = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, altRaw, destinationRaw) => {
1008
+ const destination = stripMarkdownImageDestination(destinationRaw);
1009
+ if (/^(?:https?:|data:)/i.test(destination)) {
1010
+ return _match;
1011
+ }
1012
+ const resolvedPath = isAbsolute(destination) ? destination : resolve(dirname(markdownPath), destination);
1013
+ const marker = `${ARTICLE_INLINE_IMAGE_MARKER_PREFIX}${nextIndex}]]`;
1014
+ nextIndex += 1;
1015
+ const image = {
1016
+ marker,
1017
+ path: resolvedPath,
1018
+ name: basename(resolvedPath),
1019
+ };
1020
+ const alt = altRaw.trim();
1021
+ if (alt) {
1022
+ image.alt = alt;
1023
+ }
1024
+ inlineImages.push(image);
1025
+ return `\n\n${marker}\n\n`;
1026
+ });
1027
+ return {
1028
+ markdown: prepared,
1029
+ inlineImages,
1030
+ };
1031
+ }
779
1032
  function normalizeTimelineLimit(input) {
780
1033
  const rawLimit = input.limit;
781
1034
  if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit)) {
@@ -885,22 +1138,48 @@ async function detectAuth(page) {
885
1138
  return { state: "auth_required", signals };
886
1139
  }, { op: "detect_auth" });
887
1140
  }
1141
+ function isTransientExecutionContextError(error) {
1142
+ if (!(error instanceof Error)) {
1143
+ return false;
1144
+ }
1145
+ const message = error.message.toLowerCase();
1146
+ return (message.includes("execution context was destroyed") ||
1147
+ message.includes("cannot find context with specified id") ||
1148
+ message.includes("target closed"));
1149
+ }
1150
+ async function detectAuthWithRetry(page) {
1151
+ for (let attempt = 0; attempt < AUTH_STABILIZE_ATTEMPTS; attempt += 1) {
1152
+ try {
1153
+ return await detectAuth(page);
1154
+ }
1155
+ catch (error) {
1156
+ if (!isTransientExecutionContextError(error) || attempt === AUTH_STABILIZE_ATTEMPTS - 1) {
1157
+ throw error;
1158
+ }
1159
+ await page.waitForLoadState("domcontentloaded").catch(() => {
1160
+ // The page may still be mid-navigation; let the retry loop continue.
1161
+ });
1162
+ await page.waitForTimeout(AUTH_STABILIZE_DELAY_MS);
1163
+ }
1164
+ }
1165
+ return { state: "auth_required", signals: ["auth_unknown"] };
1166
+ }
888
1167
  async function detectAuthStable(page) {
889
- let auth = await detectAuth(page);
1168
+ let auth = await detectAuthWithRetry(page);
890
1169
  for (let attempt = 1; attempt < AUTH_STABILIZE_ATTEMPTS; attempt += 1) {
891
1170
  const shouldRetry = auth.state === "auth_required" && auth.signals.includes("auth_unknown");
892
1171
  if (!shouldRetry) {
893
1172
  return auth;
894
1173
  }
895
1174
  await page.waitForTimeout(AUTH_STABILIZE_DELAY_MS);
896
- auth = await detectAuth(page);
1175
+ auth = await detectAuthWithRetry(page);
897
1176
  }
898
1177
  return auth;
899
1178
  }
900
1179
  async function warmupAuthProbe(page) {
901
1180
  const deadline = Date.now() + AUTH_WARMUP_TIMEOUT_MS;
902
1181
  for (;;) {
903
- const auth = await detectAuth(page);
1182
+ const auth = await detectAuthWithRetry(page);
904
1183
  const stable = !(auth.state === "auth_required" && auth.signals.includes("auth_unknown"));
905
1184
  if (stable || Date.now() >= deadline) {
906
1185
  return;
@@ -939,6 +1218,33 @@ function canonicalizeStatusUrl(input, fallbackId) {
939
1218
  return input;
940
1219
  }
941
1220
  }
1221
+ function canonicalizeArticleUrl(input, fallbackId) {
1222
+ if (!input) {
1223
+ return fallbackId ? `https://x.com/i/article/${fallbackId}` : undefined;
1224
+ }
1225
+ try {
1226
+ const url = new URL(input);
1227
+ if (!ALLOWED_X_HOSTS.has(url.hostname.toLowerCase())) {
1228
+ return input;
1229
+ }
1230
+ const segments = url.pathname.split("/").filter(Boolean);
1231
+ const articleIndex = segments.findIndex((segment) => segment === "article" || segment === "articles");
1232
+ if (articleIndex < 0) {
1233
+ return input;
1234
+ }
1235
+ const articleId = segments[articleIndex + 1] ?? fallbackId;
1236
+ if (!articleId) {
1237
+ return input;
1238
+ }
1239
+ if (articleIndex > 0 && segments[0] !== "i") {
1240
+ return `${url.origin}/${segments[0]}/article/${articleId}`;
1241
+ }
1242
+ return `${url.origin}/i/article/${articleId}`;
1243
+ }
1244
+ catch {
1245
+ return input;
1246
+ }
1247
+ }
942
1248
  function enrichNotificationItem(item) {
943
1249
  const text = normalizeInlineText(item.text);
944
1250
  const summary = item.summary ? normalizeInlineText(item.summary) : undefined;
@@ -998,6 +1304,7 @@ function enrichNotificationItem(item) {
998
1304
  return next;
999
1305
  }
1000
1306
  const READ_PAGE_CACHE = new WeakMap();
1307
+ const ARTICLE_DRAFT_PAGE_CACHE = new WeakMap();
1001
1308
  const PROCESS_TEMPLATE_CACHE = new TemplateCache();
1002
1309
  async function readTimelineViaNetwork(page, options) {
1003
1310
  const fallbackTemplate = PROCESS_TEMPLATE_CACHE.get(options.mode);
@@ -1202,7 +1509,20 @@ async function readTimelineViaNetwork(page, options) {
1202
1509
  item.createdAt = createdAt;
1203
1510
  }
1204
1511
  if (media.length > 0) {
1205
- item.media = toTweetMediaArray(media);
1512
+ item.media = media;
1513
+ }
1514
+ const articleResult = tweet?.article?.article_results
1515
+ ?.result ?? undefined;
1516
+ const articleRestId = typeof articleResult?.rest_id === "string" ? articleResult.rest_id : "";
1517
+ if (articleRestId) {
1518
+ const screenName = typeof userLegacy.screen_name === "string" ? userLegacy.screen_name : "";
1519
+ const articleUrl = screenName
1520
+ ? `https://x.com/${screenName.replace(/^@+/, "")}/article/${articleRestId}`
1521
+ : `https://x.com/i/article/${articleRestId}`;
1522
+ item.article = {
1523
+ id: articleRestId,
1524
+ url: articleUrl,
1525
+ };
1206
1526
  }
1207
1527
  outputItems.push(item);
1208
1528
  }
@@ -1468,7 +1788,15 @@ async function extractTweetCards(page, limit) {
1468
1788
  }
1469
1789
  const media = collectDomMedia(article);
1470
1790
  if (media.length > 0) {
1471
- item.media = toTweetMediaArray(media);
1791
+ item.media = media;
1792
+ }
1793
+ const articleAnchor = article.querySelector("a[href*='/article/'], a[href*='/i/article/'], a[href*='/articles/']");
1794
+ const articleUrl = canonicalizeArticleUrl(articleAnchor?.href);
1795
+ const articleId = articleUrl ? articleUrl.match(/\/article(?:s)?\/(\d+)/)?.[1] : undefined;
1796
+ if (articleId) {
1797
+ item.article = articleUrl
1798
+ ? { id: articleId, url: articleUrl }
1799
+ : { id: articleId };
1472
1800
  }
1473
1801
  pushItem(item);
1474
1802
  if (items.length >= maxItems) {
@@ -1496,7 +1824,15 @@ async function extractTweetCards(page, limit) {
1496
1824
  }
1497
1825
  const media = collectDomMedia(cell);
1498
1826
  if (media.length > 0) {
1499
- item.media = toTweetMediaArray(media);
1827
+ item.media = media;
1828
+ }
1829
+ const articleAnchor = cell.querySelector("a[href*='/article/'], a[href*='/i/article/'], a[href*='/articles/']");
1830
+ const articleUrl = canonicalizeArticleUrl(articleAnchor?.href);
1831
+ const articleId = articleUrl ? articleUrl.match(/\/article(?:s)?\/(\d+)/)?.[1] : undefined;
1832
+ if (articleId) {
1833
+ item.article = articleUrl
1834
+ ? { id: articleId, url: articleUrl }
1835
+ : { id: articleId };
1500
1836
  }
1501
1837
  pushItem(item);
1502
1838
  }
@@ -1791,6 +2127,51 @@ async function closeCachedReadPages(ownerPage) {
1791
2127
  }
1792
2128
  }
1793
2129
  }
2130
+ function getArticleDraftPageCache(ownerPage) {
2131
+ let cache = ARTICLE_DRAFT_PAGE_CACHE.get(ownerPage);
2132
+ if (!cache) {
2133
+ cache = new Map();
2134
+ ARTICLE_DRAFT_PAGE_CACHE.set(ownerPage, cache);
2135
+ }
2136
+ return cache;
2137
+ }
2138
+ async function cacheArticleDraftPage(ownerPage, articleId, articlePage) {
2139
+ const cache = getArticleDraftPageCache(ownerPage);
2140
+ const existing = cache.get(articleId);
2141
+ if (existing && existing !== articlePage && !existing.isClosed()) {
2142
+ await existing.close().catch(() => { });
2143
+ }
2144
+ cache.set(articleId, articlePage);
2145
+ }
2146
+ function getCachedArticleDraftPage(ownerPage, articleId) {
2147
+ const cache = ARTICLE_DRAFT_PAGE_CACHE.get(ownerPage);
2148
+ const page = cache?.get(articleId);
2149
+ if (!page || page.isClosed()) {
2150
+ cache?.delete(articleId);
2151
+ return undefined;
2152
+ }
2153
+ return page;
2154
+ }
2155
+ async function removeCachedArticleDraftPage(ownerPage, articleId) {
2156
+ const cache = ARTICLE_DRAFT_PAGE_CACHE.get(ownerPage);
2157
+ const page = cache?.get(articleId);
2158
+ cache?.delete(articleId);
2159
+ if (page && !page.isClosed()) {
2160
+ await page.close().catch(() => { });
2161
+ }
2162
+ }
2163
+ async function closeCachedArticleDraftPages(ownerPage) {
2164
+ const cache = ARTICLE_DRAFT_PAGE_CACHE.get(ownerPage);
2165
+ ARTICLE_DRAFT_PAGE_CACHE.delete(ownerPage);
2166
+ if (!cache) {
2167
+ return;
2168
+ }
2169
+ for (const articlePage of cache.values()) {
2170
+ if (!articlePage.isClosed()) {
2171
+ await articlePage.close().catch(() => { });
2172
+ }
2173
+ }
2174
+ }
1794
2175
  async function waitForTweetSurface(page) {
1795
2176
  await page
1796
2177
  .waitForFunction(() => {
@@ -1814,6 +2195,9 @@ function mapTweetCards(items) {
1814
2195
  if (item.media && item.media.length > 0) {
1815
2196
  mapped.media = item.media;
1816
2197
  }
2198
+ if (item.article) {
2199
+ mapped.article = item.article;
2200
+ }
1817
2201
  return mapped;
1818
2202
  });
1819
2203
  }
@@ -2576,447 +2960,2183 @@ async function waitForReplyConfirmation(page, targetUrl, text, timeoutMs) {
2576
2960
  const secondPassTimeoutMs = Math.max(2_500, timeoutMs - firstPassTimeoutMs);
2577
2961
  return await waitForComposeConfirmation(page, text, secondPassTimeoutMs);
2578
2962
  }
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 })
2963
+ async function deleteTweetDetail(page, targetUrl, dryRun) {
2964
+ const matchId = targetUrl.match(/status\/(\d+)/)?.[1];
2965
+ return await withEphemeralPage(page, targetUrl, async (detailPage) => {
2966
+ await waitForTweetSurface(detailPage);
2967
+ const menuOpened = await detailPage.evaluate(({ op }) => {
2968
+ if (op !== "tweet_open_delete_menu") {
2969
+ return false;
2970
+ }
2971
+ const menuButton = Array.from(document.querySelectorAll("button, div[role='button']")).find((element) => {
2972
+ const testId = element.getAttribute("data-testid") || "";
2973
+ const aria = (element.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim();
2974
+ return testId === "caret" || aria === "More";
2975
+ });
2976
+ if (!menuButton) {
2977
+ return false;
2978
+ }
2979
+ menuButton.click();
2980
+ return true;
2981
+ }, { op: "tweet_open_delete_menu" }).catch(() => false);
2982
+ if (!menuOpened) {
2983
+ return errorResult("UPSTREAM_CHANGED", "tweet delete controls not found", {
2984
+ reason: "more_button_not_found",
2985
+ });
2986
+ }
2987
+ const deleteReady = await detailPage.waitForFunction(() => {
2988
+ return Array.from(document.querySelectorAll("[role='menuitem'], button, div[role='button']")).some((element) => {
2989
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
2990
+ return text === "Delete";
2991
+ });
2992
+ }, undefined, { timeout: 5_000 }).then(() => true).catch(() => false);
2993
+ if (!deleteReady) {
2994
+ return errorResult("UPSTREAM_CHANGED", "tweet delete controls not found", {
2995
+ reason: "delete_menu_item_not_found",
2996
+ });
2997
+ }
2998
+ if (dryRun) {
2999
+ const output = {
3000
+ ok: true,
3001
+ dryRun: true,
3002
+ deleteVisible: true,
3003
+ };
3004
+ if (matchId) {
3005
+ output.tweetId = matchId;
3006
+ }
3007
+ output.url = targetUrl;
3008
+ return output;
3009
+ }
3010
+ const firstDelete = await detailPage.evaluate(({ op }) => {
3011
+ if (op !== "tweet_click_delete_menu_item") {
3012
+ return false;
3013
+ }
3014
+ const item = Array.from(document.querySelectorAll("[role='menuitem'], button, div[role='button']")).find((element) => {
3015
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
3016
+ return text === "Delete";
3017
+ });
3018
+ if (!item) {
3019
+ return false;
3020
+ }
3021
+ item.click();
3022
+ return true;
3023
+ }, { op: "tweet_click_delete_menu_item" }).catch(() => false);
3024
+ if (!firstDelete) {
3025
+ return errorResult("UPSTREAM_CHANGED", "tweet delete controls not found", {
3026
+ reason: "delete_menu_click_failed",
3027
+ });
3028
+ }
3029
+ await detailPage.waitForTimeout(700);
3030
+ await detailPage.evaluate(({ op }) => {
3031
+ if (op !== "tweet_confirm_delete") {
3032
+ return;
3033
+ }
3034
+ const dialog = document.querySelector("[role='dialog'], [data-testid='confirmationSheetDialog']");
3035
+ const dialogButtons = dialog
3036
+ ? Array.from(dialog.querySelectorAll("button, div[role='button']"))
3037
+ : [];
3038
+ const dialogConfirm = dialogButtons.find((element) => {
3039
+ const testId = element.getAttribute("data-testid") || "";
3040
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
3041
+ return testId === "confirmationSheetConfirm" || text === "Delete";
3042
+ });
3043
+ if (dialogConfirm) {
3044
+ dialogConfirm.click();
3045
+ return;
3046
+ }
3047
+ const fallback = Array.from(document.querySelectorAll("button, div[role='button']")).filter((element) => {
3048
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
3049
+ return text === "Delete";
3050
+ });
3051
+ fallback[fallback.length - 1]?.click();
3052
+ }, { op: "tweet_confirm_delete" }).catch(() => { });
3053
+ const deleted = await detailPage
3054
+ .waitForFunction(({ tweetId }) => {
3055
+ const bodyText = document.body?.innerText || "";
3056
+ if (bodyText.includes("This Post was deleted") || bodyText.includes("This Tweet was deleted")) {
3057
+ return true;
3058
+ }
3059
+ if (tweetId) {
3060
+ const currentUrl = window.location.href;
3061
+ return !currentUrl.includes(`/status/${tweetId}`);
3062
+ }
3063
+ return false;
3064
+ }, { tweetId: matchId }, { timeout: 15_000 })
3065
+ .then(() => true)
3066
+ .catch(() => false);
3067
+ if (!deleted) {
3068
+ return errorResult("ACTION_UNCONFIRMED", "tweet delete was not confirmed");
3069
+ }
3070
+ const output = {
3071
+ ok: true,
3072
+ confirmed: true,
3073
+ url: targetUrl,
3074
+ };
3075
+ if (matchId) {
3076
+ output.tweetId = matchId;
3077
+ }
3078
+ return output;
3079
+ });
3080
+ }
3081
+ async function waitForArticleEditorSurface(page) {
3082
+ await page
3083
+ .waitForFunction(() => {
3084
+ const title = document.querySelector("textarea[placeholder='Add a title']");
3085
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3086
+ const publishButton = Array.from(document.querySelectorAll("button")).find((button) => (button.textContent || "").replace(/\s+/g, " ").trim() === "Publish");
3087
+ return title !== null && composer !== null && publishButton !== undefined;
3088
+ }, undefined, { timeout: 20_000 })
2590
3089
  .catch(() => { });
2591
3090
  await page.waitForTimeout(800);
2592
3091
  }
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;
3092
+ async function ensureArticleDraftLoaded(page, articleId) {
3093
+ if (!articleId) {
3094
+ return;
2616
3095
  }
2617
- if (!composerSelector) {
2618
- return { ok: false, reason: "composer_not_found" };
3096
+ const hasContent = async () => {
3097
+ return await page.evaluate(() => {
3098
+ const title = document.querySelector("textarea[placeholder='Add a title']");
3099
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3100
+ const titleValue = title instanceof HTMLTextAreaElement ? title.value.trim() : "";
3101
+ const composerText = composer instanceof HTMLElement ? (composer.textContent || "").trim() : "";
3102
+ return titleValue.length > 0 || composerText.length > 0;
3103
+ }).catch(() => false);
3104
+ };
3105
+ if (await hasContent()) {
3106
+ return;
2619
3107
  }
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 });
3108
+ await page.goto("https://x.com/compose/articles", { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
3109
+ await page.waitForTimeout(1_200);
3110
+ await page.evaluate(({ targetId }) => {
3111
+ const anchors = Array.from(document.querySelectorAll("a[href]"));
3112
+ const draftAnchor = anchors.find((anchor) => anchor.href.includes(`/compose/articles/edit/${targetId}`));
3113
+ draftAnchor?.click();
3114
+ }, { targetId: articleId }).catch(() => { });
3115
+ await page.waitForTimeout(1_200);
3116
+ if (await hasContent()) {
3117
+ return;
2625
3118
  }
2626
- catch {
2627
- return { ok: false, reason: "compose_input_failed" };
3119
+ await page.goto(`https://x.com/compose/articles/edit/${articleId}`, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
3120
+ await waitForArticleEditorSurface(page);
3121
+ await page.waitForTimeout(1_000);
3122
+ }
3123
+ async function waitForArticleDraftPersisted(page, articleId, title) {
3124
+ await page
3125
+ .evaluate(() => {
3126
+ const active = document.activeElement;
3127
+ if (active instanceof HTMLElement) {
3128
+ active.blur();
3129
+ }
3130
+ })
3131
+ .catch(() => { });
3132
+ await page.keyboard.press("Escape").catch(() => { });
3133
+ return await page
3134
+ .waitForFunction(({ targetId, expectedTitle }) => {
3135
+ const anchors = Array.from(document.querySelectorAll("a[href]"));
3136
+ const draftAnchor = anchors.find((anchor) => anchor.href.includes(`/compose/articles/edit/${targetId}`));
3137
+ const containerText = draftAnchor?.closest("article, li, div")?.textContent || draftAnchor?.textContent || "";
3138
+ const normalized = containerText.replace(/\s+/g, " ").trim();
3139
+ return normalized.includes(expectedTitle) && !normalized.includes("(Needs title)");
3140
+ }, { targetId: articleId, expectedTitle: title.trim() }, { timeout: 20_000 })
3141
+ .then(() => true)
3142
+ .catch(() => false);
3143
+ }
3144
+ async function openNewArticleEditor(page) {
3145
+ await page.goto("https://x.com/compose/articles", { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
3146
+ await page.waitForTimeout(2_500);
3147
+ let clicked = false;
3148
+ const createSelectors = [
3149
+ "a[data-testid='empty_state_button_text']",
3150
+ "button[aria-label='create']",
3151
+ "a:has-text('Write')",
3152
+ ];
3153
+ for (const selector of createSelectors) {
3154
+ try {
3155
+ await page.click(selector, { timeout: 4_000 });
3156
+ clicked = true;
3157
+ break;
3158
+ }
3159
+ catch {
3160
+ // Try the next known article entry point.
3161
+ }
2628
3162
  }
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;
3163
+ if (!clicked) {
3164
+ const openedExistingDraft = await page
3165
+ .evaluate(() => {
3166
+ const draftAnchor = Array.from(document.querySelectorAll("a[href]")).find((anchor) => {
3167
+ return anchor.href.includes("/compose/articles/edit/");
3168
+ });
3169
+ if (!draftAnchor) {
3170
+ return false;
2638
3171
  }
2639
- const ariaDisabled = (element.getAttribute("aria-disabled") ?? "").toLowerCase();
2640
- if (ariaDisabled === "true") {
2641
- continue;
3172
+ draftAnchor.click();
3173
+ return true;
3174
+ })
3175
+ .catch(() => false);
3176
+ if (openedExistingDraft) {
3177
+ try {
3178
+ await page.waitForFunction(() => window.location.pathname.includes("/compose/articles/edit/"), undefined, { timeout: 20_000 });
3179
+ await waitForArticleEditorSurface(page);
3180
+ return { ok: true, editUrl: page.url() };
2642
3181
  }
2643
- const label = normalize(element.getAttribute("aria-label") ?? element.textContent ?? "");
2644
- if (label.includes("stop")) {
2645
- continue;
3182
+ catch {
3183
+ // Fall through to existing edit-url fallback below.
2646
3184
  }
2647
- return selector;
2648
3185
  }
2649
- return undefined;
2650
- }, submitSelectors);
2651
- if (!submitSelector) {
2652
- return { ok: false, reason: "submit_not_found" };
3186
+ if (page.url().includes("/compose/articles/edit/")) {
3187
+ await waitForArticleEditorSurface(page);
3188
+ return { ok: true, editUrl: page.url() };
3189
+ }
3190
+ return { ok: false, reason: "create_button_not_found" };
2653
3191
  }
2654
3192
  try {
2655
- await page.click(submitSelector);
3193
+ await page.waitForFunction(({ op }) => op === "article_wait_editor" && window.location.pathname.includes("/compose/articles/edit/"), { op: "article_wait_editor" }, {
3194
+ timeout: 20_000,
3195
+ });
2656
3196
  }
2657
3197
  catch {
2658
- return { ok: false, reason: "submit_click_failed" };
3198
+ return { ok: false, reason: "edit_url_not_reached" };
2659
3199
  }
2660
- return { ok: true };
3200
+ await waitForArticleEditorSurface(page);
3201
+ const editUrl = page.url();
3202
+ return { ok: true, editUrl };
2661
3203
  }
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;
3204
+ async function setArticleTitle(page, title) {
3205
+ const trimmed = title.trim();
3206
+ if (!trimmed) {
3207
+ return false;
2672
3208
  }
2673
- const currentConversationId = (() => {
2674
- try {
2675
- return new URL(page.url()).searchParams.get("conversation") ?? undefined;
2676
- }
2677
- catch {
2678
- return undefined;
3209
+ let interacted = false;
3210
+ if (typeof page.locator === "function") {
3211
+ const titleLocator = page.locator("textarea[placeholder='Add a title']").first();
3212
+ interacted = await titleLocator.click().then(() => true).catch(() => false);
3213
+ if (interacted) {
3214
+ await page.keyboard.press("Meta+A").catch(() => { });
3215
+ await page.keyboard.press("Backspace").catch(() => { });
3216
+ const keyboard = page.keyboard;
3217
+ if (typeof keyboard.insertText === "function") {
3218
+ interacted = await keyboard.insertText(trimmed).then(() => true).catch(() => false);
3219
+ }
3220
+ else if (typeof keyboard.type === "function") {
3221
+ interacted = await keyboard.type(trimmed).then(() => true).catch(() => false);
3222
+ }
3223
+ else {
3224
+ interacted = await titleLocator.fill(trimmed).then(() => true).catch(() => false);
3225
+ }
3226
+ await titleLocator.blur().catch(() => { });
2679
3227
  }
2680
- })();
2681
- if (typeof page.locator !== "function") {
2682
- return;
2683
3228
  }
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;
3229
+ const injected = interacted
3230
+ ? true
3231
+ : await page.evaluate(({ op, value }) => {
3232
+ if (op !== "article_set_title") {
3233
+ return false;
3234
+ }
3235
+ const input = document.querySelector("textarea[placeholder='Add a title']");
3236
+ if (!(input instanceof HTMLTextAreaElement)) {
3237
+ return false;
3238
+ }
3239
+ input.focus();
3240
+ input.value = "";
3241
+ input.dispatchEvent(new Event("input", { bubbles: true }));
3242
+ input.value = value;
3243
+ input.dispatchEvent(new Event("input", { bubbles: true }));
3244
+ input.dispatchEvent(new Event("change", { bubbles: true }));
3245
+ return true;
3246
+ }, { op: "article_set_title", value: trimmed }).catch(() => false);
3247
+ if (!injected) {
3248
+ return false;
2689
3249
  }
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;
3250
+ return await page
3251
+ .waitForFunction(({ expectedTitle }) => {
3252
+ const input = document.querySelector("textarea[placeholder='Add a title']");
3253
+ return input instanceof HTMLTextAreaElement && input.value.trim() === expectedTitle;
3254
+ }, { expectedTitle: trimmed }, { timeout: 3_000 })
3255
+ .then(() => true)
3256
+ .catch(() => false);
3257
+ }
3258
+ async function pasteArticleMarkdown(page, markdown) {
3259
+ let success = false;
3260
+ if (typeof page.locator === "function") {
3261
+ const composerLocator = page.locator("[data-testid='composer'][role='textbox']").first();
3262
+ success = await composerLocator.click().then(() => true).catch(() => false);
3263
+ if (success) {
3264
+ const wroteClipboard = await page.evaluate(async ({ value }) => {
3265
+ try {
3266
+ await navigator.clipboard.writeText(value);
3267
+ return true;
3268
+ }
3269
+ catch {
3270
+ return false;
3271
+ }
3272
+ }, { value: markdown }).catch(() => false);
3273
+ if (wroteClipboard) {
3274
+ success = await page.keyboard.press("Meta+V").then(() => true).catch(() => false);
3275
+ }
3276
+ if (!success) {
3277
+ const keyboard = page.keyboard;
3278
+ if (typeof keyboard.insertText === "function") {
3279
+ success = await keyboard.insertText(markdown).then(() => true).catch(() => false);
3280
+ }
3281
+ else if (typeof keyboard.type === "function") {
3282
+ success = await keyboard.type(markdown).then(() => true).catch(() => false);
3283
+ }
2698
3284
  }
2699
- return conversation !== previousConversationId;
2700
- }
2701
- catch {
2702
- return false;
2703
3285
  }
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
3286
  }
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;
3287
+ if (!success) {
3288
+ success = await page.evaluate(({ op, markdownText }) => {
3289
+ if (op !== "article_paste_markdown") {
3290
+ return false;
3291
+ }
3292
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3293
+ if (!(composer instanceof HTMLElement)) {
3294
+ return false;
3295
+ }
3296
+ composer.focus();
3297
+ const data = new DataTransfer();
3298
+ data.setData("text/plain", markdownText);
3299
+ data.setData("text/markdown", markdownText);
3300
+ const event = new ClipboardEvent("paste", {
3301
+ bubbles: true,
3302
+ cancelable: true,
3303
+ clipboardData: data,
3304
+ });
3305
+ composer.dispatchEvent(event);
3306
+ return true;
3307
+ }, { op: "article_paste_markdown", markdownText: markdown }).catch(() => false);
2728
3308
  }
2729
- if (!uploadSelector) {
2730
- return { ok: false, reason: "attachment_input_not_found" };
3309
+ if (!success) {
3310
+ return false;
3311
+ }
3312
+ const requiredSnippets = markdown
3313
+ .split(/\n+/)
3314
+ .map((line) => line.trim())
3315
+ .filter((line) => line.length > 0)
3316
+ .slice(0, 3);
3317
+ if (requiredSnippets.length === 0) {
3318
+ return true;
2731
3319
  }
2732
3320
  try {
2733
- await page.setInputFiles(uploadSelector, attachments.map((attachment) => attachment.path));
3321
+ await page.waitForFunction(({ snippets }) => {
3322
+ const bodyText = document.body?.innerText ?? "";
3323
+ return snippets.every((snippet) => bodyText.includes(snippet));
3324
+ }, { snippets: requiredSnippets }, { timeout: 10_000 });
3325
+ return true;
2734
3326
  }
2735
3327
  catch {
2736
- return { ok: false, reason: "attachment_upload_failed" };
3328
+ return false;
2737
3329
  }
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
3330
  }
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;
3331
+ async function triggerArticleCoverUpload(page) {
3332
+ return ((await page.evaluate(({ op }) => {
3333
+ if (op !== "article_trigger_cover_upload") {
3334
+ return false;
2764
3335
  }
2765
- if (typeof entry.result?.messageTag === "string") {
2766
- output.messageTag = entry.result.messageTag;
3336
+ const hint = Array.from(document.querySelectorAll("button, div[role='button'], label, div")).find((element) => {
3337
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
3338
+ return text.includes("5:2 aspect ratio");
3339
+ });
3340
+ if (hint) {
3341
+ hint.click();
3342
+ return true;
2767
3343
  }
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;
3344
+ const button = Array.from(document.querySelectorAll("button")).find((element) => {
3345
+ const aria = (element.getAttribute("aria-label") || "").toLowerCase();
3346
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
3347
+ return aria.includes("cover") || text.includes("cover");
3348
+ });
3349
+ if (button) {
3350
+ button.click();
3351
+ return true;
3352
+ }
3353
+ return document.querySelector("input[data-testid='fileInput']") !== null;
3354
+ }, { op: "article_trigger_cover_upload" }).catch(() => false)) === true);
3355
+ }
3356
+ async function triggerArticleInlineImageUpload(page) {
3357
+ return ((await page.evaluate(({ op }) => {
3358
+ if (op !== "article_trigger_inline_upload") {
3359
+ return false;
3360
+ }
3361
+ const candidates = [
3362
+ "button[aria-label='Add Media']",
3363
+ "button[aria-label='Add photos or video']",
3364
+ ];
3365
+ for (const selector of candidates) {
3366
+ const button = document.querySelector(selector);
3367
+ if (!button) {
3368
+ continue;
3369
+ }
3370
+ button.click();
3371
+ return true;
3372
+ }
3373
+ return document.querySelector("input[data-testid='fileInput']") !== null;
3374
+ }, { op: "article_trigger_inline_upload" }).catch(() => false)) === true);
3375
+ }
3376
+ async function uploadArticleFile(page, filePath) {
3377
+ try {
3378
+ await page.setInputFiles("input[data-testid='fileInput']", filePath);
3379
+ const applyReady = await page
3380
+ .waitForFunction(() => {
3381
+ const buttons = Array.from(document.querySelectorAll("button"));
3382
+ const apply = buttons.find((button) => (button.textContent || "").replace(/\s+/g, " ").trim() === "Apply");
3383
+ if (!apply) {
3384
+ return false;
3385
+ }
3386
+ const ariaDisabled = (apply.getAttribute("aria-disabled") || "").toLowerCase();
3387
+ return !apply.disabled && ariaDisabled !== "true";
3388
+ }, undefined, { timeout: 8_000 })
3389
+ .then(() => true)
3390
+ .catch(() => false);
3391
+ if (applyReady) {
3392
+ if (typeof page.locator === "function") {
3393
+ const applyLocator = page.locator("button:has-text('Apply')").last();
3394
+ await applyLocator.click({ timeout: 2_000, force: true }).catch(() => { });
3395
+ }
3396
+ else {
3397
+ await page.click("button:has-text('Apply')", { timeout: 2_000 }).catch(() => { });
3398
+ }
3399
+ await page
3400
+ .evaluate(() => {
3401
+ const buttons = Array.from(document.querySelectorAll("button, div[role='button']")).filter((element) => {
3402
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
3403
+ return text === "Apply";
3404
+ });
3405
+ const button = buttons[buttons.length - 1];
3406
+ button?.click();
3407
+ })
3408
+ .catch(() => { });
3409
+ await page
3410
+ .waitForFunction(() => {
3411
+ const buttons = Array.from(document.querySelectorAll("button"));
3412
+ return !buttons.some((button) => (button.textContent || "").replace(/\s+/g, " ").trim() === "Apply");
3413
+ }, undefined, { timeout: 8_000 })
3414
+ .catch(() => { });
3415
+ }
3416
+ else {
3417
+ await page.waitForTimeout(1_500);
2774
3418
  }
3419
+ return true;
2775
3420
  }
2776
- const finalResponse = joinTextParts(finalParts).trim();
2777
- if (!finalResponse) {
3421
+ catch {
3422
+ return false;
3423
+ }
3424
+ }
3425
+ async function placeArticleCursorAtMarker(page, marker) {
3426
+ return ((await page.evaluate(({ op, markerText }) => {
3427
+ if (op !== "article_place_marker") {
3428
+ return false;
3429
+ }
3430
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3431
+ if (!(composer instanceof HTMLElement)) {
3432
+ return false;
3433
+ }
3434
+ const walker = document.createTreeWalker(composer, NodeFilter.SHOW_TEXT);
3435
+ let current = walker.nextNode();
3436
+ while (current) {
3437
+ const textNode = current;
3438
+ const content = textNode.textContent ?? "";
3439
+ const index = content.indexOf(markerText);
3440
+ if (index >= 0) {
3441
+ const selection = window.getSelection();
3442
+ if (!selection) {
3443
+ return false;
3444
+ }
3445
+ const range = document.createRange();
3446
+ range.setStart(textNode, index);
3447
+ range.setEnd(textNode, index + markerText.length);
3448
+ selection.removeAllRanges();
3449
+ selection.addRange(range);
3450
+ return true;
3451
+ }
3452
+ current = walker.nextNode();
3453
+ }
3454
+ return false;
3455
+ }, { op: "article_place_marker", markerText: marker }).catch(() => false)) === true);
3456
+ }
3457
+ async function deleteArticleSelectedMarker(page) {
3458
+ await page.keyboard.press("Backspace").catch(() => { });
3459
+ await page.waitForTimeout(200);
3460
+ }
3461
+ async function uploadArticleInlineImages(page, images) {
3462
+ for (const image of images) {
3463
+ const resolved = await resolveArticleAttachment(image.path, image.marker);
3464
+ if (!resolved.ok || !resolved.attachment) {
3465
+ return { ok: false, reason: "inline_image_missing" };
3466
+ }
3467
+ const positioned = await placeArticleCursorAtMarker(page, image.marker);
3468
+ if (!positioned) {
3469
+ return { ok: false, reason: "inline_marker_not_found" };
3470
+ }
3471
+ await deleteArticleSelectedMarker(page);
3472
+ const triggered = await triggerArticleInlineImageUpload(page);
3473
+ if (!triggered) {
3474
+ return { ok: false, reason: "inline_upload_trigger_not_found" };
3475
+ }
3476
+ const uploaded = await uploadArticleFile(page, resolved.attachment.path);
3477
+ if (!uploaded) {
3478
+ return { ok: false, reason: "inline_upload_failed" };
3479
+ }
3480
+ }
3481
+ return { ok: true };
3482
+ }
3483
+ async function clearArticleBody(page) {
3484
+ let cleared = false;
3485
+ if (typeof page.locator === "function") {
3486
+ const composerLocator = page.locator("[data-testid='composer'][role='textbox']").first();
3487
+ cleared = await composerLocator.click().then(() => true).catch(() => false);
3488
+ if (cleared) {
3489
+ await page.keyboard.press("Meta+A").catch(() => { });
3490
+ await page.keyboard.press("Backspace").catch(() => { });
3491
+ await page.waitForTimeout(200);
3492
+ }
3493
+ }
3494
+ if (cleared) {
3495
+ return true;
3496
+ }
3497
+ return await page.evaluate(({ op }) => {
3498
+ if (op !== "article_clear_body") {
3499
+ return false;
3500
+ }
3501
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3502
+ if (!(composer instanceof HTMLElement)) {
3503
+ return false;
3504
+ }
3505
+ composer.focus();
3506
+ composer.textContent = "";
3507
+ composer.dispatchEvent(new Event("input", { bubbles: true }));
3508
+ return true;
3509
+ }, { op: "article_clear_body" }).catch(() => false);
3510
+ }
3511
+ function parseArticleIdFromUrl(url) {
3512
+ let parsed;
3513
+ try {
3514
+ parsed = new URL(url, "https://x.com");
3515
+ }
3516
+ catch {
2778
3517
  return undefined;
2779
3518
  }
2780
- const artifactResult = await materializeGrokArtifacts(finalResponse);
3519
+ if (!ALLOWED_X_HOSTS.has(parsed.hostname.toLowerCase())) {
3520
+ return undefined;
3521
+ }
3522
+ const match = parsed.pathname.match(/^\/(?:compose\/articles\/edit|i\/article|i\/articles|[^/]+\/article|articles)\/(\d+)(?:\/|$)/);
3523
+ return match?.[1];
3524
+ }
3525
+ async function waitForCapturedOperation(page, op, timeoutMs = 10_000) {
3526
+ await page.waitForFunction(({ targetOp }) => {
3527
+ const globalAny = window;
3528
+ const entries = Array.isArray(globalAny.__WEBMCP_X_CAPTURE__?.entries)
3529
+ ? globalAny.__WEBMCP_X_CAPTURE__.entries
3530
+ : [];
3531
+ return entries.some((entry) => entry && entry.op === targetOp && !!entry.url && !!entry.method);
3532
+ }, { targetOp: op }, { timeout: timeoutMs }).catch(() => { });
3533
+ }
3534
+ function normalizeArticleUrl(url, articleId) {
3535
+ const trimmed = typeof url === "string" ? url.trim() : "";
3536
+ if (trimmed) {
3537
+ return trimmed;
3538
+ }
3539
+ return articleId ? `https://x.com/compose/articles/edit/${encodeURIComponent(articleId)}` : undefined;
3540
+ }
3541
+ function sanitizeArticleText(text) {
3542
+ return text
3543
+ .replace(/\u00a0/g, " ")
3544
+ .replace(/\s+\n/g, "\n")
3545
+ .replace(/\n{3,}/g, "\n\n")
3546
+ .trim();
3547
+ }
3548
+ async function waitForArticleReadSurface(page) {
3549
+ await page
3550
+ .waitForFunction(() => {
3551
+ const title = document.querySelector("h1, textarea");
3552
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3553
+ const articleNodes = document.querySelectorAll("article, main img[src], script[type='application/ld+json']").length;
3554
+ return title !== null || composer !== null || articleNodes > 0;
3555
+ }, undefined, { timeout: 12_000 })
3556
+ .catch(() => { });
3557
+ await page.waitForTimeout(600);
3558
+ }
3559
+ async function readArticleFromEditorPage(page, articleId, sessionScoped) {
3560
+ const article = await page.evaluate(({ op }) => {
3561
+ if (op !== "article_collect_editor") {
3562
+ return undefined;
3563
+ }
3564
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
3565
+ const title = document.querySelector("textarea")?.value?.trim() ||
3566
+ normalize(document.querySelector("h1")?.innerText || "");
3567
+ const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3568
+ const rawText = (composer?.innerText || composer?.textContent || "").trim();
3569
+ const images = Array.from(document.querySelectorAll("img[src]"))
3570
+ .map((img) => ({
3571
+ url: img.currentSrc || img.src,
3572
+ alt: normalize(img.alt || ""),
3573
+ width: img.naturalWidth || 0,
3574
+ height: img.naturalHeight || 0,
3575
+ }))
3576
+ .filter((item) => /^https?:\/\//.test(item.url))
3577
+ .filter((item) => item.width > 64 || item.height > 64);
3578
+ const deduped = new Map();
3579
+ for (const image of images) {
3580
+ if (!deduped.has(image.url)) {
3581
+ deduped.set(image.url, image.alt ? { url: image.url, alt: image.alt } : { url: image.url });
3582
+ }
3583
+ }
3584
+ return {
3585
+ title,
3586
+ text: rawText,
3587
+ images: Array.from(deduped.values()),
3588
+ editUrl: window.location.href,
3589
+ };
3590
+ }, { op: "article_collect_editor" }).catch(() => undefined);
3591
+ if (!article || typeof article !== "object") {
3592
+ return errorResult("UPSTREAM_CHANGED", "article editor content not found");
3593
+ }
3594
+ const title = typeof article.title === "string" ? article.title : "";
3595
+ const rawText = typeof article.text === "string" ? article.text : "";
3596
+ const normalizedTitleHeading = title ? `# ${title}` : "";
3597
+ let text = sanitizeArticleText(rawText);
3598
+ if (normalizedTitleHeading && text.startsWith(normalizedTitleHeading)) {
3599
+ text = sanitizeArticleText(text.slice(normalizedTitleHeading.length));
3600
+ }
3601
+ const images = Array.isArray(article.images) ? article.images : [];
3602
+ const coverImage = images[0];
3603
+ const inlineImages = coverImage ? images.slice(1) : images;
2781
3604
  const output = {
2782
- ok: true,
2783
- response: artifactResult.response,
2784
- url: typeof conversationId === "string" ? `https://x.com/i/grok?conversation=${conversationId}` : page.url(),
3605
+ article: {
3606
+ ...(articleId ? { id: articleId } : {}),
3607
+ title,
3608
+ text,
3609
+ editUrl: typeof article.editUrl === "string" ? article.editUrl : page.url(),
3610
+ images: inlineImages,
3611
+ source: "editor",
3612
+ },
2785
3613
  };
2786
- if (typeof conversationId === "string") {
2787
- output.conversationId = conversationId;
3614
+ if (coverImage && typeof coverImage === "object" && coverImage !== null && "url" in coverImage) {
3615
+ output.article.coverImageUrl = coverImage.url;
2788
3616
  }
2789
- if (artifactResult.artifacts) {
2790
- output.artifacts = artifactResult.artifacts;
3617
+ if (articleId) {
3618
+ output.article.sessionScoped = sessionScoped === true;
3619
+ }
3620
+ if (sessionScoped === true) {
3621
+ output.article.published = false;
2791
3622
  }
2792
3623
  return output;
2793
3624
  }
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;
3625
+ function parseArticleReadErrorCode(value) {
3626
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3627
+ return undefined;
3628
+ }
3629
+ const error = "error" in value ? value.error : undefined;
3630
+ if (!error || typeof error !== "object" || Array.isArray(error)) {
3631
+ return undefined;
3632
+ }
3633
+ return typeof error.code === "string"
3634
+ ? error.code
3635
+ : undefined;
3636
+ }
3637
+ async function readArticleFromOwnedSlices(page, articleId) {
3638
+ return await withEphemeralPage(page, "https://x.com/compose/articles", async (articlePage) => {
3639
+ await waitForCapturedOperation(articlePage, "ArticleEntitiesSlice", 12_000);
3640
+ const article = await articlePage.evaluate(async ({ op, articleId: targetArticleId }) => {
3641
+ if (op !== "article_collect_owned") {
3642
+ return undefined;
2799
3643
  }
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());
3644
+ const globalAny = window;
3645
+ const capture = globalAny.__WEBMCP_X_CAPTURE__;
3646
+ const entries = Array.isArray(capture?.entries) ? capture.entries : [];
3647
+ const pickTemplate = () => {
3648
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
3649
+ const entry = entries[i];
3650
+ if (!entry || entry.op !== "ArticleEntitiesSlice" || !entry.url || !entry.method) {
3651
+ continue;
3652
+ }
3653
+ return {
3654
+ url: entry.url,
3655
+ method: entry.method,
3656
+ headers: entry.headers ?? {},
3657
+ };
3658
+ }
3659
+ return null;
2821
3660
  };
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)) {
3661
+ const template = pickTemplate();
3662
+ if (!template) {
3663
+ return { error: "no_template" };
3664
+ }
3665
+ const sanitizeHeaders = (headers) => {
3666
+ const blockedPrefixes = ["sec-", ":"];
3667
+ const blockedExact = new Set(["host", "content-length", "cookie", "origin", "referer", "connection"]);
3668
+ const output = {};
3669
+ if (!headers) {
3670
+ return output;
3671
+ }
3672
+ for (const [key, value] of Object.entries(headers)) {
3673
+ const normalized = key.toLowerCase();
3674
+ if (blockedExact.has(normalized) || blockedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
2841
3675
  continue;
2842
3676
  }
2843
- hasLineCandidate = true;
3677
+ output[normalized] = value;
2844
3678
  }
2845
- if (!hasStopControl && hasLineCandidate) {
2846
- return true;
3679
+ return output;
3680
+ };
3681
+ const normalizeText = (value) => value.replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
3682
+ const normalizeInline = (value) => value.replace(/\s+/g, " ").trim();
3683
+ const parseJsonSafely = (value) => {
3684
+ if (!value) {
3685
+ return {};
2847
3686
  }
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;
3687
+ try {
3688
+ const parsed = JSON.parse(value);
3689
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
2853
3690
  }
2854
- const text = normalize(node.innerText || node.textContent || "");
2855
- if (!text) {
2856
- continue;
3691
+ catch {
3692
+ return {};
2857
3693
  }
2858
- const childWithSameText = Array.from(node.children).some((child) => {
2859
- if (!(child instanceof HTMLElement)) {
2860
- return false;
3694
+ };
3695
+ const toHttpsImage = (value) => typeof value === "string" && /^https?:\/\//.test(value) ? value : undefined;
3696
+ const parseMediaImage = (value) => {
3697
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3698
+ return [];
3699
+ }
3700
+ const record = value;
3701
+ const url = toHttpsImage(record.original_img_url) ??
3702
+ toHttpsImage(record.media_url_https) ??
3703
+ toHttpsImage(record.media_url) ??
3704
+ toHttpsImage(record.image_info?.original_img_url);
3705
+ if (!url) {
3706
+ return [];
3707
+ }
3708
+ const alt = typeof record.alt_text === "string" ? normalizeInline(record.alt_text) : "";
3709
+ return [alt ? { url, alt } : { url }];
3710
+ };
3711
+ const parseBlocksText = (value) => {
3712
+ if (!Array.isArray(value)) {
3713
+ return "";
3714
+ }
3715
+ const lines = [];
3716
+ for (const rawBlock of value) {
3717
+ if (!rawBlock || typeof rawBlock !== "object" || Array.isArray(rawBlock)) {
3718
+ continue;
3719
+ }
3720
+ const block = rawBlock;
3721
+ const rawText = typeof block.text === "string" ? block.text : "";
3722
+ const text = normalizeText(rawText);
3723
+ if (!text) {
3724
+ lines.push("");
3725
+ continue;
3726
+ }
3727
+ const type = typeof block.type === "string" ? block.type : "";
3728
+ if (type === "unordered-list-item") {
3729
+ lines.push(`- ${text}`);
3730
+ }
3731
+ else if (type === "ordered-list-item") {
3732
+ lines.push(`1. ${text}`);
3733
+ }
3734
+ else {
3735
+ lines.push(text);
2861
3736
  }
2862
- return normalize(child.innerText || child.textContent || "") === text;
2863
- });
2864
- if (childWithSameText) {
2865
- continue;
2866
3737
  }
2867
- if (entries[entries.length - 1] !== text) {
2868
- entries.push(text);
3738
+ return normalizeText(lines.join("\n"));
3739
+ };
3740
+ const extractNextCursor = (sliceInfo) => {
3741
+ if (!sliceInfo || typeof sliceInfo !== "object" || Array.isArray(sliceInfo)) {
3742
+ return undefined;
2869
3743
  }
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;
3744
+ const record = sliceInfo;
3745
+ const keys = ["next_cursor", "nextCursor", "cursor", "bottom_cursor"];
3746
+ for (const key of keys) {
3747
+ if (typeof record[key] === "string" && record[key]) {
3748
+ return record[key];
3749
+ }
3750
+ }
3751
+ return undefined;
3752
+ };
3753
+ const templateUrl = new URL(template.url, location.origin);
3754
+ const templateVariables = parseJsonSafely(templateUrl.searchParams.get("variables"));
3755
+ const templateFeatures = parseJsonSafely(templateUrl.searchParams.get("features"));
3756
+ const headers = sanitizeHeaders(template.headers);
3757
+ const userId = typeof templateVariables.userId === "string" ? templateVariables.userId : "";
3758
+ if (!userId) {
3759
+ return { error: "missing_user_id" };
3760
+ }
3761
+ const fetchSlice = async (lifecycle, cursor) => {
3762
+ const vars = {
3763
+ ...templateVariables,
3764
+ userId,
3765
+ lifecycle,
3766
+ count: 20,
3767
+ };
3768
+ if (cursor) {
3769
+ vars.cursor = cursor;
3770
+ }
3771
+ else {
3772
+ delete vars.cursor;
3773
+ }
3774
+ const requestUrl = new URL(template.url, location.origin);
3775
+ requestUrl.searchParams.set("variables", JSON.stringify(vars));
3776
+ if (Object.keys(templateFeatures).length > 0) {
3777
+ requestUrl.searchParams.set("features", JSON.stringify(templateFeatures));
3778
+ }
3779
+ const response = await fetch(requestUrl.toString(), {
3780
+ method: template.method,
3781
+ headers,
3782
+ credentials: "include",
3783
+ });
3784
+ if (!response.ok) {
3785
+ throw new Error(`http_${response.status}`);
3786
+ }
3787
+ return await response.json();
3788
+ };
3789
+ for (const lifecycle of ["Draft", "Published"]) {
3790
+ let cursor;
3791
+ for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
3792
+ let responseJson;
3793
+ try {
3794
+ responseJson = await fetchSlice(lifecycle, cursor);
3795
+ }
3796
+ catch (error) {
3797
+ return { error: String(error) };
3798
+ }
3799
+ const slice = responseJson?.data?.user;
3800
+ const result = slice?.result?.articles_article_mixer_slice;
3801
+ const items = Array.isArray(result?.items) ? result.items : [];
3802
+ for (const rawItem of items) {
3803
+ if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) {
3804
+ continue;
3805
+ }
3806
+ const item = rawItem;
3807
+ const articleResult = item.article_entity_results?.result;
3808
+ if (!articleResult) {
3809
+ continue;
3810
+ }
3811
+ const restId = typeof articleResult.rest_id === "string" ? articleResult.rest_id : "";
3812
+ if (restId !== targetArticleId) {
3813
+ continue;
3814
+ }
3815
+ const title = typeof articleResult.title === "string" ? normalizeInline(articleResult.title) : "";
3816
+ const text = parseBlocksText(articleResult.content_state?.blocks);
3817
+ const metadata = articleResult.metadata ?? {};
3818
+ const authorResult = (metadata.author_results?.result ??
3819
+ {});
3820
+ const authorCore = authorResult.core ?? {};
3821
+ const authorName = typeof authorCore.name === "string" ? normalizeInline(authorCore.name) : "";
3822
+ const authorHandleRaw = typeof authorCore.screen_name === "string" ? authorCore.screen_name : "";
3823
+ const authorHandle = authorHandleRaw ? `@${authorHandleRaw.replace(/^@+/, "")}` : "";
3824
+ const coverMedia = articleResult.cover_media;
3825
+ const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : [];
3826
+ const coverImage = parseMediaImage(coverMedia)[0];
3827
+ const inlineImages = mediaEntities.flatMap((entry) => parseMediaImage(entry));
3828
+ const dedupedImages = [];
3829
+ const seenImageUrls = new Set();
3830
+ for (const image of inlineImages) {
3831
+ if (!seenImageUrls.has(image.url) && image.url !== coverImage?.url) {
3832
+ seenImageUrls.add(image.url);
3833
+ dedupedImages.push(image);
3834
+ }
3835
+ }
3836
+ const output = {
3837
+ id: restId,
3838
+ title,
3839
+ text,
3840
+ url: `https://x.com/i/article/${restId}`,
3841
+ images: dedupedImages,
3842
+ source: "owner_slice",
3843
+ published: lifecycle === "Published",
3844
+ };
3845
+ if (coverImage?.url) {
3846
+ output.coverImageUrl = coverImage.url;
3847
+ }
3848
+ if (authorName) {
3849
+ output.authorName = authorName;
3850
+ }
3851
+ if (authorHandle) {
3852
+ output.authorHandle = authorHandle;
3853
+ }
3854
+ return output;
3855
+ }
3856
+ cursor = extractNextCursor(result?.slice_info);
3857
+ if (!cursor) {
3858
+ break;
3859
+ }
2884
3860
  }
2885
- hasCandidate = true;
2886
3861
  }
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;
3862
+ return { error: "not_found" };
3863
+ }, { op: "article_collect_owned", articleId }).catch(() => undefined);
3864
+ if (!article || typeof article !== "object") {
3865
+ return errorResult("UPSTREAM_CHANGED", "article owner fallback failed");
2900
3866
  }
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());
3867
+ if ("error" in article) {
3868
+ return errorResult("UPSTREAM_CHANGED", "article owner fallback failed", article);
3869
+ }
3870
+ const outputArticle = {
3871
+ id: articleId,
3872
+ title: typeof article.title === "string" ? article.title : "",
3873
+ text: sanitizeArticleText(typeof article.text === "string" ? article.text : ""),
3874
+ url: typeof article.url === "string" ? article.url : `https://x.com/i/article/${articleId}`,
3875
+ images: Array.isArray(article.images) ? article.images : [],
3876
+ source: typeof article.source === "string" ? article.source : "owner_slice",
3877
+ published: article.published === true,
2922
3878
  };
2923
- const scope = document.querySelector("div[aria-label='Grok']") ??
2924
- document.querySelector("main");
2925
- if (!scope) {
2926
- return undefined;
3879
+ if (typeof article.coverImageUrl === "string" && article.coverImageUrl) {
3880
+ outputArticle.coverImageUrl = article.coverImageUrl;
2927
3881
  }
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
- }
3882
+ if (typeof article.authorName === "string" && article.authorName) {
3883
+ outputArticle.authorName = article.authorName;
2942
3884
  }
2943
- let responseForPrompt;
2944
- if (lineResponseCandidates.length > 0) {
2945
- responseForPrompt = lineResponseCandidates.sort((left, right) => right.length - left.length)[0];
3885
+ if (typeof article.authorHandle === "string" && article.authorHandle) {
3886
+ outputArticle.authorHandle = article.authorHandle;
2946
3887
  }
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;
3888
+ return { article: outputArticle };
3889
+ });
3890
+ }
3891
+ async function readArticleFromProfileArticles(page, articleId, authorHandle) {
3892
+ const normalizedHandle = authorHandle.replace(/^@+/, "").trim();
3893
+ if (!normalizedHandle) {
3894
+ return errorResult("VALIDATION_ERROR", "authorHandle must be a non-empty string");
3895
+ }
3896
+ return await withEphemeralPage(page, `https://x.com/${encodeURIComponent(normalizedHandle)}/articles`, async (articlePage) => {
3897
+ await waitForCapturedOperation(articlePage, "UserArticlesTweets", 15_000);
3898
+ const article = await articlePage.evaluate(async ({ op, articleId: targetArticleId }) => {
3899
+ if (op !== "article_collect_profile") {
3900
+ return undefined;
3901
+ }
3902
+ const globalAny = window;
3903
+ const capture = globalAny.__WEBMCP_X_CAPTURE__;
3904
+ const entries = Array.isArray(capture?.entries) ? capture.entries : [];
3905
+ const template = (() => {
3906
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
3907
+ const entry = entries[i];
3908
+ if (!entry || entry.op !== "UserArticlesTweets" || !entry.url || !entry.method) {
3909
+ continue;
3910
+ }
3911
+ return {
3912
+ url: entry.url,
3913
+ method: entry.method,
3914
+ headers: entry.headers ?? {},
3915
+ };
3916
+ }
3917
+ return null;
3918
+ })();
3919
+ if (!template) {
3920
+ return { error: "no_template" };
3921
+ }
3922
+ const sanitizeHeaders = (headers) => {
3923
+ const blockedPrefixes = ["sec-", ":"];
3924
+ const blockedExact = new Set(["host", "content-length", "cookie", "origin", "referer", "connection"]);
3925
+ const output = {};
3926
+ if (!headers) {
3927
+ return output;
3928
+ }
3929
+ for (const [key, value] of Object.entries(headers)) {
3930
+ const normalized = key.toLowerCase();
3931
+ if (blockedExact.has(normalized) || blockedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
3932
+ continue;
3933
+ }
3934
+ output[normalized] = value;
3935
+ }
3936
+ return output;
3937
+ };
3938
+ const normalizeText = (value) => value.replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
3939
+ const normalizeInline = (value) => value.replace(/\s+/g, " ").trim();
3940
+ const parseJsonSafely = (value) => {
3941
+ if (!value) {
3942
+ return {};
3943
+ }
3944
+ try {
3945
+ const parsed = JSON.parse(value);
3946
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
3947
+ }
3948
+ catch {
3949
+ return {};
3950
+ }
3951
+ };
3952
+ const toHttpsImage = (value) => typeof value === "string" && /^https?:\/\//.test(value) ? value : undefined;
3953
+ const parseMediaImage = (value) => {
3954
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3955
+ return [];
3956
+ }
3957
+ const record = value;
3958
+ const url = toHttpsImage(record.original_img_url) ??
3959
+ toHttpsImage(record.media_url_https) ??
3960
+ toHttpsImage(record.media_url) ??
3961
+ toHttpsImage(record.image_info?.original_img_url);
3962
+ if (!url) {
3963
+ return [];
3964
+ }
3965
+ const alt = typeof record.alt_text === "string" ? normalizeInline(record.alt_text) : "";
3966
+ return [alt ? { url, alt } : { url }];
3967
+ };
3968
+ const parseBlocksText = (value) => {
3969
+ if (!Array.isArray(value)) {
3970
+ return "";
3971
+ }
3972
+ const lines = [];
3973
+ for (const rawBlock of value) {
3974
+ if (!rawBlock || typeof rawBlock !== "object" || Array.isArray(rawBlock)) {
3975
+ continue;
3976
+ }
3977
+ const block = rawBlock;
3978
+ const rawText = typeof block.text === "string" ? block.text : "";
3979
+ const text = normalizeText(rawText);
3980
+ if (!text) {
3981
+ lines.push("");
3982
+ continue;
3983
+ }
3984
+ const type = typeof block.type === "string" ? block.type : "";
3985
+ if (type === "unordered-list-item") {
3986
+ lines.push(`- ${text}`);
3987
+ }
3988
+ else if (type === "ordered-list-item") {
3989
+ lines.push(`1. ${text}`);
3990
+ }
3991
+ else {
3992
+ lines.push(text);
3993
+ }
3994
+ }
3995
+ return normalizeText(lines.join("\n"));
3996
+ };
3997
+ const extractNextCursor = (instructions) => {
3998
+ for (const instruction of instructions) {
3999
+ if (!instruction || typeof instruction !== "object" || Array.isArray(instruction)) {
4000
+ continue;
4001
+ }
4002
+ const entries = Array.isArray(instruction.entries)
4003
+ ? instruction.entries
4004
+ : [];
4005
+ for (const rawEntry of entries) {
4006
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
4007
+ continue;
4008
+ }
4009
+ const entry = rawEntry;
4010
+ const content = entry.content ?? {};
4011
+ const cursorType = typeof content.cursorType === "string" ? content.cursorType : "";
4012
+ const value = typeof content.value === "string" ? content.value : "";
4013
+ if (cursorType === "Bottom" && value) {
4014
+ return value;
4015
+ }
4016
+ }
4017
+ }
4018
+ return undefined;
4019
+ };
4020
+ const templateUrl = new URL(template.url, location.origin);
4021
+ const templateVariables = parseJsonSafely(templateUrl.searchParams.get("variables"));
4022
+ const templateFeatures = parseJsonSafely(templateUrl.searchParams.get("features"));
4023
+ const templateFieldToggles = parseJsonSafely(templateUrl.searchParams.get("fieldToggles"));
4024
+ const headers = sanitizeHeaders(template.headers);
4025
+ templateFieldToggles.withArticlePlainText = true;
4026
+ const fetchTimeline = async (cursor) => {
4027
+ const vars = {
4028
+ ...templateVariables,
4029
+ count: 20,
4030
+ };
4031
+ if (cursor) {
4032
+ vars.cursor = cursor;
4033
+ }
4034
+ else {
4035
+ delete vars.cursor;
4036
+ }
4037
+ const requestUrl = new URL(template.url, location.origin);
4038
+ requestUrl.searchParams.set("variables", JSON.stringify(vars));
4039
+ if (Object.keys(templateFeatures).length > 0) {
4040
+ requestUrl.searchParams.set("features", JSON.stringify(templateFeatures));
4041
+ }
4042
+ if (Object.keys(templateFieldToggles).length > 0) {
4043
+ requestUrl.searchParams.set("fieldToggles", JSON.stringify(templateFieldToggles));
4044
+ }
4045
+ const response = await fetch(requestUrl.toString(), {
4046
+ method: template.method,
4047
+ headers,
4048
+ credentials: "include",
4049
+ });
4050
+ if (!response.ok) {
4051
+ throw new Error(`http_${response.status}`);
4052
+ }
4053
+ return await response.json();
4054
+ };
4055
+ let cursor;
4056
+ for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
4057
+ let responseJson;
4058
+ try {
4059
+ responseJson = await fetchTimeline(cursor);
4060
+ }
4061
+ catch (error) {
4062
+ return { error: String(error) };
4063
+ }
4064
+ const dataRecord = responseJson?.data;
4065
+ const userRecord = dataRecord?.user;
4066
+ const resultRecord = userRecord?.result;
4067
+ const timelineRecord = resultRecord?.timeline;
4068
+ const timelineInnerRecord = timelineRecord?.timeline;
4069
+ const instructions = timelineInnerRecord?.instructions;
4070
+ const entries = Array.isArray(instructions) ? instructions : [];
4071
+ for (const instruction of entries) {
4072
+ if (!instruction || typeof instruction !== "object" || Array.isArray(instruction)) {
4073
+ continue;
4074
+ }
4075
+ const timelineEntries = Array.isArray(instruction.entries)
4076
+ ? instruction.entries
4077
+ : [];
4078
+ for (const rawEntry of timelineEntries) {
4079
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
4080
+ continue;
4081
+ }
4082
+ const entry = rawEntry;
4083
+ const tweetResult = entry.content?.itemContent
4084
+ ?.tweet_results?.result;
4085
+ const articleResult = tweetResult?.article?.article_results
4086
+ ?.result ?? undefined;
4087
+ if (!articleResult) {
4088
+ continue;
4089
+ }
4090
+ const restId = typeof articleResult.rest_id === "string" ? articleResult.rest_id : "";
4091
+ if (restId !== targetArticleId) {
4092
+ continue;
4093
+ }
4094
+ const metadata = articleResult.metadata ?? {};
4095
+ const title = typeof articleResult.title === "string" ? normalizeInline(articleResult.title) : "";
4096
+ const previewText = typeof articleResult.preview_text === "string" ? normalizeText(articleResult.preview_text) : "";
4097
+ const plainText = typeof articleResult.plain_text === "string" ? normalizeText(articleResult.plain_text) : "";
4098
+ const text = plainText || parseBlocksText(articleResult.content_state?.blocks) || previewText;
4099
+ const coverImage = parseMediaImage(articleResult.cover_media)[0];
4100
+ const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : [];
4101
+ const inlineImages = mediaEntities.flatMap((entry) => parseMediaImage(entry));
4102
+ const dedupedImages = [];
4103
+ const seenImageUrls = new Set();
4104
+ for (const image of inlineImages) {
4105
+ if (!seenImageUrls.has(image.url) && image.url !== coverImage?.url) {
4106
+ seenImageUrls.add(image.url);
4107
+ dedupedImages.push(image);
4108
+ }
4109
+ }
4110
+ const output = {
4111
+ id: restId,
4112
+ title,
4113
+ text,
4114
+ url: `https://x.com/i/article/${restId}`,
4115
+ images: dedupedImages,
4116
+ source: "profile_articles",
4117
+ published: true,
4118
+ };
4119
+ if (coverImage?.url) {
4120
+ output.coverImageUrl = coverImage.url;
4121
+ }
4122
+ if (typeof metadata.first_published_at_secs === "number") {
4123
+ output.firstPublishedAtSecs = metadata.first_published_at_secs;
4124
+ }
4125
+ return output;
4126
+ }
4127
+ }
4128
+ cursor = extractNextCursor(entries);
4129
+ if (!cursor) {
4130
+ break;
2953
4131
  }
2954
- latestResponse = candidate;
2955
- break;
2956
4132
  }
4133
+ return { error: "not_found" };
4134
+ }, { op: "article_collect_profile", articleId }).catch(() => undefined);
4135
+ if (!article || typeof article !== "object") {
4136
+ return errorResult("UPSTREAM_CHANGED", "article profile fallback failed");
4137
+ }
4138
+ if ("error" in article) {
4139
+ return errorResult("UPSTREAM_CHANGED", "article profile fallback failed", article);
4140
+ }
4141
+ const outputArticle = {
4142
+ id: articleId,
4143
+ title: typeof article.title === "string" ? article.title : "",
4144
+ text: sanitizeArticleText(typeof article.text === "string" ? article.text : ""),
4145
+ url: typeof article.url === "string" ? article.url : `https://x.com/i/article/${articleId}`,
4146
+ images: Array.isArray(article.images) ? article.images : [],
4147
+ source: typeof article.source === "string" ? article.source : "profile_articles",
4148
+ published: true,
4149
+ authorHandle: `@${normalizedHandle}`,
4150
+ };
4151
+ if (typeof article.coverImageUrl === "string" && article.coverImageUrl) {
4152
+ outputArticle.coverImageUrl = article.coverImageUrl;
4153
+ }
4154
+ return { article: outputArticle };
4155
+ });
4156
+ }
4157
+ async function readArticleFromPublicPage(page, targetUrl) {
4158
+ return await withEphemeralPage(page, targetUrl, async (readPage) => {
4159
+ await waitForArticleReadSurface(readPage);
4160
+ const article = await readPage.evaluate(({ op }) => {
4161
+ if (op !== "article_collect_public") {
4162
+ return undefined;
4163
+ }
4164
+ const normalizeInline = (value) => value.replace(/\s+/g, " ").trim();
4165
+ const normalizeBlock = (value) => value.replace(/\u00a0/g, " ").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
4166
+ const asArray = (value) => (Array.isArray(value) ? value : [value]);
4167
+ const readJsonLd = () => {
4168
+ const scripts = Array.from(document.querySelectorAll("script[type='application/ld+json']"));
4169
+ for (const script of scripts) {
4170
+ const raw = script.textContent?.trim();
4171
+ if (!raw) {
4172
+ continue;
4173
+ }
4174
+ try {
4175
+ const parsed = JSON.parse(raw);
4176
+ const queue = asArray(parsed);
4177
+ while (queue.length > 0) {
4178
+ const next = queue.shift();
4179
+ if (!next || typeof next !== "object") {
4180
+ continue;
4181
+ }
4182
+ const record = next;
4183
+ const typeValue = record["@type"];
4184
+ const types = asArray(typeValue).filter((item) => typeof item === "string");
4185
+ if (types.some((item) => item.toLowerCase().includes("article"))) {
4186
+ return record;
4187
+ }
4188
+ const graph = record["@graph"];
4189
+ if (graph) {
4190
+ queue.push(...asArray(graph));
4191
+ }
4192
+ }
4193
+ }
4194
+ catch {
4195
+ continue;
4196
+ }
4197
+ }
4198
+ return undefined;
4199
+ };
4200
+ const ld = readJsonLd();
4201
+ const ldHeadline = typeof ld?.headline === "string" ? normalizeInline(ld.headline) : "";
4202
+ const ldBody = typeof ld?.articleBody === "string" ? normalizeBlock(ld.articleBody) : "";
4203
+ const ldImage = typeof ld?.image === "string"
4204
+ ? ld.image
4205
+ : Array.isArray(ld?.image)
4206
+ ? ld.image.find((item) => typeof item === "string")
4207
+ : undefined;
4208
+ const ldAuthor = (() => {
4209
+ const author = ld?.author;
4210
+ if (!author || typeof author !== "object" || Array.isArray(author)) {
4211
+ return undefined;
4212
+ }
4213
+ const authorRecord = author;
4214
+ const name = typeof authorRecord.name === "string" ? normalizeInline(authorRecord.name) : "";
4215
+ const url = typeof authorRecord.url === "string" ? authorRecord.url : "";
4216
+ const handleMatch = url.match(/x\.com\/([^/?#]+)/i);
4217
+ return {
4218
+ name,
4219
+ handle: handleMatch?.[1] ? `@${handleMatch[1].replace(/^@+/, "")}` : undefined,
4220
+ };
4221
+ })();
4222
+ const title = ldHeadline ||
4223
+ normalizeInline(document.querySelector("h1")?.innerText || "") ||
4224
+ normalizeInline(document.querySelector("meta[property='og:title']")?.getAttribute("content") || "");
4225
+ const articleTextCandidates = [
4226
+ ...Array.from(document.querySelectorAll("main article [dir='auto'], main article div[lang], main article p")),
4227
+ ...Array.from(document.querySelectorAll("article [dir='auto'], article div[lang], article p")),
4228
+ ]
4229
+ .map((node) => normalizeBlock(node.innerText || node.textContent || ""))
4230
+ .filter((value) => value.length > 0);
4231
+ const text = ldBody || articleTextCandidates.join("\n\n");
4232
+ const canonicalUrl = document.querySelector("link[rel='canonical']")?.href ||
4233
+ document.querySelector("meta[property='og:url']")?.getAttribute("content") ||
4234
+ window.location.href;
4235
+ const articleIdMatch = canonicalUrl.match(/\/articles\/(\d+)(?:[/?#]|$)/) || window.location.href.match(/\/articles\/(\d+)(?:[/?#]|$)/);
4236
+ const allImages = Array.from(document.querySelectorAll("img[src]"))
4237
+ .map((img) => ({
4238
+ url: img.currentSrc || img.src,
4239
+ alt: normalizeInline(img.alt || ""),
4240
+ width: img.naturalWidth || 0,
4241
+ height: img.naturalHeight || 0,
4242
+ }))
4243
+ .filter((item) => /^https?:\/\//.test(item.url))
4244
+ .filter((item) => item.width > 100 || item.height > 100);
4245
+ const imageByUrl = new Map();
4246
+ for (const image of allImages) {
4247
+ if (!imageByUrl.has(image.url)) {
4248
+ imageByUrl.set(image.url, image.alt ? { url: image.url, alt: image.alt } : { url: image.url });
4249
+ }
4250
+ }
4251
+ const coverImageUrl = (typeof ldImage === "string" && /^https?:\/\//.test(ldImage) ? ldImage : undefined) ||
4252
+ Array.from(imageByUrl.keys())[0];
4253
+ const images = Array.from(imageByUrl.values()).filter((item) => item.url !== coverImageUrl);
4254
+ const bodyText = normalizeBlock(document.body?.innerText || "");
2957
4255
  return {
2958
- responseForPrompt,
2959
- latestResponse,
4256
+ id: articleIdMatch?.[1],
4257
+ url: canonicalUrl,
4258
+ title,
4259
+ text,
4260
+ coverImageUrl,
4261
+ images,
4262
+ authorName: ldAuthor?.name,
4263
+ authorHandle: ldAuthor?.handle,
4264
+ unsupported: bodyText.includes("This page is not supported."),
2960
4265
  };
4266
+ }, { op: "article_collect_public" }).catch(() => undefined);
4267
+ if (!article || typeof article !== "object") {
4268
+ return errorResult("UPSTREAM_CHANGED", "article content not found");
2961
4269
  }
2962
- const entries = [];
2963
- for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
2964
- if (node.closest("button, a, textarea, nav")) {
4270
+ if (article.unsupported === true) {
4271
+ return errorResult("UPSTREAM_CHANGED", "article unavailable on public article route", {
4272
+ reason: "public_article_unsupported",
4273
+ });
4274
+ }
4275
+ const title = typeof article.title === "string" ? article.title : "";
4276
+ const text = sanitizeArticleText(typeof article.text === "string" ? article.text : "");
4277
+ const images = Array.isArray(article.images) ? article.images : [];
4278
+ const coverImageUrl = typeof article.coverImageUrl === "string" ? article.coverImageUrl : "";
4279
+ if ((!title || title === "X") && !text && images.length === 0 && !coverImageUrl) {
4280
+ return errorResult("UPSTREAM_CHANGED", "article content not found on public article route", {
4281
+ reason: "public_article_empty",
4282
+ });
4283
+ }
4284
+ const outputArticle = {
4285
+ source: "public",
4286
+ published: true,
4287
+ title,
4288
+ text,
4289
+ url: typeof article.url === "string" ? article.url : targetUrl,
4290
+ images,
4291
+ };
4292
+ if (typeof article.id === "string" && article.id) {
4293
+ outputArticle.id = article.id;
4294
+ }
4295
+ if (typeof article.coverImageUrl === "string" && article.coverImageUrl) {
4296
+ outputArticle.coverImageUrl = article.coverImageUrl;
4297
+ }
4298
+ if (typeof article.authorName === "string" && article.authorName) {
4299
+ outputArticle.authorName = article.authorName;
4300
+ }
4301
+ if (typeof article.authorHandle === "string" && article.authorHandle) {
4302
+ outputArticle.authorHandle = article.authorHandle;
4303
+ }
4304
+ return { article: outputArticle };
4305
+ });
4306
+ }
4307
+ async function readArticleByUrl(page, targetUrl, authorHandle) {
4308
+ let articleId = parseArticleIdFromUrl(targetUrl);
4309
+ if (!articleId && /\/status\/\d+(?:[/?#]|$)/.test(targetUrl)) {
4310
+ const tweetResult = await readTweetByUrl(page, targetUrl);
4311
+ if (tweetResult &&
4312
+ typeof tweetResult === "object" &&
4313
+ "tweet" in tweetResult &&
4314
+ tweetResult.tweet &&
4315
+ typeof tweetResult.tweet === "object" &&
4316
+ !Array.isArray(tweetResult.tweet)) {
4317
+ const tweetRecord = tweetResult.tweet;
4318
+ const article = tweetRecord.article;
4319
+ if (article && typeof article === "object" && !Array.isArray(article)) {
4320
+ const articleRecord = article;
4321
+ const nestedUrl = typeof articleRecord.url === "string" ? articleRecord.url : "";
4322
+ const nestedId = typeof articleRecord.id === "string" ? articleRecord.id : "";
4323
+ if (nestedUrl) {
4324
+ targetUrl = nestedUrl;
4325
+ }
4326
+ articleId = nestedId || parseArticleIdFromUrl(nestedUrl);
4327
+ }
4328
+ }
4329
+ }
4330
+ const useEditor = targetUrl.includes("/compose/articles/edit/");
4331
+ if (articleId) {
4332
+ const cachedPage = getCachedArticleDraftPage(page, articleId);
4333
+ if (cachedPage) {
4334
+ await waitForArticleEditorSurface(cachedPage);
4335
+ await ensureArticleDraftLoaded(cachedPage, articleId);
4336
+ return await readArticleFromEditorPage(cachedPage, articleId, true);
4337
+ }
4338
+ }
4339
+ if (useEditor) {
4340
+ return await withEphemeralPage(page, targetUrl, async (articlePage) => {
4341
+ await waitForArticleEditorSurface(articlePage);
4342
+ await ensureArticleDraftLoaded(articlePage, articleId);
4343
+ return await readArticleFromEditorPage(articlePage, articleId, false);
4344
+ });
4345
+ }
4346
+ const publicResult = await readArticleFromPublicPage(page, targetUrl);
4347
+ if (!articleId) {
4348
+ return publicResult;
4349
+ }
4350
+ if (!parseArticleReadErrorCode(publicResult)) {
4351
+ return publicResult;
4352
+ }
4353
+ if (typeof authorHandle === "string" && authorHandle.trim()) {
4354
+ const profileResult = await readArticleFromProfileArticles(page, articleId, authorHandle);
4355
+ if (!parseArticleReadErrorCode(profileResult)) {
4356
+ return profileResult;
4357
+ }
4358
+ }
4359
+ const ownerResult = await readArticleFromOwnedSlices(page, articleId);
4360
+ return parseArticleReadErrorCode(ownerResult) ? publicResult : ownerResult;
4361
+ }
4362
+ async function publishArticleEditor(page, timeoutMs) {
4363
+ const editUrl = page.url();
4364
+ await page
4365
+ .evaluate(() => {
4366
+ const active = document.activeElement;
4367
+ if (active instanceof HTMLElement) {
4368
+ active.blur();
4369
+ }
4370
+ })
4371
+ .catch(() => { });
4372
+ await page.keyboard.press("Escape").catch(() => { });
4373
+ await page
4374
+ .waitForFunction(() => {
4375
+ const buttons = Array.from(document.querySelectorAll("button"));
4376
+ return buttons.some((button) => {
4377
+ const label = (button.textContent || "").replace(/\s+/g, " ").trim();
4378
+ const ariaDisabled = (button.getAttribute("aria-disabled") || "").toLowerCase();
4379
+ return label === "Publish" && !button.disabled && ariaDisabled !== "true";
4380
+ });
4381
+ }, undefined, { timeout: Math.min(timeoutMs, 15_000) })
4382
+ .catch(() => { });
4383
+ const clickPrimaryPublish = async () => {
4384
+ return ((await page.evaluate(({ op }) => {
4385
+ if (op !== "article_click_publish") {
4386
+ return false;
4387
+ }
4388
+ const buttons = Array.from(document.querySelectorAll("button")).filter((button) => {
4389
+ const label = (button.textContent || "").replace(/\s+/g, " ").trim();
4390
+ const ariaDisabled = (button.getAttribute("aria-disabled") || "").toLowerCase();
4391
+ return label === "Publish" && !button.disabled && ariaDisabled !== "true";
4392
+ });
4393
+ const button = buttons[buttons.length - 1];
4394
+ if (!button) {
4395
+ return false;
4396
+ }
4397
+ button.click();
4398
+ return true;
4399
+ }, { op: "article_click_publish" }).catch(() => false)) === true);
4400
+ };
4401
+ if (!(await clickPrimaryPublish())) {
4402
+ const details = await page
4403
+ .evaluate(() => {
4404
+ const titleAreas = Array.from(document.querySelectorAll("textarea")).map((input) => ({
4405
+ placeholder: input.getAttribute("placeholder") || "",
4406
+ value: input instanceof HTMLTextAreaElement ? input.value : "",
4407
+ }));
4408
+ const composers = Array.from(document.querySelectorAll("[data-testid='composer'][role='textbox']")).map((node) => ({
4409
+ text: (node.textContent || "").slice(0, 500),
4410
+ }));
4411
+ const buttonLabels = Array.from(document.querySelectorAll("button"))
4412
+ .map((button) => ({
4413
+ text: (button.textContent || "").replace(/\s+/g, " ").trim(),
4414
+ aria: button.getAttribute("aria-label") || "",
4415
+ disabled: button.disabled || (button.getAttribute("aria-disabled") || "").toLowerCase() === "true",
4416
+ }))
4417
+ .filter((item) => item.text || item.aria)
4418
+ .slice(0, 30);
4419
+ const bodyLines = (document.body?.innerText || "")
4420
+ .split(/\n+/)
4421
+ .map((line) => line.trim())
4422
+ .filter((line) => line.length > 0)
4423
+ .slice(0, 40);
4424
+ return {
4425
+ currentUrl: window.location.href,
4426
+ titleAreas,
4427
+ composers,
4428
+ buttonLabels,
4429
+ bodyLines,
4430
+ };
4431
+ })
4432
+ .catch(() => undefined);
4433
+ return details
4434
+ ? { ok: false, reason: "publish_button_not_found", details }
4435
+ : { ok: false, reason: "publish_button_not_found" };
4436
+ }
4437
+ await page.waitForTimeout(1_000);
4438
+ await clickPrimaryPublish().catch(() => false);
4439
+ try {
4440
+ await page.waitForFunction(({ previousUrl }) => {
4441
+ const currentUrl = window.location.href;
4442
+ if (!currentUrl.includes("/compose/articles/edit/")) {
4443
+ return true;
4444
+ }
4445
+ if (currentUrl !== previousUrl && !currentUrl.includes("/preview")) {
4446
+ return true;
4447
+ }
4448
+ return (document.body?.innerText || "").includes("Published");
4449
+ }, { previousUrl: editUrl }, { timeout: timeoutMs });
4450
+ }
4451
+ catch {
4452
+ return { ok: false, reason: "publish_not_confirmed" };
4453
+ }
4454
+ const details = await page.evaluate(({ op }) => {
4455
+ if (op !== "article_collect_publish_details") {
4456
+ return {
4457
+ currentUrl: window.location.href,
4458
+ editUrl: undefined,
4459
+ publicUrl: undefined,
4460
+ };
4461
+ }
4462
+ const editAnchor = Array.from(document.querySelectorAll("a[href]")).find((anchor) => anchor.href.includes("/compose/articles/edit/"));
4463
+ const publicAnchor = Array.from(document.querySelectorAll("a[href]")).find((anchor) => {
4464
+ return anchor.href.includes("/articles/") && !anchor.href.includes("/compose/articles/edit/");
4465
+ });
4466
+ return {
4467
+ currentUrl: window.location.href,
4468
+ editUrl: editAnchor?.href,
4469
+ publicUrl: publicAnchor?.href,
4470
+ };
4471
+ }, { op: "article_collect_publish_details" }).catch(() => ({ currentUrl: page.url(), editUrl: undefined, publicUrl: undefined }));
4472
+ const articleId = parseArticleIdFromUrl(details.currentUrl) ??
4473
+ (typeof details.editUrl === "string" ? parseArticleIdFromUrl(details.editUrl) : undefined) ??
4474
+ undefined;
4475
+ const articleUrl = typeof details.publicUrl === "string" && details.publicUrl.length > 0
4476
+ ? details.publicUrl
4477
+ : !details.currentUrl.includes("/compose/articles/edit/")
4478
+ ? details.currentUrl
4479
+ : undefined;
4480
+ const output = {
4481
+ ok: true,
4482
+ editUrl: typeof details.editUrl === "string" && details.editUrl.length > 0 ? details.editUrl : editUrl,
4483
+ };
4484
+ if (articleId) {
4485
+ output.articleId = articleId;
4486
+ }
4487
+ if (articleUrl) {
4488
+ output.articleUrl = articleUrl;
4489
+ }
4490
+ return output;
4491
+ }
4492
+ async function deleteArticleEditor(page, dryRun) {
4493
+ const menuOpened = await page.evaluate(({ op }) => {
4494
+ if (op !== "article_open_delete_menu") {
4495
+ return false;
4496
+ }
4497
+ const buttons = Array.from(document.querySelectorAll("button[aria-label='More']"));
4498
+ const button = buttons[buttons.length - 1];
4499
+ if (!button) {
4500
+ return false;
4501
+ }
4502
+ button.click();
4503
+ return true;
4504
+ }, { op: "article_open_delete_menu" }).catch(() => false);
4505
+ if (!menuOpened) {
4506
+ return errorResult("UPSTREAM_CHANGED", "article delete controls not found", {
4507
+ reason: "more_button_not_found",
4508
+ });
4509
+ }
4510
+ const deleteReady = await page.waitForFunction(() => {
4511
+ return Array.from(document.querySelectorAll("[role='menuitem'], button, div[role='button']")).some((element) => {
4512
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
4513
+ return text === "Delete Article";
4514
+ });
4515
+ }, undefined, { timeout: 5_000 }).then(() => true).catch(() => false);
4516
+ if (!deleteReady) {
4517
+ return errorResult("UPSTREAM_CHANGED", "article delete controls not found", {
4518
+ reason: "delete_menu_item_not_found",
4519
+ });
4520
+ }
4521
+ if (dryRun) {
4522
+ return {
4523
+ ok: true,
4524
+ dryRun: true,
4525
+ deleteVisible: true,
4526
+ };
4527
+ }
4528
+ const firstDelete = await page.evaluate(({ op }) => {
4529
+ if (op !== "article_click_delete_menu_item") {
4530
+ return false;
4531
+ }
4532
+ const item = Array.from(document.querySelectorAll("[role='menuitem'], button, div[role='button']")).find((element) => {
4533
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
4534
+ return text === "Delete Article";
4535
+ });
4536
+ if (!item) {
4537
+ return false;
4538
+ }
4539
+ item.click();
4540
+ return true;
4541
+ }, { op: "article_click_delete_menu_item" }).catch(() => false);
4542
+ if (!firstDelete) {
4543
+ return errorResult("UPSTREAM_CHANGED", "article delete controls not found", {
4544
+ reason: "delete_menu_click_failed",
4545
+ });
4546
+ }
4547
+ await page.waitForTimeout(700);
4548
+ await page.evaluate(({ op }) => {
4549
+ if (op !== "article_confirm_delete") {
4550
+ return;
4551
+ }
4552
+ const dialog = document.querySelector("[role='dialog'], [data-testid='confirmationSheetDialog']");
4553
+ const dialogButtons = dialog
4554
+ ? Array.from(dialog.querySelectorAll("button, div[role='button']"))
4555
+ : [];
4556
+ const dialogConfirm = dialogButtons.find((element) => {
4557
+ const testId = element.getAttribute("data-testid") || "";
4558
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
4559
+ return testId === "confirmationSheetConfirm" || text === "Delete Article";
4560
+ });
4561
+ if (dialogConfirm) {
4562
+ dialogConfirm.click();
4563
+ return;
4564
+ }
4565
+ const buttons = Array.from(document.querySelectorAll("button, div[role='button']")).filter((element) => {
4566
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
4567
+ return text === "Delete Article";
4568
+ });
4569
+ const button = buttons[buttons.length - 1];
4570
+ button?.click();
4571
+ }, { op: "article_confirm_delete" }).catch(() => { });
4572
+ const deleted = await page
4573
+ .waitForFunction(() => {
4574
+ const bodyText = document.body?.innerText || "";
4575
+ return window.location.pathname === "/compose/articles" || bodyText.includes("Continue a draft or create a new Article");
4576
+ }, undefined, { timeout: 15_000 })
4577
+ .then(() => true)
4578
+ .catch(() => false);
4579
+ if (!deleted) {
4580
+ const details = await page.evaluate(() => {
4581
+ const buttonLabels = Array.from(document.querySelectorAll("button, div[role='button']"))
4582
+ .map((element) => ({
4583
+ text: (element.textContent || "").replace(/\s+/g, " ").trim(),
4584
+ aria: element.getAttribute("aria-label") || "",
4585
+ testId: element.getAttribute("data-testid") || "",
4586
+ }))
4587
+ .filter((item) => item.text || item.aria || item.testId)
4588
+ .slice(0, 40);
4589
+ const bodyLines = (document.body?.innerText || "")
4590
+ .split(/\n+/)
4591
+ .map((line) => line.trim())
4592
+ .filter((line) => line.length > 0)
4593
+ .slice(0, 40);
4594
+ return {
4595
+ currentUrl: window.location.href,
4596
+ dialogOpen: document.querySelector("[role='dialog'], [data-testid='confirmationSheetDialog']") !== null,
4597
+ buttonLabels,
4598
+ bodyLines,
4599
+ };
4600
+ }).catch(() => undefined);
4601
+ return errorResult("ACTION_UNCONFIRMED", "article delete was not confirmed", details);
4602
+ }
4603
+ return {
4604
+ ok: true,
4605
+ confirmed: true,
4606
+ };
4607
+ }
4608
+ async function draftArticleMarkdown(page, markdownPath, explicitTitle, coverImagePath) {
4609
+ const resolvedMarkdown = await resolveArticleAttachment(markdownPath, "markdownPath");
4610
+ if (!resolvedMarkdown.ok || !resolvedMarkdown.attachment) {
4611
+ return resolvedMarkdown.ok ? errorResult("VALIDATION_ERROR", "markdownPath was not found") : resolvedMarkdown.result;
4612
+ }
4613
+ const markdown = await readFile(resolvedMarkdown.attachment.path, "utf8").catch(() => undefined);
4614
+ if (markdown === undefined) {
4615
+ return errorResult("VALIDATION_ERROR", `markdownPath was not found: ${markdownPath}`);
4616
+ }
4617
+ const title = extractArticleTitle(markdown, resolvedMarkdown.attachment.path, explicitTitle);
4618
+ const draftAssets = prepareArticleMarkdown(markdown, resolvedMarkdown.attachment.path);
4619
+ const resolvedInlineImages = [];
4620
+ for (const image of draftAssets.inlineImages) {
4621
+ const resolved = await resolveArticleAttachment(image.path, image.marker);
4622
+ if (!resolved.ok || !resolved.attachment) {
4623
+ return resolved.ok
4624
+ ? errorResult("VALIDATION_ERROR", `${image.marker} was not found`)
4625
+ : resolved.result;
4626
+ }
4627
+ resolvedInlineImages.push({
4628
+ ...image,
4629
+ path: resolved.attachment.path,
4630
+ name: resolved.attachment.name,
4631
+ });
4632
+ }
4633
+ const articlePage = await page.context().newPage();
4634
+ let shouldClose = true;
4635
+ try {
4636
+ await ensureNetworkCaptureInstalled(articlePage);
4637
+ await articlePage.goto("https://x.com/compose/articles", { waitUntil: "domcontentloaded", timeout: 60_000 });
4638
+ const started = await openNewArticleEditor(articlePage);
4639
+ if (!started.ok) {
4640
+ return errorResult("UPSTREAM_CHANGED", "article editor could not be opened", {
4641
+ reason: started.reason,
4642
+ });
4643
+ }
4644
+ const titleSet = await setArticleTitle(articlePage, title);
4645
+ if (!titleSet) {
4646
+ return errorResult("UPSTREAM_CHANGED", "article title controls not found");
4647
+ }
4648
+ if (coverImagePath) {
4649
+ const resolvedCover = await resolveArticleAttachment(coverImagePath, "coverImagePath");
4650
+ if (!resolvedCover.ok || !resolvedCover.attachment) {
4651
+ return resolvedCover.ok ? errorResult("VALIDATION_ERROR", "coverImagePath was not found") : resolvedCover.result;
4652
+ }
4653
+ const coverTriggered = await triggerArticleCoverUpload(articlePage);
4654
+ if (!coverTriggered) {
4655
+ return errorResult("UPSTREAM_CHANGED", "article cover upload controls not found");
4656
+ }
4657
+ const coverUploaded = await uploadArticleFile(articlePage, resolvedCover.attachment.path);
4658
+ if (!coverUploaded) {
4659
+ return errorResult("UPSTREAM_CHANGED", "article cover upload failed");
4660
+ }
4661
+ }
4662
+ const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown);
4663
+ if (!pasted) {
4664
+ return errorResult("UPSTREAM_CHANGED", "article markdown paste failed");
4665
+ }
4666
+ const inlineUploadResult = await uploadArticleInlineImages(articlePage, resolvedInlineImages);
4667
+ if (!inlineUploadResult.ok) {
4668
+ return errorResult("UPSTREAM_CHANGED", "article inline image upload failed", {
4669
+ reason: inlineUploadResult.reason,
4670
+ });
4671
+ }
4672
+ const editUrl = started.editUrl;
4673
+ const articleId = parseArticleIdFromUrl(editUrl);
4674
+ const output = {
4675
+ ok: true,
4676
+ editUrl,
4677
+ title,
4678
+ inlineImageCount: resolvedInlineImages.length,
4679
+ hasCoverImage: typeof coverImagePath === "string" && coverImagePath.trim().length > 0,
4680
+ };
4681
+ if (articleId) {
4682
+ output.articleId = articleId;
4683
+ const persisted = await waitForArticleDraftPersisted(articlePage, articleId, title);
4684
+ output.persisted = persisted;
4685
+ output.sessionScoped = !persisted;
4686
+ await cacheArticleDraftPage(page, articleId, articlePage);
4687
+ shouldClose = false;
4688
+ }
4689
+ return output;
4690
+ }
4691
+ finally {
4692
+ if (shouldClose) {
4693
+ await articlePage.close().catch(() => { });
4694
+ }
4695
+ }
4696
+ }
4697
+ async function publishArticleMarkdown(page, markdownPath, explicitTitle, coverImagePath, dryRun, timeoutMs) {
4698
+ const drafted = await draftArticleMarkdown(page, markdownPath, explicitTitle, coverImagePath);
4699
+ if (dryRun) {
4700
+ if (drafted && typeof drafted === "object" && !Array.isArray(drafted) && !("error" in drafted)) {
4701
+ return { ...drafted, dryRun: true };
4702
+ }
4703
+ return drafted;
4704
+ }
4705
+ if (!drafted || typeof drafted !== "object" || Array.isArray(drafted) || ("error" in drafted)) {
4706
+ return drafted;
4707
+ }
4708
+ const editUrl = typeof drafted.editUrl === "string" ? drafted.editUrl : "";
4709
+ if (!editUrl) {
4710
+ return errorResult("UPSTREAM_CHANGED", "article draft edit url not found");
4711
+ }
4712
+ return await withEphemeralPage(page, editUrl, async (articlePage) => {
4713
+ await waitForArticleEditorSurface(articlePage);
4714
+ const published = await publishArticleEditor(articlePage, timeoutMs);
4715
+ if (!published.ok) {
4716
+ const details = {
4717
+ reason: published.reason,
4718
+ };
4719
+ if (published.details) {
4720
+ details.debug = published.details;
4721
+ }
4722
+ return errorResult("ACTION_UNCONFIRMED", "article publish was not confirmed", {
4723
+ ...details,
4724
+ });
4725
+ }
4726
+ const output = {
4727
+ ...drafted,
4728
+ confirmed: true,
4729
+ editUrl: published.editUrl,
4730
+ };
4731
+ if (published.articleId) {
4732
+ output.articleId = published.articleId;
4733
+ }
4734
+ if (published.articleUrl) {
4735
+ output.articleUrl = published.articleUrl;
4736
+ }
4737
+ return output;
4738
+ });
4739
+ }
4740
+ async function publishExistingArticle(page, targetUrl, timeoutMs) {
4741
+ const articleId = parseArticleIdFromUrl(targetUrl);
4742
+ const cachedPage = articleId ? getCachedArticleDraftPage(page, articleId) : undefined;
4743
+ const runPublish = async (articlePage) => {
4744
+ await waitForArticleEditorSurface(articlePage);
4745
+ await ensureArticleDraftLoaded(articlePage, articleId);
4746
+ const published = await publishArticleEditor(articlePage, timeoutMs);
4747
+ if (!published.ok) {
4748
+ const details = {
4749
+ reason: published.reason,
4750
+ };
4751
+ if (published.details) {
4752
+ details.debug = published.details;
4753
+ }
4754
+ return errorResult("ACTION_UNCONFIRMED", "article publish was not confirmed", details);
4755
+ }
4756
+ const output = {
4757
+ ok: true,
4758
+ confirmed: true,
4759
+ editUrl: published.editUrl,
4760
+ };
4761
+ if (published.articleId) {
4762
+ output.articleId = published.articleId;
4763
+ }
4764
+ if (published.articleUrl) {
4765
+ output.articleUrl = published.articleUrl;
4766
+ }
4767
+ return output;
4768
+ };
4769
+ if (cachedPage) {
4770
+ const result = await runPublish(cachedPage);
4771
+ if (articleId && (!result || typeof result !== "object" || Array.isArray(result) || !("error" in result))) {
4772
+ await removeCachedArticleDraftPage(page, articleId);
4773
+ }
4774
+ return result;
4775
+ }
4776
+ return await withEphemeralPage(page, targetUrl, runPublish);
4777
+ }
4778
+ async function withArticleDraftPage(ownerPage, targetUrl, run) {
4779
+ const articleId = parseArticleIdFromUrl(targetUrl);
4780
+ const cachedPage = articleId ? getCachedArticleDraftPage(ownerPage, articleId) : undefined;
4781
+ if (cachedPage) {
4782
+ const cachedUrl = cachedPage.url();
4783
+ const cachedArticleId = parseArticleIdFromUrl(cachedUrl);
4784
+ if (!articleId || cachedArticleId === articleId) {
4785
+ await waitForArticleEditorSurface(cachedPage);
4786
+ await ensureArticleDraftLoaded(cachedPage, articleId);
4787
+ return await run(cachedPage, articleId, true);
4788
+ }
4789
+ }
4790
+ return await withEphemeralPage(ownerPage, targetUrl, async (articlePage) => {
4791
+ await waitForArticleEditorSurface(articlePage);
4792
+ await ensureArticleDraftLoaded(articlePage, articleId);
4793
+ return await run(articlePage, articleId, false);
4794
+ });
4795
+ }
4796
+ async function setArticleCoverImage(page, targetUrl, coverImagePath) {
4797
+ const resolvedCover = await resolveArticleAttachment(coverImagePath, "coverImagePath");
4798
+ if (!resolvedCover.ok || !resolvedCover.attachment) {
4799
+ return resolvedCover.ok ? errorResult("VALIDATION_ERROR", "coverImagePath was not found") : resolvedCover.result;
4800
+ }
4801
+ const coverAttachment = resolvedCover.attachment;
4802
+ return await withArticleDraftPage(page, targetUrl, async (articlePage, articleId, sessionScoped) => {
4803
+ const coverTriggered = await triggerArticleCoverUpload(articlePage);
4804
+ if (!coverTriggered) {
4805
+ return errorResult("UPSTREAM_CHANGED", "article cover upload controls not found");
4806
+ }
4807
+ const coverUploaded = await uploadArticleFile(articlePage, coverAttachment.path);
4808
+ if (!coverUploaded) {
4809
+ return errorResult("UPSTREAM_CHANGED", "article cover upload failed");
4810
+ }
4811
+ const output = {
4812
+ ok: true,
4813
+ editUrl: articlePage.url(),
4814
+ hasCoverImage: true,
4815
+ };
4816
+ if (articleId) {
4817
+ output.articleId = articleId;
4818
+ output.sessionScoped = sessionScoped === true;
4819
+ }
4820
+ return output;
4821
+ });
4822
+ }
4823
+ async function updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitle) {
4824
+ const resolvedMarkdown = await resolveArticleAttachment(markdownPath, "markdownPath");
4825
+ if (!resolvedMarkdown.ok || !resolvedMarkdown.attachment) {
4826
+ return resolvedMarkdown.ok ? errorResult("VALIDATION_ERROR", "markdownPath was not found") : resolvedMarkdown.result;
4827
+ }
4828
+ const markdown = await readFile(resolvedMarkdown.attachment.path, "utf8").catch(() => undefined);
4829
+ if (markdown === undefined) {
4830
+ return errorResult("VALIDATION_ERROR", `markdownPath was not found: ${markdownPath}`);
4831
+ }
4832
+ const title = extractArticleTitle(markdown, resolvedMarkdown.attachment.path, explicitTitle);
4833
+ const draftAssets = prepareArticleMarkdown(markdown, resolvedMarkdown.attachment.path);
4834
+ const resolvedInlineImages = [];
4835
+ for (const image of draftAssets.inlineImages) {
4836
+ const resolved = await resolveArticleAttachment(image.path, image.marker);
4837
+ if (!resolved.ok || !resolved.attachment) {
4838
+ return resolved.ok
4839
+ ? errorResult("VALIDATION_ERROR", `${image.marker} was not found`)
4840
+ : resolved.result;
4841
+ }
4842
+ resolvedInlineImages.push({
4843
+ ...image,
4844
+ path: resolved.attachment.path,
4845
+ name: resolved.attachment.name,
4846
+ });
4847
+ }
4848
+ return await withArticleDraftPage(page, targetUrl, async (articlePage, articleId, sessionScoped) => {
4849
+ const titleSet = await setArticleTitle(articlePage, title);
4850
+ if (!titleSet) {
4851
+ return errorResult("UPSTREAM_CHANGED", "article title controls not found");
4852
+ }
4853
+ const cleared = await clearArticleBody(articlePage);
4854
+ if (!cleared) {
4855
+ return errorResult("UPSTREAM_CHANGED", "article body controls not found");
4856
+ }
4857
+ const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown);
4858
+ if (!pasted) {
4859
+ return errorResult("UPSTREAM_CHANGED", "article markdown paste failed");
4860
+ }
4861
+ const inlineUploadResult = await uploadArticleInlineImages(articlePage, resolvedInlineImages);
4862
+ if (!inlineUploadResult.ok) {
4863
+ return errorResult("UPSTREAM_CHANGED", "article inline image upload failed", {
4864
+ reason: inlineUploadResult.reason,
4865
+ });
4866
+ }
4867
+ const output = {
4868
+ ok: true,
4869
+ title,
4870
+ editUrl: articlePage.url(),
4871
+ inlineImageCount: resolvedInlineImages.length,
4872
+ };
4873
+ if (articleId) {
4874
+ output.articleId = articleId;
4875
+ const persisted = await waitForArticleDraftPersisted(articlePage, articleId, title);
4876
+ output.persisted = persisted;
4877
+ output.sessionScoped = sessionScoped === true || !persisted;
4878
+ }
4879
+ return output;
4880
+ });
4881
+ }
4882
+ async function waitForGrokSurface(page) {
4883
+ await page
4884
+ .waitForFunction(() => {
4885
+ const composer = document.querySelector("textarea") ||
4886
+ document.querySelector("[contenteditable='true'][role='textbox']") ||
4887
+ document.querySelector("[role='textbox'][contenteditable='true']");
4888
+ const messages = document.querySelector("[data-message-author-role='assistant']") ||
4889
+ document.querySelector("[data-testid*='assistant']") ||
4890
+ document.querySelector("article");
4891
+ return composer !== null || messages !== null;
4892
+ }, undefined, { timeout: 12_000 })
4893
+ .catch(() => { });
4894
+ await page.waitForTimeout(800);
4895
+ }
4896
+ async function submitGrokPrompt(page, prompt) {
4897
+ const composerSelectors = [
4898
+ "textarea",
4899
+ "[contenteditable='true'][role='textbox']",
4900
+ "[role='textbox'][contenteditable='true']",
4901
+ ];
4902
+ const submitSelectors = [
4903
+ "button[aria-label*='Grok something']",
4904
+ "button[aria-label*='Send']",
4905
+ "button[aria-label*='send']",
4906
+ "button[data-testid*='send']",
4907
+ "button[type='submit']",
4908
+ ];
4909
+ const selectAllShortcut = process.platform === "darwin" ? "Meta+A" : "Control+A";
4910
+ let composerSelector;
4911
+ for (const selector of composerSelectors) {
4912
+ const handle = await page.waitForSelector(selector, { timeout: 1_200 }).catch(() => null);
4913
+ if (!handle) {
4914
+ continue;
4915
+ }
4916
+ await handle.dispose().catch(() => { });
4917
+ composerSelector = selector;
4918
+ break;
4919
+ }
4920
+ if (!composerSelector) {
4921
+ return { ok: false, reason: "composer_not_found" };
4922
+ }
4923
+ try {
4924
+ await page.click(composerSelector);
4925
+ await page.keyboard.press(selectAllShortcut).catch(() => { });
4926
+ await page.keyboard.press("Backspace").catch(() => { });
4927
+ const setPromptInOneShot = await page.evaluate(({ selector, value }) => {
4928
+ const element = document.querySelector(selector);
4929
+ if (!element) {
4930
+ return false;
4931
+ }
4932
+ if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
4933
+ element.focus();
4934
+ element.value = value;
4935
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
4936
+ element.dispatchEvent(new Event("change", { bubbles: true }));
4937
+ return true;
4938
+ }
4939
+ if (element instanceof HTMLElement && element.isContentEditable) {
4940
+ element.focus();
4941
+ try {
4942
+ const selection = window.getSelection();
4943
+ const range = document.createRange();
4944
+ range.selectNodeContents(element);
4945
+ selection?.removeAllRanges();
4946
+ selection?.addRange(range);
4947
+ document.execCommand("insertText", false, value);
4948
+ }
4949
+ catch {
4950
+ // Ignore and fallback to direct assignment below.
4951
+ }
4952
+ if ((element.textContent ?? "").trim() !== value) {
4953
+ element.textContent = value;
4954
+ }
4955
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
4956
+ element.dispatchEvent(new Event("change", { bubbles: true }));
4957
+ return true;
4958
+ }
4959
+ return false;
4960
+ }, { selector: composerSelector, value: prompt }).catch(() => false);
4961
+ if (!setPromptInOneShot) {
4962
+ await page.type(composerSelector, prompt, { delay: 12 });
4963
+ }
4964
+ }
4965
+ catch {
4966
+ return { ok: false, reason: "compose_input_failed" };
4967
+ }
4968
+ const submitSelector = await page.evaluate((selectors) => {
4969
+ const normalize = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
4970
+ for (const selector of selectors) {
4971
+ const element = document.querySelector(selector);
4972
+ if (!element) {
2965
4973
  continue;
2966
4974
  }
2967
- const text = normalize(node.innerText || node.textContent || "");
2968
- if (!text) {
4975
+ if (element instanceof HTMLButtonElement && element.disabled) {
2969
4976
  continue;
2970
4977
  }
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) {
4978
+ const ariaDisabled = (element.getAttribute("aria-disabled") ?? "").toLowerCase();
4979
+ if (ariaDisabled === "true") {
2978
4980
  continue;
2979
4981
  }
2980
- if (entries[entries.length - 1] !== text) {
2981
- entries.push(text);
4982
+ const label = normalize(element.getAttribute("aria-label") ?? element.textContent ?? "");
4983
+ if (label.includes("stop")) {
4984
+ continue;
2982
4985
  }
4986
+ return selector;
4987
+ }
4988
+ return undefined;
4989
+ }, submitSelectors);
4990
+ if (!submitSelector) {
4991
+ return { ok: false, reason: "submit_not_found" };
4992
+ }
4993
+ try {
4994
+ await page.click(submitSelector);
4995
+ }
4996
+ catch {
4997
+ return { ok: false, reason: "submit_click_failed" };
4998
+ }
4999
+ return { ok: true };
5000
+ }
5001
+ async function prepareGrokSession(page, conversationId) {
5002
+ const targetUrl = typeof conversationId === "string" && conversationId.trim().length > 0
5003
+ ? `https://x.com/i/grok?conversation=${encodeURIComponent(conversationId.trim())}`
5004
+ : "https://x.com/i/grok";
5005
+ if (!isSameLocation(page.url(), targetUrl)) {
5006
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
5007
+ }
5008
+ await waitForGrokSurface(page);
5009
+ if (conversationId && conversationId.trim().length > 0) {
5010
+ return;
5011
+ }
5012
+ const currentConversationId = (() => {
5013
+ try {
5014
+ return new URL(page.url()).searchParams.get("conversation") ?? undefined;
5015
+ }
5016
+ catch {
5017
+ return undefined;
2983
5018
  }
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);
5019
+ })();
5020
+ if (typeof page.locator !== "function") {
5021
+ return;
5022
+ }
5023
+ const newChatButton = page
5024
+ .locator("button[aria-label*='New Chat'], button:has-text('New Chat')")
5025
+ .first();
5026
+ if ((await newChatButton.count().catch(() => 0)) === 0) {
5027
+ return;
5028
+ }
5029
+ await newChatButton.click({ timeout: 2_000 }).catch(() => { });
5030
+ await page
5031
+ .waitForFunction(({ previousConversationId }) => {
5032
+ const currentUrl = window.location.href;
5033
+ try {
5034
+ const conversation = new URL(currentUrl).searchParams.get("conversation") ?? undefined;
5035
+ if (!previousConversationId) {
5036
+ return conversation === undefined || conversation.length === 0;
2993
5037
  }
5038
+ return conversation !== previousConversationId;
2994
5039
  }
2995
- responseForPrompt = undefined;
2996
- if (responseCandidates.length > 0) {
2997
- responseForPrompt = responseCandidates.sort((left, right) => right.length - left.length)[0];
5040
+ catch {
5041
+ return false;
2998
5042
  }
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;
5043
+ }, { previousConversationId: currentConversationId }, { timeout: 5_000 })
5044
+ .catch(() => { });
5045
+ await page.waitForTimeout(600);
5046
+ await waitForGrokSurface(page);
5047
+ }
5048
+ async function uploadGrokAttachments(page, attachments) {
5049
+ if (attachments.length === 0) {
5050
+ return { ok: true };
5051
+ }
5052
+ const uploadSelectors = [
5053
+ "input[type='file'][accept*='application/pdf']",
5054
+ "input[type='file'][accept*='text/csv']",
5055
+ "input[type='file'][accept*='text/plain']",
5056
+ "input[type='file']",
5057
+ ];
5058
+ let uploadSelector;
5059
+ for (const selector of uploadSelectors) {
5060
+ const handle = await page.waitForSelector(selector, { timeout: 1_200 }).catch(() => null);
5061
+ if (!handle) {
5062
+ continue;
3007
5063
  }
3008
- return {
3009
- responseForPrompt,
3010
- latestResponse,
3011
- };
5064
+ await handle.dispose().catch(() => { });
5065
+ uploadSelector = selector;
5066
+ break;
5067
+ }
5068
+ if (!uploadSelector) {
5069
+ return { ok: false, reason: "attachment_input_not_found" };
5070
+ }
5071
+ try {
5072
+ await page.setInputFiles(uploadSelector, attachments.map((attachment) => attachment.path));
5073
+ }
5074
+ catch {
5075
+ return { ok: false, reason: "attachment_upload_failed" };
5076
+ }
5077
+ const attachmentNames = attachments.map((attachment) => attachment.name);
5078
+ await page
5079
+ .waitForFunction(({ names }) => {
5080
+ const bodyText = document.body?.innerText ?? "";
5081
+ return names.every((name) => bodyText.includes(name));
5082
+ }, { names: attachmentNames }, { timeout: 10_000 })
5083
+ .catch(() => { });
5084
+ await page.waitForTimeout(600);
5085
+ return { ok: true };
5086
+ }
5087
+ async function askGrokViaNetwork(page, prompt, timeoutMs) {
5088
+ const captured = await captureRoutedResponseText(page, "https://grok.x.com/2/grok/add_response.json*", async () => {
5089
+ const submitResult = await submitGrokPrompt(page, prompt);
5090
+ return submitResult.ok;
3012
5091
  }, {
3013
- op: "grok_extract_state",
3014
- promptText: prompt,
3015
- previousText: previousResponse ?? "",
5092
+ timeoutMs,
3016
5093
  });
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 }) => {
5094
+ if (!captured || captured.status < 200 || captured.status >= 300) {
5095
+ return undefined;
5096
+ }
5097
+ const responseText = captured.text;
5098
+ const entries = parseNdjsonLines(responseText);
5099
+ const finalParts = collectTextByTag(entries.map((entry) => {
5100
+ const output = {};
5101
+ if (typeof entry.result?.message === "string") {
5102
+ output.message = entry.result.message;
5103
+ }
5104
+ if (typeof entry.result?.messageTag === "string") {
5105
+ output.messageTag = entry.result.messageTag;
5106
+ }
5107
+ return output;
5108
+ }), "final");
5109
+ let conversationId;
5110
+ for (const entry of entries) {
5111
+ if (!conversationId && typeof entry.conversationId === "string") {
5112
+ conversationId = entry.conversationId;
5113
+ }
5114
+ }
5115
+ const finalResponse = joinTextParts(finalParts).trim();
5116
+ if (!finalResponse) {
5117
+ return undefined;
5118
+ }
5119
+ const artifactResult = await materializeGrokArtifacts(finalResponse);
5120
+ const output = {
5121
+ ok: true,
5122
+ response: artifactResult.response,
5123
+ url: typeof conversationId === "string" ? `https://x.com/i/grok?conversation=${conversationId}` : page.url(),
5124
+ };
5125
+ if (typeof conversationId === "string") {
5126
+ output.conversationId = conversationId;
5127
+ }
5128
+ if (artifactResult.artifacts) {
5129
+ output.artifacts = artifactResult.artifacts;
5130
+ }
5131
+ return output;
5132
+ }
5133
+ async function waitForGrokResponse(page, previousResponse, prompt, timeoutMs) {
5134
+ const startedAt = Date.now();
5135
+ const hardDeadline = startedAt + Math.max(timeoutMs * 3, timeoutMs + 120_000);
5136
+ let idleDeadline = startedAt + timeoutMs;
5137
+ let previousSignature = "";
5138
+ while (Date.now() < hardDeadline && Date.now() < idleDeadline) {
5139
+ const state = await page.evaluate(({ op, promptText, previousText }) => {
3020
5140
  if (op !== "grok_extract_state") {
3021
5141
  return undefined;
3022
5142
  }
@@ -3042,10 +5162,19 @@ async function waitForGrokResponse(page, previousResponse, prompt, timeoutMs) {
3042
5162
  lower === normalizedPrompt.toLowerCase() ||
3043
5163
  lower === normalizedPrevious.toLowerCase());
3044
5164
  };
5165
+ const hasStopControl = Array.from(document.querySelectorAll("button")).some((button) => {
5166
+ const label = normalize(button.getAttribute("aria-label") ?? button.textContent ?? "").toLowerCase();
5167
+ return label.includes("stop");
5168
+ });
3045
5169
  const scope = document.querySelector("div[aria-label='Grok']") ??
3046
5170
  document.querySelector("main");
3047
5171
  if (!scope) {
3048
- return undefined;
5172
+ return {
5173
+ hasStopControl,
5174
+ responseForPrompt: undefined,
5175
+ latestResponse: undefined,
5176
+ signature: `stop:${String(hasStopControl)}`,
5177
+ };
3049
5178
  }
3050
5179
  const lines = (scope.innerText || scope.textContent || "")
3051
5180
  .split(/\n+/)
@@ -3062,11 +5191,9 @@ async function waitForGrokResponse(page, previousResponse, prompt, timeoutMs) {
3062
5191
  lineResponseCandidates.push(candidate);
3063
5192
  }
3064
5193
  }
3065
- if (lineResponseCandidates.length > 0) {
3066
- return {
3067
- responseForPrompt: lineResponseCandidates.sort((left, right) => right.length - left.length)[0],
3068
- };
3069
- }
5194
+ let responseForPrompt = lineResponseCandidates.length > 0
5195
+ ? lineResponseCandidates.sort((left, right) => right.length - left.length)[0]
5196
+ : undefined;
3070
5197
  const entries = [];
3071
5198
  for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
3072
5199
  if (node.closest("button, a, textarea, nav")) {
@@ -3089,44 +5216,69 @@ async function waitForGrokResponse(page, previousResponse, prompt, timeoutMs) {
3089
5216
  entries.push(text);
3090
5217
  }
3091
5218
  }
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;
5219
+ if (!responseForPrompt) {
5220
+ const promptIndex = entries.lastIndexOf(normalizedPrompt);
5221
+ if (promptIndex >= 0) {
5222
+ const responseCandidates = [];
5223
+ for (let index = promptIndex + 1; index < entries.length; index += 1) {
5224
+ const candidate = entries[index];
5225
+ if (!candidate || isIgnoredResponse(candidate)) {
5226
+ continue;
5227
+ }
5228
+ responseCandidates.push(candidate);
5229
+ }
5230
+ if (responseCandidates.length > 0) {
5231
+ responseForPrompt = responseCandidates.sort((left, right) => right.length - left.length)[0];
3099
5232
  }
3100
- responseCandidates.push(candidate);
3101
5233
  }
3102
5234
  }
3103
- let responseForPrompt;
3104
- if (responseCandidates.length > 0) {
3105
- responseForPrompt = responseCandidates.sort((left, right) => right.length - left.length)[0];
5235
+ let latestResponse;
5236
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
5237
+ const candidate = entries[index];
5238
+ if (!candidate || isIgnoredResponse(candidate)) {
5239
+ continue;
5240
+ }
5241
+ latestResponse = candidate;
5242
+ break;
3106
5243
  }
3107
5244
  return {
5245
+ hasStopControl,
3108
5246
  responseForPrompt,
5247
+ latestResponse,
5248
+ signature: JSON.stringify({
5249
+ hasStopControl,
5250
+ responseForPrompt: responseForPrompt ?? "",
5251
+ latestResponse: latestResponse ?? "",
5252
+ }),
3109
5253
  };
3110
5254
  }, {
3111
5255
  op: "grok_extract_state",
3112
5256
  promptText: prompt,
3113
5257
  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
- };
5258
+ }).catch(() => undefined);
5259
+ if (state && typeof state === "object") {
5260
+ const signature = typeof state.signature === "string" ? state.signature : "";
5261
+ if (signature && signature !== previousSignature) {
5262
+ previousSignature = signature;
5263
+ idleDeadline = Date.now() + timeoutMs;
5264
+ }
5265
+ if (state.hasStopControl === true) {
5266
+ idleDeadline = Date.now() + timeoutMs;
5267
+ }
5268
+ if (state.hasStopControl !== true &&
5269
+ typeof state.responseForPrompt === "string" &&
5270
+ state.responseForPrompt.length > 0) {
5271
+ return {
5272
+ confirmed: true,
5273
+ response: state.responseForPrompt,
5274
+ };
5275
+ }
5276
+ }
5277
+ const remainingIdleMs = idleDeadline - Date.now();
5278
+ const sleepMs = Math.min(1_000, Math.max(0, remainingIdleMs));
5279
+ if (sleepMs > 0) {
5280
+ await page.waitForTimeout(sleepMs);
3123
5281
  }
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
5282
  }
3131
5283
  return { confirmed: false };
3132
5284
  }
@@ -3147,7 +5299,11 @@ async function readLatestGrokResponse(page) {
3147
5299
  lower === "thinking" ||
3148
5300
  lower === "expert" ||
3149
5301
  lower.startsWith("grok") ||
3150
- lower.includes("explore "));
5302
+ lower.includes("explore ") ||
5303
+ lower.includes("discuss ") ||
5304
+ lower.includes("create images") ||
5305
+ lower.includes("edit image") ||
5306
+ lower.includes("latest news"));
3151
5307
  };
3152
5308
  const scope = document.querySelector("div[aria-label='Grok']") ??
3153
5309
  document.querySelector("main");
@@ -3158,20 +5314,13 @@ async function readLatestGrokResponse(page) {
3158
5314
  .split(/\n+/)
3159
5315
  .map((line) => normalize(line))
3160
5316
  .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
5317
  const entries = [];
3169
5318
  for (const node of Array.from(scope.querySelectorAll("div, span, p"))) {
3170
5319
  if (node.closest("button, a, textarea, nav")) {
3171
5320
  continue;
3172
5321
  }
3173
5322
  const text = normalize(node.innerText || node.textContent || "");
3174
- if (!text || isIgnoredResponse(text)) {
5323
+ if (!text) {
3175
5324
  continue;
3176
5325
  }
3177
5326
  const childWithSameText = Array.from(node.children).some((child) => {
@@ -3188,15 +5337,18 @@ async function readLatestGrokResponse(page) {
3188
5337
  }
3189
5338
  }
3190
5339
  let latestResponse;
3191
- for (let index = entries.length - 1; index >= 0; index -= 1) {
3192
- const candidate = entries[index];
5340
+ const responseCandidates = entries.length > 0 ? entries : lines;
5341
+ for (let index = responseCandidates.length - 1; index >= 0; index -= 1) {
5342
+ const candidate = responseCandidates[index];
3193
5343
  if (!candidate || isIgnoredResponse(candidate)) {
3194
5344
  continue;
3195
5345
  }
3196
5346
  latestResponse = candidate;
3197
5347
  break;
3198
5348
  }
3199
- return { latestResponse };
5349
+ return {
5350
+ latestResponse,
5351
+ };
3200
5352
  }, { op: "grok_extract_state" });
3201
5353
  if (state && typeof state === "object" && typeof state.latestResponse === "string") {
3202
5354
  return state.latestResponse;
@@ -3259,8 +5411,6 @@ async function askGrok(page, prompt, timeoutMs, attachments, conversationId) {
3259
5411
  }
3260
5412
  return output;
3261
5413
  }
3262
- await grokPage.goto("https://x.com/i/grok", { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => { });
3263
- await waitForGrokSurface(grokPage);
3264
5414
  const previousResponse = await readLatestGrokResponse(grokPage);
3265
5415
  logGrokPhase("previous_response_read", {
3266
5416
  hasPreviousResponse: previousResponse !== undefined,
@@ -3386,6 +5536,7 @@ async function requireAuthenticated(page) {
3386
5536
  export function createXAdapter(options) {
3387
5537
  const composeConfirmTimeoutMs = options?.composeConfirmTimeoutMs ?? DEFAULT_COMPOSE_CONFIRM_TIMEOUT_MS;
3388
5538
  const grokResponseTimeoutMs = options?.grokResponseTimeoutMs ?? DEFAULT_GROK_RESPONSE_TIMEOUT_MS;
5539
+ const articlePublishTimeoutMs = options?.articlePublishTimeoutMs ?? DEFAULT_ARTICLE_PUBLISH_TIMEOUT_MS;
3389
5540
  const maxPostLength = options?.maxPostLength ?? DEFAULT_MAX_POST_LENGTH;
3390
5541
  return {
3391
5542
  name: "adapter-x",
@@ -3642,6 +5793,20 @@ export function createXAdapter(options) {
3642
5793
  const dryRun = args.dryRun === true;
3643
5794
  return await replyToTweet(page, targetUrl, text, dryRun, composeConfirmTimeoutMs);
3644
5795
  }
5796
+ if (name === "tweet.delete") {
5797
+ const authCheck = await requireAuthenticated(page);
5798
+ if (!authCheck.ok) {
5799
+ return authCheck.result;
5800
+ }
5801
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5802
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5803
+ if (!url && !id) {
5804
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5805
+ }
5806
+ const targetUrl = url || `https://x.com/i/web/status/${encodeURIComponent(id)}`;
5807
+ const dryRun = args.dryRun === true;
5808
+ return await deleteTweetDetail(page, targetUrl, dryRun);
5809
+ }
3645
5810
  if (name === "grok.chat") {
3646
5811
  const authCheck = await requireAuthenticated(page);
3647
5812
  if (!authCheck.ok) {
@@ -3658,10 +5823,157 @@ export function createXAdapter(options) {
3658
5823
  const conversationId = typeof args.conversationId === "string" ? args.conversationId.trim() : "";
3659
5824
  return await askGrok(page, prompt, grokResponseTimeoutMs, resolvedAttachments.attachments, conversationId || undefined);
3660
5825
  }
5826
+ if (name === "article.get") {
5827
+ const authCheck = await requireAuthenticated(page);
5828
+ if (!authCheck.ok) {
5829
+ return authCheck.result;
5830
+ }
5831
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5832
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5833
+ const authorHandle = typeof args.authorHandle === "string" ? args.authorHandle.trim() : "";
5834
+ const targetUrl = normalizeArticleUrl(url, id);
5835
+ if (!targetUrl) {
5836
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5837
+ }
5838
+ return await readArticleByUrl(page, targetUrl, authorHandle || undefined);
5839
+ }
5840
+ if (name === "article.draftMarkdown") {
5841
+ const authCheck = await requireAuthenticated(page);
5842
+ if (!authCheck.ok) {
5843
+ return authCheck.result;
5844
+ }
5845
+ const markdownPath = typeof args.markdownPath === "string" ? args.markdownPath.trim() : "";
5846
+ if (!markdownPath) {
5847
+ return errorResult("VALIDATION_ERROR", "markdownPath is required");
5848
+ }
5849
+ const explicitTitle = typeof args.title === "string" ? args.title.trim() : "";
5850
+ const coverImagePath = typeof args.coverImagePath === "string" ? args.coverImagePath.trim() : "";
5851
+ return await draftArticleMarkdown(page, markdownPath, explicitTitle || undefined, coverImagePath || undefined);
5852
+ }
5853
+ if (name === "article.publishMarkdown") {
5854
+ const authCheck = await requireAuthenticated(page);
5855
+ if (!authCheck.ok) {
5856
+ return authCheck.result;
5857
+ }
5858
+ const markdownPath = typeof args.markdownPath === "string" ? args.markdownPath.trim() : "";
5859
+ if (!markdownPath) {
5860
+ return errorResult("VALIDATION_ERROR", "markdownPath is required");
5861
+ }
5862
+ const explicitTitle = typeof args.title === "string" ? args.title.trim() : "";
5863
+ const coverImagePath = typeof args.coverImagePath === "string" ? args.coverImagePath.trim() : "";
5864
+ const dryRun = args.dryRun === true;
5865
+ return await publishArticleMarkdown(page, markdownPath, explicitTitle || undefined, coverImagePath || undefined, dryRun, articlePublishTimeoutMs);
5866
+ }
5867
+ if (name === "article.publish") {
5868
+ const authCheck = await requireAuthenticated(page);
5869
+ if (!authCheck.ok) {
5870
+ return authCheck.result;
5871
+ }
5872
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5873
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5874
+ const articleId = id || (url ? parseArticleIdFromUrl(url) : undefined);
5875
+ if (!articleId && !url) {
5876
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5877
+ }
5878
+ const targetUrl = url && url.includes("/compose/articles/edit/")
5879
+ ? url
5880
+ : articleId
5881
+ ? `https://x.com/compose/articles/edit/${articleId}`
5882
+ : url;
5883
+ if (!targetUrl) {
5884
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5885
+ }
5886
+ return await publishExistingArticle(page, targetUrl, articlePublishTimeoutMs);
5887
+ }
5888
+ if (name === "article.setCoverImage") {
5889
+ const authCheck = await requireAuthenticated(page);
5890
+ if (!authCheck.ok) {
5891
+ return authCheck.result;
5892
+ }
5893
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5894
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5895
+ const articleId = id || (url ? parseArticleIdFromUrl(url) : undefined);
5896
+ if (!articleId && !url) {
5897
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5898
+ }
5899
+ const coverImagePath = typeof args.coverImagePath === "string" ? args.coverImagePath.trim() : "";
5900
+ if (!coverImagePath) {
5901
+ return errorResult("VALIDATION_ERROR", "coverImagePath is required");
5902
+ }
5903
+ const targetUrl = url && url.includes("/compose/articles/edit/")
5904
+ ? url
5905
+ : articleId
5906
+ ? `https://x.com/compose/articles/edit/${articleId}`
5907
+ : url;
5908
+ if (!targetUrl) {
5909
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5910
+ }
5911
+ return await setArticleCoverImage(page, targetUrl, coverImagePath);
5912
+ }
5913
+ if (name === "article.updateMarkdown") {
5914
+ const authCheck = await requireAuthenticated(page);
5915
+ if (!authCheck.ok) {
5916
+ return authCheck.result;
5917
+ }
5918
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5919
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5920
+ const articleId = id || (url ? parseArticleIdFromUrl(url) : undefined);
5921
+ if (!articleId && !url) {
5922
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5923
+ }
5924
+ const markdownPath = typeof args.markdownPath === "string" ? args.markdownPath.trim() : "";
5925
+ if (!markdownPath) {
5926
+ return errorResult("VALIDATION_ERROR", "markdownPath is required");
5927
+ }
5928
+ const explicitTitle = typeof args.title === "string" ? args.title.trim() : "";
5929
+ const targetUrl = url && url.includes("/compose/articles/edit/")
5930
+ ? url
5931
+ : articleId
5932
+ ? `https://x.com/compose/articles/edit/${articleId}`
5933
+ : url;
5934
+ if (!targetUrl) {
5935
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5936
+ }
5937
+ return await updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitle || undefined);
5938
+ }
5939
+ if (name === "article.delete") {
5940
+ const authCheck = await requireAuthenticated(page);
5941
+ if (!authCheck.ok) {
5942
+ return authCheck.result;
5943
+ }
5944
+ const url = typeof args.url === "string" ? args.url.trim() : "";
5945
+ const id = typeof args.id === "string" ? args.id.trim() : "";
5946
+ const dryRun = args.dryRun === true;
5947
+ const articleId = id || (url ? parseArticleIdFromUrl(url) : undefined);
5948
+ if (!articleId && !url) {
5949
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5950
+ }
5951
+ const targetUrl = url && url.includes("/compose/articles/edit/")
5952
+ ? url
5953
+ : articleId
5954
+ ? `https://x.com/compose/articles/edit/${articleId}`
5955
+ : url;
5956
+ if (!targetUrl) {
5957
+ return errorResult("VALIDATION_ERROR", "url or id is required");
5958
+ }
5959
+ const cachedPage = articleId ? getCachedArticleDraftPage(page, articleId) : undefined;
5960
+ if (cachedPage) {
5961
+ const result = await deleteArticleEditor(cachedPage, dryRun);
5962
+ if (!dryRun && articleId && (!result || typeof result !== "object" || Array.isArray(result) || !("error" in result))) {
5963
+ await removeCachedArticleDraftPage(page, articleId);
5964
+ }
5965
+ return result;
5966
+ }
5967
+ return await withEphemeralPage(page, targetUrl, async (articlePage) => {
5968
+ await waitForArticleEditorSurface(articlePage);
5969
+ return await deleteArticleEditor(articlePage, dryRun);
5970
+ });
5971
+ }
3661
5972
  return errorResult("TOOL_NOT_FOUND", `unknown tool: ${name}`);
3662
5973
  },
3663
5974
  stop: async ({ page }) => {
3664
5975
  await closeCachedReadPages(page);
5976
+ await closeCachedArticleDraftPages(page);
3665
5977
  },
3666
5978
  };
3667
5979
  }