@gobing-ai/knowledge-kit 0.1.11 → 0.1.13

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.
Files changed (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +2372 -1722
  3. package/package.json +1 -1
  4. package/plugins/generations/image-gen/README.md +108 -0
  5. package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
  6. package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
  7. package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
  8. package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
  9. package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
  10. package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
  11. package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
  12. package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
  13. package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
  14. package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
  15. package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
  16. package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
  17. package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
  18. package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
  19. package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
  20. package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
  21. package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
  22. package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
  23. package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
  24. package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
  25. package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
  26. package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
  27. package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
  28. package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
  29. package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
  30. package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
  31. package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
  32. package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
  33. package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
  34. package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
  35. package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
  36. package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
  37. package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
  38. package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
  39. package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
  40. package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
  41. package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
  42. package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
  43. package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
  44. package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
  45. package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
  46. package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
  47. package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
  48. package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
  49. package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
  50. package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
  51. package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
  52. package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
  53. package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
  54. package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
  55. package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
  56. package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
  57. package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
  58. package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
  59. package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
  60. package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
  61. package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
  62. package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
  63. package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
  64. package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
  65. package/plugins/generations/image-gen/dist/index.js +102 -58
  66. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
  67. package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
  68. package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
  69. package/plugins/generations/image-gen/src/presets.ts +6 -3
  70. package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
  71. package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
  72. package/plugins/ingestions/web-search/src/index.ts +1 -1
  73. package/plugins/kk/README.md +18 -0
  74. package/plugins/kk/commands/image-extract.md +1 -0
  75. package/plugins/kk/commands/image-generate.md +1 -0
  76. package/plugins/kk/commands/tell-me.md +1 -0
  77. package/plugins/kk/commands/workflow-run.md +7 -1
  78. package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
  79. package/plugins/kk/decisions/kk-itc.yaml +6 -4
  80. package/plugins/kk/plugin.json +1 -1
  81. package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
  82. package/plugins/kk/publish-presets/daily-ai-voice.json +15 -7
  83. package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
  84. package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
  85. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
  86. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
  87. package/plugins/kk/scripts/daily-scheduled-run.ts +91 -41
  88. package/plugins/kk/scripts/daily-stages.ts +394 -53
  89. package/plugins/kk/scripts/kk-workflow-stages.ts +190 -0
  90. package/plugins/kk/scripts/safety-artifact-check.ts +70 -28
  91. package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
  92. package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
  93. package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
  94. package/plugins/kk/skills/content-judge/SKILL.md +1 -0
  95. package/plugins/kk/skills/content-safety/SKILL.md +1 -0
  96. package/plugins/kk/skills/content-safety/references/platforms/podcast-salty.md +41 -0
  97. package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
  98. package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
  99. package/plugins/kk/skills/explain-things/SKILL.md +1 -0
  100. package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
  101. package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
  102. package/plugins/kk/skills/publish/SKILL.md +1 -0
  103. package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
  104. package/plugins/kk/skills/storm-research/SKILL.md +1 -0
  105. package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
  106. package/plugins/kk/skills/topic/SKILL.md +1 -0
  107. package/plugins/kk/skills/web-automation/SKILL.md +1 -0
  108. package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
  109. package/plugins/kk/workflows/kk-collect.yaml +3 -3
  110. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +21 -19
  111. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +163 -63
  112. package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
  113. package/plugins/kk/workflows/kk-daily-safety.yaml +39 -31
  114. package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
  115. package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
  116. package/plugins/kk/workflows/kk-itc.yaml +36 -4
  117. package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
  118. package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
  119. package/plugins/publishings/linkedin-pub/package.json +19 -0
  120. package/plugins/publishings/linkedin-pub/plugin.json +12 -0
  121. package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
  122. package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
  123. package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/surfdash-pub/dist/index.js +53 -24
  125. package/plugins/publishings/surfdash-pub/src/index.ts +72 -25
  126. package/plugins/publishings/threads-pub/dist/index.js +7 -0
  127. package/plugins/publishings/threads-pub/src/index.ts +12 -0
  128. package/plugins/publishings/wechatmp-pub/dist/index.js +176 -32
  129. package/plugins/publishings/wechatmp-pub/src/flow.ts +298 -52
  130. package/plugins/publishings/weibo-pub/dist/index.js +100 -7
  131. package/plugins/publishings/weibo-pub/src/index.ts +111 -11
  132. package/plugins/publishings/x-pub/dist/index.js +7 -0
  133. package/plugins/publishings/x-pub/src/index.ts +11 -0
  134. package/plugins/publishings/xhs-pub/dist/index.js +164 -12
  135. package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
  136. package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
  137. package/plugins/publishings/xhs-pub/src/page.ts +26 -0
@@ -71,6 +71,15 @@ const TITLE_BLOCK = [
71
71
  ];
72
72
  /** `textarea#title` is the HIDDEN form mirror — filling it alone saves an EMPTY title (§5.2 trap 1). */
73
73
  const TITLE_MIRROR = ['textarea#title', 'textarea.js_title'];
74
+ /** The 播客 editor (createType 7) has NO ProseMirror title block — `js_title_main` holds a plain
75
+ * visible `textarea.js_title` where filling IS the input (2026-11-06 measured). */
76
+ const PODCAST_TITLE = ['textarea.js_title'];
77
+ /** The 播客 body is the same single ProseMirror, but only `ProseMirror-focused` appears after a
78
+ * click; without `data-placeholder` the article variant double-matches, so the plain node wins. */
79
+ const PODCAST_BODY = [
80
+ 'div.ProseMirror.ProseMirror-focused:not([data-placeholder])',
81
+ 'div.ProseMirror:not([data-placeholder])',
82
+ ];
74
83
  /** The body is the second ProseMirror instance: `ProseMirror-focused`, no `data-placeholder` (§5.2). */
75
84
  const BODY_BLOCK = [
76
85
  'div.ProseMirror.ProseMirror-focused:not([data-placeholder])',
@@ -213,21 +222,28 @@ const VIDEO_CONFIRM = ['div.weui-desktop-dialog button:has-text("确定")', 'but
213
222
 
214
223
  /** 播客's media modal (§5.2): 从素材库选择 + 搜索音频 + 上传音频 + 插入/取消. */
215
224
  const PODCAST_UPLOAD = ['text=上传音频', 'button:has-text("上传音频")'];
225
+ /** The library migrated its rows from `li.audio_item_wrp` to `div.audio_item_wrp` mid-run
226
+ * (2026-11-06 measured): a `li`-only selector matched nothing and made the reuse path look empty.
227
+ * The union covers both renders; clicking the LABEL inside is what toggles the row's checkbox. */
228
+ const PODCAST_ROW = (title: string): string =>
229
+ `li.audio_item_wrp:has-text("${JSON.stringify(title).slice(1, -1)}"), div.audio_item_wrp:has-text("${JSON.stringify(title).slice(1, -1)}")`;
230
+ // 2026-11-06 measured: the upload dialog's audio input (`name=audio_id`) carries NO accept
231
+ // attribute, so every accept-qualified selector missed and the attach timed out. The generic
232
+ // dialog input[type=file] stay scoped from the editor: the editor's own image input lives
233
+ // outside `.weui-desktop-dialog`.
216
234
  const PODCAST_AUDIO_INPUT = [
235
+ 'div.weui-desktop-dialog input.weui-desktop-upload-input',
236
+ 'div.weui-desktop-dialog input[name="audio_id"]',
237
+ // Legacy accept-qualified fallbacks for older MP builds whose input carried an accept list.
217
238
  'div.weui-desktop-dialog input[type="file"][accept*="audio"]',
218
239
  'div.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
219
- 'input[type="file"][accept*="audio"]',
220
- 'input[type="file"][accept*=".mp3"]',
221
240
  ];
222
- /**
223
- * The 播客 cover stays scoped to the modal: the editor's own image input carries an `image/…`
224
- * accept list too, and picking that one would attach the cover as *body* content instead.
225
- */
226
241
  const PODCAST_COVER_INPUT = [
242
+ 'input[type="file"][accept*="image/bmp"]',
227
243
  'div.weui-desktop-dialog input[type="file"][accept*="image/"]',
228
- 'div.weui-desktop-dialog input[type="file"][accept*=".png"]',
229
- 'div.weui-desktop-dialog input[type="file"][accept*=".jpg"]',
230
244
  ];
245
+ const PODCAST_TITLE_INPUT = '.weui-desktop-dialog .title_form_wrp input.weui-desktop-form__input';
246
+ const PODCAST_COVER_CHOOSER = '.weui-desktop-dialog:visible:has(h3:has-text("选择图片"))';
231
247
  const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'button:has-text("插入")'];
232
248
 
233
249
  /** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
@@ -633,11 +649,8 @@ async function typeTitle(page: WechatPage, deps: ModeRunDeps): Promise<'absent'
633
649
  }
634
650
 
635
651
  /** The hidden form mirror's value; `observed: false` when the console does not expose one. */
636
- async function readTitleMirror(page: WechatPage): Promise<{ observed: boolean; value: string }> {
637
- const found = await trySelectors(page, TITLE_MIRROR, { visible: false }).catch(() => ({
638
- found: false,
639
- selector: null,
640
- }));
652
+ export async function readTitleMirror(page: WechatPage): Promise<{ observed: boolean; value: string }> {
653
+ const found = await trySelectors(page, TITLE_MIRROR, { visible: false });
641
654
  if (!found.found || !found.selector) return { observed: false, value: '' };
642
655
  const locator = page.locator(found.selector).first();
643
656
  if (typeof locator.inputValue !== 'function') return { observed: false, value: '' };
@@ -653,13 +666,26 @@ async function readTitleMirror(page: WechatPage): Promise<{ observed: boolean; v
653
666
  * helper (ADR-029 rail). The retired iframe UEditor and its `setInnerHtml` were the drift this
654
667
  * replaces (W1): the body only registers on a real paste, which is what `pasteHtml` sends.
655
668
  */
656
- async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
669
+ async function insertBody(page: WechatPage, deps: ModeRunDeps, selectors: string[] = BODY_BLOCK): Promise<void> {
657
670
  const body = bodyOf(deps.content);
658
671
  if (body.trim().length === 0) return;
659
- const selector = await findSelector(page, BODY_BLOCK, 'article body', deps.stepTimeoutMs);
672
+ const selector = await findSelector(page, selectors, 'article body', deps.stepTimeoutMs);
660
673
  await pasteHtml(asPlaywrightPage(page), selector, markdownToWechatHtml(body), { timeoutMs: deps.stepTimeoutMs });
661
674
  }
662
675
 
676
+ /** 播客 titles type into the visible `textarea.js_title` directly; fill + read-back is the proof. */
677
+ async function typePodcastTitle(page: WechatPage, deps: ModeRunDeps): Promise<void> {
678
+ const title = titleOf(deps.content);
679
+ if (title.length === 0) return;
680
+ const sel = await findSelector(page, PODCAST_TITLE, 'podcast title', deps.stepTimeoutMs);
681
+ await fillSelector(page, sel, title);
682
+ const locator = page.locator(sel).first();
683
+ const mirror = typeof locator.inputValue === 'function' ? await locator.inputValue() : '';
684
+
685
+ if (mirror !== title)
686
+ throw new Error(`podcast title: textarea.js_title reads '${mirror}' but the flow typed '${title}'`);
687
+ }
688
+
663
689
  /**
664
690
  * The current UEditor build keeps the layout choices as nested, unlabelled divs rather than
665
691
  * role/menuitem nodes. Playwright's visibility probe rejects those descendants even while the
@@ -798,7 +824,7 @@ async function waitForNativeLayoutSave(page: WechatPage, deps: ModeRunDeps): Pro
798
824
  * label (the 2026-10-05 dogfood reading) is accepted only when its own measured commit signal is
799
825
  * available; an unrecognized control fails closed before submission.
800
826
  */
801
- async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
827
+ export async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
802
828
  // Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
803
829
  // control is the platform's layout — silently skip; only a found-but-failing control warns.
804
830
  const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
@@ -862,7 +888,7 @@ async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string
862
888
  }
863
889
 
864
890
  /** Fill an optional field, recording a warning instead of failing the run. */
865
- async function fillOptional(
891
+ export async function fillOptional(
866
892
  page: WechatPage,
867
893
  selectors: readonly string[],
868
894
  value: string,
@@ -895,7 +921,7 @@ async function coverLibraryName(path: string): Promise<string> {
895
921
  return `kk-cover-${hash.slice(0, 32)}${extname(path).toLowerCase()}`;
896
922
  }
897
923
 
898
- function coverAssetIdentity(value: string): string {
924
+ export function coverAssetIdentity(value: string): string {
899
925
  try {
900
926
  const url = new URL(value);
901
927
  return url.hostname === 'mmbiz.qpic.cn'
@@ -1159,7 +1185,7 @@ function audioBlockOf(content: Content): { title: string; audioPath: string; cov
1159
1185
  }
1160
1186
 
1161
1187
  /** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
1162
- async function enrichArticleDraft(
1188
+ export async function enrichArticleDraft(
1163
1189
  page: WechatPage,
1164
1190
  content: Content,
1165
1191
  stepTimeoutMs: number,
@@ -1269,14 +1295,17 @@ async function composeVideo(
1269
1295
  const confirm = await findSelector(page, VIDEO_CONFIRM, 'video media confirm', deps.stepTimeoutMs);
1270
1296
  await clickSelector(page, confirm, deps.stepTimeoutMs);
1271
1297
  await awaitSaveBar(page, deps);
1272
- const titlePath = await typeTitle(page, deps);
1273
- await insertBody(page, deps);
1274
- return { warnings: [], titlePath, enrichWarnings: [] };
1298
+ await typePodcastTitle(page, deps);
1299
+ await insertBody(page, deps, PODCAST_BODY);
1300
+ return { warnings: [], titlePath: 'mirror-agreed' as const, enrichWarnings: [] };
1275
1301
  }
1276
1302
 
1277
1303
  /**
1278
- * Compose 播客 (createType 7). The modal needs audio AND a cover (§5.2), and it covers the save bar,
1279
- * so `插入` + the bar's appearance gate every later step.
1304
+ * Compose 播客 (createType 7). 2026-11-06 walk of the live modal: `上传音频` opens the shared
1305
+ * audio-upload dialog (音频 file + 封面 via the 选择图片 chooser + 素材标题 + 服务规则 + 保存);
1306
+ * 保存 closes it and the material lands in the library while it is still 转码中 — the row is
1307
+ * clickable and `插入` is enabled once 转码/审核 markers clear. A partial upload never reaches this
1308
+ * function (it fails before the browser opens).
1280
1309
  */
1281
1310
  async function composePodcast(
1282
1311
  input: { operation: '播客'; audio: string; cover: string },
@@ -1284,18 +1313,231 @@ async function composePodcast(
1284
1313
  entry: ModeEntry,
1285
1314
  ): Promise<ComposeReport> {
1286
1315
  const page = entry.editor;
1316
+ // Reuse fast path: a prior run that already saved this exact material leaves it ready in the
1317
+ // library — skip the whole upload/cover/title/save cycle (it would re-upload as a duplicate).
1318
+ const materialTitle = titleOf(deps.content);
1319
+ // The library list loads by XHR after the dialog mounts — poll briefly instead of a one-shot read.
1320
+ const reuseProbe = `(() => {
1321
+ const rows = [...document.querySelectorAll('.weui-desktop-dialog li.audio_item_wrp, .weui-desktop-dialog div.audio_item_wrp')].filter((r) => r.offsetWidth > 0);
1322
+ if (rows.length === 0) return 'LOADING';
1323
+ const row = rows.find((r) => ((r.textContent ?? '').replace(/[s\u00a0]/g, '')).includes(${JSON.stringify(materialTitle).replace(/[\s\u00a0]/g, '')}));
1324
+ return row ? (row.textContent ?? '') : 'NONE';
1325
+ })()`;
1326
+ // Lonng decision 2026-11-06: prioritize the row over the dialog. If a ready row appears within
1327
+ // 25s, reuse it; only when that window closes empty (or all rows still busy) run the upload path.
1328
+ let reuseRowText = '';
1329
+ const reuseHistory: string[] = [];
1330
+ {
1331
+ const probe = async () => String(((await page.evaluate(reuseProbe)) as string) ?? '');
1332
+ for (let i = 0; i < 10; i++) {
1333
+ const text = await probe();
1334
+ reuseHistory.push(text === 'LOADING' || text === 'ERR' ? text : text.slice(0, 25));
1335
+ if (text === 'LOADING' || text === 'ERR') {
1336
+ await page.waitForTimeout?.(2_500);
1337
+ continue;
1338
+ }
1339
+ if (text === 'NONE') break;
1340
+ if (!/转码中|审核中|处理中/.test(text)) {
1341
+ reuseRowText = text;
1342
+ break;
1343
+ }
1344
+ // Rows exist but the title is missing or the: only the matching row counts.
1345
+ }
1346
+ }
1347
+ if (reuseRowText) {
1348
+ // Reuse means skipping the duplicate upload entirely — wait out 转码/审核 on the existing row.
1349
+ const reuseReady = await waitUntil(async () => {
1350
+ const text = String(((await page.evaluate(reuseProbe)) as string) ?? '');
1351
+ return text !== 'ERR' && text !== 'LOADING' && text !== 'NONE' && !/转码中|审核中|处理中/.test(text);
1352
+ }, 40 * 60_000);
1353
+ if (!reuseReady) throw new Error(`播客: 复用素材列表在 40min 内未见「${materialTitle}」完成转码/审核`);
1354
+ await clickSelector(
1355
+ page,
1356
+ await findSelector(
1357
+ page,
1358
+ [`${PODCAST_ROW(materialTitle)} label.audio_item`, PODCAST_ROW(materialTitle)],
1359
+ '播客素材 row',
1360
+ deps.stepTimeoutMs,
1361
+ ),
1362
+ deps.stepTimeoutMs,
1363
+ );
1364
+ await clickSelector(
1365
+ page,
1366
+ await findSelector(page, PODCAST_INSERT, '插入 control', deps.stepTimeoutMs),
1367
+ deps.stepTimeoutMs,
1368
+ );
1369
+ await awaitSaveBar(page, deps);
1370
+ await typePodcastTitle(page, deps);
1371
+ await insertBody(page, deps, PODCAST_BODY);
1372
+ return { warnings: [], titlePath: 'mirror-agreed' as const, enrichWarnings: [] };
1373
+ }
1287
1374
  const upload = await findSelector(page, PODCAST_UPLOAD, '上传音频 control', deps.stepTimeoutMs);
1288
1375
  await clickSelector(page, upload, deps.stepTimeoutMs);
1289
1376
  const audioInput = await findAttachedSelector(page, PODCAST_AUDIO_INPUT, 'audio file input', deps.stepTimeoutMs);
1290
- await uploadFile(asPlaywrightPage(page), audioInput, input.audio, { timeoutMs: deps.stepTimeoutMs });
1291
- const coverInput = await findAttachedSelector(page, PODCAST_COVER_INPUT, 'cover file input', deps.stepTimeoutMs);
1377
+ // 9MB made Playwright's internal set-file timeout flake — give it a explicit 120s.
1378
+ await uploadFile(asPlaywrightPage(page), audioInput, input.audio, { timeoutMs: 120_000 });
1379
+
1380
+ // The shared upload form (measured 2026-11-06, same DOM the 音频-material flow walks):
1381
+ // audio file → 封面 through the 选择图片 chooser → 素材标题 → 服务规则 → 保存. 保存 stays
1382
+ // disabled until all four are provided, so finding it enabled is the gate, not an assumption.
1383
+ const uploadDialog = '.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("上传音频"))';
1384
+ await waitUntil(
1385
+ async () =>
1386
+ (await page.locator(`${uploadDialog} button:has-text("重新上传")`).count()) > 0 &&
1387
+ /100%/.test(
1388
+ (await page.evaluate(
1389
+ `(() => {\n const d = [...document.querySelectorAll(".weui-desktop-dialog")].find((el) => el.offsetWidth > 0 && el.querySelector("input[name=audio_id]"));\n return d ? (d.textContent ?? "") : "";\n })()`,
1390
+ )) as string,
1391
+ ),
1392
+ 120_000,
1393
+ );
1394
+ const coverTrigger = await findSelector(
1395
+ page,
1396
+ [`${uploadDialog} .select_audio_cover .upload_cover_text`, '.select_audio_cover .upload_cover_text'],
1397
+ '上传封面 control (audio upload form)',
1398
+ deps.stepTimeoutMs,
1399
+ );
1400
+ await clickSelector(page, coverTrigger, deps.stepTimeoutMs);
1401
+ const coverInput = await findAttachedSelector(
1402
+ page,
1403
+ PODCAST_COVER_INPUT,
1404
+ 'cover file input (选择图片 chooser)',
1405
+ deps.stepTimeoutMs,
1406
+ );
1292
1407
  await uploadFile(asPlaywrightPage(page), coverInput, input.cover, { timeoutMs: deps.stepTimeoutMs });
1293
- const insert = await findSelector(page, PODCAST_INSERT, '插入 control', deps.stepTimeoutMs);
1294
- await clickSelector(page, insert, deps.stepTimeoutMs);
1408
+ // The chooser advances through the same 下一步 → 完成 crop pages the 音频 cover flow recorded.
1409
+ const chooser = PODCAST_COVER_CHOOSER;
1410
+ const next = await findSelector(
1411
+ page,
1412
+ [`${chooser} button:has-text("下一步"):not(.weui-desktop-btn_disabled):not([disabled])`],
1413
+ '播客封面 chooser 下一步',
1414
+ 30_000,
1415
+ );
1416
+ await clickSelector(page, next, deps.stepTimeoutMs);
1417
+ // The crop page is 上一步+完成; clicking 完成 before it fully renders bounces the picker back
1418
+ // to its 确定/取消 page (measured with the real 865KB cover; settle it first).
1419
+ const cropSettled = await waitUntil(
1420
+ async () => (await page.locator(`${chooser} button:has-text("上一步")`).count()) > 0,
1421
+ 15_000,
1422
+ );
1423
+ if (!cropSettled) throw new Error('播客: 封面裁剪页未出现(下一步后无上一步按钮)');
1424
+ await page.waitForTimeout?.(4_000);
1425
+ const finish = await findSelector(
1426
+ page,
1427
+ [`${chooser} button:has-text("完成"):not(.weui-desktop-btn_disabled):not([disabled])`],
1428
+ '播客封面 chooser 完成',
1429
+ 30_000,
1430
+ );
1431
+ await clickSelector(page, finish, deps.stepTimeoutMs);
1432
+ // A required crop bounces the picker back to its 确定/取消 page after 完成 applies (measured:
1433
+ // the throwaway tile skipped crop, the daily cover didn't); the explicit 确定 closes it.
1434
+ let chooserClosed = await waitUntil(async () => (await page.locator(chooser).count()) === 0, 25_000);
1435
+ if (!chooserClosed) {
1436
+ const confirm = await tryFindSelector(page, [`${chooser} button:has-text("确定")`], 5_000);
1437
+ if (confirm) {
1438
+ await clickSelector(page, confirm, deps.stepTimeoutMs);
1439
+ chooserClosed = await waitUntil(async () => (await page.locator(chooser).count()) === 0, 10_000);
1440
+ }
1441
+ }
1442
+ if (!chooserClosed) {
1443
+ const residue = String(
1444
+ await page
1445
+ .evaluate(
1446
+ '(() => JSON.stringify([...document.querySelectorAll(".weui-desktop-dialog")]\n .filter((d) => d.offsetWidth > 0).map((d) => (d.querySelector("h3")?.textContent ?? "") + ":" + (d.textContent ?? "").replace(/\\s+/g, " ").slice(0, 120)) + " toast:" + (document.querySelector(".weui-desktop-toast")?.textContent ?? "")))()',
1447
+ )
1448
+ .catch(() => null),
1449
+ ).slice(0, 900);
1450
+ throw new Error(`播客: 封面选择器完成后未关闭,未保存音频素材 — 现场残留 ${residue ?? '(probe 失败)'}`);
1451
+ }
1452
+
1453
+ await fillSelector(
1454
+ page,
1455
+ await findSelector(page, [PODCAST_TITLE_INPUT], '素材标题 input', deps.stepTimeoutMs),
1456
+ materialTitle,
1457
+ );
1458
+ const agreed = String(
1459
+ await page.evaluate(`(() => {
1460
+ const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find((d) => d.querySelector('.title_form_wrp'));
1461
+ if (!dialog) return 'no-dialog';
1462
+ const cb = dialog.querySelector('.audio-upload-dialog_agreement input[type=checkbox]');
1463
+ if (!cb) return 'no-box';
1464
+ if (!cb.checked) cb.click();
1465
+ return cb.checked ? 'checked' : 'unchecked';
1466
+ })()`),
1467
+ );
1468
+ if (agreed !== 'checked') throw new Error(`播客: 服务规则勾选失败 (${agreed})`);
1469
+ const save = await findSelector(
1470
+ page,
1471
+ [
1472
+ `${uploadDialog} button.weui-desktop-btn_primary:text-is("保存"):not(.weui-desktop-btn_disabled):not([disabled])`,
1473
+ ],
1474
+ '音频素材保存 (enabled after cover + title + agreement)',
1475
+ 90_000,
1476
+ );
1477
+ // WeChat's failure toasts auto-dismiss in seconds; watch them live around the save click.
1478
+ await page.evaluate(
1479
+ '(() => {\n window.__kkToasts = [];\n new MutationObserver(() => {\n const t = document.querySelector(".weui-desktop-toast");\n if (t && t.textContent) window.__kkToasts.push(t.textContent.trim().slice(0, 200));\n }).observe(document.body, { subtree: true, childList: true, characterData: true });\n})()',
1480
+ );
1481
+ const saveDiagnostics = `(() => {
1482
+ const form = [...document.querySelectorAll(".weui-desktop-dialog")].find((d) => d.querySelector(".title_form_wrp"));
1483
+ if (!form) return { form: "gone" };
1484
+ const btn = [...form.querySelectorAll("button")].find((b) => (b.textContent ?? "").trim() === "保存");
1485
+ return {
1486
+ btn: btn ? { html: btn.outerHTML.slice(0, 120), disabled: btn.disabled, offW: btn.offsetWidth } : null,
1487
+ fields: [...form.querySelectorAll("input, textarea")].map((f) => ({ cls: String(f.className ?? "").slice(0, 40), v: String(f.value ?? "").slice(0, 30), offW: f.offsetWidth, cb: f.type === "checkbox" ? f.checked : undefined })),
1488
+ toasts: (window.__kkToasts ?? []).slice(-3),
1489
+ };
1490
+ })()`;
1491
+ const saveBefore = String(await page.evaluate(saveDiagnostics).catch(() => null)).slice(0, 450);
1492
+ await clickSelector(page, save, deps.stepTimeoutMs);
1493
+ // 保存 closes the upload form; if it stays open the save failed silently — dump the residue
1494
+ // (toast/h3s) as evidence instead of waiting on the library row forever.
1495
+ const formClosed = await waitUntil(async () => (await page.locator(uploadDialog).count()) === 0, 30_000);
1496
+ if (!formClosed) {
1497
+ // React may ignore the synthetic-only activation — JS-dispatch a click once more.
1498
+ const dispatched = String(
1499
+ await page
1500
+ .evaluate(
1501
+ '(() => {\n const form = [...document.querySelectorAll(".weui-desktop-dialog")].find((d) => d.querySelector(".title_form_wrp"));\n const btn = form && [...form.querySelectorAll("button")].find((b) => (b.textContent ?? "").trim() === "保存");\n if (!btn) return "no-btn";\n btn.click();\n return btn.disabled ? "was-disabled" : "clicked";\n})()',
1502
+ )
1503
+ .catch(() => null),
1504
+ );
1505
+ const dispatchClosed = await waitUntil(async () => (await page.locator(uploadDialog).count()) === 0, 10_000);
1506
+ if (!dispatchClosed) {
1507
+ const after = String(await page.evaluate(saveDiagnostics).catch(() => null)).slice(0, 600);
1508
+ throw new Error(`播客: 保存失败 — dispatch=${dispatched} before=${saveBefore} after=${after}`);
1509
+ }
1510
+ }
1511
+ const rowSelector = PODCAST_ROW(materialTitle);
1512
+ const rowProbe = `(() => {
1513
+ const row = [...document.querySelectorAll('.weui-desktop-dialog li.audio_item_wrp, .weui-desktop-dialog div.audio_item_wrp')].find((r) => (r.textContent ?? '').includes(${JSON.stringify(materialTitle)}));
1514
+ return row ? (row.textContent ?? '') : null;
1515
+ })()`;
1516
+ const materialReady = await waitUntil(async () => {
1517
+ const rowText = (await page.evaluate(rowProbe)) as string | null;
1518
+ return rowText !== null && !/转码中|审核中|处理中/.test(rowText);
1519
+ }, 15 * 60_000);
1520
+ if (!materialReady) {
1521
+ const probeNow = String(((await page.evaluate(reuseProbe)) as string | undefined) ?? 'ERR');
1522
+ throw new Error(
1523
+ `播客: 音频素材 '${materialTitle}' 未在 15min 内完成转码/审核,插入未执行(可重跑)` +
1524
+ ` — reuse 窗口 [${reuseHistory.join(',')}] 最近读=${probeNow.slice(0, 30)}`,
1525
+ );
1526
+ }
1527
+ await clickSelector(
1528
+ page,
1529
+ await findSelector(page, [`${rowSelector} label.audio_item`, rowSelector], '播客素材 row', deps.stepTimeoutMs),
1530
+ deps.stepTimeoutMs,
1531
+ );
1532
+ await clickSelector(
1533
+ page,
1534
+ await findSelector(page, PODCAST_INSERT, '插入 control', deps.stepTimeoutMs),
1535
+ deps.stepTimeoutMs,
1536
+ );
1295
1537
  await awaitSaveBar(page, deps);
1296
- const titlePath = await typeTitle(page, deps);
1297
- await insertBody(page, deps);
1298
- return { warnings: [], titlePath, enrichWarnings: [] };
1538
+ await typePodcastTitle(page, deps);
1539
+ await insertBody(page, deps, PODCAST_BODY);
1540
+ return { warnings: [], titlePath: 'mirror-agreed' as const, enrichWarnings: [] };
1299
1541
  }
1300
1542
 
1301
1543
  /**
@@ -1389,7 +1631,7 @@ async function saveDraft(
1389
1631
  await sleep(deps.submitSettleMs);
1390
1632
  if ((await closeOpenDialogs(entry.editor)) !== 'closed') {
1391
1633
  residuals += 1;
1392
- residualDialogs.push(await openDialogInventory(entry.editor).catch(() => 'unreadable'));
1634
+ residualDialogs.push(await openDialogInventory(entry.editor));
1393
1635
  // Refuse to click through an unresolved overlay (0260 R3/AC5): the click would land on
1394
1636
  // the dialog footer again, not 保存为草稿. Retry the cleanup on the next attempt.
1395
1637
  if (attempts < deps.submitAttempts) continue;
@@ -1419,7 +1661,7 @@ async function saveDraft(
1419
1661
  if (signal === null || control === null) {
1420
1662
  if (control === null)
1421
1663
  return fail(
1422
- `draft confirmation: a residual modal refused cleanup after ${attempts} attempt(s), ${residuals} refused cleanup(s) (${residualDialogs.join(' | ') || 'no inventory'}) — '保存为草稿' was not clicked through an unresolved overlay (inventory: ${await openDialogInventory(entry.editor).catch(() => 'unreadable')})`,
1664
+ `draft confirmation: a residual modal refused cleanup after ${attempts} attempt(s), ${residuals} refused cleanup(s) (${residualDialogs.join(' | ') || 'no inventory'}) — '保存为草稿' was not clicked through an unresolved overlay (inventory: ${await openDialogInventory(entry.editor)})`,
1423
1665
  );
1424
1666
  return fail(
1425
1667
  `draft confirmation: '${SAVE_DRAFT[0]}' was clicked ${attempts}× but no fresh 草稿箱 row for '${title}' (its ${DRAFT_STAMP} stamp) was observed within ${deps.draftTimeoutMs}ms (before=${deps.draftBaseline.stamp ?? 'none'}, after=${stampAfter ?? 'none'}, page=${deps.entry.url()}, frames=${await describeFrames(deps.entry, 5)})`,
@@ -1474,7 +1716,7 @@ interface PublicSaveEvidence {
1474
1716
  * used here — the live console has rendered that card on a different surface from the editor, and a
1475
1717
  * row cannot prove that this editor is the latest version.
1476
1718
  */
1477
- function canonicalSavedEditorUrl(value: string): string | null {
1719
+ export function canonicalSavedEditorUrl(value: string): string | null {
1478
1720
  try {
1479
1721
  const url = new URL(value);
1480
1722
  if (url.origin !== 'https://mp.weixin.qq.com' || url.pathname !== '/cgi-bin/appmsg') return null;
@@ -1523,7 +1765,7 @@ function normalizeSnapshotText(value: string): string {
1523
1765
  }
1524
1766
 
1525
1767
  /** Read only the known title/body/media/cover surfaces; unreadable evidence is never treated as equal. */
1526
- async function readPublicDraftSnapshot(page: WechatPage): Promise<PublicDraftSnapshot | null> {
1768
+ export async function readPublicDraftSnapshot(page: WechatPage): Promise<PublicDraftSnapshot | null> {
1527
1769
  try {
1528
1770
  const raw = await page.evaluate(PUBLIC_DRAFT_SNAPSHOT_SCRIPT);
1529
1771
  const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
@@ -1590,6 +1832,9 @@ function snapshotValueIsEmpty(value: string): boolean {
1590
1832
  function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: PublicDraftSnapshot): boolean {
1591
1833
  if (!snapshotValueIsEmpty(expected.title) && snapshotValueIsEmpty(actual.title)) return true;
1592
1834
  if (!snapshotValueIsEmpty(expected.body) && snapshotValueIsEmpty(actual.body)) return true;
1835
+ // The editor renders a long body progressively; a strict prefix is still hydrating, not a
1836
+ // different version (20261104: 查看新草稿 failed on its first non-empty read).
1837
+ if (actual.body.length < expected.body.length && expected.body.startsWith(actual.body)) return true;
1593
1838
  if (expected.media.length > actual.media.length) return true;
1594
1839
  if (!snapshotValueIsEmpty(expected.cover) && snapshotValueIsEmpty(actual.cover)) return true;
1595
1840
  if (expected.originalUrl !== '' && actual.originalUrl === '') return true;
@@ -1603,19 +1848,15 @@ function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: Publi
1603
1848
  */
1604
1849
  const PUBLIC_SAVED_EDITOR_RELOAD_LIMIT = 6;
1605
1850
 
1606
- function appmsgIdFromCanonical(value: string): string | null {
1851
+ export function appmsgIdFromCanonical(value: string): string | null {
1607
1852
  const canonical = canonicalSavedEditorUrl(value);
1608
1853
  if (canonical === null) return null;
1609
- try {
1610
- const appmsgId = new URL(canonical).searchParams.get('appmsgid')?.trim();
1611
- return appmsgId && appmsgId.length > 0 ? appmsgId : null;
1612
- } catch {
1613
- return null;
1614
- }
1854
+ const appmsgId = new URL(canonical).searchParams.get('appmsgid')?.trim();
1855
+ return appmsgId && appmsgId.length > 0 ? appmsgId : null;
1615
1856
  }
1616
1857
 
1617
1858
  /** Require two consecutive native readiness observations after any editor-version transition. */
1618
- async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
1859
+ export async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
1619
1860
  let consecutiveSaved = 0;
1620
1861
  return waitUntil(
1621
1862
  async () => {
@@ -1643,7 +1884,7 @@ async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps)
1643
1884
  * the persisted surfaces before the one and only public click. A refusal or missing native signal
1644
1885
  * stops before `发表`; no draft-box lookup or blind save retry is safe for the public rail.
1645
1886
  */
1646
- async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
1887
+ export async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
1647
1888
  const composed = await readPublicDraftSnapshot(entry.editor);
1648
1889
  if (composed === null)
1649
1890
  return fail(
@@ -1768,7 +2009,7 @@ interface StaleVersionRecovery {
1768
2009
  * save and never retries an unknown outcome: 查看新草稿 is the one measured action that switches the
1769
2010
  * editor to the platform's latest persisted version before a second initial 发表 click.
1770
2011
  */
1771
- async function recoverStaleVersion(
2012
+ export async function recoverStaleVersion(
1772
2013
  deps: ModeRunDeps,
1773
2014
  entry: ModeEntry,
1774
2015
  saved: PublicSaveEvidence,
@@ -1837,9 +2078,11 @@ async function recoverStaleVersion(
1837
2078
  return fail(
1838
2079
  'public stale-version recovery: latest editor snapshot was unreadable; no second 发表 click attempted',
1839
2080
  );
2081
+ // TS narrows the closure-assigned `let` to never after the null guard; re-read it through a cast.
2082
+ const latestBody = (latestSnapshot as PublicDraftSnapshot).body;
1840
2083
  if (differences.length > 0)
1841
2084
  return fail(
1842
- `public stale-version recovery: latest editor content mismatch (${differences.join(', ')}); no second 发表 click attempted`,
2085
+ `public stale-version recovery: latest editor content mismatch (${differences.join(', ')}; body ${latestBody.length}/${saved.snapshot.body.length} chars); no second 发表 click attempted`,
1843
2086
  );
1844
2087
 
1845
2088
  if (!(await waitForPublicEditorReadiness(latestEditor, deps)))
@@ -1920,7 +2163,7 @@ async function findUniqueVisibleSelector(
1920
2163
  return found && selector !== undefined ? { selector, reason } : { reason };
1921
2164
  }
1922
2165
 
1923
- async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
2166
+ export async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
1924
2167
  try {
1925
2168
  const value = await page.evaluate(PUBLISH_NOTIFY_STATE_SCRIPT);
1926
2169
  return typeof value === 'boolean' ? value : null;
@@ -1929,7 +2172,7 @@ async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean |
1929
2172
  }
1930
2173
  }
1931
2174
 
1932
- function shouldNotifySubscribers(content: Content): boolean {
2175
+ export function shouldNotifySubscribers(content: Content): boolean {
1933
2176
  const wechatmp = content.metadata?.wechatmp;
1934
2177
  if (wechatmp === null || typeof wechatmp !== 'object' || Array.isArray(wechatmp)) return true;
1935
2178
  return (wechatmp as Record<string, unknown>).notifySubscribers !== false;
@@ -1942,13 +2185,16 @@ function shouldNotifySubscribers(content: Content): boolean {
1942
2185
  * state is the platform's actual pre-click switch value; the policy is recorded separately because
1943
2186
  * the default preserves an already-OFF platform setting rather than enabling notifications.
1944
2187
  */
1945
- async function confirmPublicPublish(
2188
+ export async function confirmPublicPublish(
1946
2189
  page: WechatPage,
1947
2190
  notifySubscribers: boolean,
1948
2191
  stepTimeoutMs: number,
1949
2192
  pollMs: number,
1950
2193
  ): Promise<Result | PublicConfirmation | PreconfirmationStaleVersion> {
1951
- const confirmationTimeoutMs = Math.min(CONFIRM_TIMEOUT_MS, stepTimeoutMs);
2194
+ // Not CONFIRM_TIMEOUT_MS: this dialog runs a server-side check before it mounts (3.6s on an idle
2195
+ // editor, 2026-11-04), so a 5s cap reported it missing on 2026-11-03/04. Waiting costs nothing
2196
+ // irreversible — no click happens until the dialog is uniquely observed.
2197
+ const confirmationTimeoutMs = stepTimeoutMs;
1952
2198
  const dialog = await findUniqueVisibleSelector(page, PUBLISH_DIALOG, confirmationTimeoutMs, pollMs);
1953
2199
  if (dialog.selector === undefined) {
1954
2200
  if (dialog.reason === 'missing') {
@@ -2000,7 +2246,7 @@ async function confirmPublicPublish(
2000
2246
  * the only pre-confirmation recovery is the bounded, native 查看新草稿 transition handled below.
2001
2247
  * An unobserved final send is never repeated because a second click could duplicate the message.
2002
2248
  */
2003
- async function publishContent(
2249
+ export async function publishContent(
2004
2250
  input: ModeInput,
2005
2251
  deps: ModeRunDeps,
2006
2252
  entry: ModeEntry,
@@ -2234,7 +2480,7 @@ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Resu
2234
2480
  * TypeScript only — no Python, no theme CLI.
2235
2481
  *
2236
2482
  * Kept instead of the shared `markdownToHtml` because the two differ on safety, measured on Bun
2237
- * 1.3.14: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
2483
+ * 1.3.14 and again on 1.4.3: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
2238
2484
  * `javascript:` link destinations, while this renderer escapes text and refuses non-http(s) link
2239
2485
  * schemes (R9). The *insertion* is the shared `pasteHtml` either way, so the ADR-029 rail holds.
2240
2486
  */