@webmcp-bridge/adapter-x 0.5.1 → 0.5.3

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
@@ -480,6 +480,43 @@ const TOOL_DEFINITIONS = [
480
480
  readOnlyHint: true,
481
481
  },
482
482
  },
483
+ {
484
+ name: "article.listDrafts",
485
+ description: "List existing X article drafts from the authenticated account",
486
+ inputSchema: {
487
+ type: "object",
488
+ description: "List article drafts owned by the authenticated X account.",
489
+ properties: {},
490
+ additionalProperties: false,
491
+ },
492
+ annotations: {
493
+ readOnlyHint: true,
494
+ },
495
+ },
496
+ {
497
+ name: "article.getDraft",
498
+ description: "Read one X article draft by id, preview url, or edit url",
499
+ inputSchema: {
500
+ type: "object",
501
+ description: "Fetch one draft from the X article editor using article id, preview URL, or edit URL.",
502
+ properties: {
503
+ url: {
504
+ type: "string",
505
+ description: "Draft preview URL or edit URL.",
506
+ minLength: 1,
507
+ },
508
+ id: {
509
+ type: "string",
510
+ description: "Draft article id. Used when url is not provided.",
511
+ minLength: 1,
512
+ },
513
+ },
514
+ additionalProperties: false,
515
+ },
516
+ annotations: {
517
+ readOnlyHint: true,
518
+ },
519
+ },
483
520
  {
484
521
  name: "article.draftMarkdown",
485
522
  description: "Create one X article draft from a local markdown file",
@@ -509,6 +546,45 @@ const TOOL_DEFINITIONS = [
509
546
  additionalProperties: false,
510
547
  },
511
548
  },
549
+ {
550
+ name: "article.upsertDraftMarkdown",
551
+ description: "Create or update one X article draft from a local markdown file",
552
+ inputSchema: {
553
+ type: "object",
554
+ description: "Create a new draft when id/url is omitted, or update the existing draft identified by id, preview URL, or edit URL.",
555
+ properties: {
556
+ url: {
557
+ type: "string",
558
+ description: "Draft preview URL or edit URL.",
559
+ minLength: 1,
560
+ },
561
+ id: {
562
+ type: "string",
563
+ description: "Draft article id. Used when url is not provided.",
564
+ minLength: 1,
565
+ },
566
+ markdownPath: {
567
+ type: "string",
568
+ description: "Absolute local file path to the markdown file to apply.",
569
+ minLength: 1,
570
+ "x-uxc-kind": "file-path",
571
+ },
572
+ title: {
573
+ type: "string",
574
+ description: "Optional title override. When omitted, the first markdown heading becomes the article title.",
575
+ minLength: 1,
576
+ },
577
+ coverImagePath: {
578
+ type: "string",
579
+ description: "Optional absolute local image path for the article cover image when creating a new draft.",
580
+ minLength: 1,
581
+ "x-uxc-kind": "file-path",
582
+ },
583
+ },
584
+ required: ["markdownPath"],
585
+ additionalProperties: false,
586
+ },
587
+ },
512
588
  {
513
589
  name: "article.publishMarkdown",
514
590
  description: "Publish one X article from a local markdown file",
@@ -1001,6 +1077,215 @@ function extractArticleTitle(markdown, markdownPath, explicitTitle) {
1001
1077
  }
1002
1078
  return basename(markdownPath, extname(markdownPath)).trim() || "Untitled";
1003
1079
  }
1080
+ function normalizeArticleTitleForComparison(value) {
1081
+ return value
1082
+ .normalize("NFKC")
1083
+ .toLowerCase()
1084
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
1085
+ .trim();
1086
+ }
1087
+ function normalizeArticleMarkdown(markdown, markdownPath, explicitTitle) {
1088
+ const normalized = markdown.replace(/\r\n/g, "\n");
1089
+ const lines = normalized.split("\n");
1090
+ const explicit = typeof explicitTitle === "string" ? explicitTitle.trim() : "";
1091
+ const derivedTitle = extractArticleTitle(normalized, markdownPath, explicitTitle);
1092
+ const comparisonTitle = normalizeArticleTitleForComparison(derivedTitle);
1093
+ const output = [];
1094
+ let insideFence = false;
1095
+ let firstH1Handled = false;
1096
+ for (const line of lines) {
1097
+ if (/^\s*```/.test(line)) {
1098
+ insideFence = !insideFence;
1099
+ output.push(line);
1100
+ continue;
1101
+ }
1102
+ if (insideFence) {
1103
+ output.push(line);
1104
+ continue;
1105
+ }
1106
+ const match = line.match(/^(\s*)#\s+(.+?)\s*$/);
1107
+ if (!match) {
1108
+ output.push(line);
1109
+ continue;
1110
+ }
1111
+ const headingText = (match[2] ?? "").trim();
1112
+ if (!firstH1Handled) {
1113
+ firstH1Handled = true;
1114
+ if (!explicit || normalizeArticleTitleForComparison(headingText) === comparisonTitle) {
1115
+ continue;
1116
+ }
1117
+ }
1118
+ output.push(`${match[1]}## ${headingText}`);
1119
+ }
1120
+ const bodyMarkdown = output.join("\n").replace(/^\s*\n/, "").trim();
1121
+ return {
1122
+ title: derivedTitle,
1123
+ bodyMarkdown,
1124
+ };
1125
+ }
1126
+ function escapeHtml(value) {
1127
+ return value
1128
+ .replaceAll("&", "&")
1129
+ .replaceAll("<", "&lt;")
1130
+ .replaceAll(">", "&gt;")
1131
+ .replaceAll('"', "&quot;")
1132
+ .replaceAll("'", "&#39;");
1133
+ }
1134
+ function convertMarkdownInlineToHtml(value) {
1135
+ const placeholders = [];
1136
+ const reserve = (html) => {
1137
+ const token = `__WEBMCP_HTML_${placeholders.length}__`;
1138
+ placeholders.push(html);
1139
+ return token;
1140
+ };
1141
+ let rendered = value
1142
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, altRaw, destinationRaw) => {
1143
+ const alt = escapeHtml(altRaw.trim());
1144
+ const destination = escapeHtml(stripMarkdownImageDestination(destinationRaw));
1145
+ return reserve(`<img src="${destination}" alt="${alt}">`);
1146
+ })
1147
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, textRaw, hrefRaw) => {
1148
+ const text = escapeHtml(textRaw.trim() || hrefRaw.trim());
1149
+ const href = escapeHtml(hrefRaw.trim());
1150
+ return reserve(`<a href="${href}">${text}</a>`);
1151
+ })
1152
+ .replace(/`([^`]+)`/g, (_match, codeRaw) => reserve(`<code>${escapeHtml(codeRaw)}</code>`))
1153
+ .replace(/\*\*([^*]+)\*\*/g, (_match, textRaw) => reserve(`<strong>${escapeHtml(textRaw)}</strong>`))
1154
+ .replace(/\*([^*]+)\*/g, (_match, textRaw) => reserve(`<em>${escapeHtml(textRaw)}</em>`));
1155
+ rendered = escapeHtml(rendered);
1156
+ return rendered.replace(/__WEBMCP_HTML_(\d+)__/g, (_match, indexRaw) => {
1157
+ const index = Number.parseInt(indexRaw, 10);
1158
+ return placeholders[index] ?? "";
1159
+ });
1160
+ }
1161
+ function convertMarkdownToHtml(markdown) {
1162
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
1163
+ const blocks = [];
1164
+ let index = 0;
1165
+ const flushParagraph = (paragraphLines) => {
1166
+ if (paragraphLines.length === 0) {
1167
+ return;
1168
+ }
1169
+ const text = paragraphLines.join(" ").trim();
1170
+ if (!text) {
1171
+ return;
1172
+ }
1173
+ blocks.push(`<p>${convertMarkdownInlineToHtml(text)}</p>`);
1174
+ };
1175
+ while (index < lines.length) {
1176
+ const line = lines[index] ?? "";
1177
+ const trimmed = line.trim();
1178
+ if (!trimmed) {
1179
+ index += 1;
1180
+ continue;
1181
+ }
1182
+ const fenceMatch = trimmed.match(/^```([^`]*)$/);
1183
+ if (fenceMatch) {
1184
+ const language = escapeHtml(fenceMatch[1]?.trim() ?? "");
1185
+ const codeLines = [];
1186
+ index += 1;
1187
+ while (index < lines.length && !lines[index].trim().startsWith("```")) {
1188
+ codeLines.push(lines[index]);
1189
+ index += 1;
1190
+ }
1191
+ if (index < lines.length) {
1192
+ index += 1;
1193
+ }
1194
+ const code = escapeHtml(codeLines.join("\n"));
1195
+ blocks.push(language ? `<pre><code class="language-${language}">${code}</code></pre>` : `<pre><code>${code}</code></pre>`);
1196
+ continue;
1197
+ }
1198
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
1199
+ if (headingMatch) {
1200
+ const level = Math.min(6, headingMatch[1].length);
1201
+ const content = convertMarkdownInlineToHtml(headingMatch[2].trim());
1202
+ blocks.push(`<h${level}>${content}</h${level}>`);
1203
+ index += 1;
1204
+ continue;
1205
+ }
1206
+ const bulletMatch = trimmed.match(/^[-*+]\s+(.+)$/);
1207
+ if (bulletMatch) {
1208
+ const items = [];
1209
+ while (index < lines.length) {
1210
+ const itemLine = lines[index].trim();
1211
+ const match = itemLine.match(/^[-*+]\s+(.+)$/);
1212
+ if (!match) {
1213
+ break;
1214
+ }
1215
+ items.push(`<li>${convertMarkdownInlineToHtml(match[1].trim())}</li>`);
1216
+ index += 1;
1217
+ }
1218
+ blocks.push(`<ul>${items.join("")}</ul>`);
1219
+ continue;
1220
+ }
1221
+ const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
1222
+ if (orderedMatch) {
1223
+ const items = [];
1224
+ while (index < lines.length) {
1225
+ const itemLine = lines[index].trim();
1226
+ const match = itemLine.match(/^\d+\.\s+(.+)$/);
1227
+ if (!match) {
1228
+ break;
1229
+ }
1230
+ items.push(`<li>${convertMarkdownInlineToHtml(match[1].trim())}</li>`);
1231
+ index += 1;
1232
+ }
1233
+ blocks.push(`<ol>${items.join("")}</ol>`);
1234
+ continue;
1235
+ }
1236
+ const paragraphLines = [];
1237
+ while (index < lines.length) {
1238
+ const paragraphLine = lines[index] ?? "";
1239
+ const paragraphTrimmed = paragraphLine.trim();
1240
+ if (!paragraphTrimmed) {
1241
+ break;
1242
+ }
1243
+ if (/^```/.test(paragraphTrimmed) || /^(#{1,6})\s+/.test(paragraphTrimmed) || /^[-*+]\s+/.test(paragraphTrimmed) || /^\d+\.\s+/.test(paragraphTrimmed)) {
1244
+ break;
1245
+ }
1246
+ paragraphLines.push(paragraphLine.trim());
1247
+ index += 1;
1248
+ }
1249
+ flushParagraph(paragraphLines);
1250
+ }
1251
+ return blocks.join("\n");
1252
+ }
1253
+ function convertMarkdownLineToPlainText(line) {
1254
+ return line
1255
+ .replace(/^#{1,6}\s+/, "")
1256
+ .replace(/^[-*+]\s+/, "")
1257
+ .replace(/^\d+\.\s+/, "")
1258
+ .replace(/^>\s+/, "")
1259
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")
1260
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
1261
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1262
+ .replace(/\*([^*]+)\*/g, "$1")
1263
+ .replace(/`([^`]+)`/g, "$1")
1264
+ .replace(/\s+/g, " ")
1265
+ .trim();
1266
+ }
1267
+ function extractArticleConfirmationSnippets(markdown) {
1268
+ const snippets = [];
1269
+ let insideFence = false;
1270
+ for (const rawLine of markdown.replace(/\r\n/g, "\n").split("\n")) {
1271
+ const trimmed = rawLine.trim();
1272
+ if (!trimmed) {
1273
+ continue;
1274
+ }
1275
+ if (/^```/.test(trimmed)) {
1276
+ insideFence = !insideFence;
1277
+ continue;
1278
+ }
1279
+ const normalized = insideFence ? trimmed : convertMarkdownLineToPlainText(trimmed);
1280
+ if (normalized) {
1281
+ snippets.push(normalized);
1282
+ }
1283
+ if (snippets.length >= 3) {
1284
+ break;
1285
+ }
1286
+ }
1287
+ return snippets;
1288
+ }
1004
1289
  function prepareArticleMarkdown(markdown, markdownPath) {
1005
1290
  const inlineImages = [];
1006
1291
  let nextIndex = 1;
@@ -1026,6 +1311,7 @@ function prepareArticleMarkdown(markdown, markdownPath) {
1026
1311
  });
1027
1312
  return {
1028
1313
  markdown: prepared,
1314
+ html: convertMarkdownToHtml(prepared),
1029
1315
  inlineImages,
1030
1316
  };
1031
1317
  }
@@ -3255,37 +3541,53 @@ async function setArticleTitle(page, title) {
3255
3541
  .then(() => true)
3256
3542
  .catch(() => false);
3257
3543
  }
3258
- async function pasteArticleMarkdown(page, markdown) {
3259
- let success = false;
3544
+ async function pasteArticleMarkdown(page, markdown, html) {
3545
+ let pasted = false;
3260
3546
  if (typeof page.locator === "function") {
3261
3547
  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 }) => {
3548
+ const clicked = await composerLocator.click().then(() => true).catch(() => false);
3549
+ if (clicked) {
3550
+ const wroteClipboard = await page.evaluate(async ({ plainText, htmlText }) => {
3265
3551
  try {
3266
- await navigator.clipboard.writeText(value);
3552
+ const ClipboardItemCtor = window.ClipboardItem;
3553
+ if (typeof navigator.clipboard?.write === "function" && ClipboardItemCtor) {
3554
+ const items = {
3555
+ "text/plain": new Blob([plainText], { type: "text/plain" }),
3556
+ };
3557
+ if (typeof htmlText === "string" && htmlText.trim().length > 0) {
3558
+ items["text/html"] = new Blob([htmlText], { type: "text/html" });
3559
+ }
3560
+ await navigator.clipboard.write([new ClipboardItemCtor(items)]);
3561
+ }
3562
+ else if (typeof navigator.clipboard?.writeText === "function") {
3563
+ await navigator.clipboard.writeText(plainText);
3564
+ }
3565
+ else {
3566
+ return false;
3567
+ }
3267
3568
  return true;
3268
3569
  }
3269
3570
  catch {
3270
3571
  return false;
3271
3572
  }
3272
- }, { value: markdown }).catch(() => false);
3573
+ }, { plainText: markdown, htmlText: html }).catch(() => false);
3273
3574
  if (wroteClipboard) {
3274
- success = await page.keyboard.press("Meta+V").then(() => true).catch(() => false);
3575
+ const pasteShortcut = process.platform === "darwin" ? "Meta+V" : "Control+V";
3576
+ pasted = await page.keyboard.press(pasteShortcut).then(() => true).catch(() => false);
3275
3577
  }
3276
- if (!success) {
3578
+ if (!pasted) {
3277
3579
  const keyboard = page.keyboard;
3278
3580
  if (typeof keyboard.insertText === "function") {
3279
- success = await keyboard.insertText(markdown).then(() => true).catch(() => false);
3581
+ pasted = await keyboard.insertText(markdown).then(() => true).catch(() => false);
3280
3582
  }
3281
3583
  else if (typeof keyboard.type === "function") {
3282
- success = await keyboard.type(markdown).then(() => true).catch(() => false);
3584
+ pasted = await keyboard.type(markdown).then(() => true).catch(() => false);
3283
3585
  }
3284
3586
  }
3285
3587
  }
3286
3588
  }
3287
- if (!success) {
3288
- success = await page.evaluate(({ op, markdownText }) => {
3589
+ if (!pasted) {
3590
+ pasted = await page.evaluate(({ op, markdownText, htmlText }) => {
3289
3591
  if (op !== "article_paste_markdown") {
3290
3592
  return false;
3291
3593
  }
@@ -3297,6 +3599,9 @@ async function pasteArticleMarkdown(page, markdown) {
3297
3599
  const data = new DataTransfer();
3298
3600
  data.setData("text/plain", markdownText);
3299
3601
  data.setData("text/markdown", markdownText);
3602
+ if (typeof htmlText === "string" && htmlText.trim().length > 0) {
3603
+ data.setData("text/html", htmlText);
3604
+ }
3300
3605
  const event = new ClipboardEvent("paste", {
3301
3606
  bubbles: true,
3302
3607
  cancelable: true,
@@ -3304,16 +3609,12 @@ async function pasteArticleMarkdown(page, markdown) {
3304
3609
  });
3305
3610
  composer.dispatchEvent(event);
3306
3611
  return true;
3307
- }, { op: "article_paste_markdown", markdownText: markdown }).catch(() => false);
3612
+ }, { op: "article_paste_markdown", markdownText: markdown, htmlText: html }).catch(() => false);
3308
3613
  }
3309
- if (!success) {
3614
+ if (!pasted) {
3310
3615
  return false;
3311
3616
  }
3312
- const requiredSnippets = markdown
3313
- .split(/\n+/)
3314
- .map((line) => line.trim())
3315
- .filter((line) => line.length > 0)
3316
- .slice(0, 3);
3617
+ const requiredSnippets = extractArticleConfirmationSnippets(markdown);
3317
3618
  if (requiredSnippets.length === 0) {
3318
3619
  return true;
3319
3620
  }
@@ -3422,6 +3723,29 @@ async function uploadArticleFile(page, filePath) {
3422
3723
  return false;
3423
3724
  }
3424
3725
  }
3726
+ async function waitForArticleCoverApplied(page) {
3727
+ return await page
3728
+ .waitForFunction(() => {
3729
+ const hasRemoveControl = Array.from(document.querySelectorAll("button, div[role='button']")).some((element) => {
3730
+ const aria = (element.getAttribute("aria-label") || "").toLowerCase();
3731
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
3732
+ return aria.includes("remove photo") || text === "remove photo";
3733
+ });
3734
+ const coverImages = Array.from((document.querySelector("main") ?? document.body).querySelectorAll("img[src]"))
3735
+ .filter((img) => !img.closest("nav, header, aside"))
3736
+ .map((img) => ({
3737
+ url: img.currentSrc || img.src,
3738
+ width: img.naturalWidth || 0,
3739
+ height: img.naturalHeight || 0,
3740
+ }))
3741
+ .filter((item) => /^https?:\/\//.test(item.url))
3742
+ .filter((item) => !/\/profile_images\/|\/emoji\/|\/hashflags\//.test(item.url))
3743
+ .filter((item) => item.width >= 200 || item.height >= 120);
3744
+ return hasRemoveControl && coverImages.length > 0;
3745
+ }, undefined, { timeout: 15_000 })
3746
+ .then(() => true)
3747
+ .catch(() => false);
3748
+ }
3425
3749
  async function placeArticleCursorAtMarker(page, marker) {
3426
3750
  return ((await page.evaluate(({ op, markerText }) => {
3427
3751
  if (op !== "article_place_marker") {
@@ -3519,9 +3843,24 @@ function parseArticleIdFromUrl(url) {
3519
3843
  if (!ALLOWED_X_HOSTS.has(parsed.hostname.toLowerCase())) {
3520
3844
  return undefined;
3521
3845
  }
3522
- const match = parsed.pathname.match(/^\/(?:compose\/articles\/edit|i\/article|i\/articles|[^/]+\/article|articles)\/(\d+)(?:\/|$)/);
3846
+ const match = parsed.pathname.match(/^\/(?:compose\/articles\/edit|i\/article|i\/articles|[^/]+\/article|articles)\/(\d+)(?:\/preview)?(?:\/|$)/);
3523
3847
  return match?.[1];
3524
3848
  }
3849
+ function buildArticleEditUrl(articleId) {
3850
+ return `https://x.com/compose/articles/edit/${encodeURIComponent(articleId)}`;
3851
+ }
3852
+ function buildArticlePreviewUrl(articleId) {
3853
+ return `https://x.com/i/articles/${encodeURIComponent(articleId)}/preview`;
3854
+ }
3855
+ function isArticlePreviewUrl(url) {
3856
+ try {
3857
+ const parsed = new URL(url, "https://x.com");
3858
+ return /\/preview(?:\/|$)/.test(parsed.pathname) && parseArticleIdFromUrl(parsed.toString()) !== undefined;
3859
+ }
3860
+ catch {
3861
+ return false;
3862
+ }
3863
+ }
3525
3864
  async function waitForCapturedOperation(page, op, timeoutMs = 10_000) {
3526
3865
  await page.waitForFunction(({ targetOp }) => {
3527
3866
  const globalAny = window;
@@ -3566,7 +3905,9 @@ async function readArticleFromEditorPage(page, articleId, sessionScoped) {
3566
3905
  normalize(document.querySelector("h1")?.innerText || "");
3567
3906
  const composer = document.querySelector("[data-testid='composer'][role='textbox']");
3568
3907
  const rawText = (composer?.innerText || composer?.textContent || "").trim();
3569
- const images = Array.from(document.querySelectorAll("img[src]"))
3908
+ const editorRoot = document.querySelector("main") ?? document.body;
3909
+ const images = Array.from(editorRoot.querySelectorAll("img[src]"))
3910
+ .filter((img) => !img.closest("nav, header, aside, [data-testid='SideNav_AccountSwitcher_Button']"))
3570
3911
  .map((img) => ({
3571
3912
  url: img.currentSrc || img.src,
3572
3913
  alt: normalize(img.alt || ""),
@@ -3574,7 +3915,8 @@ async function readArticleFromEditorPage(page, articleId, sessionScoped) {
3574
3915
  height: img.naturalHeight || 0,
3575
3916
  }))
3576
3917
  .filter((item) => /^https?:\/\//.test(item.url))
3577
- .filter((item) => item.width > 64 || item.height > 64);
3918
+ .filter((item) => item.width > 64 || item.height > 64)
3919
+ .filter((item) => !/\/profile_images\/|\/emoji\/|\/hashflags\//.test(item.url));
3578
3920
  const deduped = new Map();
3579
3921
  for (const image of images) {
3580
3922
  if (!deduped.has(image.url)) {
@@ -3609,19 +3951,270 @@ async function readArticleFromEditorPage(page, articleId, sessionScoped) {
3609
3951
  editUrl: typeof article.editUrl === "string" ? article.editUrl : page.url(),
3610
3952
  images: inlineImages,
3611
3953
  source: "editor",
3954
+ published: false,
3612
3955
  },
3613
3956
  };
3957
+ if (articleId) {
3958
+ output.article.previewUrl = buildArticlePreviewUrl(articleId);
3959
+ }
3614
3960
  if (coverImage && typeof coverImage === "object" && coverImage !== null && "url" in coverImage) {
3615
3961
  output.article.coverImageUrl = coverImage.url;
3962
+ output.article.hasCoverImage = true;
3963
+ }
3964
+ else {
3965
+ output.article.hasCoverImage = false;
3616
3966
  }
3617
3967
  if (articleId) {
3618
3968
  output.article.sessionScoped = sessionScoped === true;
3619
3969
  }
3620
- if (sessionScoped === true) {
3621
- output.article.published = false;
3622
- }
3623
3970
  return output;
3624
3971
  }
3972
+ async function listArticleDrafts(page) {
3973
+ return await withEphemeralPage(page, "https://x.com/compose/articles", async (articlePage) => {
3974
+ await articlePage.waitForTimeout(1_000);
3975
+ await waitForCapturedOperation(articlePage, "ArticleEntitiesSlice", 12_000);
3976
+ const result = await articlePage.evaluate(async ({ op }) => {
3977
+ if (op !== "article_list_drafts") {
3978
+ return undefined;
3979
+ }
3980
+ const normalizeInline = (value) => value.replace(/\s+/g, " ").trim();
3981
+ const parseJsonSafely = (value) => {
3982
+ if (!value) {
3983
+ return {};
3984
+ }
3985
+ try {
3986
+ const parsed = JSON.parse(value);
3987
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
3988
+ }
3989
+ catch {
3990
+ return {};
3991
+ }
3992
+ };
3993
+ const toHttpsImage = (value) => typeof value === "string" && /^https?:\/\//.test(value) ? value : undefined;
3994
+ const sanitizeHeaders = (headers) => {
3995
+ const blockedPrefixes = ["sec-", ":"];
3996
+ const blockedExact = new Set(["host", "content-length", "cookie", "origin", "referer", "connection"]);
3997
+ const output = {};
3998
+ if (!headers) {
3999
+ return output;
4000
+ }
4001
+ for (const [key, value] of Object.entries(headers)) {
4002
+ const normalized = key.toLowerCase();
4003
+ if (blockedExact.has(normalized) || blockedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
4004
+ continue;
4005
+ }
4006
+ output[normalized] = value;
4007
+ }
4008
+ return output;
4009
+ };
4010
+ const extractUpdatedAt = (record) => {
4011
+ const metadata = record.metadata ?? {};
4012
+ const candidates = [
4013
+ metadata.updated_at_secs,
4014
+ metadata.last_edited_at_secs,
4015
+ metadata.last_updated_at_secs,
4016
+ metadata.created_at_secs,
4017
+ metadata.first_published_at_secs,
4018
+ ];
4019
+ for (const candidate of candidates) {
4020
+ if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
4021
+ return new Date(candidate * 1000).toISOString();
4022
+ }
4023
+ }
4024
+ return undefined;
4025
+ };
4026
+ const extractNextCursor = (sliceInfo) => {
4027
+ if (!sliceInfo || typeof sliceInfo !== "object" || Array.isArray(sliceInfo)) {
4028
+ return undefined;
4029
+ }
4030
+ const record = sliceInfo;
4031
+ const keys = ["next_cursor", "nextCursor", "cursor", "bottom_cursor"];
4032
+ for (const key of keys) {
4033
+ if (typeof record[key] === "string" && record[key]) {
4034
+ return record[key];
4035
+ }
4036
+ }
4037
+ return undefined;
4038
+ };
4039
+ const globalAny = window;
4040
+ const entries = Array.isArray(globalAny.__WEBMCP_X_CAPTURE__?.entries)
4041
+ ? globalAny.__WEBMCP_X_CAPTURE__?.entries ?? []
4042
+ : [];
4043
+ const template = (() => {
4044
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
4045
+ const entry = entries[i];
4046
+ if (!entry || entry.op !== "ArticleEntitiesSlice" || !entry.url || !entry.method) {
4047
+ continue;
4048
+ }
4049
+ return {
4050
+ url: entry.url,
4051
+ method: entry.method,
4052
+ headers: entry.headers ?? {},
4053
+ };
4054
+ }
4055
+ return null;
4056
+ })();
4057
+ if (!template) {
4058
+ return { drafts: [] };
4059
+ }
4060
+ const templateUrl = new URL(template.url, location.origin);
4061
+ const templateVariables = parseJsonSafely(templateUrl.searchParams.get("variables"));
4062
+ const templateFeatures = parseJsonSafely(templateUrl.searchParams.get("features"));
4063
+ const headers = sanitizeHeaders(template.headers);
4064
+ const userId = typeof templateVariables.userId === "string" ? templateVariables.userId : "";
4065
+ if (!userId) {
4066
+ return { drafts: [] };
4067
+ }
4068
+ const fetchSlice = async (cursor) => {
4069
+ const vars = {
4070
+ ...templateVariables,
4071
+ userId,
4072
+ lifecycle: "Draft",
4073
+ count: 20,
4074
+ };
4075
+ if (cursor) {
4076
+ vars.cursor = cursor;
4077
+ }
4078
+ else {
4079
+ delete vars.cursor;
4080
+ }
4081
+ const requestUrl = new URL(template.url, location.origin);
4082
+ requestUrl.searchParams.set("variables", JSON.stringify(vars));
4083
+ if (Object.keys(templateFeatures).length > 0) {
4084
+ requestUrl.searchParams.set("features", JSON.stringify(templateFeatures));
4085
+ }
4086
+ const response = await fetch(requestUrl.toString(), {
4087
+ method: template.method,
4088
+ headers,
4089
+ credentials: "include",
4090
+ });
4091
+ if (!response.ok) {
4092
+ throw new Error(`http_${response.status}`);
4093
+ }
4094
+ return await response.json();
4095
+ };
4096
+ const drafts = [];
4097
+ let cursor;
4098
+ for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
4099
+ let responseJson;
4100
+ try {
4101
+ responseJson = await fetchSlice(cursor);
4102
+ }
4103
+ catch {
4104
+ break;
4105
+ }
4106
+ const slice = responseJson?.data?.user;
4107
+ const result = slice?.result?.articles_article_mixer_slice;
4108
+ const items = Array.isArray(result?.items) ? result.items : [];
4109
+ for (const rawItem of items) {
4110
+ if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) {
4111
+ continue;
4112
+ }
4113
+ const item = rawItem;
4114
+ const articleResult = item.article_entity_results?.result;
4115
+ if (!articleResult) {
4116
+ continue;
4117
+ }
4118
+ const restId = typeof articleResult.rest_id === "string" ? articleResult.rest_id : "";
4119
+ if (!restId) {
4120
+ continue;
4121
+ }
4122
+ const coverMedia = articleResult.cover_media;
4123
+ const hasCoverImage = toHttpsImage(coverMedia?.original_img_url) ??
4124
+ toHttpsImage(coverMedia?.media_url_https) ??
4125
+ toHttpsImage(coverMedia?.media_url);
4126
+ drafts.push({
4127
+ id: restId,
4128
+ title: typeof articleResult.title === "string" ? normalizeInline(articleResult.title) : "",
4129
+ updatedAt: extractUpdatedAt(articleResult),
4130
+ hasCoverImage: Boolean(hasCoverImage),
4131
+ editUrl: buildArticleEditUrl(restId),
4132
+ previewUrl: buildArticlePreviewUrl(restId),
4133
+ });
4134
+ }
4135
+ cursor = extractNextCursor(result?.slice_info);
4136
+ if (!cursor) {
4137
+ break;
4138
+ }
4139
+ }
4140
+ return { drafts };
4141
+ }, { op: "article_list_drafts" }).catch(() => undefined);
4142
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
4143
+ return errorResult("UPSTREAM_CHANGED", "article drafts could not be listed");
4144
+ }
4145
+ const drafts = Array.isArray(result.drafts)
4146
+ ? result.drafts
4147
+ : [];
4148
+ const normalizedDrafts = drafts
4149
+ .map((draft) => {
4150
+ if (!draft || typeof draft !== "object" || Array.isArray(draft)) {
4151
+ return undefined;
4152
+ }
4153
+ const entry = draft;
4154
+ if (typeof entry.id !== "string" ||
4155
+ typeof entry.editUrl !== "string" ||
4156
+ typeof entry.previewUrl !== "string") {
4157
+ return undefined;
4158
+ }
4159
+ return {
4160
+ id: entry.id,
4161
+ editUrl: entry.editUrl,
4162
+ previewUrl: entry.previewUrl,
4163
+ title: typeof entry.title === "string" ? entry.title : "",
4164
+ hasCoverImage: entry.hasCoverImage === true,
4165
+ ...(typeof entry.updatedAt === "string" ? { updatedAt: entry.updatedAt } : {}),
4166
+ };
4167
+ })
4168
+ .filter((draft) => draft !== undefined);
4169
+ for (const draft of normalizedDrafts) {
4170
+ if (draft.hasCoverImage) {
4171
+ continue;
4172
+ }
4173
+ const cachedPage = getCachedArticleDraftPage(page, draft.id);
4174
+ if (cachedPage) {
4175
+ const cachedRead = await readArticleFromEditorPage(cachedPage, draft.id, true);
4176
+ if (cachedRead &&
4177
+ typeof cachedRead === "object" &&
4178
+ !Array.isArray(cachedRead) &&
4179
+ "article" in cachedRead &&
4180
+ cachedRead.article &&
4181
+ typeof cachedRead.article === "object" &&
4182
+ !Array.isArray(cachedRead.article) &&
4183
+ cachedRead.article.hasCoverImage === true) {
4184
+ draft.hasCoverImage = true;
4185
+ continue;
4186
+ }
4187
+ }
4188
+ const liveRead = await withEphemeralPage(page, buildArticleEditUrl(draft.id), async (draftPage) => {
4189
+ await waitForArticleEditorSurface(draftPage);
4190
+ await ensureArticleDraftLoaded(draftPage, draft.id);
4191
+ return await readArticleFromEditorPage(draftPage, draft.id, false);
4192
+ }).catch(() => undefined);
4193
+ if (liveRead &&
4194
+ typeof liveRead === "object" &&
4195
+ !Array.isArray(liveRead) &&
4196
+ "article" in liveRead &&
4197
+ liveRead.article &&
4198
+ typeof liveRead.article === "object" &&
4199
+ !Array.isArray(liveRead.article) &&
4200
+ liveRead.article.hasCoverImage === true) {
4201
+ draft.hasCoverImage = true;
4202
+ }
4203
+ }
4204
+ return {
4205
+ drafts: normalizedDrafts,
4206
+ };
4207
+ });
4208
+ }
4209
+ async function getArticleDraft(page, targetUrl) {
4210
+ const articleId = parseArticleIdFromUrl(targetUrl);
4211
+ if (!articleId) {
4212
+ return errorResult("VALIDATION_ERROR", "url or id is required");
4213
+ }
4214
+ return await withArticleDraftPage(page, buildArticleEditUrl(articleId), async (articlePage, resolvedId, sessionScoped) => {
4215
+ return await readArticleFromEditorPage(articlePage, resolvedId ?? articleId, sessionScoped);
4216
+ });
4217
+ }
3625
4218
  function parseArticleReadErrorCode(value) {
3626
4219
  if (!value || typeof value !== "object" || Array.isArray(value)) {
3627
4220
  return undefined;
@@ -4327,7 +4920,7 @@ async function readArticleByUrl(page, targetUrl, authorHandle) {
4327
4920
  }
4328
4921
  }
4329
4922
  }
4330
- const useEditor = targetUrl.includes("/compose/articles/edit/");
4923
+ const useEditor = targetUrl.includes("/compose/articles/edit/") || isArticlePreviewUrl(targetUrl);
4331
4924
  if (articleId) {
4332
4925
  const cachedPage = getCachedArticleDraftPage(page, articleId);
4333
4926
  if (cachedPage) {
@@ -4337,7 +4930,7 @@ async function readArticleByUrl(page, targetUrl, authorHandle) {
4337
4930
  }
4338
4931
  }
4339
4932
  if (useEditor) {
4340
- return await withEphemeralPage(page, targetUrl, async (articlePage) => {
4933
+ return await withEphemeralPage(page, articleId ? buildArticleEditUrl(articleId) : targetUrl, async (articlePage) => {
4341
4934
  await waitForArticleEditorSurface(articlePage);
4342
4935
  await ensureArticleDraftLoaded(articlePage, articleId);
4343
4936
  return await readArticleFromEditorPage(articlePage, articleId, false);
@@ -4614,8 +5207,9 @@ async function draftArticleMarkdown(page, markdownPath, explicitTitle, coverImag
4614
5207
  if (markdown === undefined) {
4615
5208
  return errorResult("VALIDATION_ERROR", `markdownPath was not found: ${markdownPath}`);
4616
5209
  }
4617
- const title = extractArticleTitle(markdown, resolvedMarkdown.attachment.path, explicitTitle);
4618
- const draftAssets = prepareArticleMarkdown(markdown, resolvedMarkdown.attachment.path);
5210
+ const normalized = normalizeArticleMarkdown(markdown, resolvedMarkdown.attachment.path, explicitTitle);
5211
+ const title = normalized.title;
5212
+ const draftAssets = prepareArticleMarkdown(normalized.bodyMarkdown, resolvedMarkdown.attachment.path);
4619
5213
  const resolvedInlineImages = [];
4620
5214
  for (const image of draftAssets.inlineImages) {
4621
5215
  const resolved = await resolveArticleAttachment(image.path, image.marker);
@@ -4658,8 +5252,12 @@ async function draftArticleMarkdown(page, markdownPath, explicitTitle, coverImag
4658
5252
  if (!coverUploaded) {
4659
5253
  return errorResult("UPSTREAM_CHANGED", "article cover upload failed");
4660
5254
  }
5255
+ const coverApplied = await waitForArticleCoverApplied(articlePage);
5256
+ if (!coverApplied) {
5257
+ return errorResult("ACTION_UNCONFIRMED", "article cover upload was not confirmed");
5258
+ }
4661
5259
  }
4662
- const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown);
5260
+ const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown, draftAssets.html);
4663
5261
  if (!pasted) {
4664
5262
  return errorResult("UPSTREAM_CHANGED", "article markdown paste failed");
4665
5263
  }
@@ -4680,6 +5278,8 @@ async function draftArticleMarkdown(page, markdownPath, explicitTitle, coverImag
4680
5278
  };
4681
5279
  if (articleId) {
4682
5280
  output.articleId = articleId;
5281
+ output.draftId = articleId;
5282
+ output.previewUrl = buildArticlePreviewUrl(articleId);
4683
5283
  const persisted = await waitForArticleDraftPersisted(articlePage, articleId, title);
4684
5284
  output.persisted = persisted;
4685
5285
  output.sessionScoped = !persisted;
@@ -4808,6 +5408,10 @@ async function setArticleCoverImage(page, targetUrl, coverImagePath) {
4808
5408
  if (!coverUploaded) {
4809
5409
  return errorResult("UPSTREAM_CHANGED", "article cover upload failed");
4810
5410
  }
5411
+ const coverApplied = await waitForArticleCoverApplied(articlePage);
5412
+ if (!coverApplied) {
5413
+ return errorResult("ACTION_UNCONFIRMED", "article cover upload was not confirmed");
5414
+ }
4811
5415
  const output = {
4812
5416
  ok: true,
4813
5417
  editUrl: articlePage.url(),
@@ -4829,8 +5433,9 @@ async function updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitl
4829
5433
  if (markdown === undefined) {
4830
5434
  return errorResult("VALIDATION_ERROR", `markdownPath was not found: ${markdownPath}`);
4831
5435
  }
4832
- const title = extractArticleTitle(markdown, resolvedMarkdown.attachment.path, explicitTitle);
4833
- const draftAssets = prepareArticleMarkdown(markdown, resolvedMarkdown.attachment.path);
5436
+ const normalized = normalizeArticleMarkdown(markdown, resolvedMarkdown.attachment.path, explicitTitle);
5437
+ const title = normalized.title;
5438
+ const draftAssets = prepareArticleMarkdown(normalized.bodyMarkdown, resolvedMarkdown.attachment.path);
4834
5439
  const resolvedInlineImages = [];
4835
5440
  for (const image of draftAssets.inlineImages) {
4836
5441
  const resolved = await resolveArticleAttachment(image.path, image.marker);
@@ -4854,7 +5459,7 @@ async function updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitl
4854
5459
  if (!cleared) {
4855
5460
  return errorResult("UPSTREAM_CHANGED", "article body controls not found");
4856
5461
  }
4857
- const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown);
5462
+ const pasted = await pasteArticleMarkdown(articlePage, draftAssets.markdown, draftAssets.html);
4858
5463
  if (!pasted) {
4859
5464
  return errorResult("UPSTREAM_CHANGED", "article markdown paste failed");
4860
5465
  }
@@ -4872,6 +5477,8 @@ async function updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitl
4872
5477
  };
4873
5478
  if (articleId) {
4874
5479
  output.articleId = articleId;
5480
+ output.draftId = articleId;
5481
+ output.previewUrl = buildArticlePreviewUrl(articleId);
4875
5482
  const persisted = await waitForArticleDraftPersisted(articlePage, articleId, title);
4876
5483
  output.persisted = persisted;
4877
5484
  output.sessionScoped = sessionScoped === true || !persisted;
@@ -4879,6 +5486,12 @@ async function updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitl
4879
5486
  return output;
4880
5487
  });
4881
5488
  }
5489
+ async function upsertArticleDraftMarkdown(page, targetUrl, markdownPath, explicitTitle, coverImagePath) {
5490
+ if (targetUrl) {
5491
+ return await updateArticleMarkdown(page, targetUrl, markdownPath, explicitTitle);
5492
+ }
5493
+ return await draftArticleMarkdown(page, markdownPath, explicitTitle, coverImagePath);
5494
+ }
4882
5495
  async function waitForGrokSurface(page) {
4883
5496
  await page
4884
5497
  .waitForFunction(() => {
@@ -5837,6 +6450,35 @@ export function createXAdapter(options) {
5837
6450
  }
5838
6451
  return await readArticleByUrl(page, targetUrl, authorHandle || undefined);
5839
6452
  }
6453
+ if (name === "article.listDrafts") {
6454
+ const authCheck = await requireAuthenticated(page);
6455
+ if (!authCheck.ok) {
6456
+ return authCheck.result;
6457
+ }
6458
+ return await listArticleDrafts(page);
6459
+ }
6460
+ if (name === "article.getDraft") {
6461
+ const authCheck = await requireAuthenticated(page);
6462
+ if (!authCheck.ok) {
6463
+ return authCheck.result;
6464
+ }
6465
+ const url = typeof args.url === "string" ? args.url.trim() : "";
6466
+ const id = typeof args.id === "string" ? args.id.trim() : "";
6467
+ if (!id && !url) {
6468
+ return errorResult("VALIDATION_ERROR", "url or id is required");
6469
+ }
6470
+ let articleId = id || undefined;
6471
+ if (!articleId && url) {
6472
+ articleId = parseArticleIdFromUrl(url);
6473
+ if (!articleId) {
6474
+ return errorResult("VALIDATION_ERROR", "could not parse article id from url");
6475
+ }
6476
+ }
6477
+ if (!articleId) {
6478
+ return errorResult("VALIDATION_ERROR", "url or id is required");
6479
+ }
6480
+ return await getArticleDraft(page, buildArticleEditUrl(articleId));
6481
+ }
5840
6482
  if (name === "article.draftMarkdown") {
5841
6483
  const authCheck = await requireAuthenticated(page);
5842
6484
  if (!authCheck.ok) {
@@ -5850,6 +6492,29 @@ export function createXAdapter(options) {
5850
6492
  const coverImagePath = typeof args.coverImagePath === "string" ? args.coverImagePath.trim() : "";
5851
6493
  return await draftArticleMarkdown(page, markdownPath, explicitTitle || undefined, coverImagePath || undefined);
5852
6494
  }
6495
+ if (name === "article.upsertDraftMarkdown") {
6496
+ const authCheck = await requireAuthenticated(page);
6497
+ if (!authCheck.ok) {
6498
+ return authCheck.result;
6499
+ }
6500
+ const url = typeof args.url === "string" ? args.url.trim() : "";
6501
+ const id = typeof args.id === "string" ? args.id.trim() : "";
6502
+ const markdownPath = typeof args.markdownPath === "string" ? args.markdownPath.trim() : "";
6503
+ if (!markdownPath) {
6504
+ return errorResult("VALIDATION_ERROR", "markdownPath is required");
6505
+ }
6506
+ const explicitTitle = typeof args.title === "string" ? args.title.trim() : "";
6507
+ const coverImagePath = typeof args.coverImagePath === "string" ? args.coverImagePath.trim() : "";
6508
+ let articleId = id || undefined;
6509
+ if (!articleId && url) {
6510
+ articleId = parseArticleIdFromUrl(url);
6511
+ if (!articleId) {
6512
+ return errorResult("VALIDATION_ERROR", "url is invalid or unsupported");
6513
+ }
6514
+ }
6515
+ const targetUrl = articleId ? buildArticleEditUrl(articleId) : undefined;
6516
+ return await upsertArticleDraftMarkdown(page, targetUrl, markdownPath, explicitTitle || undefined, coverImagePath || undefined);
6517
+ }
5853
6518
  if (name === "article.publishMarkdown") {
5854
6519
  const authCheck = await requireAuthenticated(page);
5855
6520
  if (!authCheck.ok) {