@gobing-ai/knowledge-kit 0.1.12 → 0.1.14

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