@gobing-ai/knowledge-kit 0.0.34 → 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 (130) 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 +11769 -9250
  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 +1019 -10804
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/daily-article-gen/src/index.ts +32 -3
  18. package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
  19. package/plugins/generations/dailynews-gen/package.json +1 -1
  20. package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
  21. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  22. package/plugins/generations/episode-plan-gen/package.json +1 -1
  23. package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
  24. package/plugins/generations/gemini-tts/package.json +1 -1
  25. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  26. package/plugins/generations/image-gen/package.json +1 -1
  27. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  28. package/plugins/generations/news-report-gen/package.json +1 -1
  29. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  30. package/plugins/generations/voice-gen/dist/index.js +1066 -10864
  31. package/plugins/generations/voice-gen/package.json +1 -1
  32. package/plugins/generations/voice-gen/src/index.ts +9 -3
  33. package/plugins/generations/voxcpm-tts/README.md +72 -0
  34. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  35. package/plugins/generations/voxcpm-tts/package.json +16 -0
  36. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  37. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  38. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  39. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  40. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  41. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  42. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  43. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  44. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  45. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  46. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  47. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  48. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  49. package/plugins/ingestions/karakeep-local/package.json +1 -1
  50. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  51. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  52. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  53. package/plugins/ingestions/web-search/package.json +1 -1
  54. package/plugins/kk/README.md +29 -2
  55. package/plugins/kk/plugin.json +1 -1
  56. package/plugins/kk/schedules/cron.tmpl +9 -1
  57. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  58. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  59. package/plugins/kk/scripts/daily-stages.ts +702 -63
  60. package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
  61. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  62. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  63. package/plugins/kk/skills/publish/SKILL.md +8 -4
  64. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  65. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  66. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  67. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
  68. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
  69. package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
  70. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  71. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  72. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  73. package/plugins/publishings/emdash-pub/package.json +1 -1
  74. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  75. package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
  76. package/plugins/publishings/infoq-pub/package.json +1 -1
  77. package/plugins/publishings/infoq-pub/plugin.json +6 -2
  78. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  79. package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
  80. package/plugins/publishings/juejin-pub/package.json +1 -1
  81. package/plugins/publishings/juejin-pub/plugin.json +6 -2
  82. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  83. package/plugins/publishings/medium-pub/package.json +1 -1
  84. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  85. package/plugins/publishings/podcast-pub/package.json +1 -1
  86. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  87. package/plugins/publishings/qiita-pub/package.json +1 -1
  88. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  89. package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
  90. package/plugins/publishings/substack-pub/package.json +1 -1
  91. package/plugins/publishings/substack-pub/plugin.json +5 -2
  92. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  93. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  94. package/plugins/publishings/surfdash-pub/package.json +1 -1
  95. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  96. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  97. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  98. package/plugins/publishings/threads-pub/package.json +17 -0
  99. package/plugins/publishings/threads-pub/plugin.json +7 -0
  100. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  101. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  102. package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
  103. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
  104. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  105. package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
  106. package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
  107. package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
  108. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  109. package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
  110. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  111. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  112. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  113. package/plugins/publishings/weibo-pub/package.json +19 -0
  114. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  115. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  116. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  117. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  118. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  119. package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
  120. package/plugins/publishings/x-pub/package.json +1 -1
  121. package/plugins/publishings/x-pub/plugin.json +6 -2
  122. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  123. package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
  124. package/plugins/publishings/xhs-pub/package.json +1 -1
  125. package/plugins/publishings/xhs-pub/plugin.json +6 -2
  126. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  127. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  128. package/plugins/publishings/zenn-pub/package.json +1 -1
  129. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  130. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -3,7 +3,7 @@ import { ResultSchema } from '@gobing-ai/kk-core';
3
3
  import { trySelectors } from '@gobing-ai/publish-harness';
4
4
  import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
5
5
  import type { Page } from 'playwright';
6
- import { enrichArticle, insertAudio } from './enrich';
6
+ import { enrichArticle } from './enrich';
7
7
  import {
8
8
  artifactPaths,
9
9
  CREATE_TYPES,
@@ -21,8 +21,10 @@ import {
21
21
  findAttachedSelector,
22
22
  findSelector,
23
23
  followNewPage,
24
+ modalState,
24
25
  tryFindSelector,
25
26
  type WechatPage,
27
+ waitForVisible,
26
28
  } from './page';
27
29
 
28
30
  /**
@@ -65,23 +67,92 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
65
67
 
66
68
  /**
67
69
  * 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
68
- * (`a.js_selectCoverFromContent`) / 从图片库选择 / 微信扫码上传 / AI 配图, and states 默认首图为封面.
69
- * Only the body-image path is implemented; the other three are recorded non-goals (0205).
70
+ * (`a.js_selectCoverFromContent`) / `从图片库选择` (`a.js_imagedialog`) / 微信扫码上传 / AI 配图.
71
+ * `从图片库选择` is the default because the daily flow uploads the episode cover there as the audio
72
+ * material's cover, so the library holds exactly the image the operator wants (2026-10-10);
73
+ * `从正文选择` remains selectable via `metadata.wechatmp.coverSource`.
70
74
  */
71
75
  const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
72
76
  const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
77
+ const COVER_FROM_LIBRARY = ['a.js_imagedialog', 'text=从图片库选择'];
78
+ /** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
79
+ const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
73
80
  /**
74
81
  * The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
75
82
  * confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
76
83
  * to do). A miss is recorded as a warning, never as a false cover claim.
77
84
  */
78
- const COVER_ITEM = [
79
- 'div[class*="cover" i] li',
80
- 'div[class*="imgList" i] li',
81
- 'div[class*="weui-desktop-img-picker" i] li',
82
- ];
83
85
  const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
84
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
+
85
156
  /** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
86
157
  const VIDEO_LOCAL_UPLOAD = ['text=本地上传', 'button:has-text("本地上传")'];
87
158
  const VIDEO_FILE_INPUT = [
@@ -111,6 +182,40 @@ const PODCAST_COVER_INPUT = [
111
182
  ];
112
183
  const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'button:has-text("插入")'];
113
184
 
185
+ /** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
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';
190
+ const AUDIO_DIALOG = '.audio_dialog';
191
+ /** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
192
+ const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
193
+ const AUDIO_ROW_RETRIES = 2;
194
+ const AUDIO_ROW_SETTLE_MS = 1_200;
195
+ /**
196
+ * The upload form (operator walk 2026-10-10: `上传音频` → file → `上传封面` → file → title → agree →
197
+ * 保存). The form lives inside the audio dialog, so the file inputs stay scoped to it.
198
+ */
199
+ const UPLOAD_AUDIO = ['text=上传音频', 'button:has-text("上传音频")'];
200
+ const AUDIO_UPLOAD_INPUT = [
201
+ '.weui-desktop-dialog input[type="file"][accept*="audio"]',
202
+ '.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
203
+ '.audio_dialog input[type="file"][accept*="audio"]',
204
+ 'input[type="file"][accept*=".mp3"]',
205
+ ];
206
+ const UPLOAD_COVER = ['text=上传封面', 'button:has-text("上传封面")'];
207
+ const AUDIO_COVER_INPUT = [
208
+ '.weui-desktop-dialog input[type="file"][accept*="image/"]',
209
+ '.audio_dialog input[type="file"][accept*="image/"]',
210
+ 'input[type="file"][accept*=".png"]',
211
+ ];
212
+ const AUDIO_TITLE_INPUT = [
213
+ '.weui-desktop-dialog input[placeholder*="标题"]',
214
+ '.audio_dialog input[placeholder*="标题"]',
215
+ '.audio_dialog input.frm_input',
216
+ ];
217
+ const UPLOAD_SAVE = ['.weui-desktop-dialog button:has-text("保存")', 'button:has-text("保存")'];
218
+
114
219
  /** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
115
220
  const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
116
221
  const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
@@ -452,20 +557,33 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
452
557
  }
453
558
 
454
559
  /**
455
- * `一键排版` applies the layout directly. Measured live (dogfood 2026-10-05): after the click the only
456
- * new text was ONE element carrying `保留源格式 匹配目标格式 仅保留文本` as a label — a format hint, not a
457
- * clickable option set (the discovery note misread that concatenation as a menu). Waiting 30 s for
458
- * `匹配目标格式` therefore failed a real draft. Click the control when present, honour an option only
459
- * if one appears promptly, and never let the layout step gate the save.
560
+ * `一键排版` applies the platform's own layout to the pasted body. The operator walk of 2026-10-10 shows
561
+ * the control is present and expected to be applied, so a click that leads nowhere is now REPORTED
562
+ * rather than swallowed (the earlier version failed silently, which is why 正文 came out unformatted).
563
+ *
564
+ * The format set (`保留源格式` / `匹配目标格式` / `仅保留文本`) is what we want when it renders as a menu;
565
+ * `匹配目标格式` is the correct choice for markdown-derived content. A build where the trio is only a
566
+ * label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
460
567
  */
461
- async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<void> {
568
+ async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
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;
462
573
  try {
463
- const control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
464
574
  await clickSelector(page, control, deps.stepTimeoutMs);
465
- const option = await findSelector(page, LAYOUT_TARGET, '匹配目标格式 option', 1500).catch(() => null);
466
- if (option !== null) await clickSelector(page, option, 1500);
467
- } catch {
468
- // No layout control on this editor build — the draft still saves.
575
+ } catch (error) {
576
+ return `一键排版: click failed — ${error instanceof Error ? error.message : String(error)}`;
577
+ }
578
+ const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
579
+ if (option === undefined) {
580
+ return '一键排版: clicked, but no 匹配目标格式 option appeared — the body may be unformatted; check the draft';
581
+ }
582
+ try {
583
+ await clickSelector(page, option, 5_000);
584
+ return null;
585
+ } catch (error) {
586
+ return `一键排版: 匹配目标格式 not applied — ${error instanceof Error ? error.message : String(error)}`;
469
587
  }
470
588
  }
471
589
 
@@ -489,47 +607,207 @@ async function fillOptional(
489
607
  }
490
608
  }
491
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
+
492
625
  /**
493
- * 封面 from the body (§5.2): there is no cover file input, so the panel's `从正文选择` picks the first
494
- * body image. Best-effort and recorded: the panel states 默认首图为封面, so the first body image is
495
- * already the cover, and only the explicit pick's failure is reported as a warning.
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).
496
632
  */
497
- async function setCoverFromBody(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
498
- const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
499
- if (open === undefined)
500
- return 'cover: the panel offers neither 从正文选择 nor its openers — the platform default (默认首图为封面) is all this run has';
633
+ async function setCover(
634
+ page: WechatPage,
635
+ source: 'library' | 'body',
636
+ deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
637
+ identity: CoverIdentity,
638
+ ): Promise<string | null> {
639
+ const picker = source === 'library' ? COVER_FROM_LIBRARY : COVER_FROM_CONTENT;
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
+ };
501
653
  try {
654
+ const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
655
+ if (open === undefined)
656
+ return await warn(
657
+ 'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
658
+ );
502
659
  await clickSelector(page, open, deps.stepTimeoutMs);
503
- const pick = await tryFindSelector(page, COVER_FROM_CONTENT, Math.min(deps.stepTimeoutMs, 5_000));
504
- if (pick === undefined) return 'cover: 从正文选择 was not offered by the open cover panel';
660
+ const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
661
+ if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
505
662
  await clickSelector(page, pick, deps.stepTimeoutMs);
506
- const item = await tryFindSelector(page, COVER_ITEM, Math.min(deps.stepTimeoutMs, 5_000));
507
- if (item === undefined)
508
- return 'cover: the panel listed no body image to choose (its list is not <img>-based, §5.2)';
509
- await clickSelector(page, item, deps.stepTimeoutMs);
510
- const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 5_000));
511
- if (confirm === undefined) return 'cover: the choice panel offered no confirm control';
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
+ }
697
+ // The library picker stages the choice: 下一步 advances it, then the dialog confirms.
698
+ const next = await tryFindSelector(page, COVER_LIBRARY_NEXT, Math.min(deps.stepTimeoutMs, 4_000));
699
+ if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
700
+ const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
701
+ if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
512
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
+ );
513
736
  return null;
514
737
  } catch (error) {
515
- return `cover: ${error instanceof Error ? error.message : String(error)}`;
738
+ return await warn(`cover: ${error instanceof Error ? error.message : String(error)}`);
516
739
  }
517
740
  }
518
741
 
519
- /** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (§5.2, ADR-026). */
520
- async function enrichArticleDraft(page: WechatPage, content: Content): Promise<string[]> {
742
+ /** `metadata.wechatmp.coverSource` — `library` (default) is the uploaded episode cover. */
743
+ function coverSourceOf(content: Content): 'library' | 'body' {
744
+ const meta = content.metadata?.wechatmp;
745
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return 'library';
746
+ return (meta as Record<string, unknown>).coverSource === 'body' ? 'body' : 'library';
747
+ }
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
+
769
+ /** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
770
+ async function enrichArticleDraft(
771
+ page: WechatPage,
772
+ content: Content,
773
+ ): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
521
774
  const enrichMeta = content.metadata?.wechatmp;
522
- if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta)) return [];
523
- const warnings = await enrichArticle(page, enrichMeta as Record<string, unknown>);
524
- const audioTitle = (enrichMeta as Record<string, unknown>).audioTitle;
525
- if (typeof audioTitle === 'string' && audioTitle.trim() !== '') {
526
- const audioWarning = await insertAudio(page, audioTitle.trim());
527
- if (audioWarning !== '') warnings.push(audioWarning);
775
+ if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
776
+ return { enrichWarnings: [], coverWarnings: [] };
777
+ const meta = enrichMeta as Record<string, unknown>;
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
+ }
528
806
  }
529
- return warnings;
807
+ return { enrichWarnings, coverWarnings };
530
808
  }
531
809
 
532
- /** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover from the body. */
810
+ /** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover, settings. */
533
811
  async function composeArticle(
534
812
  input: { operation: '文章'; images: string[]; digest?: string },
535
813
  deps: ModeRunDeps,
@@ -540,16 +818,28 @@ async function composeArticle(
540
818
  await attachImages(page, input.images, deps);
541
819
  const titlePath = await typeTitle(page, deps);
542
820
  await insertBody(page, deps);
543
- await applyLayout(page, deps);
821
+ const layout = await applyLayout(page, deps);
822
+ if (layout !== null) warnings.push(layout);
544
823
  for (const warning of [
545
824
  await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
546
825
  await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
547
826
  ]) {
548
827
  if (warning !== null) warnings.push(warning);
549
828
  }
550
- const cover = await setCoverFromBody(page, deps);
551
- if (cover !== null) warnings.push(cover);
552
- 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 };
553
843
  }
554
844
 
555
845
  /** Compose 贴图 (createType 8): ≥1 image is the message; title and body are 选填. */
@@ -647,8 +937,18 @@ async function compose(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): P
647
937
  }
648
938
 
649
939
  function authorOf(content: Content): string {
650
- const author = content.metadata?.author;
651
- return typeof author === 'string' ? author : '';
940
+ // The 作者 field is the byline readers see. The daily stage carries it as `metadata.wechatmp.author`
941
+ // (with the other MP preferences); `metadata.author` is the generic Content field and wins when a
942
+ // producer sets it directly. Reading only the generic one left 请输入作者 empty on every daily run
943
+ // (operator report 2026-10-10).
944
+ const wechatmp = content.metadata?.wechatmp;
945
+ const scoped =
946
+ wechatmp !== null && typeof wechatmp === 'object' && !Array.isArray(wechatmp)
947
+ ? (wechatmp as Record<string, unknown>).author
948
+ : undefined;
949
+ const generic = content.metadata?.author;
950
+ if (typeof generic === 'string' && generic.trim() !== '') return generic.trim();
951
+ return typeof scoped === 'string' ? scoped.trim() : '';
652
952
  }
653
953
 
654
954
  function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Record<string, unknown> {
@@ -685,10 +985,20 @@ async function saveDraft(
685
985
  let control: string | null = null;
686
986
  let signal: string | null = null;
687
987
  let attempts = 0;
988
+ let residuals = 0;
989
+ const residualDialogs: string[] = [];
688
990
  let stampAfter: string | null = deps.draftBaseline.stamp;
689
991
  while (signal === null && attempts < deps.submitAttempts) {
690
992
  attempts += 1;
691
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
+ }
692
1002
  control = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
693
1003
  await clickSelector(entry.editor, control, deps.stepTimeoutMs);
694
1004
  // The editor lives in its own tab, so re-read the console entry page — and ENTER the 草稿箱
@@ -711,6 +1021,10 @@ async function saveDraft(
711
1021
  }
712
1022
 
713
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
+ );
714
1028
  return fail(
715
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)})`,
716
1030
  );
@@ -727,6 +1041,13 @@ async function saveDraft(
727
1041
  draftStampBefore: deps.draftBaseline.stamp,
728
1042
  draftStampAfter: signal,
729
1043
  titlePath: composed.titlePath,
1044
+ ...(residuals > 0
1045
+ ? {
1046
+ warnings: [
1047
+ `保存前清理了 ${residuals} 个残留弹层(${residualDialogs.join(' | ')}),草稿仍保存成功`,
1048
+ ],
1049
+ }
1050
+ : {}),
730
1051
  ...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
731
1052
  ...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
732
1053
  },
@@ -792,6 +1113,23 @@ async function publishContent(
792
1113
  });
793
1114
  }
794
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
+
795
1133
  /**
796
1134
  * Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
797
1135
  * or — only on an explicit live request — publish it.
@@ -799,9 +1137,24 @@ async function publishContent(
799
1137
  export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
800
1138
  const entry = await enterMode(deps, input.operation);
801
1139
  const composed = await compose(input, deps, entry);
802
- return deps.publishStatus === 'public'
803
- ? publishContent(input, deps, entry, composed)
804
- : 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
+ }
805
1158
  }
806
1159
 
807
1160
  /**
@@ -894,3 +1247,207 @@ export function markdownToWechatHtml(markdown: string): string {
894
1247
  function escapeHtml(text: string): string {
895
1248
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
896
1249
  }
1250
+
1251
+ /**
1252
+ * Audio at the article top (operator request 2026-09-29; upload path added 2026-10-10).
1253
+ *
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.
1259
+ *
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).
1262
+ */
1263
+ export async function insertAudioMaterial(
1264
+ page: WechatPage,
1265
+ input: { title: string; audioPath: string; coverPath: string },
1266
+ ): Promise<string> {
1267
+ const { title, audioPath, coverPath } = input;
1268
+ const CARET = `(() => {
1269
+ const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
1270
+ if (!pm || !pm.firstChild) return;
1271
+ const r = document.createRange();
1272
+ r.setStart(pm.firstChild, 0);
1273
+ r.collapse(true);
1274
+ const sel = window.getSelection();
1275
+ sel?.removeAllRanges();
1276
+ sel?.addRange(r);
1277
+ })()`;
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];
1292
+ const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
1293
+ if (!cb) return 'nobox';
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 });
1300
+ })()`;
1301
+ /** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
1302
+ const AGREE = `(() => {
1303
+ const node = [...document.querySelectorAll('label, span, div')]
1304
+ .find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.children.length === 0);
1305
+ if (!node) return 'no-label';
1306
+ const scope = node.closest('label') ?? node.parentElement;
1307
+ const cb = scope?.querySelector('input[type=checkbox]') ?? document.querySelector('input[type=checkbox].js_agree');
1308
+ if (!cb) return 'no-box';
1309
+ if (!cb.checked) cb.click();
1310
+ return cb.checked ? 'checked' : 'unchecked';
1311
+ })()`;
1312
+ const INSERT = `(() => {
1313
+ const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
1314
+ if (!btn) return false;
1315
+ btn.click();
1316
+ return true;
1317
+ })()`;
1318
+ const CANCEL = `(() => {
1319
+ const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
1320
+ btn?.click();
1321
+ })()`;
1322
+ const VERIFY = `(() => {
1323
+ const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
1324
+ if (!pm) return JSON.stringify({ state: 'noeditor' });
1325
+ const first = pm.firstElementChild;
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 });
1332
+ })()`;
1333
+
1334
+ /** Upload the audio + its cover, acknowledge the rules, and 保存. Returns a warning or null. */
1335
+ const upload = async (): Promise<string | null> => {
1336
+ const uploadControl = await trySelectors(page, UPLOAD_AUDIO);
1337
+ if (!uploadControl.found) return '音频: 上传音频 control not found in the audio dialog';
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);
1341
+ const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
1342
+ if (!audioInput.found) return '音频: no audio file input appeared';
1343
+ await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
1344
+ timeoutMs: 60_000,
1345
+ });
1346
+ // The form requires a cover before it will save.
1347
+ const coverControl = await trySelectors(page, UPLOAD_COVER);
1348
+ if (coverControl.found) {
1349
+ await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
1350
+ const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
1351
+ if (coverInput.found)
1352
+ await uploadFile(
1353
+ asPlaywrightPage(page),
1354
+ coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
1355
+ coverPath,
1356
+ {
1357
+ timeoutMs: 60_000,
1358
+ },
1359
+ );
1360
+ }
1361
+ const titleInput = await trySelectors(page, AUDIO_TITLE_INPUT);
1362
+ if (titleInput.found) await fillSelector(page, titleInput.selector ?? AUDIO_TITLE_INPUT.join(', '), title);
1363
+ const agreed = String(await page.evaluate(AGREE));
1364
+ if (agreed !== 'checked') return `音频: 服务规则勾选失败 (${agreed})`;
1365
+ const save = await trySelectors(page, UPLOAD_SAVE);
1366
+ if (!save.found) return '音频: 上传表单的保存按钮未找到';
1367
+ await clickSelector(page, UPLOAD_SAVE.join(', '), 8_000);
1368
+ return null;
1369
+ };
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
+ };
1388
+ try {
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);
1394
+ await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
1395
+ await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
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).
1400
+ const uploadWarning = await upload();
1401
+ if (uploadWarning !== null) {
1402
+ await page.evaluate(CANCEL);
1403
+ return uploadWarning;
1404
+ }
1405
+ await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
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') {
1424
+ await page.evaluate(CANCEL);
1425
+ return `音频: 素材「${title}」${parsed.state === 'ambiguous' ? '列表匹配到多个同名条目,身份不唯一' : parsed.state === 'nobox' ? '行没有勾选框' : '未选中'},未插入音频`;
1426
+ }
1427
+ identity = parsed.identity ?? '';
1428
+ } catch {
1429
+ await page.evaluate(CANCEL);
1430
+ return '音频: 素材匹配探测返回未知结果,未插入音频';
1431
+ }
1432
+ if ((await page.evaluate(INSERT)) !== true) {
1433
+ await page.evaluate(CANCEL);
1434
+ return '音频: 插入按钮未找到,请手动插入';
1435
+ }
1436
+ await page.waitForTimeout?.(2_000);
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) || '未读到'}),请手动核对`;
1449
+ return '';
1450
+ } catch (error) {
1451
+ return `音频: ${error instanceof Error ? error.message.slice(0, 120) : '自动插入失败'},请手动插入`;
1452
+ }
1453
+ }