@gobing-ai/knowledge-kit 0.1.11 → 0.1.13
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 +2372 -1722
- package/package.json +1 -1
- package/plugins/generations/image-gen/README.md +108 -0
- package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/dist/index.js +102 -58
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
- package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
- package/plugins/generations/image-gen/src/presets.ts +6 -3
- package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
- package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
- package/plugins/ingestions/web-search/src/index.ts +1 -1
- package/plugins/kk/README.md +18 -0
- package/plugins/kk/commands/image-extract.md +1 -0
- package/plugins/kk/commands/image-generate.md +1 -0
- package/plugins/kk/commands/tell-me.md +1 -0
- package/plugins/kk/commands/workflow-run.md +7 -1
- package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
- package/plugins/kk/decisions/kk-itc.yaml +6 -4
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
- package/plugins/kk/publish-presets/daily-ai-voice.json +15 -7
- package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
- package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
- package/plugins/kk/scripts/daily-scheduled-run.ts +91 -41
- package/plugins/kk/scripts/daily-stages.ts +394 -53
- package/plugins/kk/scripts/kk-workflow-stages.ts +190 -0
- package/plugins/kk/scripts/safety-artifact-check.ts +70 -28
- package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
- package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/content-judge/SKILL.md +1 -0
- package/plugins/kk/skills/content-safety/SKILL.md +1 -0
- 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/skills/explain-things/SKILL.md +1 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -0
- package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
- package/plugins/kk/skills/storm-research/SKILL.md +1 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
- package/plugins/kk/skills/topic/SKILL.md +1 -0
- package/plugins/kk/skills/web-automation/SKILL.md +1 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +3 -3
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +21 -19
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +163 -63
- package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
- package/plugins/kk/workflows/kk-daily-safety.yaml +39 -31
- package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
- package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
- package/plugins/kk/workflows/kk-itc.yaml +36 -4
- package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
- package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
- package/plugins/publishings/linkedin-pub/package.json +19 -0
- package/plugins/publishings/linkedin-pub/plugin.json +12 -0
- package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
- package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
- package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +53 -24
- package/plugins/publishings/surfdash-pub/src/index.ts +72 -25
- package/plugins/publishings/threads-pub/dist/index.js +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +12 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +176 -32
- package/plugins/publishings/wechatmp-pub/src/flow.ts +298 -52
- package/plugins/publishings/weibo-pub/dist/index.js +100 -7
- package/plugins/publishings/weibo-pub/src/index.ts +111 -11
- package/plugins/publishings/x-pub/dist/index.js +7 -0
- package/plugins/publishings/x-pub/src/index.ts +11 -0
- 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;
|
|
@@ -1590,6 +1832,9 @@ function snapshotValueIsEmpty(value: string): boolean {
|
|
|
1590
1832
|
function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: PublicDraftSnapshot): boolean {
|
|
1591
1833
|
if (!snapshotValueIsEmpty(expected.title) && snapshotValueIsEmpty(actual.title)) return true;
|
|
1592
1834
|
if (!snapshotValueIsEmpty(expected.body) && snapshotValueIsEmpty(actual.body)) return true;
|
|
1835
|
+
// The editor renders a long body progressively; a strict prefix is still hydrating, not a
|
|
1836
|
+
// different version (20261104: 查看新草稿 failed on its first non-empty read).
|
|
1837
|
+
if (actual.body.length < expected.body.length && expected.body.startsWith(actual.body)) return true;
|
|
1593
1838
|
if (expected.media.length > actual.media.length) return true;
|
|
1594
1839
|
if (!snapshotValueIsEmpty(expected.cover) && snapshotValueIsEmpty(actual.cover)) return true;
|
|
1595
1840
|
if (expected.originalUrl !== '' && actual.originalUrl === '') return true;
|
|
@@ -1603,19 +1848,15 @@ function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: Publi
|
|
|
1603
1848
|
*/
|
|
1604
1849
|
const PUBLIC_SAVED_EDITOR_RELOAD_LIMIT = 6;
|
|
1605
1850
|
|
|
1606
|
-
function appmsgIdFromCanonical(value: string): string | null {
|
|
1851
|
+
export function appmsgIdFromCanonical(value: string): string | null {
|
|
1607
1852
|
const canonical = canonicalSavedEditorUrl(value);
|
|
1608
1853
|
if (canonical === null) return null;
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
return appmsgId && appmsgId.length > 0 ? appmsgId : null;
|
|
1612
|
-
} catch {
|
|
1613
|
-
return null;
|
|
1614
|
-
}
|
|
1854
|
+
const appmsgId = new URL(canonical).searchParams.get('appmsgid')?.trim();
|
|
1855
|
+
return appmsgId && appmsgId.length > 0 ? appmsgId : null;
|
|
1615
1856
|
}
|
|
1616
1857
|
|
|
1617
1858
|
/** Require two consecutive native readiness observations after any editor-version transition. */
|
|
1618
|
-
async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
|
|
1859
|
+
export async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
|
|
1619
1860
|
let consecutiveSaved = 0;
|
|
1620
1861
|
return waitUntil(
|
|
1621
1862
|
async () => {
|
|
@@ -1643,7 +1884,7 @@ async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps)
|
|
|
1643
1884
|
* the persisted surfaces before the one and only public click. A refusal or missing native signal
|
|
1644
1885
|
* stops before `发表`; no draft-box lookup or blind save retry is safe for the public rail.
|
|
1645
1886
|
*/
|
|
1646
|
-
async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
|
|
1887
|
+
export async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
|
|
1647
1888
|
const composed = await readPublicDraftSnapshot(entry.editor);
|
|
1648
1889
|
if (composed === null)
|
|
1649
1890
|
return fail(
|
|
@@ -1768,7 +2009,7 @@ interface StaleVersionRecovery {
|
|
|
1768
2009
|
* save and never retries an unknown outcome: 查看新草稿 is the one measured action that switches the
|
|
1769
2010
|
* editor to the platform's latest persisted version before a second initial 发表 click.
|
|
1770
2011
|
*/
|
|
1771
|
-
async function recoverStaleVersion(
|
|
2012
|
+
export async function recoverStaleVersion(
|
|
1772
2013
|
deps: ModeRunDeps,
|
|
1773
2014
|
entry: ModeEntry,
|
|
1774
2015
|
saved: PublicSaveEvidence,
|
|
@@ -1837,9 +2078,11 @@ async function recoverStaleVersion(
|
|
|
1837
2078
|
return fail(
|
|
1838
2079
|
'public stale-version recovery: latest editor snapshot was unreadable; no second 发表 click attempted',
|
|
1839
2080
|
);
|
|
2081
|
+
// TS narrows the closure-assigned `let` to never after the null guard; re-read it through a cast.
|
|
2082
|
+
const latestBody = (latestSnapshot as PublicDraftSnapshot).body;
|
|
1840
2083
|
if (differences.length > 0)
|
|
1841
2084
|
return fail(
|
|
1842
|
-
`public stale-version recovery: latest editor content mismatch (${differences.join(', ')}); no second 发表 click attempted`,
|
|
2085
|
+
`public stale-version recovery: latest editor content mismatch (${differences.join(', ')}; body ${latestBody.length}/${saved.snapshot.body.length} chars); no second 发表 click attempted`,
|
|
1843
2086
|
);
|
|
1844
2087
|
|
|
1845
2088
|
if (!(await waitForPublicEditorReadiness(latestEditor, deps)))
|
|
@@ -1920,7 +2163,7 @@ async function findUniqueVisibleSelector(
|
|
|
1920
2163
|
return found && selector !== undefined ? { selector, reason } : { reason };
|
|
1921
2164
|
}
|
|
1922
2165
|
|
|
1923
|
-
async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
|
|
2166
|
+
export async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
|
|
1924
2167
|
try {
|
|
1925
2168
|
const value = await page.evaluate(PUBLISH_NOTIFY_STATE_SCRIPT);
|
|
1926
2169
|
return typeof value === 'boolean' ? value : null;
|
|
@@ -1929,7 +2172,7 @@ async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean |
|
|
|
1929
2172
|
}
|
|
1930
2173
|
}
|
|
1931
2174
|
|
|
1932
|
-
function shouldNotifySubscribers(content: Content): boolean {
|
|
2175
|
+
export function shouldNotifySubscribers(content: Content): boolean {
|
|
1933
2176
|
const wechatmp = content.metadata?.wechatmp;
|
|
1934
2177
|
if (wechatmp === null || typeof wechatmp !== 'object' || Array.isArray(wechatmp)) return true;
|
|
1935
2178
|
return (wechatmp as Record<string, unknown>).notifySubscribers !== false;
|
|
@@ -1942,13 +2185,16 @@ function shouldNotifySubscribers(content: Content): boolean {
|
|
|
1942
2185
|
* state is the platform's actual pre-click switch value; the policy is recorded separately because
|
|
1943
2186
|
* the default preserves an already-OFF platform setting rather than enabling notifications.
|
|
1944
2187
|
*/
|
|
1945
|
-
async function confirmPublicPublish(
|
|
2188
|
+
export async function confirmPublicPublish(
|
|
1946
2189
|
page: WechatPage,
|
|
1947
2190
|
notifySubscribers: boolean,
|
|
1948
2191
|
stepTimeoutMs: number,
|
|
1949
2192
|
pollMs: number,
|
|
1950
2193
|
): Promise<Result | PublicConfirmation | PreconfirmationStaleVersion> {
|
|
1951
|
-
|
|
2194
|
+
// Not CONFIRM_TIMEOUT_MS: this dialog runs a server-side check before it mounts (3.6s on an idle
|
|
2195
|
+
// editor, 2026-11-04), so a 5s cap reported it missing on 2026-11-03/04. Waiting costs nothing
|
|
2196
|
+
// irreversible — no click happens until the dialog is uniquely observed.
|
|
2197
|
+
const confirmationTimeoutMs = stepTimeoutMs;
|
|
1952
2198
|
const dialog = await findUniqueVisibleSelector(page, PUBLISH_DIALOG, confirmationTimeoutMs, pollMs);
|
|
1953
2199
|
if (dialog.selector === undefined) {
|
|
1954
2200
|
if (dialog.reason === 'missing') {
|
|
@@ -2000,7 +2246,7 @@ async function confirmPublicPublish(
|
|
|
2000
2246
|
* the only pre-confirmation recovery is the bounded, native 查看新草稿 transition handled below.
|
|
2001
2247
|
* An unobserved final send is never repeated because a second click could duplicate the message.
|
|
2002
2248
|
*/
|
|
2003
|
-
async function publishContent(
|
|
2249
|
+
export async function publishContent(
|
|
2004
2250
|
input: ModeInput,
|
|
2005
2251
|
deps: ModeRunDeps,
|
|
2006
2252
|
entry: ModeEntry,
|
|
@@ -2234,7 +2480,7 @@ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Resu
|
|
|
2234
2480
|
* TypeScript only — no Python, no theme CLI.
|
|
2235
2481
|
*
|
|
2236
2482
|
* Kept instead of the shared `markdownToHtml` because the two differ on safety, measured on Bun
|
|
2237
|
-
* 1.3.14: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
|
|
2483
|
+
* 1.3.14 and again on 1.4.3: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
|
|
2238
2484
|
* `javascript:` link destinations, while this renderer escapes text and refuses non-http(s) link
|
|
2239
2485
|
* schemes (R9). The *insertion* is the shared `pasteHtml` either way, so the ADR-029 rail holds.
|
|
2240
2486
|
*/
|