@gobing-ai/knowledge-kit 0.0.35 → 0.0.36

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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11768 -9249
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1004 -10803
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/dailynews-gen/dist/index.js +1092 -10891
  18. package/plugins/generations/dailynews-gen/package.json +1 -1
  19. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  20. package/plugins/generations/episode-plan-gen/package.json +1 -1
  21. package/plugins/generations/gemini-tts/dist/index.js +1104 -10903
  22. package/plugins/generations/gemini-tts/package.json +1 -1
  23. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  24. package/plugins/generations/image-gen/package.json +1 -1
  25. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  26. package/plugins/generations/news-report-gen/package.json +1 -1
  27. package/plugins/generations/voice-gen/dist/index.js +1062 -10861
  28. package/plugins/generations/voice-gen/package.json +1 -1
  29. package/plugins/generations/voxcpm-tts/README.md +72 -0
  30. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  31. package/plugins/generations/voxcpm-tts/package.json +16 -0
  32. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  33. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  34. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  35. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  36. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  37. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  38. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  39. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  40. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  41. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  42. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  43. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  44. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  45. package/plugins/ingestions/karakeep-local/package.json +1 -1
  46. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  47. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  48. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  49. package/plugins/ingestions/web-search/package.json +1 -1
  50. package/plugins/kk/README.md +29 -2
  51. package/plugins/kk/plugin.json +1 -1
  52. package/plugins/kk/schedules/cron.tmpl +9 -1
  53. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  54. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  55. package/plugins/kk/scripts/daily-stages.ts +428 -46
  56. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
  57. package/plugins/kk/skills/publish/SKILL.md +8 -4
  58. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  59. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  60. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  61. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +4 -2
  62. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -15
  63. package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
  64. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  65. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  66. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  67. package/plugins/publishings/emdash-pub/package.json +1 -1
  68. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  69. package/plugins/publishings/infoq-pub/dist/profile-check.js +1677 -11199
  70. package/plugins/publishings/infoq-pub/package.json +1 -1
  71. package/plugins/publishings/infoq-pub/plugin.json +5 -1
  72. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  73. package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
  74. package/plugins/publishings/juejin-pub/package.json +1 -1
  75. package/plugins/publishings/juejin-pub/plugin.json +5 -1
  76. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  77. package/plugins/publishings/medium-pub/package.json +1 -1
  78. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  79. package/plugins/publishings/podcast-pub/package.json +1 -1
  80. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  81. package/plugins/publishings/qiita-pub/package.json +1 -1
  82. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  83. package/plugins/publishings/substack-pub/dist/profile-check.js +1698 -11200
  84. package/plugins/publishings/substack-pub/package.json +1 -1
  85. package/plugins/publishings/substack-pub/plugin.json +4 -1
  86. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  87. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  88. package/plugins/publishings/surfdash-pub/package.json +1 -1
  89. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  90. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  91. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  92. package/plugins/publishings/threads-pub/package.json +17 -0
  93. package/plugins/publishings/threads-pub/plugin.json +7 -0
  94. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  95. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  96. package/plugins/publishings/wechatmp-pub/dist/index.js +2508 -11695
  97. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
  98. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  99. package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
  100. package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
  101. package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
  102. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  103. package/plugins/publishings/wechatmp-pub/src/page.ts +16 -0
  104. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  105. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  106. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  107. package/plugins/publishings/weibo-pub/package.json +19 -0
  108. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  109. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  110. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  111. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  112. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  113. package/plugins/publishings/x-pub/dist/profile-check.js +1677 -11199
  114. package/plugins/publishings/x-pub/package.json +1 -1
  115. package/plugins/publishings/x-pub/plugin.json +5 -1
  116. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  117. package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
  118. package/plugins/publishings/xhs-pub/package.json +1 -1
  119. package/plugins/publishings/xhs-pub/plugin.json +5 -1
  120. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  121. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  122. package/plugins/publishings/zenn-pub/package.json +1 -1
@@ -21,6 +21,7 @@ import {
21
21
  findAttachedSelector,
22
22
  findSelector,
23
23
  followNewPage,
24
+ modalState,
24
25
  tryFindSelector,
25
26
  type WechatPage,
26
27
  waitForVisible,
@@ -74,17 +75,6 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
74
75
  const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
75
76
  const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
76
77
  const COVER_FROM_LIBRARY = ['a.js_imagedialog', 'text=从图片库选择'];
77
- /**
78
- * The library picker's item list — the same `not <img>-based` widget as the body-source panel, so
79
- * these stay candidate shapes. The newest upload sorts first (operator walk 2026-10-10: the just-uploaded
80
- * audio cover is the default selection, `默认第一个`), so the first item is the intended one.
81
- */
82
- const COVER_LIBRARY_ITEM = [
83
- 'div.weui-desktop-img-picker__item',
84
- 'div[class*="img_picker" i] li',
85
- 'div[class*="imgList" i] li',
86
- 'div[class*="cover" i] li',
87
- ];
88
78
  /** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
89
79
  const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
90
80
  /**
@@ -92,9 +82,77 @@ const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
92
82
  * confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
93
83
  * to do). A miss is recorded as a warning, never as a false cover claim.
94
84
  */
95
- const COVER_ITEM = COVER_LIBRARY_ITEM;
96
85
  const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
97
86
 
87
+ /** The controls that close whatever modal the cover step left open, most specific first. */
88
+ const DIALOG_DISMISS = [
89
+ '.weui-desktop-dialog:visible button:has-text("取消")',
90
+ '.weui-desktop-dialog__close',
91
+ 'button:has-text("取消")',
92
+ ];
93
+
94
+ /**
95
+ * Close an open console dialog, best-effort. Measured 2026-10-12: `setCover` returned its warning
96
+ * while the cover panel stayed open, and an open panel's footer (`div.weui-desktop-dialog__ft`)
97
+ * covers the editor — 赞赏 / 原文链接 / 创作来源 / 音频 each click-timed out behind it and the final
98
+ * `保存为草稿` was blocked for 30 s, so the run saved no draft at all. The same sweep shape the
99
+ * settings rail already uses (`./enrich`): the dialog's own 取消, else Escape.
100
+ */
101
+ async function dismissOpenDialog(page: WechatPage): Promise<void> {
102
+ for (const selector of DIALOG_DISMISS) {
103
+ const control = await tryFindSelector(page, [selector], 2_000);
104
+ if (control === undefined) continue;
105
+ try {
106
+ await clickSelector(page, control, 2_000);
107
+ } catch {
108
+ // Gone between the probe and the click — nothing left to dismiss.
109
+ }
110
+ break;
111
+ }
112
+ await page.press?.('Escape');
113
+ await page.waitForTimeout?.(400);
114
+ }
115
+
116
+ /** The close controls a console modal exposes, most specific first (the audio picker shows no 取消).
117
+ * `.weui-desktop-dialog__close-btn` is the 0272-captured audio-dialog variant the bare `__close`
118
+ * probe missed. */
119
+ const DIALOG_CLOSE = [
120
+ '.weui-desktop-dialog__wrp .weui-desktop-dialog__close',
121
+ '.weui-desktop-dialog__close',
122
+ '.weui-desktop-dialog__close-btn',
123
+ '.weui-desktop-dialog__wrp button:has-text("取消")',
124
+ 'button:has-text("取消")',
125
+ ];
126
+
127
+ /**
128
+ * Close every open console modal before a submit click. Measured 2026-10-12: the 插入音频 dialog
129
+ * (`weui-desktop-dialog__wrp align_edge audio_dialog js_audio_music_dialog`) stayed open after its
130
+ * own 上传音频 click timed out, its footer covered `保存为草稿` for 30 s, and the run saved no draft
131
+ * at all. A failed enrichment is a warning by contract, never a gate — so nothing may outlive it.
132
+ *
133
+ * Returns `'residual'` when a dialog is still open — or its state is UNREADABLE — after all passes:
134
+ * an unknown overlay must never read as cleaned up (task 0260 R3/AC5).
135
+ */
136
+ async function closeOpenDialogs(page: WechatPage): Promise<'closed' | 'residual'> {
137
+ for (let pass = 0; pass < 3; pass += 1) {
138
+ const state = await modalState(page);
139
+ if (state === 'closed') return 'closed';
140
+ for (const selector of DIALOG_CLOSE) {
141
+ const control = await tryFindSelector(page, [selector], 1_500);
142
+ if (control === undefined) continue;
143
+ try {
144
+ await clickSelector(page, control, 2_000);
145
+ } catch {
146
+ // Gone between the probe and the click.
147
+ }
148
+ break;
149
+ }
150
+ await page.press?.('Escape');
151
+ await page.waitForTimeout?.(400);
152
+ }
153
+ return (await modalState(page)) === 'closed' ? 'closed' : 'residual';
154
+ }
155
+
98
156
  /** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
99
157
  const VIDEO_LOCAL_UPLOAD = ['text=本地上传', 'button:has-text("本地上传")'];
100
158
  const VIDEO_FILE_INPUT = [
@@ -126,6 +184,9 @@ const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'bu
126
184
 
127
185
  /** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
128
186
  const AUDIO_MENU = 'li.js_insertaudio';
187
+ /** The toolbar dropdown that reveals the 音频 menu item (measured 2026-10-13): the item lives in a
188
+ * `display:none` menu under `#audio_music_plugin_btn` until that trigger is clicked. */
189
+ const AUDIO_TOOLBAR_TRIGGER = '#audio_music_plugin_btn';
129
190
  const AUDIO_DIALOG = '.audio_dialog';
130
191
  /** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
131
192
  const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
@@ -505,12 +566,10 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
505
566
  * label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
506
567
  */
507
568
  async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
508
- let control: string;
509
- try {
510
- control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
511
- } catch (error) {
512
- return `一键排版: ${error instanceof Error ? error.message : String(error)}`;
513
- }
569
+ // Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
570
+ // control is the platform's layout — silently skip; only a found-but-failing control warns.
571
+ const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
572
+ if (control === undefined) return null;
514
573
  try {
515
574
  await clickSelector(page, control, deps.stepTimeoutMs);
516
575
  } catch (error) {
@@ -548,35 +607,135 @@ async function fillOptional(
548
607
  }
549
608
  }
550
609
 
610
+ /** Local path basename — the only piece of a requested file a remote asset could ever expose. */
611
+ function baseName(path: string): string {
612
+ return path.split(/[\\/]/).pop() ?? path;
613
+ }
614
+
615
+ /** The identities `setCover` can accept for a candidate: the requested article image and/or the
616
+ * upload binding actually observed going into the audio form. Either is optional; at least one
617
+ * must produce a UNIQUE exposed match before anything is clicked (task 0260 R1). */
618
+ interface CoverIdentity {
619
+ /** `coverPath(input)` — the article's first body image, requested as the cover. */
620
+ requested?: string;
621
+ /** The cover file the audio upload just bound into the form/library (when audio ran). */
622
+ binding?: string;
623
+ }
624
+
551
625
  /**
552
- * 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover, which the daily
553
- * flow has already uploaded as the audio material's cover; `从正文选择` picks the first body image,
554
- * which is what the platform's 默认首图为封面 fallback would use anyway. Best-effort and recorded: a
555
- * miss is a warning naming the step, never a false claim that the cover landed.
626
+ * 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover, `从正文选择`
627
+ * the first body image. A candidate is identified ONLY by an exposed exact filename (`title` /
628
+ * `data-name` / its own filename text) uniquely equal to one of the requested identities; a miss or
629
+ * an ambiguity warns WITHOUT clicking first/newest. After the confirm, the committed preview under
630
+ * `#js_cover_area` is read back: the empty state (`add_cover` icon still visible) or an unreadable
631
+ * preview is a named warning, never a success claim (task 0260 R1).
556
632
  */
557
- async function setCover(page: WechatPage, source: 'library' | 'body', deps: ModeRunDeps): Promise<string | null> {
633
+ async function setCover(
634
+ page: WechatPage,
635
+ source: 'library' | 'body',
636
+ deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
637
+ identity: CoverIdentity,
638
+ ): Promise<string | null> {
558
639
  const picker = source === 'library' ? COVER_FROM_LIBRARY : COVER_FROM_CONTENT;
559
640
  const what = source === 'library' ? '从图片库选择' : '从正文选择';
641
+ const wanted = [
642
+ ...(identity.requested ? [baseName(identity.requested)] : []),
643
+ ...(identity.binding ? [baseName(identity.binding)] : []),
644
+ ]
645
+ .map((name) => name.trim())
646
+ .filter((name) => name.length > 0);
647
+ // A cover miss is a warning, never a gate — so the panel must not outlive the step that opened
648
+ // it (see {@link dismissOpenDialog} for what its footer blocked on 2026-10-12).
649
+ const warn = async (message: string): Promise<string> => {
650
+ await dismissOpenDialog(page);
651
+ return message;
652
+ };
560
653
  try {
561
654
  const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
562
655
  if (open === undefined)
563
- return 'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has';
656
+ return await warn(
657
+ 'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
658
+ );
564
659
  await clickSelector(page, open, deps.stepTimeoutMs);
565
660
  const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
566
- if (pick === undefined) return `cover: ${what} was not offered by the open cover panel`;
661
+ if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
567
662
  await clickSelector(page, pick, deps.stepTimeoutMs);
568
- const item = await tryFindSelector(page, COVER_ITEM, Math.min(deps.stepTimeoutMs, 8_000));
569
- if (item === undefined) return `cover: ${what} listed no image to choose (its list is not <img>-based, §5.2)`;
570
- await clickSelector(page, item, deps.stepTimeoutMs);
663
+ if (wanted.length === 0)
664
+ return await warn(`cover: no requested cover file identity to match against — ${what} not driven`);
665
+ // Identify before clicking: the item list is not <img>-based (§5.2) and never sorted the way
666
+ // a first-item assumption needs, so the only honest pick is an exposed unique exact name.
667
+ const IDENTIFY = `(() => {
668
+ const wanted = ${JSON.stringify(wanted)};
669
+ const rows = [...document.querySelectorAll('.weui-desktop-img-picker__item, div[class*="img_picker" i] li, div[class*="imgList" i] li, div[class*="cover" i] li')]
670
+ .filter((row) => row.offsetWidth > 0);
671
+ const nameOf = (row) => {
672
+ const el = row.querySelector('[title], [data-name], [data-file_name]') ?? row;
673
+ const attrs = [el.getAttribute?.('title'), el.getAttribute?.('data-name'), el.getAttribute?.('data-file_name')];
674
+ const text = [...row.querySelectorAll('[class*="name" i], [class*="title" i]')].map((n) => n.textContent ?? '');
675
+ return [...attrs, ...text].map((v) => (v ?? '').trim()).find((v) => v.length > 0) ?? '';
676
+ };
677
+ const hits = rows.filter((row) => wanted.some((w) => nameOf(row) === w));
678
+ if (hits.length === 0) return 'missing';
679
+ if (hits.length > 1) return 'ambiguous';
680
+ const row = hits[0];
681
+ row.querySelector('input[type=checkbox], [role=checkbox]')?.click();
682
+ row.click();
683
+ const checked = row.querySelector('input[type=checkbox], [role=checkbox]');
684
+ const src = row.querySelector('img')?.src ?? (row.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '');
685
+ return JSON.stringify({ picked: true, src });
686
+ })()`;
687
+ const rawIdentify = String(await page.evaluate(IDENTIFY));
688
+ if (rawIdentify === 'missing')
689
+ return await warn(`cover: ${what} 未标出与请求文件同名的素材,未选择(不按首图猜测)`);
690
+ if (rawIdentify === 'ambiguous') return await warn(`cover: ${what} 匹配到多个同名素材,身份不唯一,未选择`);
691
+ let selected: { picked?: boolean; src?: string } = {};
692
+ try {
693
+ selected = JSON.parse(rawIdentify) as { picked?: boolean; src?: string };
694
+ } catch {
695
+ return await warn('cover: 素材身份探测返回未知结果,未选择');
696
+ }
571
697
  // The library picker stages the choice: 下一步 advances it, then the dialog confirms.
572
698
  const next = await tryFindSelector(page, COVER_LIBRARY_NEXT, Math.min(deps.stepTimeoutMs, 4_000));
573
699
  if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
574
700
  const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
575
- if (confirm === undefined) return 'cover: the choice panel offered no confirm control';
701
+ if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
576
702
  await clickSelector(page, confirm, deps.stepTimeoutMs);
703
+ // Committed readback (observed capture: the preview lives under #js_cover_area; the empty
704
+ // state keeps the `add_cover` icon visible). Poll briefly — the preview renders late.
705
+ const READBACK = `(() => {
706
+ const area = document.querySelector('#js_cover_area');
707
+ if (!area) return 'noarea';
708
+ if (area.querySelector('.icon20_common.add_cover') && area.querySelector('.icon20_common.add_cover').offsetWidth > 0) return 'empty';
709
+ const preview = area.querySelector('.js_cover_preview_new');
710
+ if (!preview || preview.offsetWidth === 0) return 'nopreview';
711
+ const src = preview.querySelector('img')?.src ??
712
+ (preview.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '') ||
713
+ (preview.querySelector('input[type=hidden]')?.value ?? '');
714
+ return src ? src : 'unknown';
715
+ })()`;
716
+ let committed = '';
717
+ for (let read = 0; read < 5 && committed === ''; read += 1) {
718
+ const raw = String(await page.evaluate(READBACK).catch(() => 'error'));
719
+ if (raw !== '' && raw !== 'nopreview' && raw !== 'noarea') {
720
+ committed = raw;
721
+ break;
722
+ }
723
+ await page.waitForTimeout?.(600);
724
+ }
725
+ if (committed === 'empty') return await warn('cover: 确认后面板仍显示未设置封面,提交未生效');
726
+ if (committed === '' || committed === 'noarea' || committed === 'error')
727
+ return await warn('cover: 已确认但无法读取已提交封面,请手动核对');
728
+ // Identity comparison: both sides are remote asset references from the same widget. Only an
729
+ // exact match proves the requested asset committed; an unreadable/edited preview warns
730
+ // rather than inventing equivalence (local paths are never compared to asset URLs).
731
+ const selectedSrc = (selected.src ?? '').trim();
732
+ if (selectedSrc === '' || (committed !== selectedSrc && !committed.includes(selectedSrc)))
733
+ return await warn(
734
+ `cover: 已提交封面的素材身份与所选不一致(selected=${selectedSrc || '未读到'} committed=${committed.slice(0, 80)})`,
735
+ );
577
736
  return null;
578
737
  } catch (error) {
579
- return `cover: ${error instanceof Error ? error.message : String(error)}`;
738
+ return await warn(`cover: ${error instanceof Error ? error.message : String(error)}`);
580
739
  }
581
740
  }
582
741
 
@@ -587,29 +746,65 @@ function coverSourceOf(content: Content): 'library' | 'body' {
587
746
  return (meta as Record<string, unknown>).coverSource === 'body' ? 'body' : 'library';
588
747
  }
589
748
 
749
+ /** The audio-embed block of `metadata.wechatmp`, or null when this run embeds no audio. Shared by
750
+ * the cover step (the library only holds the episode cover once audio uploaded — task 0272). */
751
+ function audioBlockOf(content: Content): { title: string; audioPath: string; coverPath: string } | null {
752
+ const meta = content.metadata?.wechatmp;
753
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return null;
754
+ const m = meta as Record<string, unknown>;
755
+ if (
756
+ typeof m.audioTitle !== 'string' ||
757
+ m.audioTitle.trim() === '' ||
758
+ typeof m.audioPath !== 'string' ||
759
+ m.audioPath === ''
760
+ )
761
+ return null;
762
+ return {
763
+ title: m.audioTitle.trim(),
764
+ audioPath: m.audioPath,
765
+ coverPath: typeof m.audioCoverPath === 'string' ? m.audioCoverPath : '',
766
+ };
767
+ }
768
+
590
769
  /** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
591
- async function enrichArticleDraft(page: WechatPage, content: Content): Promise<string[]> {
770
+ async function enrichArticleDraft(
771
+ page: WechatPage,
772
+ content: Content,
773
+ ): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
592
774
  const enrichMeta = content.metadata?.wechatmp;
593
- if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta)) return [];
775
+ if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
776
+ return { enrichWarnings: [], coverWarnings: [] };
594
777
  const meta = enrichMeta as Record<string, unknown>;
595
- const warnings = await enrichArticle(page, meta);
596
- const audioTitle = meta.audioTitle;
597
- const audioPath = meta.audioPath;
598
- const audioCoverPath = meta.audioCoverPath;
599
- if (
600
- typeof audioTitle === 'string' &&
601
- audioTitle.trim() !== '' &&
602
- typeof audioPath === 'string' &&
603
- audioPath !== ''
604
- ) {
605
- const audioWarning = await insertAudioMaterial(page, {
606
- title: audioTitle.trim(),
607
- audioPath,
608
- coverPath: typeof audioCoverPath === 'string' ? audioCoverPath : '',
609
- });
610
- if (audioWarning !== '') warnings.push(audioWarning);
778
+ const enrichWarnings = await enrichArticle(page, meta);
779
+ const audio = audioBlockOf(content);
780
+ let audioFailed = false;
781
+ if (audio !== null) {
782
+ const audioWarning = await insertAudioMaterial(page, audio);
783
+ if (audioWarning !== '') {
784
+ enrichWarnings.push(audioWarning);
785
+ audioFailed = true;
786
+ }
787
+ }
788
+ // Deferred library cover (task 0272): the audio upload just put the episode cover in the 图片库 —
789
+ // but ONLY a committed upload guarantees that (task 0260 R1: a failed/uncertain audio upload
790
+ // leaves the library without the requested cover, so the dependent picker must not run).
791
+ const coverWarnings: string[] = [];
792
+ if (coverSourceOf(content) === 'library' && audio !== null) {
793
+ if (audioFailed) {
794
+ coverWarnings.push('cover: 音频上传未成功,图片库不保证已有请求封面,已跳过图片库封面');
795
+ } else {
796
+ const cover = await setCover(
797
+ page,
798
+ 'library',
799
+ { stepTimeoutMs: 8_000 },
800
+ // The upload binding is the image the audio form just bound (the episode cover
801
+ // file); `coverPath(input)` is the article's own first body image, not this.
802
+ { binding: audio.coverPath === '' ? undefined : audio.coverPath },
803
+ );
804
+ if (cover !== null) coverWarnings.push(cover);
805
+ }
611
806
  }
612
- return warnings;
807
+ return { enrichWarnings, coverWarnings };
613
808
  }
614
809
 
615
810
  /** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover, settings. */
@@ -631,9 +826,20 @@ async function composeArticle(
631
826
  ]) {
632
827
  if (warning !== null) warnings.push(warning);
633
828
  }
634
- const cover = await setCover(page, coverSourceOf(deps.content), deps);
635
- if (cover !== null) warnings.push(cover);
636
- return { warnings, titlePath, enrichWarnings: await enrichArticleDraft(page, deps.content) };
829
+ const coverSource = coverSourceOf(deps.content);
830
+ const audioRuns = audioBlockOf(deps.content) !== null;
831
+ if (!(coverSource === 'library' && audioRuns)) {
832
+ const cover = await setCover(page, coverSource, deps, {
833
+ // Requested identity = the article's first body image (the 默认首图为封面 semantics of
834
+ // modes.coverPath). No audio ran, so the library binding is unknown — only an exposed
835
+ // exact filename can match.
836
+ requested: input.images[0],
837
+ });
838
+ if (cover !== null) warnings.push(cover);
839
+ }
840
+ const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content);
841
+ warnings.push(...coverWarnings);
842
+ return { warnings, titlePath, enrichWarnings };
637
843
  }
638
844
 
639
845
  /** Compose 贴图 (createType 8): ≥1 image is the message; title and body are 选填. */
@@ -779,10 +985,20 @@ async function saveDraft(
779
985
  let control: string | null = null;
780
986
  let signal: string | null = null;
781
987
  let attempts = 0;
988
+ let residuals = 0;
989
+ const residualDialogs: string[] = [];
782
990
  let stampAfter: string | null = deps.draftBaseline.stamp;
783
991
  while (signal === null && attempts < deps.submitAttempts) {
784
992
  attempts += 1;
785
993
  await sleep(deps.submitSettleMs);
994
+ if ((await closeOpenDialogs(entry.editor)) !== 'closed') {
995
+ residuals += 1;
996
+ residualDialogs.push(await openDialogInventory(entry.editor).catch(() => 'unreadable'));
997
+ // Refuse to click through an unresolved overlay (0260 R3/AC5): the click would land on
998
+ // the dialog footer again, not 保存为草稿. Retry the cleanup on the next attempt.
999
+ if (attempts < deps.submitAttempts) continue;
1000
+ break;
1001
+ }
786
1002
  control = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
787
1003
  await clickSelector(entry.editor, control, deps.stepTimeoutMs);
788
1004
  // The editor lives in its own tab, so re-read the console entry page — and ENTER the 草稿箱
@@ -805,6 +1021,10 @@ async function saveDraft(
805
1021
  }
806
1022
 
807
1023
  if (signal === null || control === null) {
1024
+ if (control === null)
1025
+ return fail(
1026
+ `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')})`,
1027
+ );
808
1028
  return fail(
809
1029
  `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)})`,
810
1030
  );
@@ -821,6 +1041,13 @@ async function saveDraft(
821
1041
  draftStampBefore: deps.draftBaseline.stamp,
822
1042
  draftStampAfter: signal,
823
1043
  titlePath: composed.titlePath,
1044
+ ...(residuals > 0
1045
+ ? {
1046
+ warnings: [
1047
+ `保存前清理了 ${residuals} 个残留弹层(${residualDialogs.join(' | ')}),草稿仍保存成功`,
1048
+ ],
1049
+ }
1050
+ : {}),
824
1051
  ...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
825
1052
  ...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
826
1053
  },
@@ -886,6 +1113,23 @@ async function publishContent(
886
1113
  });
887
1114
  }
888
1115
 
1116
+ /**
1117
+ * Text inventory of whatever modal is still open on `page` — the diagnostic a blocked submit click
1118
+ * needs (2026-10-12: `保存为草稿` was covered by a dialog footer for 30 s and the failure named only
1119
+ * the Playwright timeout). Covers the inner dialog and the overlay wrapper, both of which can be the
1120
+ * element that intercepts pointer events.
1121
+ */
1122
+ async function openDialogInventory(page: WechatPage): Promise<string> {
1123
+ const raw = await page
1124
+ .evaluate(
1125
+ `[...document.querySelectorAll('.weui-desktop-dialog, .weui-desktop-dialog__wrp')]` +
1126
+ `.filter((d) => d.offsetWidth).map((d) => (d.className || '') + ' :: ' + ` +
1127
+ `(d.innerText || '').replace(/\\s+/g, ' ').slice(0, 160)).join(' || ')`,
1128
+ )
1129
+ .catch(() => '');
1130
+ return typeof raw === 'string' ? raw.slice(0, 600) : '';
1131
+ }
1132
+
889
1133
  /**
890
1134
  * Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
891
1135
  * or — only on an explicit live request — publish it.
@@ -893,9 +1137,24 @@ async function publishContent(
893
1137
  export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
894
1138
  const entry = await enterMode(deps, input.operation);
895
1139
  const composed = await compose(input, deps, entry);
896
- return deps.publishStatus === 'public'
897
- ? publishContent(input, deps, entry, composed)
898
- : saveDraft(input, deps, entry, composed);
1140
+ // 2026-10-12: a throw from the submit step drops every diagnostic the compose phase collected —
1141
+ // a leaked settings dialog blocked 保存为草稿 on the daily run and the failure Result carried only
1142
+ // the Playwright timeout, so the stuck step had to be rediscovered by hand. Ride them on the error.
1143
+ const diagnostics = [...composed.warnings, ...composed.enrichWarnings];
1144
+ try {
1145
+ return deps.publishStatus === 'public'
1146
+ ? await publishContent(input, deps, entry, composed)
1147
+ : await saveDraft(input, deps, entry, composed);
1148
+ } catch (error) {
1149
+ const message = error instanceof Error ? error.message : String(error);
1150
+ const blocked = await openDialogInventory(entry.editor);
1151
+ const carried = [
1152
+ ...(diagnostics.length > 0 ? [`compose diagnostics: ${diagnostics.join(' | ')}`] : []),
1153
+ ...(blocked !== '' ? [`open dialogs at submit: ${blocked}`] : []),
1154
+ ];
1155
+ if (carried.length === 0) throw error;
1156
+ throw new Error(`${message} — ${carried.join(' — ')}`);
1157
+ }
899
1158
  }
900
1159
 
901
1160
  /**
@@ -992,12 +1251,14 @@ function escapeHtml(text: string): string {
992
1251
  /**
993
1252
  * Audio at the article top (operator request 2026-09-29; upload path added 2026-10-10).
994
1253
  *
995
- * Reuses a same-title 素材库 material when one exists; otherwise UPLOADS it. The operator's measured
996
- * upload shape: 音频 menu → 上传音频 → pick the audio file → 上传封面 → pick the cover → type the title
997
- * → tick 我已阅读并同意《公众平台音频上传服务规则》 → 保存 → the material list reopens → select the
998
- * default (first) row → 插入. 插入 places it at the caret, so the caret is put at the body start first.
1254
+ * Reuses a same-title 素材库 material when one exists (UNIQUE exposed-title match, identity
1255
+ * captured); otherwise UPLOADS it. The operator's measured upload shape: 音频 menu → 上传音频 → pick
1256
+ * the audio file → 上传封面 → pick the cover → type the title → tick 我已阅读并同意《公众平台音频上传服务规则》
1257
+ * → 保存 → the material list reopens → select the row whose exposed title matches uniquely → 插入.
1258
+ * 插入 places it at the caret, so the caret is put at the body start first.
999
1259
  *
1000
- * Fail-soft — every non-committed outcome returns a warning string, `''` means placed at the top.
1260
+ * Fail-soft — every non-committed outcome returns a warning string; `''` means the inserted node is
1261
+ * the body's first content AND carries the selected material's identity (task 0260 R2).
1001
1262
  */
1002
1263
  export async function insertAudioMaterial(
1003
1264
  page: WechatPage,
@@ -1014,15 +1275,28 @@ export async function insertAudioMaterial(
1014
1275
  sel?.removeAllRanges();
1015
1276
  sel?.addRange(r);
1016
1277
  })()`;
1017
- const DEDUPE = `(() => {
1018
- const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
1019
- const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
1020
- .find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
1021
- if (!hit) return 'missing';
1278
+ /** Trim + collapse whitespace/NBSP — MP titles carry `&nbsp;` entities (0272 capture). */
1279
+ const norm = (value: unknown): string =>
1280
+ typeof value === 'string' ? value.replace(/[\s\u00a0]+/g, ' ').trim() : '';
1281
+ /** Unique exposed-title match; ticks only an unticked row and captures its stable identity
1282
+ * (data-mediaid/data-id, else the thumb's media URL) for the body readback. Never first-item. */
1283
+ const MATCH = `(() => {
1284
+ const norm = (v) => (v ?? '').replace(/[\\s\\u00a0]+/g, ' ').trim();
1285
+ const want = ${JSON.stringify(norm(title))};
1286
+ const rows = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
1287
+ .filter((l) => l.offsetWidth > 0);
1288
+ const hits = rows.filter((l) => norm((l.querySelector('.audio_title') ?? l).textContent) === want);
1289
+ if (hits.length === 0) return 'missing';
1290
+ if (hits.length > 1) return 'ambiguous';
1291
+ const hit = hits[0];
1022
1292
  const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
1023
1293
  if (!cb) return 'nobox';
1024
- cb.click();
1025
- return 'checked';
1294
+ if (!cb.checked) cb.click();
1295
+ const wrp = hit.closest('.audio_item_wrp') ?? hit.parentElement;
1296
+ const thumb = wrp?.querySelector('.audio_item_thumb');
1297
+ const identity = wrp?.getAttribute('data-mediaid') ?? wrp?.getAttribute('data-id') ??
1298
+ (thumb ? (thumb.style.backgroundImage.match(/url\\("?([^")]+)"?\\)/)?.[1] ?? '') : '');
1299
+ return JSON.stringify({ state: 'checked', identity });
1026
1300
  })()`;
1027
1301
  /** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
1028
1302
  const AGREE = `(() => {
@@ -1035,15 +1309,6 @@ export async function insertAudioMaterial(
1035
1309
  if (!cb.checked) cb.click();
1036
1310
  return cb.checked ? 'checked' : 'unchecked';
1037
1311
  })()`;
1038
- /** Select the material the upload just created — the operator's 默认第一个. */
1039
- const SELECT_FIRST = `(() => {
1040
- const row = document.querySelector('label.frm_checkbox_label.audio_item');
1041
- if (!row) return 'norow';
1042
- const cb = (row.closest('li') ?? row.parentElement)?.querySelector('input[type=checkbox]');
1043
- if (!cb) return 'nobox';
1044
- if (!cb.checked) cb.click();
1045
- return 'checked';
1046
- })()`;
1047
1312
  const INSERT = `(() => {
1048
1313
  const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
1049
1314
  if (!btn) return false;
@@ -1056,18 +1321,23 @@ export async function insertAudioMaterial(
1056
1321
  })()`;
1057
1322
  const VERIFY = `(() => {
1058
1323
  const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
1059
- if (!pm) return 'noeditor';
1060
- const node = pm.querySelector('mpvoice, [class*="audio"]');
1061
- if (!node) return 'absent';
1324
+ if (!pm) return JSON.stringify({ state: 'noeditor' });
1062
1325
  const first = pm.firstElementChild;
1063
- return first && (first === node || first.contains(node)) ? 'top' : 'notop';
1326
+ if (!first) return JSON.stringify({ state: 'absent' });
1327
+ const node = first.matches('mpvoice, [data-mediaid]') ? first : first.querySelector('mpvoice, [data-mediaid]');
1328
+ if (!node) return JSON.stringify({ state: 'absent' });
1329
+ const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('data-appid') ??
1330
+ node.getAttribute('data-src') ?? node.getAttribute('src') ?? '';
1331
+ return JSON.stringify({ state: 'top', identity });
1064
1332
  })()`;
1065
1333
 
1066
1334
  /** Upload the audio + its cover, acknowledge the rules, and 保存. Returns a warning or null. */
1067
1335
  const upload = async (): Promise<string | null> => {
1068
1336
  const uploadControl = await trySelectors(page, UPLOAD_AUDIO);
1069
1337
  if (!uploadControl.found) return '音频: 上传音频 control not found in the audio dialog';
1070
- await clickSelector(page, UPLOAD_AUDIO.join(', '), 8_000);
1338
+ // Click the winner, never a joined union: `text=…, css…` mixes selector engines and
1339
+ // Playwright parses the whole string as CSS — zero matches (DOM fixture 0260).
1340
+ await clickSelector(page, uploadControl.selector ?? UPLOAD_AUDIO[0] ?? 'text=上传音频', 8_000);
1071
1341
  const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
1072
1342
  if (!audioInput.found) return '音频: no audio file input appeared';
1073
1343
  await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
@@ -1076,7 +1346,7 @@ export async function insertAudioMaterial(
1076
1346
  // The form requires a cover before it will save.
1077
1347
  const coverControl = await trySelectors(page, UPLOAD_COVER);
1078
1348
  if (coverControl.found) {
1079
- await clickSelector(page, UPLOAD_COVER.join(', '), 8_000);
1349
+ await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
1080
1350
  const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
1081
1351
  if (coverInput.found)
1082
1352
  await uploadFile(
@@ -1098,38 +1368,84 @@ export async function insertAudioMaterial(
1098
1368
  return null;
1099
1369
  };
1100
1370
 
1371
+ /** One MATCH read; a throwing probe stays 'error' (unknown, never treated as missing). */
1372
+ const matchOnce = async (): Promise<string> => {
1373
+ try {
1374
+ return String(await page.evaluate(MATCH));
1375
+ } catch {
1376
+ return 'error';
1377
+ }
1378
+ };
1379
+ /** Poll the picker while rows stream in; 'missing' keeps polling, JSON answers settle it. */
1380
+ const awaitMatch = async (): Promise<string> => {
1381
+ let match = await matchOnce();
1382
+ for (let i = 0; match === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
1383
+ await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
1384
+ match = await matchOnce();
1385
+ }
1386
+ return match;
1387
+ };
1101
1388
  try {
1102
1389
  await page.evaluate(CARET);
1390
+ // Open the toolbar dropdown first (measured 2026-10-13): the menu item is hidden until its
1391
+ // trigger is clicked, so the old direct `js_insertaudio` click timed out.
1392
+ await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_TOOLBAR_TRIGGER)})?.click()`);
1393
+ await page.waitForTimeout?.(300);
1103
1394
  await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
1104
1395
  await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
1105
- // The 素材库 list loads async after the dialog opens — retry while rows stream in.
1106
- let dedupe = String(await page.evaluate(DEDUPE));
1107
- for (let i = 0; dedupe === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
1108
- await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
1109
- dedupe = String(await page.evaluate(DEDUPE));
1110
- }
1111
- if (dedupe !== 'checked') {
1112
- // No reusable material: upload one, which reopens the list with it selected-by-default.
1396
+ let match = await awaitMatch();
1397
+ if (match === 'missing') {
1398
+ // No reusable material: upload one, then POLL until the new row actually exists — never
1399
+ // assume the upload auto-selected a default row (task 0260 AC2).
1113
1400
  const uploadWarning = await upload();
1114
1401
  if (uploadWarning !== null) {
1115
1402
  await page.evaluate(CANCEL);
1116
1403
  return uploadWarning;
1117
1404
  }
1118
1405
  await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
1119
- const selected = String(await page.evaluate(SELECT_FIRST));
1120
- if (selected !== 'checked') {
1406
+ match = await awaitMatch();
1407
+ if (match === 'missing') {
1408
+ await page.evaluate(CANCEL);
1409
+ return `音频: 保存后素材列表未出现「${title}」,未插入音频`;
1410
+ }
1411
+ if (match === 'error') {
1412
+ await page.evaluate(CANCEL);
1413
+ return '音频: 保存后素材匹配探测失败,未插入音频';
1414
+ }
1415
+ } else if (match === 'error') {
1416
+ await page.evaluate(CANCEL);
1417
+ return '音频: 素材匹配探测失败,未插入音频';
1418
+ }
1419
+ // Parse the match; anything but a unique ticked row stays a warning (never first-item).
1420
+ let identity = '';
1421
+ try {
1422
+ const parsed = JSON.parse(match) as { state?: string; identity?: string };
1423
+ if (parsed.state !== 'checked') {
1121
1424
  await page.evaluate(CANCEL);
1122
- return `音频: 上传后未找到素材行 (${selected})`;
1425
+ return `音频: 素材「${title}」${parsed.state === 'ambiguous' ? '列表匹配到多个同名条目,身份不唯一' : parsed.state === 'nobox' ? '行没有勾选框' : '未选中'},未插入音频`;
1123
1426
  }
1427
+ identity = parsed.identity ?? '';
1428
+ } catch {
1429
+ await page.evaluate(CANCEL);
1430
+ return '音频: 素材匹配探测返回未知结果,未插入音频';
1124
1431
  }
1125
1432
  if ((await page.evaluate(INSERT)) !== true) {
1126
1433
  await page.evaluate(CANCEL);
1127
1434
  return '音频: 插入按钮未找到,请手动插入';
1128
1435
  }
1129
1436
  await page.waitForTimeout?.(2_000);
1130
- const state = String(await page.evaluate(VERIFY));
1131
- if (state === 'absent' || state === 'noeditor') return '音频: 插入未生效,请手动检查';
1132
- if (state === 'notop') return '音频: 已插入但未在文首,请手动调整位置';
1437
+ let verdict: { state?: string; identity?: string };
1438
+ try {
1439
+ verdict = JSON.parse(String(await page.evaluate(VERIFY))) as { state?: string; identity?: string };
1440
+ } catch {
1441
+ return '音频: 已插入但无法读取正文状态,请手动检查';
1442
+ }
1443
+ if (verdict.state === 'absent' || verdict.state === 'noeditor') return '音频: 插入未生效,请手动检查';
1444
+ if (verdict.state !== 'top') return '音频: 已插入但未在文首,请手动调整位置';
1445
+ // Identity readback (0260 R2): success requires the selected identity to reappear on the
1446
+ // inserted node; an unreadable identity is an unverifiable warning, never a silent pass.
1447
+ if (identity === '' || (verdict.identity ?? '') === '' || verdict.identity !== identity)
1448
+ return `音频: 已插入文首但无法核对素材身份(selected=${identity || '未读到'} node=${(verdict.identity ?? '').slice(0, 60) || '未读到'}),请手动核对`;
1133
1449
  return '';
1134
1450
  } catch (error) {
1135
1451
  return `音频: ${error instanceof Error ? error.message.slice(0, 120) : '自动插入失败'},请手动插入`;