@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +16 -4
- package/package.json +1 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice-migration.json +3 -3
- package/plugins/kk/publish-presets/daily-ai-voice.json +4 -4
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +2 -2
- package/plugins/kk/scripts/daily-scheduled-run.ts +138 -44
- package/plugins/kk/scripts/daily-stages.ts +52 -9
- package/plugins/kk/skills/content-safety/references/platforms/podcast-salty.md +41 -0
- package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
- package/plugins/kk/workflows/kk-collect.yaml +3 -3
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +12 -12
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +2 -2
- package/plugins/publishings/surfdash-pub/dist/index.js +52 -23
- package/plugins/publishings/surfdash-pub/src/index.ts +69 -24
- package/plugins/publishings/wechatmp-pub/dist/index.js +171 -30
- package/plugins/publishings/wechatmp-pub/src/flow.ts +287 -49
- package/plugins/publishings/weibo-pub/dist/index.js +114 -8
- package/plugins/publishings/weibo-pub/src/index.ts +140 -16
- package/plugins/publishings/xhs-pub/dist/index.js +164 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
- 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 })
|
|
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,
|
|
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
|
-
|
|
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).
|
|
1279
|
-
*
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1613
|
-
|
|
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,
|