@gobing-ai/knowledge-kit 0.1.10 → 0.1.11

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 (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +128 -25
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  6. package/plugins/kk/scripts/daily-stages.ts +1057 -108
  7. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  8. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  9. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
  10. package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
  11. package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
  12. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  13. package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
  14. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  15. package/plugins/publishings/csdn-pub/dist/index.js +2 -1
  16. package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
  17. package/plugins/publishings/infoq-pub/dist/index.js +2 -1
  18. package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
  19. package/plugins/publishings/jike-pub/dist/index.js +2 -1
  20. package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
  21. package/plugins/publishings/juejin-pub/dist/index.js +2 -1
  22. package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
  23. package/plugins/publishings/podcast-pub/dist/index.js +46 -8
  24. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  25. package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
  26. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  27. package/plugins/publishings/sspai-pub/dist/index.js +2 -1
  28. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  29. package/plugins/publishings/substack-pub/dist/index.js +2 -1
  30. package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
  32. package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
  33. package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
  34. package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
  35. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
  36. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  37. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  38. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  39. package/plugins/publishings/weibo-pub/dist/index.js +2 -1
  40. package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
  41. package/plugins/publishings/x-pub/dist/index.js +2 -1
  42. package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
  43. package/plugins/publishings/xhs-pub/dist/index.js +2 -1
  44. package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
  45. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
  46. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
@@ -1,7 +1,20 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { tmpdir } from 'node:os';
3
+ import { extname, join } from 'node:path';
1
4
  import type { Content, Result } from '@gobing-ai/kk-core';
2
5
  import { ResultSchema } from '@gobing-ai/kk-core';
3
6
  import { trySelectors } from '@gobing-ai/publish-harness';
4
- import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
7
+ import {
8
+ DEFAULT_SIGNAL_CONTAINERS,
9
+ expectOutcome,
10
+ mkdirExclusive,
11
+ pasteHtml,
12
+ readScopedSignal,
13
+ rmdir,
14
+ uploadFile,
15
+ waitUntil,
16
+ } from '@gobing-ai/publish-harness/browser';
17
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
18
  import type { Page } from 'playwright';
6
19
  import { readLocalImageBytes } from './cgi';
7
20
  import {
@@ -30,6 +43,7 @@ import {
30
43
  findSelector,
31
44
  followNewPage,
32
45
  modalState,
46
+ sanitizeSessionEvidence,
33
47
  tryFindSelector,
34
48
  type WechatPage,
35
49
  waitForVisible,
@@ -69,9 +83,18 @@ const AUTHOR_INPUT = ['input#author', 'input.js_author'];
69
83
  /** The editor's own image input (§3: `accept="image/gif,image/jpeg,image/jpg,image/png,image/svg,image/webp"`). */
70
84
  const IMAGE_FILE_INPUT = ['input[type="file"][accept*="image/"]', 'input[type="file"][accept*=".png"]'];
71
85
 
72
- /** `一键排版` (the format hint's trio is a LABEL, not a menu — see applyLayout). */
86
+ /** `一键排版` and its measured UEditor menu (see applyLayout). */
73
87
  const LAYOUT_CONTROL = ['button:has-text("一键排版")', 'div.ailayout-btn_wrp', 'div.ailayout-btn_label'];
74
- const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'li:has-text("匹配目标格式")'];
88
+ const LAYOUT_TARGET = [
89
+ 'div.edui-popup:visible .edui-pastecontainer > .edui-button > div.edui-default:nth-child(2)',
90
+ 'div[role="menuitem"]:has-text("匹配目标格式")',
91
+ 'li:has-text("匹配目标格式")',
92
+ ];
93
+ /** November 2 live console: the layout control opens a full-page preview with this commit action. */
94
+ const LAYOUT_PREVIEW_APPLY = ['button:has-text("使用此排版")', 'a:has-text("使用此排版")', 'text=使用此排版'];
95
+ const LAYOUT_TARGET_MARKER = '__KK_WECHAT_LAYOUT_TARGET__';
96
+ const LAYOUT_SAVE_STATE_MARKER = '__KK_WECHAT_LAYOUT_SAVE_STATE__';
97
+ const LEGACY_EDITOR_READY_MARKER = '__KK_WECHAT_LEGACY_EDITOR_READY__';
75
98
 
76
99
  /**
77
100
  * 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
@@ -80,17 +103,34 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
80
103
  * material's cover, so the library holds exactly the image the operator wants (2026-10-10);
81
104
  * `从正文选择` remains selectable via `metadata.wechatmp.coverSource`.
82
105
  */
83
- const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
84
- const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
85
- const COVER_FROM_LIBRARY = ['a.js_imagedialog', 'text=从图片库选择'];
106
+ const COVER_PANEL_OPEN = [
107
+ '.js_cover_btn_area:visible',
108
+ 'text=换一张',
109
+ 'text=修改',
110
+ 'a.js_selectCoverFromContent:visible',
111
+ ];
112
+ const COVER_FROM_CONTENT = [
113
+ '#js_cover_area a.js_selectCoverFromContent:visible',
114
+ 'a.js_selectCoverFromContent:visible',
115
+ ];
116
+ const COVER_FROM_LIBRARY = ['#js_cover_area a.js_imagedialog:visible', 'a.js_imagedialog:visible'];
86
117
  /** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
87
- const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
118
+ const COVER_LIBRARY_NEXT = [
119
+ '.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) button:has-text("下一步")',
120
+ 'button:has-text("下一步")',
121
+ ];
88
122
  /**
89
123
  * The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
90
124
  * confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
91
125
  * to do). A miss is recorded as a warning, never as a false cover claim.
92
126
  */
93
- const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
127
+ const COVER_CONFIRM = [
128
+ '.weui-desktop-dialog:visible:has(h3:has-text("编辑封面")) button:text-is("确认")',
129
+ '.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) button:has-text("完成"):not(.weui-desktop-btn_disabled)',
130
+ 'button:has-text("确定")',
131
+ 'button:has-text("确认")',
132
+ 'button:has-text("完成")',
133
+ ];
94
134
 
95
135
  /** The controls that close whatever modal the cover step left open, most specific first. */
96
136
  const DIALOG_DISMISS = [
@@ -229,25 +269,68 @@ const AUDIO_COVER_INPUT = [
229
269
  // skipped on every run and the draft shipped with no cover.
230
270
  '.weui-desktop-dialog input[type="file"][accept*="image"]',
231
271
  '.audio_dialog input[type="file"][accept*="image"]',
272
+ // The image chooser portals this input outside its dialog; the toolbar input excludes BMP.
273
+ 'input[type="file"][accept*="image/bmp"]',
232
274
  ];
233
275
  const AUDIO_TITLE_INPUT = [
234
276
  '.weui-desktop-dialog input[placeholder*="标题"]',
235
277
  '.audio_dialog input[placeholder*="标题"]',
236
278
  '.audio_dialog input.frm_input',
237
279
  ];
238
- const UPLOAD_SAVE = ['.weui-desktop-dialog button:has-text("保存")', 'button:has-text("保存")'];
280
+ const UPLOAD_SAVE = ['.audio-upload-dialog button:text-is("保存")', '.weui-desktop-dialog button:text-is("保存")'];
239
281
 
240
282
  /** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
241
283
  const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
242
284
  const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
243
- const PUBLISH_CONFIRM = ['div.weui-desktop-dialog button:has-text("确定")', 'button:has-text("确定")'];
285
+ /** Live 2026-11-02 mass-send dialog: the final action is the green 发表 button, not 确定. */
286
+ const PUBLISH_DIALOG = ['div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表"))'];
287
+ const PUBLISH_NOTIFY_TOGGLE = [
288
+ 'div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表")) .mass_send__notify .weui-desktop-switch__input:visible',
289
+ ];
290
+ const PUBLISH_NOTIFY_STATE_SCRIPT = `(() => {
291
+ const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find((candidate) => {
292
+ const title = candidate.querySelector('h3.weui-desktop-dialog__title')?.textContent?.trim();
293
+ const rect = candidate.getBoundingClientRect();
294
+ return title === '发表' && rect.width > 0 && rect.height > 0;
295
+ });
296
+ const input = dialog?.querySelector('.mass_send__notify input.weui-desktop-switch__input');
297
+ return input instanceof HTMLInputElement ? input.checked : null;
298
+ })()`;
299
+ const PUBLISH_FINAL = [
300
+ 'div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表")) .mass-send__footer button.weui-desktop-btn_primary:text-is("发表")',
301
+ ];
302
+ /** Known pre-confirmation stale-version dialog action; it is never a generic retry control. */
303
+ const PUBLISH_VIEW_LATEST = [
304
+ 'div.version_fail_dialog:visible a.btn_primary.js_btn:text-is("查看新草稿"):visible',
305
+ 'div.weui-desktop-dialog:visible button:text-is("查看新草稿")',
306
+ 'div.weui-desktop-dialog:visible a:text-is("查看新草稿")',
307
+ 'div.weui-desktop-dialog:visible [role="button"]:text-is("查看新草稿")',
308
+ 'div.weui-desktop-dialog:visible button:has-text("查看新草稿")',
309
+ ];
244
310
  const PUBLISH_SUCCESS = ['发表成功', '已发表'];
311
+ const PUBLIC_SAVE_REFUSAL = ['当前编辑的草稿不是最新版本'];
312
+ const PUBLIC_SAVE_REFUSAL_CONTAINERS = [
313
+ ...DEFAULT_SIGNAL_CONTAINERS,
314
+ 'div.version_fail_dialog:visible',
315
+ 'div.weui-desktop-dialog:visible',
316
+ ];
317
+
318
+ async function readStaleVersionRefusal(page: Page): Promise<string | null> {
319
+ return readScopedSignal(page, PUBLIC_SAVE_REFUSAL, PUBLIC_SAVE_REFUSAL_CONTAINERS);
320
+ }
245
321
  /**
246
322
  * The platform's own refusal/hint lines (§5, §5.3) — relayed as the Result's actionable message
247
323
  * instead of a generic failure. `系统繁忙,请稍后再试` is deliberately absent: it appears routinely
248
324
  * and is NOT a failure indicator (§5.1).
249
325
  */
250
- const PLATFORM_REFUSAL = ['内容不完整,无法发表', '请补充封面图', '未设置头像和名称', '公众号尚未实名'];
326
+ const PLATFORM_REFUSAL = [
327
+ '内容不完整,无法发表',
328
+ '请补充封面图',
329
+ '必须插入一张图片',
330
+ '未设置头像和名称',
331
+ '公众号尚未实名',
332
+ '当前编辑的草稿不是最新版本',
333
+ ];
251
334
 
252
335
  /** The 草稿箱 row's own update label (§5.1: `kk-discovery-文章 请忽略 … 更新于 13:12`). */
253
336
  const DRAFT_STAMP = '更新于';
@@ -301,7 +384,7 @@ interface ModeEntry {
301
384
  }
302
385
 
303
386
  function fail(error: string): Result {
304
- return ResultSchema.parse({ ok: false, target: TARGET, error });
387
+ return ResultSchema.parse({ ok: false, target: TARGET, error: sanitizeSessionEvidence(error) });
305
388
  }
306
389
 
307
390
  function sleep(ms: number): Promise<void> {
@@ -577,35 +660,201 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
577
660
  await pasteHtml(asPlaywrightPage(page), selector, markdownToWechatHtml(body), { timeoutMs: deps.stepTimeoutMs });
578
661
  }
579
662
 
663
+ /**
664
+ * The current UEditor build keeps the layout choices as nested, unlabelled divs rather than
665
+ * role/menuitem nodes. Playwright's visibility probe rejects those descendants even while the
666
+ * popup is visibly open, so the measured DOM click is kept in a page-side seam and requires one
667
+ * uniquely named target. No arbitrary first-match click is safe here.
668
+ */
669
+ function nativeLayoutTargetScript(): string {
670
+ return `/* ${LAYOUT_TARGET_MARKER} */ (() => {
671
+ const popups = [...document.querySelectorAll('.edui-popup')].filter((popup) => {
672
+ const rect = popup.getBoundingClientRect();
673
+ const style = getComputedStyle(popup);
674
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
675
+ });
676
+ const targets = popups
677
+ .flatMap((popup) => [...popup.querySelectorAll('.edui-pastecontainer > .edui-button > div')])
678
+ .filter((candidate) => candidate.textContent?.trim() === '匹配目标格式');
679
+ if (targets.length !== 1) return JSON.stringify({ state: targets.length === 0 ? 'missing' : 'ambiguous' });
680
+ const target = targets[0];
681
+ if (!(target instanceof HTMLElement)) return JSON.stringify({ state: 'missing' });
682
+ target.click();
683
+ return JSON.stringify({ state: 'clicked' });
684
+ })()`;
685
+ }
686
+
687
+ /** The measured editor autosave rail: it is the native completion acknowledgement for layout. */
688
+ function nativeLayoutSaveStateScript(): string {
689
+ return `/* ${LAYOUT_SAVE_STATE_MARKER} */ (() => {
690
+ const container = document.querySelector('#auto_save_container');
691
+ if (!container) return 'unknown';
692
+ if (container.textContent?.includes('自动保存中')) return 'saving';
693
+ if (container.querySelector('.save_done')?.textContent?.includes('已保存')) return 'saved';
694
+ return 'unknown';
695
+ })()`;
696
+ }
697
+
698
+ /**
699
+ * The legacy `media/appmsg_edit` editor has no active v2 autosave rail. Its measured manual-save
700
+ * readiness is narrower: the canonical route is explicit, the v2 container is empty, the legacy
701
+ * autosave and save-success markers exist but are hidden, and exactly one visible 保存为草稿 control
702
+ * remains. Unknown DOM shapes stay unknown; they are never treated as idle.
703
+ */
704
+ function nativeLegacyEditorReadyScript(): string {
705
+ return `/* ${LEGACY_EDITOR_READY_MARKER} */ (() => {
706
+ const current = new URL(window.location.href);
707
+ const canonicalLegacyRoute =
708
+ current.origin === 'https://mp.weixin.qq.com' &&
709
+ current.pathname === '/cgi-bin/appmsg' &&
710
+ current.searchParams.get('t') === 'media/appmsg_edit' &&
711
+ current.searchParams.get('action') === 'edit' &&
712
+ (current.searchParams.get('appmsgid') ?? '').trim() !== '' &&
713
+ current.searchParams.get('isNew') !== '1';
714
+ if (!canonicalLegacyRoute) return 'unknown';
715
+ const autoSaveContainer = document.querySelector('#auto_save_container');
716
+ const legacyAutoSave = document.querySelector('#js_autosave');
717
+ const saveSuccessMarkers = [...document.querySelectorAll(
718
+ '#js_save_success, #js_save_success_with_ad, #js_save_success_with_ad_op',
719
+ )];
720
+ if (!autoSaveContainer || !legacyAutoSave || saveSuccessMarkers.length === 0) return 'unknown';
721
+ const hidden = (node) => {
722
+ const style = window.getComputedStyle(node);
723
+ const rect = node.getBoundingClientRect();
724
+ return style.display === 'none' || style.visibility === 'hidden' || rect.width === 0 || rect.height === 0;
725
+ };
726
+ const visibleSaveControls = [...document.querySelectorAll('button, a')].filter((node) => {
727
+ const text = (node.textContent ?? '').replace(/[\\s\\u00a0]+/g, '').trim();
728
+ if (text !== '保存为草稿') return false;
729
+ const style = window.getComputedStyle(node);
730
+ const rect = node.getBoundingClientRect();
731
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
732
+ });
733
+ return autoSaveContainer.textContent?.trim() === '' &&
734
+ hidden(legacyAutoSave) &&
735
+ saveSuccessMarkers.every(hidden) &&
736
+ visibleSaveControls.length === 1
737
+ ? 'ready'
738
+ : 'unknown';
739
+ })()`;
740
+ }
741
+
742
+ async function clickNativeLayoutTarget(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
743
+ let state: string = 'missing';
744
+ const observed = await waitUntil(
745
+ async () => {
746
+ const raw = await page.evaluate(nativeLayoutTargetScript()).catch(() => null);
747
+ if (typeof raw !== 'string') return false;
748
+ try {
749
+ const parsed = JSON.parse(raw) as { state?: string };
750
+ if (parsed.state === 'ambiguous') {
751
+ state = 'ambiguous';
752
+ return true;
753
+ }
754
+ if (parsed.state === 'clicked') {
755
+ state = 'clicked';
756
+ return true;
757
+ }
758
+ } catch {
759
+ state = 'missing';
760
+ }
761
+ return false;
762
+ },
763
+ Math.min(deps.stepTimeoutMs, 5_000),
764
+ deps.pollMs,
765
+ );
766
+ if (!observed) return false;
767
+ if (state === 'ambiguous')
768
+ throw new Error(
769
+ '一键排版: 匹配目标格式 was ambiguous in the visible native menu — composition stopped before submission',
770
+ );
771
+ return state === 'clicked';
772
+ }
773
+
774
+ async function waitForNativeLayoutSave(page: WechatPage, deps: ModeRunDeps): Promise<void> {
775
+ let sawSaving = false;
776
+ const saved = await waitUntil(
777
+ async () => {
778
+ const state = await page.evaluate(nativeLayoutSaveStateScript()).catch(() => 'unknown');
779
+ if (state === 'saving') sawSaving = true;
780
+ return sawSaving && state === 'saved';
781
+ },
782
+ Math.min(deps.stepTimeoutMs, 15_000),
783
+ deps.pollMs,
784
+ );
785
+ if (!saved)
786
+ throw new Error(
787
+ '一键排版: native auto-save did not transition from 自动保存中... to 已保存 within the layout deadline — composition stopped before submission',
788
+ );
789
+ }
790
+
580
791
  /**
581
792
  * `一键排版` applies the platform's own layout to the pasted body. The operator walk of 2026-10-10 shows
582
- * the control is present and expected to be applied, so a click that leads nowhere is now REPORTED
793
+ * the control is present and expected to be applied, so a click that leads nowhere is now rejected
583
794
  * rather than swallowed (the earlier version failed silently, which is why 正文 came out unformatted).
584
795
  *
585
796
  * The format set (`保留源格式` / `匹配目标格式` / `仅保留文本`) is what we want when it renders as a menu;
586
797
  * `匹配目标格式` is the correct choice for markdown-derived content. A build where the trio is only a
587
- * label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
798
+ * label (the 2026-10-05 dogfood reading) is accepted only when its own measured commit signal is
799
+ * available; an unrecognized control fails closed before submission.
588
800
  */
589
801
  async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
590
802
  // Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
591
803
  // control is the platform's layout — silently skip; only a found-but-failing control warns.
592
804
  const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
593
805
  if (control === undefined) return null;
806
+ const before = deps.pages().length;
594
807
  try {
595
808
  await clickSelector(page, control, deps.stepTimeoutMs);
596
809
  } catch (error) {
597
810
  return `一键排版: click failed — ${error instanceof Error ? error.message : String(error)}`;
598
811
  }
599
- const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
812
+ if (await clickNativeLayoutTarget(page, deps)) {
813
+ await waitForNativeLayoutSave(page, deps);
814
+ return null;
815
+ }
816
+ let layoutPage = page;
817
+ let option: string | undefined;
818
+ await waitUntil(
819
+ async () => {
820
+ for (const candidate of [...deps.pages().slice(before), page]) {
821
+ const found = await trySelectors(candidate, [...LAYOUT_PREVIEW_APPLY, ...LAYOUT_TARGET]);
822
+ if (found.found && found.selector) {
823
+ layoutPage = candidate;
824
+ option = found.selector;
825
+ return true;
826
+ }
827
+ }
828
+ return false;
829
+ },
830
+ Math.min(deps.stepTimeoutMs, 15_000),
831
+ );
832
+ if (option !== undefined && LAYOUT_PREVIEW_APPLY.includes(option)) {
833
+ const selected = option;
834
+ await clickSelector(layoutPage, selected, deps.stepTimeoutMs);
835
+ const closed = await waitUntil(
836
+ async () => {
837
+ if (!deps.pages().includes(layoutPage)) return true;
838
+ const control = layoutPage.locator(selected).first();
839
+ return !(await control.isVisible());
840
+ },
841
+ Math.min(deps.stepTimeoutMs, 5_000),
842
+ );
843
+ if (!closed)
844
+ throw new Error('一键排版: 使用此排版 did not close the preview — composition stopped before submission');
845
+ return null;
846
+ }
600
847
  if (option === undefined) {
601
- // Measured 2026-10-21 with a body in the editor: `div#ai_layout_container > div.ailayout-btn_wrp`
602
- // is present and clickable, and no option set ever renders (only a hidden `ailayout-edu_tips`
603
- // onboarding block). Nothing programmable is left to click, so name the finding instead of
604
- // guessing at the body's formatting.
605
- return '一键排版: the control exists but this build renders no 匹配目标格式 option (measured 2026-10-21 with a body in place) — layout skipped, apply 一键排版 by hand before publishing';
848
+ if (deps.pages().length > before)
849
+ throw new Error(
850
+ '一键排版: a new preview tab opened without a recognized commit action — composition stopped before submission',
851
+ );
852
+ throw new Error(
853
+ '一键排版: the visible control exposed no recognized commit action — composition stopped before submission',
854
+ );
606
855
  }
607
856
  try {
608
- await clickSelector(page, option, 5_000);
857
+ await clickSelector(layoutPage, option, 5_000);
609
858
  return null;
610
859
  } catch (error) {
611
860
  return `一键排版: 匹配目标格式 not applied — ${error instanceof Error ? error.message : String(error)}`;
@@ -637,6 +886,26 @@ function baseName(path: string): string {
637
886
  return path.split(/[\\/]/).pop() ?? path;
638
887
  }
639
888
 
889
+ /** A stable name binds the library choice to these cover bytes across interrupted retries. */
890
+ async function coverLibraryName(path: string): Promise<string> {
891
+ if (!(await Bun.file(path).exists())) return baseName(path);
892
+ const hash = new Bun.CryptoHasher('sha256')
893
+ .update(new Uint8Array(await Bun.file(path).arrayBuffer()))
894
+ .digest('hex');
895
+ return `kk-cover-${hash.slice(0, 32)}${extname(path).toLowerCase()}`;
896
+ }
897
+
898
+ function coverAssetIdentity(value: string): string {
899
+ try {
900
+ const url = new URL(value);
901
+ return url.hostname === 'mmbiz.qpic.cn'
902
+ ? `${url.hostname}${url.pathname.replace(/\/(?:0|300|640|1080)$/, '')}`
903
+ : value;
904
+ } catch {
905
+ return value;
906
+ }
907
+ }
908
+
640
909
  /** The identities `setCover` can accept for a candidate: the requested article image and/or the
641
910
  * upload binding actually observed going into the audio form. Either is optional; at least one
642
911
  * must produce a UNIQUE exposed match before anything is clicked (task 0260 R1). */
@@ -655,7 +924,7 @@ interface CoverIdentity {
655
924
  * `#js_cover_area` is read back: the empty state (`add_cover` icon still visible) or an unreadable
656
925
  * preview is a named warning, never a success claim (task 0260 R1).
657
926
  */
658
- async function setCover(
927
+ export async function setCover(
659
928
  page: WechatPage,
660
929
  source: 'library' | 'body',
661
930
  deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
@@ -672,8 +941,9 @@ async function setCover(
672
941
  // A cover miss is a warning, never a gate — so the panel must not outlive the step that opened
673
942
  // it (see {@link dismissOpenDialog} for what its footer blocked on 2026-10-12).
674
943
  const warn = async (message: string): Promise<string> => {
944
+ const inventory = await openDialogInventory(page);
675
945
  await dismissOpenDialog(page);
676
- return message;
946
+ return inventory ? `${message} (dialogs: ${inventory})` : message;
677
947
  };
678
948
  try {
679
949
  const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
@@ -682,9 +952,18 @@ async function setCover(
682
952
  'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
683
953
  );
684
954
  await clickSelector(page, open, deps.stepTimeoutMs);
685
- const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
686
- if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
687
- await clickSelector(page, pick, deps.stepTimeoutMs);
955
+ const alreadyOpen =
956
+ source === 'library' &&
957
+ (await tryFindSelector(
958
+ page,
959
+ ['.weui-desktop-dialog:visible:has(h3:has-text("选择图片"))'],
960
+ Math.min(deps.stepTimeoutMs, 1_000),
961
+ )) !== undefined;
962
+ if (!alreadyOpen) {
963
+ const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
964
+ if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
965
+ await clickSelector(page, pick, deps.stepTimeoutMs);
966
+ }
688
967
  if (wanted.length === 0)
689
968
  return await warn(`cover: no requested cover file identity to match against — ${what} not driven`);
690
969
  // Identify before clicking: the item list is not <img>-based (§5.2) and never sorted the way
@@ -697,19 +976,59 @@ async function setCover(
697
976
  const el = row.querySelector('[title], [data-name], [data-file_name]') ?? row;
698
977
  const attrs = [el.getAttribute?.('title'), el.getAttribute?.('data-name'), el.getAttribute?.('data-file_name')];
699
978
  const text = [...row.querySelectorAll('[class*="name" i], [class*="title" i]')].map((n) => n.textContent ?? '');
700
- return [...attrs, ...text].map((v) => (v ?? '').trim()).find((v) => v.length > 0) ?? '';
979
+ return [...attrs, ...text].map((v) => (v ?? '').trim()).filter((v) => v.length > 0);
701
980
  };
702
- const hits = rows.filter((row) => wanted.some((w) => nameOf(row) === w));
981
+ const hits = rows.filter((row) => wanted.some((w) => nameOf(row).includes(w)));
703
982
  if (hits.length === 0) return 'missing';
704
983
  if (hits.length > 1) return 'ambiguous';
705
984
  const row = hits[0];
706
- row.querySelector('input[type=checkbox], [role=checkbox]')?.click();
707
- row.click();
985
+ if (!row.classList.contains('selected')) row.click();
708
986
  const checked = row.querySelector('input[type=checkbox], [role=checkbox]');
709
- const src = row.querySelector('img')?.src ?? (row.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '');
987
+ const css = row.querySelector('[style*="background-image"]')?.style.backgroundImage ?? '';
988
+ const backgroundUrl = css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
989
+ const src = row.querySelector('img')?.src || backgroundUrl;
710
990
  return JSON.stringify({ picked: true, src });
711
991
  })()`;
712
- const rawIdentify = String(await page.evaluate(IDENTIFY));
992
+ let rawIdentify = 'missing';
993
+ await waitUntil(
994
+ async () => {
995
+ rawIdentify = String(await page.evaluate(IDENTIFY));
996
+ return rawIdentify !== 'missing';
997
+ },
998
+ Math.min(deps.stepTimeoutMs, 8_000),
999
+ );
1000
+ const paginationScript = `(() => {
1001
+ const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find(d => d.offsetWidth > 0 && d.querySelector('h3')?.textContent?.includes('选择图片'));
1002
+ const labels = dialog?.querySelectorAll('.weui-desktop-pagination__num');
1003
+ return JSON.stringify({ page: Number(labels?.[0]?.textContent ?? 0), total: Number(labels?.[1]?.textContent ?? 0) });
1004
+ })()`;
1005
+ for (let scanned = 0; source === 'library' && rawIdentify === 'missing' && scanned < 50; scanned += 1) {
1006
+ const pagination = JSON.parse(String(await page.evaluate(paginationScript))) as {
1007
+ page?: number;
1008
+ total?: number;
1009
+ } | null;
1010
+ if (!(Number(pagination?.page) > 0 && Number(pagination?.page) < Number(pagination?.total))) break;
1011
+ const nextPage = await tryFindSelector(
1012
+ page,
1013
+ [
1014
+ '.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) a:has-text("下一页"):visible:not(.weui-desktop-btn_disabled)',
1015
+ ],
1016
+ 1_000,
1017
+ );
1018
+ if (nextPage === undefined) break;
1019
+ await clickSelector(page, nextPage, deps.stepTimeoutMs);
1020
+ await page.waitForTimeout?.(500);
1021
+ await waitUntil(
1022
+ async () =>
1023
+ Boolean(
1024
+ await page.evaluate(
1025
+ `![...document.querySelectorAll('.weui-desktop-dialog .weui-desktop-loading')].some(node => node.offsetWidth > 0)`,
1026
+ ),
1027
+ ),
1028
+ Math.min(deps.stepTimeoutMs, 8_000),
1029
+ );
1030
+ rawIdentify = String(await page.evaluate(IDENTIFY));
1031
+ }
713
1032
  if (rawIdentify === 'missing')
714
1033
  return await warn(`cover: ${what} 未标出与请求文件同名的素材,未选择(不按首图猜测)`);
715
1034
  if (rawIdentify === 'ambiguous') return await warn(`cover: ${what} 匹配到多个同名素材,身份不唯一,未选择`);
@@ -724,37 +1043,85 @@ async function setCover(
724
1043
  if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
725
1044
  const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
726
1045
  if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
1046
+ const selectedSrc = (selected.src ?? '').trim();
1047
+ const cropSources: unknown = JSON.parse(
1048
+ String(
1049
+ await page.evaluate(`(() => {
1050
+ const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find(d => d.offsetWidth > 0 && d.querySelector('h3')?.textContent?.trim() === '编辑封面');
1051
+ return JSON.stringify([...(dialog?.querySelectorAll('img.crop-img') ?? [])]
1052
+ .filter(img => img.offsetWidth > 0 && getComputedStyle(img).visibility !== 'hidden').map(img => img.src));
1053
+ })()`),
1054
+ ),
1055
+ );
1056
+ const cropBound =
1057
+ selectedSrc !== '' &&
1058
+ Array.isArray(cropSources) &&
1059
+ cropSources.some(
1060
+ (src) => typeof src === 'string' && coverAssetIdentity(src) === coverAssetIdentity(selectedSrc),
1061
+ );
1062
+ const beforeCommit = String(
1063
+ await page.evaluate(`(() => {
1064
+ const css = document.querySelector('#js_cover_area .js_cover_preview_new')?.style.backgroundImage ?? '';
1065
+ return css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
1066
+ })()`),
1067
+ );
727
1068
  await clickSelector(page, confirm, deps.stepTimeoutMs);
728
1069
  // Committed readback (observed capture: the preview lives under #js_cover_area; the empty
729
1070
  // state keeps the `add_cover` icon visible). Poll briefly — the preview renders late.
730
1071
  const READBACK = `(() => {
731
1072
  const area = document.querySelector('#js_cover_area');
732
1073
  if (!area) return 'noarea';
733
- if (area.querySelector('.icon20_common.add_cover') && area.querySelector('.icon20_common.add_cover').offsetWidth > 0) return 'empty';
734
1074
  const preview = area.querySelector('.js_cover_preview_new');
735
- if (!preview || preview.offsetWidth === 0) return 'nopreview';
736
- const src = preview.querySelector('img')?.src ??
737
- (preview.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '') ||
738
- (preview.querySelector('input[type=hidden]')?.value ?? '');
1075
+ if (!preview || preview.offsetWidth === 0) {
1076
+ return area.querySelector('.icon20_common.add_cover')?.offsetWidth > 0 ? 'empty' : 'nopreview';
1077
+ }
1078
+ const css = preview.style.backgroundImage || preview.querySelector('[style*="background-image"]')?.style.backgroundImage || '';
1079
+ const backgroundUrl = css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
1080
+ const src = preview.querySelector('img')?.src || backgroundUrl || preview.querySelector('input[type=hidden]')?.value || '';
739
1081
  return src ? src : 'unknown';
740
1082
  })()`;
741
1083
  let committed = '';
742
- for (let read = 0; read < 5 && committed === ''; read += 1) {
743
- const raw = String(await page.evaluate(READBACK).catch(() => 'error'));
744
- if (raw !== '' && raw !== 'nopreview' && raw !== 'noarea') {
745
- committed = raw;
746
- break;
747
- }
748
- await page.waitForTimeout?.(600);
749
- }
750
- if (committed === 'empty') return await warn('cover: 确认后面板仍显示未设置封面,提交未生效');
751
- if (committed === '' || committed === 'noarea' || committed === 'error')
752
- return await warn('cover: 已确认但无法读取已提交封面,请手动核对');
1084
+ let lastRead = '';
1085
+ const observed = await waitUntil(
1086
+ async () => {
1087
+ lastRead = String(await page.evaluate(READBACK).catch(() => 'error'));
1088
+ if (['', 'nopreview', 'noarea', 'empty', 'unknown', 'error'].includes(lastRead)) return false;
1089
+ if (
1090
+ cropBound &&
1091
+ lastRead === beforeCommit &&
1092
+ coverAssetIdentity(lastRead) !== coverAssetIdentity(selectedSrc)
1093
+ )
1094
+ return false;
1095
+ committed = lastRead;
1096
+ return true;
1097
+ },
1098
+ Math.min(deps.stepTimeoutMs, 15_000),
1099
+ 200,
1100
+ );
1101
+ if (!observed)
1102
+ return await warn(
1103
+ lastRead === 'empty'
1104
+ ? 'cover: 确认后面板仍显示未设置封面,提交未生效'
1105
+ : 'cover: 已确认但无法读取已提交封面,请手动核对',
1106
+ );
753
1107
  // Identity comparison: both sides are remote asset references from the same widget. Only an
754
1108
  // exact match proves the requested asset committed; an unreadable/edited preview warns
755
1109
  // rather than inventing equivalence (local paths are never compared to asset URLs).
756
- const selectedSrc = (selected.src ?? '').trim();
757
- if (selectedSrc === '' || (committed !== selectedSrc && !committed.includes(selectedSrc)))
1110
+ let derivedFromBoundCrop = false;
1111
+ try {
1112
+ const url = new URL(committed);
1113
+ derivedFromBoundCrop =
1114
+ cropBound &&
1115
+ committed !== beforeCommit &&
1116
+ url.hostname === 'mmbiz.qpic.cn' &&
1117
+ ['http:', 'https:'].includes(url.protocol);
1118
+ } catch {
1119
+ /* Unknown preview references stay unverifiable. */
1120
+ }
1121
+ if (
1122
+ selectedSrc === '' ||
1123
+ (coverAssetIdentity(committed) !== coverAssetIdentity(selectedSrc) && !derivedFromBoundCrop)
1124
+ )
758
1125
  return await warn(
759
1126
  `cover: 已提交封面的素材身份与所选不一致(selected=${selectedSrc || '未读到'} committed=${committed.slice(0, 80)})`,
760
1127
  );
@@ -795,6 +1162,7 @@ function audioBlockOf(content: Content): { title: string; audioPath: string; cov
795
1162
  async function enrichArticleDraft(
796
1163
  page: WechatPage,
797
1164
  content: Content,
1165
+ stepTimeoutMs: number,
798
1166
  ): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
799
1167
  const enrichMeta = content.metadata?.wechatmp;
800
1168
  if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
@@ -821,10 +1189,10 @@ async function enrichArticleDraft(
821
1189
  const cover = await setCover(
822
1190
  page,
823
1191
  'library',
824
- { stepTimeoutMs: 8_000 },
1192
+ { stepTimeoutMs: Math.min(stepTimeoutMs, 8_000) },
825
1193
  // The upload binding is the image the audio form just bound (the episode cover
826
1194
  // file); `coverPath(input)` is the article's own first body image, not this.
827
- { binding: audio.coverPath === '' ? undefined : audio.coverPath },
1195
+ { binding: audio.coverPath === '' ? undefined : await coverLibraryName(audio.coverPath) },
828
1196
  );
829
1197
  if (cover !== null) coverWarnings.push(cover);
830
1198
  }
@@ -843,8 +1211,6 @@ async function composeArticle(
843
1211
  await attachImages(page, input.images, deps);
844
1212
  const titlePath = await typeTitle(page, deps);
845
1213
  await insertBody(page, deps);
846
- const layout = await applyLayout(page, deps);
847
- if (layout !== null) warnings.push(layout);
848
1214
  for (const warning of [
849
1215
  await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
850
1216
  await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
@@ -862,8 +1228,13 @@ async function composeArticle(
862
1228
  });
863
1229
  if (cover !== null) warnings.push(cover);
864
1230
  }
865
- const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content);
1231
+ const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content, deps.stepTimeoutMs);
866
1232
  warnings.push(...coverWarnings);
1233
+ // Layout is deliberately last: 原文链接, media insertion, and cover commitment must be part of
1234
+ // the body the platform formats, and the native auto-save acknowledgement must settle before
1235
+ // 保存为草稿 or the public saved-editor readback begins.
1236
+ const layout = await applyLayout(page, deps);
1237
+ if (layout !== null) warnings.push(layout);
867
1238
  return { warnings, titlePath, enrichWarnings };
868
1239
  }
869
1240
 
@@ -986,7 +1357,7 @@ function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Re
986
1357
  artifactPaths: artifactPaths(input),
987
1358
  entryPath: entry.path,
988
1359
  editorCreateType: entry.createType,
989
- editorUrl: entry.editor.url(),
1360
+ editorUrl: sanitizeSessionEvidence(entry.editor.url()),
990
1361
  ...(cover === undefined ? {} : { coverPath: cover }),
991
1362
  };
992
1363
  }
@@ -1079,6 +1450,410 @@ async function saveDraft(
1079
1450
  });
1080
1451
  }
1081
1452
 
1453
+ interface PublicDraftSnapshot {
1454
+ title: string;
1455
+ body: string;
1456
+ media: string[];
1457
+ cover: string;
1458
+ originalUrl: string;
1459
+ }
1460
+
1461
+ interface PublicSaveEvidence {
1462
+ saveControl: string;
1463
+ canonicalUrl: string;
1464
+ canonicalUrlForMetadata: string;
1465
+ saveSignal: 'canonical-url-transition+snapshot' | 'persisted-snapshot';
1466
+ snapshot: PublicDraftSnapshot;
1467
+ }
1468
+
1469
+ /**
1470
+ * The public rail's only native saved-editor readiness signal is the URL the console assigns after
1471
+ * `保存为草稿`: an existing appmsg with a concrete `appmsgid` and the editor action. The live
1472
+ * console's saved URL omits `isNew` (the CGI draft route may include `isNew=0`), while `isNew=1`
1473
+ * is still the unsaved editor and must be rejected. The old home 草稿箱 card is deliberately not
1474
+ * used here — the live console has rendered that card on a different surface from the editor, and a
1475
+ * row cannot prove that this editor is the latest version.
1476
+ */
1477
+ function canonicalSavedEditorUrl(value: string): string | null {
1478
+ try {
1479
+ const url = new URL(value);
1480
+ if (url.origin !== 'https://mp.weixin.qq.com' || url.pathname !== '/cgi-bin/appmsg') return null;
1481
+ if (url.searchParams.get('action') !== 'edit' || url.searchParams.get('isNew') === '1') return null;
1482
+ if ((url.searchParams.get('appmsgid') ?? '').trim() === '') return null;
1483
+ return url.toString();
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+
1489
+ /** Keep the navigation URL private while retaining a useful, credential-free receipt value. */
1490
+ function savedEditorUrlForMetadata(value: string): string {
1491
+ return sanitizeSessionEvidence(value);
1492
+ }
1493
+
1494
+ /** A read-only DOM snapshot used to prove the saved editor still contains the composed payload. */
1495
+ const PUBLIC_DRAFT_SNAPSHOT_SCRIPT = `/* __KK_WECHAT_PUBLISH_SNAPSHOT__ */ (() => {
1496
+ const norm = (value) => (value ?? '').replace(/[\\s\\u00a0]+/g, ' ').trim();
1497
+ const titleNode = document.querySelector('textarea#title, textarea.js_title');
1498
+ const title = titleNode?.value ?? [...document.querySelectorAll('.ProseMirror[data-placeholder*="标题"]')]
1499
+ .map((node) => node.textContent ?? '').join(' ');
1500
+ const body = [...document.querySelectorAll('.ProseMirror:not([data-placeholder])')]
1501
+ .map((node) => norm(node.textContent)).join('\\n');
1502
+ const media = [
1503
+ ...document.querySelectorAll(
1504
+ '.ProseMirror mp-common-mpaudio, .ProseMirror mpvoice, .ProseMirror [data-mediaid], .ProseMirror audio, .ProseMirror video, .ProseMirror img',
1505
+ ),
1506
+ ]
1507
+ .map((node) => node.getAttribute('data-mediaid') ?? node.getAttribute('data-id') ??
1508
+ node.getAttribute('voice_encode_fileid') ?? node.getAttribute('data-src') ?? node.getAttribute('src') ??
1509
+ norm(node.textContent))
1510
+ .map(norm).filter((value) => value.length > 0);
1511
+ const area = document.querySelector('#js_cover_area');
1512
+ const coverNode = area?.querySelector('.js_cover_preview_new img, img, [style*="background-image"], input[type="hidden"]');
1513
+ const cover = coverNode?.getAttribute('src') ?? coverNode?.getAttribute('value') ??
1514
+ coverNode?.style.backgroundImage ?? '';
1515
+ const urlArea = document.querySelector('#js_article_url_area');
1516
+ const originalUrl = urlArea?.querySelector('input.js_url_checkbox')?.checked === true
1517
+ ? norm(urlArea.querySelector('.article_url_setting')?.textContent) : '';
1518
+ return JSON.stringify({ title: norm(title), body, media, cover: norm(cover), originalUrl });
1519
+ })()`;
1520
+
1521
+ function normalizeSnapshotText(value: string): string {
1522
+ return value.replace(/[\s\u00a0]+/g, ' ').trim();
1523
+ }
1524
+
1525
+ /** 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> {
1527
+ try {
1528
+ const raw = await page.evaluate(PUBLIC_DRAFT_SNAPSHOT_SCRIPT);
1529
+ const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
1530
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
1531
+ const value = parsed as Record<string, unknown>;
1532
+ if (
1533
+ typeof value.title !== 'string' ||
1534
+ typeof value.body !== 'string' ||
1535
+ !Array.isArray(value.media) ||
1536
+ value.media.some((item) => typeof item !== 'string') ||
1537
+ typeof value.cover !== 'string'
1538
+ )
1539
+ return null;
1540
+ return {
1541
+ title: normalizeSnapshotText(value.title),
1542
+ body: normalizeSnapshotText(value.body),
1543
+ media: value.media.map((item) => normalizeSnapshotText(item)),
1544
+ cover: normalizeSnapshotText(value.cover),
1545
+ originalUrl: typeof value.originalUrl === 'string' ? normalizeSnapshotText(value.originalUrl) : '',
1546
+ };
1547
+ } catch {
1548
+ return null;
1549
+ }
1550
+ }
1551
+
1552
+ function publicSnapshotDifferences(before: PublicDraftSnapshot, after: PublicDraftSnapshot): string[] {
1553
+ const differences: string[] = [];
1554
+ if (before.title !== after.title) differences.push('title');
1555
+ if (before.body !== after.body) differences.push('body');
1556
+ // WeChat re-encodes/resizes uploaded body images on save. Its CDN preserves the opaque asset
1557
+ // path but may switch the terminal size variant (`/0` → `/640`), so compare that stable asset
1558
+ // identity rather than a transport URL that the platform is expected to rewrite. Audio IDs,
1559
+ // local paths, different opaque keys, order, and cardinality remain strict mismatches.
1560
+ const beforeMedia = before.media.map(snapshotMediaIdentity);
1561
+ const afterMedia = after.media.map(snapshotMediaIdentity);
1562
+ if (JSON.stringify(beforeMedia) !== JSON.stringify(afterMedia)) differences.push('media');
1563
+ if (before.cover !== after.cover) differences.push('cover');
1564
+ if (before.originalUrl !== after.originalUrl) differences.push('originalUrl');
1565
+ return differences;
1566
+ }
1567
+
1568
+ /**
1569
+ * Preserve every media identity component except WeChat's known CDN size suffix. In particular,
1570
+ * query parameters remain part of the comparison so a different format/transform cannot pass as a
1571
+ * resize merely because it shares a path prefix.
1572
+ */
1573
+ function snapshotMediaIdentity(value: string): string {
1574
+ try {
1575
+ const url = new URL(value);
1576
+ if (url.hostname !== 'mmbiz.qpic.cn') return value;
1577
+ const path = url.pathname.replace(/\/(?:0|300|640|1080)$/, '');
1578
+ return `${url.origin}${path}${url.search}${url.hash}`;
1579
+ } catch {
1580
+ return value;
1581
+ }
1582
+ }
1583
+
1584
+ function snapshotValueIsEmpty(value: string): boolean {
1585
+ const normalized = normalizeSnapshotText(value).toLowerCase();
1586
+ return normalized === '' || normalized === 'url("")' || normalized === "url('')" || normalized === 'url()';
1587
+ }
1588
+
1589
+ /** Partial Vue/Tiptap hydration is retryable; a non-empty changed identity is not. */
1590
+ function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: PublicDraftSnapshot): boolean {
1591
+ if (!snapshotValueIsEmpty(expected.title) && snapshotValueIsEmpty(actual.title)) return true;
1592
+ if (!snapshotValueIsEmpty(expected.body) && snapshotValueIsEmpty(actual.body)) return true;
1593
+ if (expected.media.length > actual.media.length) return true;
1594
+ if (!snapshotValueIsEmpty(expected.cover) && snapshotValueIsEmpty(actual.cover)) return true;
1595
+ if (expected.originalUrl !== '' && actual.originalUrl === '') return true;
1596
+ return false;
1597
+ }
1598
+
1599
+ /**
1600
+ * The live console can acknowledge 保存为草稿 before media/cover hydration reaches the canonical
1601
+ * editor (measured 2026-11-03: five partial reads before the two-media snapshot returned). Reloads
1602
+ * are read-only; publication remains impossible until the coherent snapshot matches.
1603
+ */
1604
+ const PUBLIC_SAVED_EDITOR_RELOAD_LIMIT = 6;
1605
+
1606
+ function appmsgIdFromCanonical(value: string): string | null {
1607
+ const canonical = canonicalSavedEditorUrl(value);
1608
+ 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
+ }
1615
+ }
1616
+
1617
+ /** Require two consecutive native readiness observations after any editor-version transition. */
1618
+ async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
1619
+ let consecutiveSaved = 0;
1620
+ return waitUntil(
1621
+ async () => {
1622
+ const state = await page.evaluate(nativeLayoutSaveStateScript()).catch(() => 'unknown');
1623
+ const legacyState =
1624
+ state === 'saved'
1625
+ ? 'not-needed'
1626
+ : await page.evaluate(nativeLegacyEditorReadyScript()).catch(() => 'unknown');
1627
+ if (state === 'saved' || legacyState === 'ready') {
1628
+ consecutiveSaved += 1;
1629
+ return consecutiveSaved >= 2;
1630
+ }
1631
+ // A fresh save transition resets the quiet window; an unreadable state is never idle
1632
+ // evidence and must not be allowed to carry a prior saved probe across the gap.
1633
+ consecutiveSaved = 0;
1634
+ return false;
1635
+ },
1636
+ Math.min(deps.stepTimeoutMs, 15_000),
1637
+ deps.pollMs,
1638
+ );
1639
+ }
1640
+
1641
+ /**
1642
+ * Save the composed editor once, require its canonical persisted URL, reopen that URL, and compare
1643
+ * the persisted surfaces before the one and only public click. A refusal or missing native signal
1644
+ * stops before `发表`; no draft-box lookup or blind save retry is safe for the public rail.
1645
+ */
1646
+ async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
1647
+ const composed = await readPublicDraftSnapshot(entry.editor);
1648
+ if (composed === null)
1649
+ return fail(
1650
+ 'public save readiness: the composed editor snapshot (title/body/media/cover) was unreadable; no publish click attempted',
1651
+ );
1652
+
1653
+ const platformMetadata = deps.content.metadata?.wechatmp;
1654
+ const requestedUrl =
1655
+ platformMetadata !== null && typeof platformMetadata === 'object' && 'originalUrl' in platformMetadata
1656
+ ? platformMetadata.originalUrl
1657
+ : undefined;
1658
+ if (typeof requestedUrl === 'string' && normalizeSnapshotText(requestedUrl) !== composed.originalUrl)
1659
+ return fail('public save readiness: requested 原文链接 was not committed; no publish click attempted');
1660
+
1661
+ const canonicalUrlBeforeSave = canonicalSavedEditorUrl(entry.editor.url());
1662
+ await sleep(deps.submitSettleMs);
1663
+ if ((await closeOpenDialogs(entry.editor)) !== 'closed')
1664
+ return fail('public save readiness: a residual editor dialog blocked 保存为草稿; no publish click attempted');
1665
+ const saveControl = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
1666
+ if (!entry.editor.saveDraftAndWait)
1667
+ return fail(
1668
+ 'public save readiness: the native 保存为草稿 acknowledgement probe was unavailable; no publish click attempted',
1669
+ );
1670
+ try {
1671
+ await entry.editor.saveDraftAndWait(saveControl, deps.draftTimeoutMs);
1672
+ } catch {
1673
+ return fail(
1674
+ 'public save readiness: native 保存为草稿 was not acknowledged successfully; no publish click attempted',
1675
+ );
1676
+ }
1677
+
1678
+ let canonicalUrl: string | null = null;
1679
+ let refusal: string | null = null;
1680
+ const saved = await waitUntil(
1681
+ async () => {
1682
+ refusal = await readStaleVersionRefusal(asPlaywrightPage(entry.editor)).catch(() => null);
1683
+ if (refusal !== null) return true;
1684
+ canonicalUrl = canonicalSavedEditorUrl(entry.editor.url());
1685
+ return canonicalUrl !== null;
1686
+ },
1687
+ deps.draftTimeoutMs,
1688
+ deps.pollMs,
1689
+ );
1690
+ if (refusal !== null) return fail(`public save refused by ${TARGET}: ${refusal}; no publish click attempted`);
1691
+ if (!saved || canonicalUrl === null) {
1692
+ return fail(
1693
+ `public save readiness: 保存为草稿 did not expose a canonical saved editor URL (action=edit with appmsgid, not isNew=1) within ${deps.draftTimeoutMs}ms; no publish click attempted`,
1694
+ );
1695
+ }
1696
+ const savedCanonicalUrl = canonicalUrl;
1697
+
1698
+ // A saved editor can already be on an existing canonical URL. That URL is navigation evidence,
1699
+ // not proof that this click completed; the persisted snapshot below is the independent save proof.
1700
+ await sleep(Math.min(Math.max(deps.pollMs, 25), 500));
1701
+ await entry.editor.goto(savedCanonicalUrl, { timeout: deps.stepTimeoutMs });
1702
+ let reloaded: PublicDraftSnapshot | null = null;
1703
+ let reloadRefusal: string | null = null;
1704
+ let reloads = 0;
1705
+ let lastReloadAt = Date.now();
1706
+ const reloadIntervalMs = Math.min(Math.max(deps.pollMs * 4, 50), 2_000);
1707
+ const canonicalUrlWasPreexisting = canonicalUrlBeforeSave === savedCanonicalUrl;
1708
+ await waitUntil(
1709
+ async () => {
1710
+ reloadRefusal = await readStaleVersionRefusal(asPlaywrightPage(entry.editor)).catch(() => null);
1711
+ if (reloadRefusal !== null) return true;
1712
+ if (canonicalSavedEditorUrl(entry.editor.url()) === null) return false;
1713
+ reloaded = await readPublicDraftSnapshot(entry.editor);
1714
+ if (reloaded === null) return false;
1715
+ const differences = publicSnapshotDifferences(composed, reloaded);
1716
+ if (differences.length === 0) return true;
1717
+ if (!publicSnapshotIsIncomplete(composed, reloaded)) return true;
1718
+ if (reloads >= PUBLIC_SAVED_EDITOR_RELOAD_LIMIT) return true;
1719
+ if (Date.now() - lastReloadAt < reloadIntervalMs) return false;
1720
+ reloads += 1;
1721
+ lastReloadAt = Date.now();
1722
+ await entry.editor.goto(savedCanonicalUrl, { timeout: deps.stepTimeoutMs });
1723
+ return false;
1724
+ },
1725
+ deps.draftTimeoutMs,
1726
+ deps.pollMs,
1727
+ );
1728
+ if (reloadRefusal !== null)
1729
+ return fail(`public saved-editor refusal by ${TARGET}: ${reloadRefusal}; no publish click attempted`);
1730
+ if (reloaded === null)
1731
+ return fail(
1732
+ `public saved-editor readiness: the canonical editor reopened but its title/body/media/cover snapshot was unreadable within ${deps.draftTimeoutMs}ms; no publish click attempted`,
1733
+ );
1734
+ const differences = publicSnapshotDifferences(composed, reloaded);
1735
+ if (differences.length > 0)
1736
+ return fail(
1737
+ `public saved editor content mismatch before publication (${differences.join(', ')}); no publish click attempted`,
1738
+ );
1739
+
1740
+ // The canonical snapshot can become readable before the editor's own autosave finishes. A
1741
+ // mass-send click in that interval is rejected by WeChat as a stale draft even though every
1742
+ // persisted field already matches; require two consecutive native readiness probes before handing
1743
+ // control to publishContent. The v2 rail accepts 已保存; the measured legacy editor has its own
1744
+ // explicit manual-save markers. A saving → saved transition and an already-idle editor are accepted;
1745
+ // this is a readiness guard, not a publish retry.
1746
+ const autoSaveSettled = await waitForPublicEditorReadiness(entry.editor, deps);
1747
+ if (!autoSaveSettled)
1748
+ return fail(
1749
+ `public saved-editor readiness: native 保存状态 did not provide two consecutive recognized readiness probes within ${Math.min(deps.stepTimeoutMs, 15_000)}ms; no publish click attempted`,
1750
+ );
1751
+ return {
1752
+ saveControl,
1753
+ canonicalUrl: savedCanonicalUrl,
1754
+ canonicalUrlForMetadata: savedEditorUrlForMetadata(savedCanonicalUrl),
1755
+ saveSignal: canonicalUrlWasPreexisting ? 'persisted-snapshot' : 'canonical-url-transition+snapshot',
1756
+ snapshot: composed,
1757
+ };
1758
+ }
1759
+
1760
+ interface StaleVersionRecovery {
1761
+ editor: WechatPage;
1762
+ canonicalUrl: string;
1763
+ canonicalUrlForMetadata: string;
1764
+ }
1765
+
1766
+ /**
1767
+ * Follow only the platform's known pre-confirmation stale-version escape hatch. This performs no
1768
+ * save and never retries an unknown outcome: 查看新草稿 is the one measured action that switches the
1769
+ * editor to the platform's latest persisted version before a second initial 发表 click.
1770
+ */
1771
+ async function recoverStaleVersion(
1772
+ deps: ModeRunDeps,
1773
+ entry: ModeEntry,
1774
+ saved: PublicSaveEvidence,
1775
+ ): Promise<Result | StaleVersionRecovery> {
1776
+ const expectedAppmsgId = appmsgIdFromCanonical(saved.canonicalUrl);
1777
+ if (expectedAppmsgId === null)
1778
+ return fail('public stale-version recovery: the saved editor identity was not canonical; no retry attempted');
1779
+
1780
+ const latest = await findUniqueVisibleSelector(
1781
+ entry.editor,
1782
+ PUBLISH_VIEW_LATEST,
1783
+ Math.min(CONFIRM_TIMEOUT_MS, deps.stepTimeoutMs),
1784
+ deps.pollMs,
1785
+ );
1786
+ if (latest.selector === undefined)
1787
+ return fail(`public stale-version recovery: 查看新草稿 was ${latest.reason}; no second 发表 click attempted`);
1788
+
1789
+ const before = deps.pages().length;
1790
+ try {
1791
+ await clickSelector(entry.editor, latest.selector, deps.stepTimeoutMs);
1792
+ } catch {
1793
+ return fail('public stale-version recovery: 查看新草稿 could not be clicked; no second 发表 click attempted');
1794
+ }
1795
+ const latestEditor =
1796
+ (await followNewPage(deps.pages, before, Math.min(deps.stepTimeoutMs, CONFIRM_TIMEOUT_MS))) ?? entry.editor;
1797
+
1798
+ let latestCanonicalUrl: string | null = null;
1799
+ const canonicalReady = await waitUntil(
1800
+ async () => {
1801
+ latestCanonicalUrl = canonicalSavedEditorUrl(latestEditor.url());
1802
+ if (latestCanonicalUrl === null) return false;
1803
+ // A click that leaves the original stale dialog visible is not a version transition. Do
1804
+ // not accept the unchanged editor's matching snapshot as latest-version evidence.
1805
+ const refusal = await readStaleVersionRefusal(asPlaywrightPage(latestEditor)).catch(() => '__unreadable__');
1806
+ return refusal === null;
1807
+ },
1808
+ deps.draftTimeoutMs,
1809
+ deps.pollMs,
1810
+ );
1811
+ if (!canonicalReady || latestCanonicalUrl === null)
1812
+ return fail(
1813
+ 'public stale-version recovery: 查看新草稿 did not expose a canonical saved editor route; no second 发表 click attempted',
1814
+ );
1815
+
1816
+ const actualAppmsgId = appmsgIdFromCanonical(latestCanonicalUrl);
1817
+ if (actualAppmsgId !== expectedAppmsgId)
1818
+ return fail(
1819
+ `public stale-version recovery: 查看新草稿 opened appmsgid=${actualAppmsgId ?? 'unknown'}, expected appmsgid=${expectedAppmsgId}; no second 发表 click attempted`,
1820
+ );
1821
+
1822
+ let latestSnapshot: PublicDraftSnapshot | null = null;
1823
+ let differences: string[] = [];
1824
+ const snapshotSettled = await waitUntil(
1825
+ async () => {
1826
+ latestSnapshot = await readPublicDraftSnapshot(latestEditor);
1827
+ if (latestSnapshot === null) return false;
1828
+ differences = publicSnapshotDifferences(saved.snapshot, latestSnapshot);
1829
+ // Partial hydration may still fill empty media/body/cover fields. A non-empty changed
1830
+ // identity is final evidence of a different latest version and must stop immediately.
1831
+ return differences.length === 0 || !publicSnapshotIsIncomplete(saved.snapshot, latestSnapshot);
1832
+ },
1833
+ deps.draftTimeoutMs,
1834
+ deps.pollMs,
1835
+ );
1836
+ if (!snapshotSettled || latestSnapshot === null)
1837
+ return fail(
1838
+ 'public stale-version recovery: latest editor snapshot was unreadable; no second 发表 click attempted',
1839
+ );
1840
+ if (differences.length > 0)
1841
+ return fail(
1842
+ `public stale-version recovery: latest editor content mismatch (${differences.join(', ')}); no second 发表 click attempted`,
1843
+ );
1844
+
1845
+ if (!(await waitForPublicEditorReadiness(latestEditor, deps)))
1846
+ return fail(
1847
+ 'public stale-version recovery: latest editor did not provide two consecutive recognized readiness probes; no second 发表 click attempted',
1848
+ );
1849
+
1850
+ return {
1851
+ editor: latestEditor,
1852
+ canonicalUrl: latestCanonicalUrl,
1853
+ canonicalUrlForMetadata: savedEditorUrlForMetadata(latestCanonicalUrl),
1854
+ };
1855
+ }
1856
+
1082
1857
  /**
1083
1858
  * The platform's own answer after 发表: its success marker, or one of the recorded refusal/hint lines
1084
1859
  * (§5, §5.3) which are relayed as the actionable message the operator needs.
@@ -1091,11 +1866,139 @@ async function publishOutcome(page: WechatPage): Promise<string | null> {
1091
1866
  return null;
1092
1867
  }
1093
1868
 
1869
+ interface VisibleSelectorProbe {
1870
+ selector?: string;
1871
+ reason: 'missing' | 'ambiguous';
1872
+ }
1873
+
1874
+ interface PublicConfirmation {
1875
+ finalSelector: string;
1876
+ notifySubscribers: boolean | null;
1877
+ notifySubscribersPolicy: 'preserve' | 'disabled';
1878
+ }
1879
+
1880
+ interface PreconfirmationStaleVersion {
1881
+ kind: 'stale-version';
1882
+ refusal: string;
1883
+ }
1884
+
1885
+ /**
1886
+ * Resolve one visible control without allowing a hidden/duplicate match to become a click target.
1887
+ * The live console can leave old dialog nodes mounted, so a first-match helper is unsafe here.
1888
+ */
1889
+ async function findUniqueVisibleSelector(
1890
+ page: WechatPage,
1891
+ selectors: readonly string[],
1892
+ timeoutMs: number,
1893
+ pollMs: number,
1894
+ ): Promise<VisibleSelectorProbe> {
1895
+ let reason: VisibleSelectorProbe['reason'] = 'missing';
1896
+ let selector: string | undefined;
1897
+ const found = await waitUntil(
1898
+ async () => {
1899
+ for (const candidate of selectors) {
1900
+ const locator = page.locator(candidate);
1901
+ const count = await locator.count().catch(() => 0);
1902
+ if (count > 1) {
1903
+ reason = 'ambiguous';
1904
+ return true;
1905
+ }
1906
+ if (count !== 1) continue;
1907
+ const visible = await locator
1908
+ .first()
1909
+ .isVisible()
1910
+ .catch(() => false);
1911
+ if (!visible) continue;
1912
+ selector = candidate;
1913
+ return true;
1914
+ }
1915
+ return false;
1916
+ },
1917
+ timeoutMs,
1918
+ pollMs,
1919
+ );
1920
+ return found && selector !== undefined ? { selector, reason } : { reason };
1921
+ }
1922
+
1923
+ async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
1924
+ try {
1925
+ const value = await page.evaluate(PUBLISH_NOTIFY_STATE_SCRIPT);
1926
+ return typeof value === 'boolean' ? value : null;
1927
+ } catch {
1928
+ return null;
1929
+ }
1930
+ }
1931
+
1932
+ function shouldNotifySubscribers(content: Content): boolean {
1933
+ const wechatmp = content.metadata?.wechatmp;
1934
+ if (wechatmp === null || typeof wechatmp !== 'object' || Array.isArray(wechatmp)) return true;
1935
+ return (wechatmp as Record<string, unknown>).notifySubscribers !== false;
1936
+ }
1937
+
1938
+ /**
1939
+ * Complete the measured mass-send dialog exactly once. The opt-out is deliberately stricter than
1940
+ * the default policy: if 群发通知 cannot be uniquely observed as ON and then OFF, stop before the
1941
+ * final 发表 click so a daily run cannot silently consume the next notification quota. The returned
1942
+ * state is the platform's actual pre-click switch value; the policy is recorded separately because
1943
+ * the default preserves an already-OFF platform setting rather than enabling notifications.
1944
+ */
1945
+ async function confirmPublicPublish(
1946
+ page: WechatPage,
1947
+ notifySubscribers: boolean,
1948
+ stepTimeoutMs: number,
1949
+ pollMs: number,
1950
+ ): Promise<Result | PublicConfirmation | PreconfirmationStaleVersion> {
1951
+ const confirmationTimeoutMs = Math.min(CONFIRM_TIMEOUT_MS, stepTimeoutMs);
1952
+ const dialog = await findUniqueVisibleSelector(page, PUBLISH_DIALOG, confirmationTimeoutMs, pollMs);
1953
+ if (dialog.selector === undefined) {
1954
+ if (dialog.reason === 'missing') {
1955
+ const refusal = await readStaleVersionRefusal(asPlaywrightPage(page)).catch(() => null);
1956
+ if (refusal !== null) return { kind: 'stale-version', refusal };
1957
+ }
1958
+ return fail(`public confirmation: the 发表 dialog was ${dialog.reason}; no final publish click attempted`);
1959
+ }
1960
+
1961
+ let observedNotifySubscribers: boolean | null = null;
1962
+ if (!notifySubscribers) {
1963
+ const toggle = await findUniqueVisibleSelector(page, PUBLISH_NOTIFY_TOGGLE, confirmationTimeoutMs, pollMs);
1964
+ if (toggle.selector === undefined)
1965
+ return fail(
1966
+ `public confirmation: 群发通知 switch was ${toggle.reason === 'missing' ? 'unavailable' : 'ambiguous'}; no final publish click attempted`,
1967
+ );
1968
+ const before = await readPublishNotifySubscribers(page);
1969
+ if (before === null)
1970
+ return fail('public confirmation: 群发通知 switch state was unreadable; no final publish click attempted');
1971
+ if (before) await clickSelector(page, toggle.selector, stepTimeoutMs);
1972
+ const off = await waitUntil(
1973
+ async () => (await readPublishNotifySubscribers(page)) === false,
1974
+ confirmationTimeoutMs,
1975
+ pollMs,
1976
+ );
1977
+ if (!off)
1978
+ return fail('public confirmation: 群发通知 switch did not verify OFF; no final publish click attempted');
1979
+ }
1980
+
1981
+ const final = await findUniqueVisibleSelector(page, PUBLISH_FINAL, confirmationTimeoutMs, pollMs);
1982
+ if (final.selector === undefined)
1983
+ return fail(`public confirmation: final 发表 button was ${final.reason}; no final publish click attempted`);
1984
+ observedNotifySubscribers = await readPublishNotifySubscribers(page);
1985
+ if (!notifySubscribers && observedNotifySubscribers !== false)
1986
+ return fail(
1987
+ 'public confirmation: 群发通知 switch did not remain verifiably OFF; no final publish click attempted',
1988
+ );
1989
+ await clickSelector(page, final.selector, stepTimeoutMs);
1990
+ return {
1991
+ finalSelector: final.selector,
1992
+ notifySubscribers: observedNotifySubscribers,
1993
+ notifySubscribersPolicy: notifySubscribers ? 'preserve' : 'disabled',
1994
+ };
1995
+ }
1996
+
1094
1997
  /**
1095
1998
  * Publish. Reached only for an explicit `publishStatus: 'public'` — the transport refuses every
1096
- * other value before a browser opens, so no other path can click a publish control. ONE click, no
1097
- * retry: an unobserved publish must never be repeated (the platform's own completeness gate is the
1098
- * last line of defence, and a second click could duplicate the message).
1999
+ * other value before a browser opens. The final confirmation is irreversible and is clicked once;
2000
+ * the only pre-confirmation recovery is the bounded, native 查看新草稿 transition handled below.
2001
+ * An unobserved final send is never repeated because a second click could duplicate the message.
1099
2002
  */
1100
2003
  async function publishContent(
1101
2004
  input: ModeInput,
@@ -1103,12 +2006,44 @@ async function publishContent(
1103
2006
  entry: ModeEntry,
1104
2007
  composed: ComposeReport,
1105
2008
  ): Promise<Result> {
2009
+ // The persisted editor snapshot is an article-only readiness contract. The other console
2010
+ // operations historically publish directly after composition; routing them through the article
2011
+ // save probe would reject valid media posts when that probe is unavailable or intentionally fails.
2012
+ const saved = input.operation === '文章' ? await savePublicDraft(deps, entry) : undefined;
2013
+ if (saved !== undefined && 'ok' in saved) return saved;
2014
+ const notifySubscribers = shouldNotifySubscribers(deps.content);
2015
+ let staleVersionRecoveryCount = 0;
2016
+ let finalSavedEditorUrl = saved?.canonicalUrlForMetadata;
1106
2017
  await sleep(deps.submitSettleMs);
1107
2018
  const control = await findSelector(entry.editor, PUBLISH_SEND, '发表 control', deps.stepTimeoutMs);
1108
2019
  const urlBefore = entry.editor.url();
1109
2020
  await clickSelector(entry.editor, control, deps.stepTimeoutMs);
1110
- const confirm = await tryFindSelector(entry.editor, PUBLISH_CONFIRM, CONFIRM_TIMEOUT_MS);
1111
- if (confirm !== undefined) await clickSelector(entry.editor, confirm, deps.stepTimeoutMs);
2021
+ let confirmation = await confirmPublicPublish(entry.editor, notifySubscribers, deps.stepTimeoutMs, deps.pollMs);
2022
+ if ('ok' in confirmation) return confirmation;
2023
+ if ('kind' in confirmation) {
2024
+ if (saved === undefined)
2025
+ return fail(
2026
+ `public confirmation: ${confirmation.refusal}; no canonical saved-editor evidence was available for recovery; no second 发表 click attempted`,
2027
+ );
2028
+ const recovered = await recoverStaleVersion(deps, entry, saved);
2029
+ if ('ok' in recovered) return recovered;
2030
+ entry.editor = recovered.editor;
2031
+ finalSavedEditorUrl = recovered.canonicalUrlForMetadata;
2032
+ staleVersionRecoveryCount = 1;
2033
+ const retryControl = await findSelector(
2034
+ entry.editor,
2035
+ PUBLISH_SEND,
2036
+ '发表 control after 查看新草稿',
2037
+ deps.stepTimeoutMs,
2038
+ );
2039
+ await clickSelector(entry.editor, retryControl, deps.stepTimeoutMs);
2040
+ confirmation = await confirmPublicPublish(entry.editor, notifySubscribers, deps.stepTimeoutMs, deps.pollMs);
2041
+ if ('ok' in confirmation) return confirmation;
2042
+ if ('kind' in confirmation)
2043
+ return fail(
2044
+ `public confirmation: ${confirmation.refusal} after bounded 查看新草稿 recovery; no final publish click attempted`,
2045
+ );
2046
+ }
1112
2047
 
1113
2048
  const outcome = await expectOutcome(() => publishOutcome(entry.editor), {
1114
2049
  timeoutMs: deps.stepTimeoutMs,
@@ -1129,7 +2064,18 @@ async function publishContent(
1129
2064
  message: `${TARGET} ${input.operation} published`,
1130
2065
  metadata: {
1131
2066
  ...baseMetadata(input, deps, entry),
2067
+ ...(saved === undefined
2068
+ ? {}
2069
+ : {
2070
+ draftSaveControl: saved.saveControl,
2071
+ savedEditorUrl: finalSavedEditorUrl ?? saved.canonicalUrlForMetadata,
2072
+ draftSaveSignal: saved.saveSignal,
2073
+ staleVersionRecoveryCount,
2074
+ }),
1132
2075
  publishControl: control,
2076
+ finalPublishControl: confirmation.finalSelector,
2077
+ notifySubscribers: confirmation.notifySubscribers,
2078
+ notifySubscribersPolicy: confirmation.notifySubscribersPolicy,
1133
2079
  publishSignal: outcome.evidence,
1134
2080
  titlePath: composed.titlePath,
1135
2081
  ...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
@@ -1263,9 +2209,14 @@ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Resu
1263
2209
  // the Playwright timeout, so the stuck step had to be rediscovered by hand. Ride them on the error.
1264
2210
  const diagnostics = [...composed.warnings, ...composed.enrichWarnings];
1265
2211
  try {
1266
- return deps.publishStatus === 'public'
1267
- ? await publishContent(input, deps, entry, composed)
1268
- : await saveDraft(input, deps, entry, composed);
2212
+ const result =
2213
+ deps.publishStatus === 'public'
2214
+ ? await publishContent(input, deps, entry, composed)
2215
+ : await saveDraft(input, deps, entry, composed);
2216
+ if (!result.ok && diagnostics.length > 0) {
2217
+ return { ...result, error: `${result.error} — compose diagnostics: ${diagnostics.join(' | ')}` };
2218
+ }
2219
+ return result;
1269
2220
  } catch (error) {
1270
2221
  const message = error instanceof Error ? error.message : String(error);
1271
2222
  const blocked = await openDialogInventory(entry.editor);
@@ -1386,15 +2337,20 @@ export async function insertAudioMaterial(
1386
2337
  input: { title: string; audioPath: string; coverPath: string },
1387
2338
  ): Promise<string> {
1388
2339
  const { title, audioPath, coverPath } = input;
1389
- const CARET = `(() => {
2340
+ const CARET = `(async () => {
1390
2341
  const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
1391
2342
  if (!pm || !pm.firstChild) return;
2343
+ pm.focus();
1392
2344
  const r = document.createRange();
1393
- r.setStart(pm.firstChild, 0);
2345
+ // The first child can be a non-editable image atom. Select the boundary before it,
2346
+ // not a position inside that atom which ProseMirror discards on toolbar focus.
2347
+ r.setStart(pm, 0);
1394
2348
  r.collapse(true);
1395
2349
  const sel = window.getSelection();
1396
2350
  sel?.removeAllRanges();
1397
2351
  sel?.addRange(r);
2352
+ document.dispatchEvent(new Event('selectionchange'));
2353
+ await new Promise(resolve => setTimeout(resolve, 100));
1398
2354
  })()`;
1399
2355
  /** Trim + collapse whitespace/NBSP — MP titles carry `&nbsp;` entities (0272 capture). */
1400
2356
  const norm = (value: unknown): string =>
@@ -1421,8 +2377,8 @@ export async function insertAudioMaterial(
1421
2377
  })()`;
1422
2378
  /** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
1423
2379
  const AGREE = `(() => {
1424
- const node = [...document.querySelectorAll('label, span, div')]
1425
- .find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.children.length === 0);
2380
+ const node = [...document.querySelectorAll('.weui-desktop-dialog label, .audio_dialog label')]
2381
+ .find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.querySelector('input[type=checkbox]'));
1426
2382
  if (!node) return 'no-label';
1427
2383
  const scope = node.closest('label') ?? node.parentElement;
1428
2384
  const cb = scope?.querySelector('input[type=checkbox]') ?? document.querySelector('input[type=checkbox].js_agree');
@@ -1445,9 +2401,10 @@ export async function insertAudioMaterial(
1445
2401
  if (!pm) return JSON.stringify({ state: 'noeditor' });
1446
2402
  const first = pm.firstElementChild;
1447
2403
  if (!first) return JSON.stringify({ state: 'absent' });
1448
- const node = first.matches('mpvoice, [data-mediaid]') ? first : first.querySelector('mpvoice, [data-mediaid]');
1449
- if (!node) return JSON.stringify({ state: 'absent' });
1450
- const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('data-appid') ??
2404
+ const selector = 'mp-common-mpaudio, mpvoice, [data-mediaid]';
2405
+ const node = first.matches(selector) ? first : first.querySelector(selector);
2406
+ if (!node) return JSON.stringify({ state: pm.querySelector(selector) ? 'not-top' : 'absent' });
2407
+ const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('cover') ?? node.getAttribute('voice_encode_fileid') ?? node.getAttribute('data-appid') ??
1451
2408
  node.getAttribute('data-src') ?? node.getAttribute('src') ?? '';
1452
2409
  return JSON.stringify({ state: 'top', identity });
1453
2410
  })()`;
@@ -1459,7 +2416,7 @@ export async function insertAudioMaterial(
1459
2416
  // Click the winner, never a joined union: `text=…, css…` mixes selector engines and
1460
2417
  // Playwright parses the whole string as CSS — zero matches (DOM fixture 0260).
1461
2418
  await clickSelector(page, uploadControl.selector ?? UPLOAD_AUDIO[0] ?? 'text=上传音频', 8_000);
1462
- const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
2419
+ const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT, { visible: false });
1463
2420
  if (!audioInput.found) return '音频: no audio file input appeared';
1464
2421
  await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
1465
2422
  timeoutMs: 60_000,
@@ -1468,24 +2425,105 @@ export async function insertAudioMaterial(
1468
2425
  const coverControl = await trySelectors(page, UPLOAD_COVER);
1469
2426
  if (coverControl.found) {
1470
2427
  await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
1471
- const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
1472
- if (coverInput.found)
1473
- await uploadFile(
1474
- asPlaywrightPage(page),
1475
- coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
1476
- coverPath,
1477
- {
1478
- timeoutMs: 60_000,
1479
- },
1480
- );
2428
+ const coverInput = await trySelectors(page, AUDIO_COVER_INPUT, { visible: false });
2429
+ if (coverInput.found) {
2430
+ const chooser = '.weui-desktop-dialog:visible:has(h3:has-text("选择图片"))';
2431
+ const hasChooser = (await page.locator(chooser).count()) > 0;
2432
+ const coverName = hasChooser ? await coverLibraryName(coverPath) : baseName(coverPath);
2433
+ let existing = 0;
2434
+ if (hasChooser) {
2435
+ const libraryReady = await waitUntil(
2436
+ async () =>
2437
+ Boolean(
2438
+ await page.evaluate(
2439
+ `!document.querySelector('#js_image_dialog_list_wrp .weui-desktop-loading')`,
2440
+ ),
2441
+ ),
2442
+ 5_000,
2443
+ );
2444
+ if (!libraryReady) return '音频: 封面素材库加载未完成,未上传重复封面';
2445
+ existing = Number(
2446
+ await page.evaluate(
2447
+ `([...document.querySelectorAll('.weui-desktop-img-picker__item')].filter(row => row.offsetWidth > 0 && row.querySelector('.weui-desktop-img-picker__img-title')?.textContent?.trim() === ${JSON.stringify(coverName)})).length`,
2448
+ ),
2449
+ );
2450
+ if (existing > 1) return '音频: 内容绑定封面存在多个同名素材,未猜测选择';
2451
+ }
2452
+ let uploadPath = coverPath;
2453
+ let temporaryDir: string | undefined;
2454
+ if (hasChooser && existing === 0) {
2455
+ temporaryDir = join(tmpdir(), `kk-wechat-cover-${randomUUID()}`);
2456
+ if (!mkdirExclusive(temporaryDir)) throw new Error('Private cover upload directory already exists');
2457
+ uploadPath = join(temporaryDir, coverName);
2458
+ }
2459
+ try {
2460
+ if (temporaryDir !== undefined) await createNodeFileSystem().copy(coverPath, uploadPath);
2461
+ if (existing === 0) {
2462
+ await uploadFile(
2463
+ asPlaywrightPage(page),
2464
+ coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
2465
+ uploadPath,
2466
+ {
2467
+ timeoutMs: 60_000,
2468
+ },
2469
+ );
2470
+ }
2471
+ if (hasChooser) {
2472
+ const selectedName = JSON.stringify(coverName);
2473
+ const selected = await waitUntil(
2474
+ async () =>
2475
+ Boolean(
2476
+ await page.evaluate(`(() => {
2477
+ const rows = [...document.querySelectorAll('.weui-desktop-img-picker__item')]
2478
+ .filter(row => row.offsetWidth > 0 && row.querySelector('.weui-desktop-img-picker__img-title')?.textContent?.trim() === ${selectedName});
2479
+ if (rows.length !== 1) return false;
2480
+ const row = rows[0];
2481
+ if (!row.classList.contains('selected')) row.click();
2482
+ return row.classList.contains('selected');
2483
+ })()`),
2484
+ ),
2485
+ 30_000,
2486
+ );
2487
+ if (!selected) return '音频: 上传封面未出现唯一已选中的同名图片,未提交封面';
2488
+ const next = await findSelector(
2489
+ page,
2490
+ [`${chooser} button:has-text("下一步"):not(.weui-desktop-btn_disabled)`],
2491
+ '音频封面 下一步',
2492
+ 30_000,
2493
+ );
2494
+ await clickSelector(page, next, 8_000);
2495
+ const finish = await findSelector(
2496
+ page,
2497
+ [`${chooser} button:has-text("完成"):not(.weui-desktop-btn_disabled)`],
2498
+ '音频封面 完成',
2499
+ 30_000,
2500
+ );
2501
+ await clickSelector(page, finish, 8_000);
2502
+ const closed = await waitUntil(
2503
+ async () => !(await page.locator(chooser).first().isVisible()),
2504
+ 8_000,
2505
+ );
2506
+ if (!closed) return '音频: 封面选择器完成后未关闭,未保存音频';
2507
+ }
2508
+ } finally {
2509
+ if (temporaryDir !== undefined) {
2510
+ await createNodeFileSystem().deleteFile(uploadPath);
2511
+ rmdir(temporaryDir);
2512
+ }
2513
+ }
2514
+ }
1481
2515
  }
1482
2516
  const titleInput = await trySelectors(page, AUDIO_TITLE_INPUT);
1483
2517
  if (titleInput.found) await fillSelector(page, titleInput.selector ?? AUDIO_TITLE_INPUT.join(', '), title);
1484
2518
  const agreed = String(await page.evaluate(AGREE));
1485
2519
  if (agreed !== 'checked') return `音频: 服务规则勾选失败 (${agreed})`;
1486
- const save = await trySelectors(page, UPLOAD_SAVE);
1487
- if (!save.found) return '音频: 上传表单的保存按钮未找到';
1488
- await clickSelector(page, UPLOAD_SAVE.join(', '), 8_000);
2520
+ const save = await findSelector(
2521
+ page,
2522
+ UPLOAD_SAVE.map((selector) => `${selector}:not(.weui-desktop-btn_disabled):not([disabled])`),
2523
+ '音频上传完成且封面已提交后的保存按钮',
2524
+ 90_000,
2525
+ );
2526
+ await clickSelector(page, save, 8_000);
1489
2527
  return null;
1490
2528
  };
1491
2529
 
@@ -1508,6 +2546,8 @@ export async function insertAudioMaterial(
1508
2546
  };
1509
2547
  try {
1510
2548
  await page.evaluate(CARET);
2549
+ // Native navigation updates ProseMirror's selection bookmark, which toolbar dialogs restore.
2550
+ await page.press?.(process.platform === 'darwin' ? 'Meta+ArrowUp' : 'Control+Home');
1511
2551
  // Open the toolbar dropdown first (measured 2026-10-13): the menu item is hidden until its
1512
2552
  // trigger is clicked, so the old direct `js_insertaudio` click timed out.
1513
2553
  await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_TOOLBAR_TRIGGER)})?.click()`);