@gobing-ai/knowledge-kit 0.1.10 → 0.1.12
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 +2478 -1737
- 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 +11 -3
- 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 +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1403 -156
- package/plugins/kk/scripts/kk-workflow-stages.ts +266 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +277 -0
- 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/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-daily-ai-voice2.yaml +177 -128
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +161 -61
- package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
- package/plugins/kk/workflows/kk-daily-safety.yaml +156 -69
- 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/bilibili-pub/dist/index.js +2 -1
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/csdn-pub/dist/index.js +2 -1
- package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/infoq-pub/dist/index.js +2 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/jike-pub/dist/index.js +2 -1
- package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/juejin-pub/dist/index.js +2 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
- 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/podcast-pub/dist/index.js +46 -8
- package/plugins/publishings/podcast-pub/src/index.ts +62 -5
- package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/sspai-pub/dist/index.js +2 -1
- package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/substack-pub/dist/index.js +2 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +461 -53
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
- package/plugins/publishings/surfdash-pub/src/index.ts +592 -65
- 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 +936 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1134 -86
- package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
- package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
- package/plugins/publishings/weibo-pub/dist/index.js +2 -1
- package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/dist/index.js +9 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/src/index.ts +11 -0
- package/plugins/publishings/xhs-pub/dist/index.js +2 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
1
4
|
import type { Content, Result } from '@gobing-ai/kk-core';
|
|
2
5
|
import { ResultSchema } from '@gobing-ai/kk-core';
|
|
3
6
|
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
4
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_SIGNAL_CONTAINERS,
|
|
9
|
+
expectOutcome,
|
|
10
|
+
mkdirExclusive,
|
|
11
|
+
pasteHtml,
|
|
12
|
+
readScopedSignal,
|
|
13
|
+
rmdir,
|
|
14
|
+
uploadFile,
|
|
15
|
+
waitUntil,
|
|
16
|
+
} from '@gobing-ai/publish-harness/browser';
|
|
17
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
5
18
|
import type { Page } from 'playwright';
|
|
6
19
|
import { readLocalImageBytes } from './cgi';
|
|
7
20
|
import {
|
|
@@ -30,6 +43,7 @@ import {
|
|
|
30
43
|
findSelector,
|
|
31
44
|
followNewPage,
|
|
32
45
|
modalState,
|
|
46
|
+
sanitizeSessionEvidence,
|
|
33
47
|
tryFindSelector,
|
|
34
48
|
type WechatPage,
|
|
35
49
|
waitForVisible,
|
|
@@ -69,9 +83,18 @@ const AUTHOR_INPUT = ['input#author', 'input.js_author'];
|
|
|
69
83
|
/** The editor's own image input (§3: `accept="image/gif,image/jpeg,image/jpg,image/png,image/svg,image/webp"`). */
|
|
70
84
|
const IMAGE_FILE_INPUT = ['input[type="file"][accept*="image/"]', 'input[type="file"][accept*=".png"]'];
|
|
71
85
|
|
|
72
|
-
/** `一键排版`
|
|
86
|
+
/** `一键排版` and its measured UEditor menu (see applyLayout). */
|
|
73
87
|
const LAYOUT_CONTROL = ['button:has-text("一键排版")', 'div.ailayout-btn_wrp', 'div.ailayout-btn_label'];
|
|
74
|
-
const LAYOUT_TARGET = [
|
|
88
|
+
const LAYOUT_TARGET = [
|
|
89
|
+
'div.edui-popup:visible .edui-pastecontainer > .edui-button > div.edui-default:nth-child(2)',
|
|
90
|
+
'div[role="menuitem"]:has-text("匹配目标格式")',
|
|
91
|
+
'li:has-text("匹配目标格式")',
|
|
92
|
+
];
|
|
93
|
+
/** November 2 live console: the layout control opens a full-page preview with this commit action. */
|
|
94
|
+
const LAYOUT_PREVIEW_APPLY = ['button:has-text("使用此排版")', 'a:has-text("使用此排版")', 'text=使用此排版'];
|
|
95
|
+
const LAYOUT_TARGET_MARKER = '__KK_WECHAT_LAYOUT_TARGET__';
|
|
96
|
+
const LAYOUT_SAVE_STATE_MARKER = '__KK_WECHAT_LAYOUT_SAVE_STATE__';
|
|
97
|
+
const LEGACY_EDITOR_READY_MARKER = '__KK_WECHAT_LEGACY_EDITOR_READY__';
|
|
75
98
|
|
|
76
99
|
/**
|
|
77
100
|
* 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
|
|
@@ -80,17 +103,34 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
|
|
|
80
103
|
* material's cover, so the library holds exactly the image the operator wants (2026-10-10);
|
|
81
104
|
* `从正文选择` remains selectable via `metadata.wechatmp.coverSource`.
|
|
82
105
|
*/
|
|
83
|
-
const COVER_PANEL_OPEN = [
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
const COVER_PANEL_OPEN = [
|
|
107
|
+
'.js_cover_btn_area:visible',
|
|
108
|
+
'text=换一张',
|
|
109
|
+
'text=修改',
|
|
110
|
+
'a.js_selectCoverFromContent:visible',
|
|
111
|
+
];
|
|
112
|
+
const COVER_FROM_CONTENT = [
|
|
113
|
+
'#js_cover_area a.js_selectCoverFromContent:visible',
|
|
114
|
+
'a.js_selectCoverFromContent:visible',
|
|
115
|
+
];
|
|
116
|
+
const COVER_FROM_LIBRARY = ['#js_cover_area a.js_imagedialog:visible', 'a.js_imagedialog:visible'];
|
|
86
117
|
/** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
|
|
87
|
-
const COVER_LIBRARY_NEXT = [
|
|
118
|
+
const COVER_LIBRARY_NEXT = [
|
|
119
|
+
'.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) button:has-text("下一步")',
|
|
120
|
+
'button:has-text("下一步")',
|
|
121
|
+
];
|
|
88
122
|
/**
|
|
89
123
|
* The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
|
|
90
124
|
* confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
|
|
91
125
|
* to do). A miss is recorded as a warning, never as a false cover claim.
|
|
92
126
|
*/
|
|
93
|
-
const COVER_CONFIRM = [
|
|
127
|
+
const COVER_CONFIRM = [
|
|
128
|
+
'.weui-desktop-dialog:visible:has(h3:has-text("编辑封面")) button:text-is("确认")',
|
|
129
|
+
'.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) button:has-text("完成"):not(.weui-desktop-btn_disabled)',
|
|
130
|
+
'button:has-text("确定")',
|
|
131
|
+
'button:has-text("确认")',
|
|
132
|
+
'button:has-text("完成")',
|
|
133
|
+
];
|
|
94
134
|
|
|
95
135
|
/** The controls that close whatever modal the cover step left open, most specific first. */
|
|
96
136
|
const DIALOG_DISMISS = [
|
|
@@ -229,25 +269,68 @@ const AUDIO_COVER_INPUT = [
|
|
|
229
269
|
// skipped on every run and the draft shipped with no cover.
|
|
230
270
|
'.weui-desktop-dialog input[type="file"][accept*="image"]',
|
|
231
271
|
'.audio_dialog input[type="file"][accept*="image"]',
|
|
272
|
+
// The image chooser portals this input outside its dialog; the toolbar input excludes BMP.
|
|
273
|
+
'input[type="file"][accept*="image/bmp"]',
|
|
232
274
|
];
|
|
233
275
|
const AUDIO_TITLE_INPUT = [
|
|
234
276
|
'.weui-desktop-dialog input[placeholder*="标题"]',
|
|
235
277
|
'.audio_dialog input[placeholder*="标题"]',
|
|
236
278
|
'.audio_dialog input.frm_input',
|
|
237
279
|
];
|
|
238
|
-
const UPLOAD_SAVE = ['.
|
|
280
|
+
const UPLOAD_SAVE = ['.audio-upload-dialog button:text-is("保存")', '.weui-desktop-dialog button:text-is("保存")'];
|
|
239
281
|
|
|
240
282
|
/** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
|
|
241
283
|
const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
|
|
242
284
|
const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
|
|
243
|
-
|
|
285
|
+
/** Live 2026-11-02 mass-send dialog: the final action is the green 发表 button, not 确定. */
|
|
286
|
+
const PUBLISH_DIALOG = ['div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表"))'];
|
|
287
|
+
const PUBLISH_NOTIFY_TOGGLE = [
|
|
288
|
+
'div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表")) .mass_send__notify .weui-desktop-switch__input:visible',
|
|
289
|
+
];
|
|
290
|
+
const PUBLISH_NOTIFY_STATE_SCRIPT = `(() => {
|
|
291
|
+
const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find((candidate) => {
|
|
292
|
+
const title = candidate.querySelector('h3.weui-desktop-dialog__title')?.textContent?.trim();
|
|
293
|
+
const rect = candidate.getBoundingClientRect();
|
|
294
|
+
return title === '发表' && rect.width > 0 && rect.height > 0;
|
|
295
|
+
});
|
|
296
|
+
const input = dialog?.querySelector('.mass_send__notify input.weui-desktop-switch__input');
|
|
297
|
+
return input instanceof HTMLInputElement ? input.checked : null;
|
|
298
|
+
})()`;
|
|
299
|
+
const PUBLISH_FINAL = [
|
|
300
|
+
'div.weui-desktop-dialog:visible:has(h3.weui-desktop-dialog__title:text-is("发表")) .mass-send__footer button.weui-desktop-btn_primary:text-is("发表")',
|
|
301
|
+
];
|
|
302
|
+
/** Known pre-confirmation stale-version dialog action; it is never a generic retry control. */
|
|
303
|
+
const PUBLISH_VIEW_LATEST = [
|
|
304
|
+
'div.version_fail_dialog:visible a.btn_primary.js_btn:text-is("查看新草稿"):visible',
|
|
305
|
+
'div.weui-desktop-dialog:visible button:text-is("查看新草稿")',
|
|
306
|
+
'div.weui-desktop-dialog:visible a:text-is("查看新草稿")',
|
|
307
|
+
'div.weui-desktop-dialog:visible [role="button"]:text-is("查看新草稿")',
|
|
308
|
+
'div.weui-desktop-dialog:visible button:has-text("查看新草稿")',
|
|
309
|
+
];
|
|
244
310
|
const PUBLISH_SUCCESS = ['发表成功', '已发表'];
|
|
311
|
+
const PUBLIC_SAVE_REFUSAL = ['当前编辑的草稿不是最新版本'];
|
|
312
|
+
const PUBLIC_SAVE_REFUSAL_CONTAINERS = [
|
|
313
|
+
...DEFAULT_SIGNAL_CONTAINERS,
|
|
314
|
+
'div.version_fail_dialog:visible',
|
|
315
|
+
'div.weui-desktop-dialog:visible',
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
async function readStaleVersionRefusal(page: Page): Promise<string | null> {
|
|
319
|
+
return readScopedSignal(page, PUBLIC_SAVE_REFUSAL, PUBLIC_SAVE_REFUSAL_CONTAINERS);
|
|
320
|
+
}
|
|
245
321
|
/**
|
|
246
322
|
* The platform's own refusal/hint lines (§5, §5.3) — relayed as the Result's actionable message
|
|
247
323
|
* instead of a generic failure. `系统繁忙,请稍后再试` is deliberately absent: it appears routinely
|
|
248
324
|
* and is NOT a failure indicator (§5.1).
|
|
249
325
|
*/
|
|
250
|
-
const PLATFORM_REFUSAL = [
|
|
326
|
+
const PLATFORM_REFUSAL = [
|
|
327
|
+
'内容不完整,无法发表',
|
|
328
|
+
'请补充封面图',
|
|
329
|
+
'必须插入一张图片',
|
|
330
|
+
'未设置头像和名称',
|
|
331
|
+
'公众号尚未实名',
|
|
332
|
+
'当前编辑的草稿不是最新版本',
|
|
333
|
+
];
|
|
251
334
|
|
|
252
335
|
/** The 草稿箱 row's own update label (§5.1: `kk-discovery-文章 请忽略 … 更新于 13:12`). */
|
|
253
336
|
const DRAFT_STAMP = '更新于';
|
|
@@ -301,7 +384,7 @@ interface ModeEntry {
|
|
|
301
384
|
}
|
|
302
385
|
|
|
303
386
|
function fail(error: string): Result {
|
|
304
|
-
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
387
|
+
return ResultSchema.parse({ ok: false, target: TARGET, error: sanitizeSessionEvidence(error) });
|
|
305
388
|
}
|
|
306
389
|
|
|
307
390
|
function sleep(ms: number): Promise<void> {
|
|
@@ -577,35 +660,201 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
|
577
660
|
await pasteHtml(asPlaywrightPage(page), selector, markdownToWechatHtml(body), { timeoutMs: deps.stepTimeoutMs });
|
|
578
661
|
}
|
|
579
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The current UEditor build keeps the layout choices as nested, unlabelled divs rather than
|
|
665
|
+
* role/menuitem nodes. Playwright's visibility probe rejects those descendants even while the
|
|
666
|
+
* popup is visibly open, so the measured DOM click is kept in a page-side seam and requires one
|
|
667
|
+
* uniquely named target. No arbitrary first-match click is safe here.
|
|
668
|
+
*/
|
|
669
|
+
function nativeLayoutTargetScript(): string {
|
|
670
|
+
return `/* ${LAYOUT_TARGET_MARKER} */ (() => {
|
|
671
|
+
const popups = [...document.querySelectorAll('.edui-popup')].filter((popup) => {
|
|
672
|
+
const rect = popup.getBoundingClientRect();
|
|
673
|
+
const style = getComputedStyle(popup);
|
|
674
|
+
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
675
|
+
});
|
|
676
|
+
const targets = popups
|
|
677
|
+
.flatMap((popup) => [...popup.querySelectorAll('.edui-pastecontainer > .edui-button > div')])
|
|
678
|
+
.filter((candidate) => candidate.textContent?.trim() === '匹配目标格式');
|
|
679
|
+
if (targets.length !== 1) return JSON.stringify({ state: targets.length === 0 ? 'missing' : 'ambiguous' });
|
|
680
|
+
const target = targets[0];
|
|
681
|
+
if (!(target instanceof HTMLElement)) return JSON.stringify({ state: 'missing' });
|
|
682
|
+
target.click();
|
|
683
|
+
return JSON.stringify({ state: 'clicked' });
|
|
684
|
+
})()`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** The measured editor autosave rail: it is the native completion acknowledgement for layout. */
|
|
688
|
+
function nativeLayoutSaveStateScript(): string {
|
|
689
|
+
return `/* ${LAYOUT_SAVE_STATE_MARKER} */ (() => {
|
|
690
|
+
const container = document.querySelector('#auto_save_container');
|
|
691
|
+
if (!container) return 'unknown';
|
|
692
|
+
if (container.textContent?.includes('自动保存中')) return 'saving';
|
|
693
|
+
if (container.querySelector('.save_done')?.textContent?.includes('已保存')) return 'saved';
|
|
694
|
+
return 'unknown';
|
|
695
|
+
})()`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The legacy `media/appmsg_edit` editor has no active v2 autosave rail. Its measured manual-save
|
|
700
|
+
* readiness is narrower: the canonical route is explicit, the v2 container is empty, the legacy
|
|
701
|
+
* autosave and save-success markers exist but are hidden, and exactly one visible 保存为草稿 control
|
|
702
|
+
* remains. Unknown DOM shapes stay unknown; they are never treated as idle.
|
|
703
|
+
*/
|
|
704
|
+
function nativeLegacyEditorReadyScript(): string {
|
|
705
|
+
return `/* ${LEGACY_EDITOR_READY_MARKER} */ (() => {
|
|
706
|
+
const current = new URL(window.location.href);
|
|
707
|
+
const canonicalLegacyRoute =
|
|
708
|
+
current.origin === 'https://mp.weixin.qq.com' &&
|
|
709
|
+
current.pathname === '/cgi-bin/appmsg' &&
|
|
710
|
+
current.searchParams.get('t') === 'media/appmsg_edit' &&
|
|
711
|
+
current.searchParams.get('action') === 'edit' &&
|
|
712
|
+
(current.searchParams.get('appmsgid') ?? '').trim() !== '' &&
|
|
713
|
+
current.searchParams.get('isNew') !== '1';
|
|
714
|
+
if (!canonicalLegacyRoute) return 'unknown';
|
|
715
|
+
const autoSaveContainer = document.querySelector('#auto_save_container');
|
|
716
|
+
const legacyAutoSave = document.querySelector('#js_autosave');
|
|
717
|
+
const saveSuccessMarkers = [...document.querySelectorAll(
|
|
718
|
+
'#js_save_success, #js_save_success_with_ad, #js_save_success_with_ad_op',
|
|
719
|
+
)];
|
|
720
|
+
if (!autoSaveContainer || !legacyAutoSave || saveSuccessMarkers.length === 0) return 'unknown';
|
|
721
|
+
const hidden = (node) => {
|
|
722
|
+
const style = window.getComputedStyle(node);
|
|
723
|
+
const rect = node.getBoundingClientRect();
|
|
724
|
+
return style.display === 'none' || style.visibility === 'hidden' || rect.width === 0 || rect.height === 0;
|
|
725
|
+
};
|
|
726
|
+
const visibleSaveControls = [...document.querySelectorAll('button, a')].filter((node) => {
|
|
727
|
+
const text = (node.textContent ?? '').replace(/[\\s\\u00a0]+/g, '').trim();
|
|
728
|
+
if (text !== '保存为草稿') return false;
|
|
729
|
+
const style = window.getComputedStyle(node);
|
|
730
|
+
const rect = node.getBoundingClientRect();
|
|
731
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
732
|
+
});
|
|
733
|
+
return autoSaveContainer.textContent?.trim() === '' &&
|
|
734
|
+
hidden(legacyAutoSave) &&
|
|
735
|
+
saveSuccessMarkers.every(hidden) &&
|
|
736
|
+
visibleSaveControls.length === 1
|
|
737
|
+
? 'ready'
|
|
738
|
+
: 'unknown';
|
|
739
|
+
})()`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function clickNativeLayoutTarget(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
|
|
743
|
+
let state: string = 'missing';
|
|
744
|
+
const observed = await waitUntil(
|
|
745
|
+
async () => {
|
|
746
|
+
const raw = await page.evaluate(nativeLayoutTargetScript()).catch(() => null);
|
|
747
|
+
if (typeof raw !== 'string') return false;
|
|
748
|
+
try {
|
|
749
|
+
const parsed = JSON.parse(raw) as { state?: string };
|
|
750
|
+
if (parsed.state === 'ambiguous') {
|
|
751
|
+
state = 'ambiguous';
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
if (parsed.state === 'clicked') {
|
|
755
|
+
state = 'clicked';
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
state = 'missing';
|
|
760
|
+
}
|
|
761
|
+
return false;
|
|
762
|
+
},
|
|
763
|
+
Math.min(deps.stepTimeoutMs, 5_000),
|
|
764
|
+
deps.pollMs,
|
|
765
|
+
);
|
|
766
|
+
if (!observed) return false;
|
|
767
|
+
if (state === 'ambiguous')
|
|
768
|
+
throw new Error(
|
|
769
|
+
'一键排版: 匹配目标格式 was ambiguous in the visible native menu — composition stopped before submission',
|
|
770
|
+
);
|
|
771
|
+
return state === 'clicked';
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function waitForNativeLayoutSave(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
775
|
+
let sawSaving = false;
|
|
776
|
+
const saved = await waitUntil(
|
|
777
|
+
async () => {
|
|
778
|
+
const state = await page.evaluate(nativeLayoutSaveStateScript()).catch(() => 'unknown');
|
|
779
|
+
if (state === 'saving') sawSaving = true;
|
|
780
|
+
return sawSaving && state === 'saved';
|
|
781
|
+
},
|
|
782
|
+
Math.min(deps.stepTimeoutMs, 15_000),
|
|
783
|
+
deps.pollMs,
|
|
784
|
+
);
|
|
785
|
+
if (!saved)
|
|
786
|
+
throw new Error(
|
|
787
|
+
'一键排版: native auto-save did not transition from 自动保存中... to 已保存 within the layout deadline — composition stopped before submission',
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
580
791
|
/**
|
|
581
792
|
* `一键排版` applies the platform's own layout to the pasted body. The operator walk of 2026-10-10 shows
|
|
582
|
-
* the control is present and expected to be applied, so a click that leads nowhere is now
|
|
793
|
+
* the control is present and expected to be applied, so a click that leads nowhere is now rejected
|
|
583
794
|
* rather than swallowed (the earlier version failed silently, which is why 正文 came out unformatted).
|
|
584
795
|
*
|
|
585
796
|
* The format set (`保留源格式` / `匹配目标格式` / `仅保留文本`) is what we want when it renders as a menu;
|
|
586
797
|
* `匹配目标格式` is the correct choice for markdown-derived content. A build where the trio is only a
|
|
587
|
-
* label (the 2026-10-05 dogfood reading)
|
|
798
|
+
* label (the 2026-10-05 dogfood reading) is accepted only when its own measured commit signal is
|
|
799
|
+
* available; an unrecognized control fails closed before submission.
|
|
588
800
|
*/
|
|
589
801
|
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
|
|
590
802
|
// Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
|
|
591
803
|
// control is the platform's layout — silently skip; only a found-but-failing control warns.
|
|
592
804
|
const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
|
|
593
805
|
if (control === undefined) return null;
|
|
806
|
+
const before = deps.pages().length;
|
|
594
807
|
try {
|
|
595
808
|
await clickSelector(page, control, deps.stepTimeoutMs);
|
|
596
809
|
} catch (error) {
|
|
597
810
|
return `一键排版: click failed — ${error instanceof Error ? error.message : String(error)}`;
|
|
598
811
|
}
|
|
599
|
-
|
|
812
|
+
if (await clickNativeLayoutTarget(page, deps)) {
|
|
813
|
+
await waitForNativeLayoutSave(page, deps);
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
let layoutPage = page;
|
|
817
|
+
let option: string | undefined;
|
|
818
|
+
await waitUntil(
|
|
819
|
+
async () => {
|
|
820
|
+
for (const candidate of [...deps.pages().slice(before), page]) {
|
|
821
|
+
const found = await trySelectors(candidate, [...LAYOUT_PREVIEW_APPLY, ...LAYOUT_TARGET]);
|
|
822
|
+
if (found.found && found.selector) {
|
|
823
|
+
layoutPage = candidate;
|
|
824
|
+
option = found.selector;
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return false;
|
|
829
|
+
},
|
|
830
|
+
Math.min(deps.stepTimeoutMs, 15_000),
|
|
831
|
+
);
|
|
832
|
+
if (option !== undefined && LAYOUT_PREVIEW_APPLY.includes(option)) {
|
|
833
|
+
const selected = option;
|
|
834
|
+
await clickSelector(layoutPage, selected, deps.stepTimeoutMs);
|
|
835
|
+
const closed = await waitUntil(
|
|
836
|
+
async () => {
|
|
837
|
+
if (!deps.pages().includes(layoutPage)) return true;
|
|
838
|
+
const control = layoutPage.locator(selected).first();
|
|
839
|
+
return !(await control.isVisible());
|
|
840
|
+
},
|
|
841
|
+
Math.min(deps.stepTimeoutMs, 5_000),
|
|
842
|
+
);
|
|
843
|
+
if (!closed)
|
|
844
|
+
throw new Error('一键排版: 使用此排版 did not close the preview — composition stopped before submission');
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
600
847
|
if (option === undefined) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
848
|
+
if (deps.pages().length > before)
|
|
849
|
+
throw new Error(
|
|
850
|
+
'一键排版: a new preview tab opened without a recognized commit action — composition stopped before submission',
|
|
851
|
+
);
|
|
852
|
+
throw new Error(
|
|
853
|
+
'一键排版: the visible control exposed no recognized commit action — composition stopped before submission',
|
|
854
|
+
);
|
|
606
855
|
}
|
|
607
856
|
try {
|
|
608
|
-
await clickSelector(
|
|
857
|
+
await clickSelector(layoutPage, option, 5_000);
|
|
609
858
|
return null;
|
|
610
859
|
} catch (error) {
|
|
611
860
|
return `一键排版: 匹配目标格式 not applied — ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -637,6 +886,26 @@ function baseName(path: string): string {
|
|
|
637
886
|
return path.split(/[\\/]/).pop() ?? path;
|
|
638
887
|
}
|
|
639
888
|
|
|
889
|
+
/** A stable name binds the library choice to these cover bytes across interrupted retries. */
|
|
890
|
+
async function coverLibraryName(path: string): Promise<string> {
|
|
891
|
+
if (!(await Bun.file(path).exists())) return baseName(path);
|
|
892
|
+
const hash = new Bun.CryptoHasher('sha256')
|
|
893
|
+
.update(new Uint8Array(await Bun.file(path).arrayBuffer()))
|
|
894
|
+
.digest('hex');
|
|
895
|
+
return `kk-cover-${hash.slice(0, 32)}${extname(path).toLowerCase()}`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function coverAssetIdentity(value: string): string {
|
|
899
|
+
try {
|
|
900
|
+
const url = new URL(value);
|
|
901
|
+
return url.hostname === 'mmbiz.qpic.cn'
|
|
902
|
+
? `${url.hostname}${url.pathname.replace(/\/(?:0|300|640|1080)$/, '')}`
|
|
903
|
+
: value;
|
|
904
|
+
} catch {
|
|
905
|
+
return value;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
640
909
|
/** The identities `setCover` can accept for a candidate: the requested article image and/or the
|
|
641
910
|
* upload binding actually observed going into the audio form. Either is optional; at least one
|
|
642
911
|
* must produce a UNIQUE exposed match before anything is clicked (task 0260 R1). */
|
|
@@ -655,7 +924,7 @@ interface CoverIdentity {
|
|
|
655
924
|
* `#js_cover_area` is read back: the empty state (`add_cover` icon still visible) or an unreadable
|
|
656
925
|
* preview is a named warning, never a success claim (task 0260 R1).
|
|
657
926
|
*/
|
|
658
|
-
async function setCover(
|
|
927
|
+
export async function setCover(
|
|
659
928
|
page: WechatPage,
|
|
660
929
|
source: 'library' | 'body',
|
|
661
930
|
deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
|
|
@@ -672,8 +941,9 @@ async function setCover(
|
|
|
672
941
|
// A cover miss is a warning, never a gate — so the panel must not outlive the step that opened
|
|
673
942
|
// it (see {@link dismissOpenDialog} for what its footer blocked on 2026-10-12).
|
|
674
943
|
const warn = async (message: string): Promise<string> => {
|
|
944
|
+
const inventory = await openDialogInventory(page);
|
|
675
945
|
await dismissOpenDialog(page);
|
|
676
|
-
return message;
|
|
946
|
+
return inventory ? `${message} (dialogs: ${inventory})` : message;
|
|
677
947
|
};
|
|
678
948
|
try {
|
|
679
949
|
const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
|
|
@@ -682,9 +952,18 @@ async function setCover(
|
|
|
682
952
|
'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
|
|
683
953
|
);
|
|
684
954
|
await clickSelector(page, open, deps.stepTimeoutMs);
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
955
|
+
const alreadyOpen =
|
|
956
|
+
source === 'library' &&
|
|
957
|
+
(await tryFindSelector(
|
|
958
|
+
page,
|
|
959
|
+
['.weui-desktop-dialog:visible:has(h3:has-text("选择图片"))'],
|
|
960
|
+
Math.min(deps.stepTimeoutMs, 1_000),
|
|
961
|
+
)) !== undefined;
|
|
962
|
+
if (!alreadyOpen) {
|
|
963
|
+
const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
|
|
964
|
+
if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
|
|
965
|
+
await clickSelector(page, pick, deps.stepTimeoutMs);
|
|
966
|
+
}
|
|
688
967
|
if (wanted.length === 0)
|
|
689
968
|
return await warn(`cover: no requested cover file identity to match against — ${what} not driven`);
|
|
690
969
|
// Identify before clicking: the item list is not <img>-based (§5.2) and never sorted the way
|
|
@@ -697,19 +976,59 @@ async function setCover(
|
|
|
697
976
|
const el = row.querySelector('[title], [data-name], [data-file_name]') ?? row;
|
|
698
977
|
const attrs = [el.getAttribute?.('title'), el.getAttribute?.('data-name'), el.getAttribute?.('data-file_name')];
|
|
699
978
|
const text = [...row.querySelectorAll('[class*="name" i], [class*="title" i]')].map((n) => n.textContent ?? '');
|
|
700
|
-
return [...attrs, ...text].map((v) => (v ?? '').trim()).
|
|
979
|
+
return [...attrs, ...text].map((v) => (v ?? '').trim()).filter((v) => v.length > 0);
|
|
701
980
|
};
|
|
702
|
-
const hits = rows.filter((row) => wanted.some((w) => nameOf(row)
|
|
981
|
+
const hits = rows.filter((row) => wanted.some((w) => nameOf(row).includes(w)));
|
|
703
982
|
if (hits.length === 0) return 'missing';
|
|
704
983
|
if (hits.length > 1) return 'ambiguous';
|
|
705
984
|
const row = hits[0];
|
|
706
|
-
row.
|
|
707
|
-
row.click();
|
|
985
|
+
if (!row.classList.contains('selected')) row.click();
|
|
708
986
|
const checked = row.querySelector('input[type=checkbox], [role=checkbox]');
|
|
709
|
-
const
|
|
987
|
+
const css = row.querySelector('[style*="background-image"]')?.style.backgroundImage ?? '';
|
|
988
|
+
const backgroundUrl = css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
|
|
989
|
+
const src = row.querySelector('img')?.src || backgroundUrl;
|
|
710
990
|
return JSON.stringify({ picked: true, src });
|
|
711
991
|
})()`;
|
|
712
|
-
|
|
992
|
+
let rawIdentify = 'missing';
|
|
993
|
+
await waitUntil(
|
|
994
|
+
async () => {
|
|
995
|
+
rawIdentify = String(await page.evaluate(IDENTIFY));
|
|
996
|
+
return rawIdentify !== 'missing';
|
|
997
|
+
},
|
|
998
|
+
Math.min(deps.stepTimeoutMs, 8_000),
|
|
999
|
+
);
|
|
1000
|
+
const paginationScript = `(() => {
|
|
1001
|
+
const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find(d => d.offsetWidth > 0 && d.querySelector('h3')?.textContent?.includes('选择图片'));
|
|
1002
|
+
const labels = dialog?.querySelectorAll('.weui-desktop-pagination__num');
|
|
1003
|
+
return JSON.stringify({ page: Number(labels?.[0]?.textContent ?? 0), total: Number(labels?.[1]?.textContent ?? 0) });
|
|
1004
|
+
})()`;
|
|
1005
|
+
for (let scanned = 0; source === 'library' && rawIdentify === 'missing' && scanned < 50; scanned += 1) {
|
|
1006
|
+
const pagination = JSON.parse(String(await page.evaluate(paginationScript))) as {
|
|
1007
|
+
page?: number;
|
|
1008
|
+
total?: number;
|
|
1009
|
+
} | null;
|
|
1010
|
+
if (!(Number(pagination?.page) > 0 && Number(pagination?.page) < Number(pagination?.total))) break;
|
|
1011
|
+
const nextPage = await tryFindSelector(
|
|
1012
|
+
page,
|
|
1013
|
+
[
|
|
1014
|
+
'.weui-desktop-dialog:visible:has(h3:has-text("选择图片")) a:has-text("下一页"):visible:not(.weui-desktop-btn_disabled)',
|
|
1015
|
+
],
|
|
1016
|
+
1_000,
|
|
1017
|
+
);
|
|
1018
|
+
if (nextPage === undefined) break;
|
|
1019
|
+
await clickSelector(page, nextPage, deps.stepTimeoutMs);
|
|
1020
|
+
await page.waitForTimeout?.(500);
|
|
1021
|
+
await waitUntil(
|
|
1022
|
+
async () =>
|
|
1023
|
+
Boolean(
|
|
1024
|
+
await page.evaluate(
|
|
1025
|
+
`![...document.querySelectorAll('.weui-desktop-dialog .weui-desktop-loading')].some(node => node.offsetWidth > 0)`,
|
|
1026
|
+
),
|
|
1027
|
+
),
|
|
1028
|
+
Math.min(deps.stepTimeoutMs, 8_000),
|
|
1029
|
+
);
|
|
1030
|
+
rawIdentify = String(await page.evaluate(IDENTIFY));
|
|
1031
|
+
}
|
|
713
1032
|
if (rawIdentify === 'missing')
|
|
714
1033
|
return await warn(`cover: ${what} 未标出与请求文件同名的素材,未选择(不按首图猜测)`);
|
|
715
1034
|
if (rawIdentify === 'ambiguous') return await warn(`cover: ${what} 匹配到多个同名素材,身份不唯一,未选择`);
|
|
@@ -724,37 +1043,85 @@ async function setCover(
|
|
|
724
1043
|
if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
|
|
725
1044
|
const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
|
|
726
1045
|
if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
|
|
1046
|
+
const selectedSrc = (selected.src ?? '').trim();
|
|
1047
|
+
const cropSources: unknown = JSON.parse(
|
|
1048
|
+
String(
|
|
1049
|
+
await page.evaluate(`(() => {
|
|
1050
|
+
const dialog = [...document.querySelectorAll('.weui-desktop-dialog')].find(d => d.offsetWidth > 0 && d.querySelector('h3')?.textContent?.trim() === '编辑封面');
|
|
1051
|
+
return JSON.stringify([...(dialog?.querySelectorAll('img.crop-img') ?? [])]
|
|
1052
|
+
.filter(img => img.offsetWidth > 0 && getComputedStyle(img).visibility !== 'hidden').map(img => img.src));
|
|
1053
|
+
})()`),
|
|
1054
|
+
),
|
|
1055
|
+
);
|
|
1056
|
+
const cropBound =
|
|
1057
|
+
selectedSrc !== '' &&
|
|
1058
|
+
Array.isArray(cropSources) &&
|
|
1059
|
+
cropSources.some(
|
|
1060
|
+
(src) => typeof src === 'string' && coverAssetIdentity(src) === coverAssetIdentity(selectedSrc),
|
|
1061
|
+
);
|
|
1062
|
+
const beforeCommit = String(
|
|
1063
|
+
await page.evaluate(`(() => {
|
|
1064
|
+
const css = document.querySelector('#js_cover_area .js_cover_preview_new')?.style.backgroundImage ?? '';
|
|
1065
|
+
return css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
|
|
1066
|
+
})()`),
|
|
1067
|
+
);
|
|
727
1068
|
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
728
1069
|
// Committed readback (observed capture: the preview lives under #js_cover_area; the empty
|
|
729
1070
|
// state keeps the `add_cover` icon visible). Poll briefly — the preview renders late.
|
|
730
1071
|
const READBACK = `(() => {
|
|
731
1072
|
const area = document.querySelector('#js_cover_area');
|
|
732
1073
|
if (!area) return 'noarea';
|
|
733
|
-
if (area.querySelector('.icon20_common.add_cover') && area.querySelector('.icon20_common.add_cover').offsetWidth > 0) return 'empty';
|
|
734
1074
|
const preview = area.querySelector('.js_cover_preview_new');
|
|
735
|
-
if (!preview || preview.offsetWidth === 0)
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1075
|
+
if (!preview || preview.offsetWidth === 0) {
|
|
1076
|
+
return area.querySelector('.icon20_common.add_cover')?.offsetWidth > 0 ? 'empty' : 'nopreview';
|
|
1077
|
+
}
|
|
1078
|
+
const css = preview.style.backgroundImage || preview.querySelector('[style*="background-image"]')?.style.backgroundImage || '';
|
|
1079
|
+
const backgroundUrl = css.startsWith('url("') || css.startsWith("url('") ? css.slice(5, -2) : css.startsWith('url(') ? css.slice(4, -1) : '';
|
|
1080
|
+
const src = preview.querySelector('img')?.src || backgroundUrl || preview.querySelector('input[type=hidden]')?.value || '';
|
|
739
1081
|
return src ? src : 'unknown';
|
|
740
1082
|
})()`;
|
|
741
1083
|
let committed = '';
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1084
|
+
let lastRead = '';
|
|
1085
|
+
const observed = await waitUntil(
|
|
1086
|
+
async () => {
|
|
1087
|
+
lastRead = String(await page.evaluate(READBACK).catch(() => 'error'));
|
|
1088
|
+
if (['', 'nopreview', 'noarea', 'empty', 'unknown', 'error'].includes(lastRead)) return false;
|
|
1089
|
+
if (
|
|
1090
|
+
cropBound &&
|
|
1091
|
+
lastRead === beforeCommit &&
|
|
1092
|
+
coverAssetIdentity(lastRead) !== coverAssetIdentity(selectedSrc)
|
|
1093
|
+
)
|
|
1094
|
+
return false;
|
|
1095
|
+
committed = lastRead;
|
|
1096
|
+
return true;
|
|
1097
|
+
},
|
|
1098
|
+
Math.min(deps.stepTimeoutMs, 15_000),
|
|
1099
|
+
200,
|
|
1100
|
+
);
|
|
1101
|
+
if (!observed)
|
|
1102
|
+
return await warn(
|
|
1103
|
+
lastRead === 'empty'
|
|
1104
|
+
? 'cover: 确认后面板仍显示未设置封面,提交未生效'
|
|
1105
|
+
: 'cover: 已确认但无法读取已提交封面,请手动核对',
|
|
1106
|
+
);
|
|
753
1107
|
// Identity comparison: both sides are remote asset references from the same widget. Only an
|
|
754
1108
|
// exact match proves the requested asset committed; an unreadable/edited preview warns
|
|
755
1109
|
// rather than inventing equivalence (local paths are never compared to asset URLs).
|
|
756
|
-
|
|
757
|
-
|
|
1110
|
+
let derivedFromBoundCrop = false;
|
|
1111
|
+
try {
|
|
1112
|
+
const url = new URL(committed);
|
|
1113
|
+
derivedFromBoundCrop =
|
|
1114
|
+
cropBound &&
|
|
1115
|
+
committed !== beforeCommit &&
|
|
1116
|
+
url.hostname === 'mmbiz.qpic.cn' &&
|
|
1117
|
+
['http:', 'https:'].includes(url.protocol);
|
|
1118
|
+
} catch {
|
|
1119
|
+
/* Unknown preview references stay unverifiable. */
|
|
1120
|
+
}
|
|
1121
|
+
if (
|
|
1122
|
+
selectedSrc === '' ||
|
|
1123
|
+
(coverAssetIdentity(committed) !== coverAssetIdentity(selectedSrc) && !derivedFromBoundCrop)
|
|
1124
|
+
)
|
|
758
1125
|
return await warn(
|
|
759
1126
|
`cover: 已提交封面的素材身份与所选不一致(selected=${selectedSrc || '未读到'} committed=${committed.slice(0, 80)})`,
|
|
760
1127
|
);
|
|
@@ -795,6 +1162,7 @@ function audioBlockOf(content: Content): { title: string; audioPath: string; cov
|
|
|
795
1162
|
async function enrichArticleDraft(
|
|
796
1163
|
page: WechatPage,
|
|
797
1164
|
content: Content,
|
|
1165
|
+
stepTimeoutMs: number,
|
|
798
1166
|
): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
|
|
799
1167
|
const enrichMeta = content.metadata?.wechatmp;
|
|
800
1168
|
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
|
|
@@ -821,10 +1189,10 @@ async function enrichArticleDraft(
|
|
|
821
1189
|
const cover = await setCover(
|
|
822
1190
|
page,
|
|
823
1191
|
'library',
|
|
824
|
-
{ stepTimeoutMs: 8_000 },
|
|
1192
|
+
{ stepTimeoutMs: Math.min(stepTimeoutMs, 8_000) },
|
|
825
1193
|
// The upload binding is the image the audio form just bound (the episode cover
|
|
826
1194
|
// file); `coverPath(input)` is the article's own first body image, not this.
|
|
827
|
-
{ binding: audio.coverPath === '' ? undefined : audio.coverPath },
|
|
1195
|
+
{ binding: audio.coverPath === '' ? undefined : await coverLibraryName(audio.coverPath) },
|
|
828
1196
|
);
|
|
829
1197
|
if (cover !== null) coverWarnings.push(cover);
|
|
830
1198
|
}
|
|
@@ -843,8 +1211,6 @@ async function composeArticle(
|
|
|
843
1211
|
await attachImages(page, input.images, deps);
|
|
844
1212
|
const titlePath = await typeTitle(page, deps);
|
|
845
1213
|
await insertBody(page, deps);
|
|
846
|
-
const layout = await applyLayout(page, deps);
|
|
847
|
-
if (layout !== null) warnings.push(layout);
|
|
848
1214
|
for (const warning of [
|
|
849
1215
|
await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
|
|
850
1216
|
await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
|
|
@@ -862,8 +1228,13 @@ async function composeArticle(
|
|
|
862
1228
|
});
|
|
863
1229
|
if (cover !== null) warnings.push(cover);
|
|
864
1230
|
}
|
|
865
|
-
const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content);
|
|
1231
|
+
const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content, deps.stepTimeoutMs);
|
|
866
1232
|
warnings.push(...coverWarnings);
|
|
1233
|
+
// Layout is deliberately last: 原文链接, media insertion, and cover commitment must be part of
|
|
1234
|
+
// the body the platform formats, and the native auto-save acknowledgement must settle before
|
|
1235
|
+
// 保存为草稿 or the public saved-editor readback begins.
|
|
1236
|
+
const layout = await applyLayout(page, deps);
|
|
1237
|
+
if (layout !== null) warnings.push(layout);
|
|
867
1238
|
return { warnings, titlePath, enrichWarnings };
|
|
868
1239
|
}
|
|
869
1240
|
|
|
@@ -986,7 +1357,7 @@ function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Re
|
|
|
986
1357
|
artifactPaths: artifactPaths(input),
|
|
987
1358
|
entryPath: entry.path,
|
|
988
1359
|
editorCreateType: entry.createType,
|
|
989
|
-
editorUrl: entry.editor.url(),
|
|
1360
|
+
editorUrl: sanitizeSessionEvidence(entry.editor.url()),
|
|
990
1361
|
...(cover === undefined ? {} : { coverPath: cover }),
|
|
991
1362
|
};
|
|
992
1363
|
}
|
|
@@ -1079,6 +1450,415 @@ async function saveDraft(
|
|
|
1079
1450
|
});
|
|
1080
1451
|
}
|
|
1081
1452
|
|
|
1453
|
+
interface PublicDraftSnapshot {
|
|
1454
|
+
title: string;
|
|
1455
|
+
body: string;
|
|
1456
|
+
media: string[];
|
|
1457
|
+
cover: string;
|
|
1458
|
+
originalUrl: string;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
interface PublicSaveEvidence {
|
|
1462
|
+
saveControl: string;
|
|
1463
|
+
canonicalUrl: string;
|
|
1464
|
+
canonicalUrlForMetadata: string;
|
|
1465
|
+
saveSignal: 'canonical-url-transition+snapshot' | 'persisted-snapshot';
|
|
1466
|
+
snapshot: PublicDraftSnapshot;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* The public rail's only native saved-editor readiness signal is the URL the console assigns after
|
|
1471
|
+
* `保存为草稿`: an existing appmsg with a concrete `appmsgid` and the editor action. The live
|
|
1472
|
+
* console's saved URL omits `isNew` (the CGI draft route may include `isNew=0`), while `isNew=1`
|
|
1473
|
+
* is still the unsaved editor and must be rejected. The old home 草稿箱 card is deliberately not
|
|
1474
|
+
* used here — the live console has rendered that card on a different surface from the editor, and a
|
|
1475
|
+
* row cannot prove that this editor is the latest version.
|
|
1476
|
+
*/
|
|
1477
|
+
function canonicalSavedEditorUrl(value: string): string | null {
|
|
1478
|
+
try {
|
|
1479
|
+
const url = new URL(value);
|
|
1480
|
+
if (url.origin !== 'https://mp.weixin.qq.com' || url.pathname !== '/cgi-bin/appmsg') return null;
|
|
1481
|
+
if (url.searchParams.get('action') !== 'edit' || url.searchParams.get('isNew') === '1') return null;
|
|
1482
|
+
if ((url.searchParams.get('appmsgid') ?? '').trim() === '') return null;
|
|
1483
|
+
return url.toString();
|
|
1484
|
+
} catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** Keep the navigation URL private while retaining a useful, credential-free receipt value. */
|
|
1490
|
+
function savedEditorUrlForMetadata(value: string): string {
|
|
1491
|
+
return sanitizeSessionEvidence(value);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** A read-only DOM snapshot used to prove the saved editor still contains the composed payload. */
|
|
1495
|
+
const PUBLIC_DRAFT_SNAPSHOT_SCRIPT = `/* __KK_WECHAT_PUBLISH_SNAPSHOT__ */ (() => {
|
|
1496
|
+
const norm = (value) => (value ?? '').replace(/[\\s\\u00a0]+/g, ' ').trim();
|
|
1497
|
+
const titleNode = document.querySelector('textarea#title, textarea.js_title');
|
|
1498
|
+
const title = titleNode?.value ?? [...document.querySelectorAll('.ProseMirror[data-placeholder*="标题"]')]
|
|
1499
|
+
.map((node) => node.textContent ?? '').join(' ');
|
|
1500
|
+
const body = [...document.querySelectorAll('.ProseMirror:not([data-placeholder])')]
|
|
1501
|
+
.map((node) => norm(node.textContent)).join('\\n');
|
|
1502
|
+
const media = [
|
|
1503
|
+
...document.querySelectorAll(
|
|
1504
|
+
'.ProseMirror mp-common-mpaudio, .ProseMirror mpvoice, .ProseMirror [data-mediaid], .ProseMirror audio, .ProseMirror video, .ProseMirror img',
|
|
1505
|
+
),
|
|
1506
|
+
]
|
|
1507
|
+
.map((node) => node.getAttribute('data-mediaid') ?? node.getAttribute('data-id') ??
|
|
1508
|
+
node.getAttribute('voice_encode_fileid') ?? node.getAttribute('data-src') ?? node.getAttribute('src') ??
|
|
1509
|
+
norm(node.textContent))
|
|
1510
|
+
.map(norm).filter((value) => value.length > 0);
|
|
1511
|
+
const area = document.querySelector('#js_cover_area');
|
|
1512
|
+
const coverNode = area?.querySelector('.js_cover_preview_new img, img, [style*="background-image"], input[type="hidden"]');
|
|
1513
|
+
const cover = coverNode?.getAttribute('src') ?? coverNode?.getAttribute('value') ??
|
|
1514
|
+
coverNode?.style.backgroundImage ?? '';
|
|
1515
|
+
const urlArea = document.querySelector('#js_article_url_area');
|
|
1516
|
+
const originalUrl = urlArea?.querySelector('input.js_url_checkbox')?.checked === true
|
|
1517
|
+
? norm(urlArea.querySelector('.article_url_setting')?.textContent) : '';
|
|
1518
|
+
return JSON.stringify({ title: norm(title), body, media, cover: norm(cover), originalUrl });
|
|
1519
|
+
})()`;
|
|
1520
|
+
|
|
1521
|
+
function normalizeSnapshotText(value: string): string {
|
|
1522
|
+
return value.replace(/[\s\u00a0]+/g, ' ').trim();
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** 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> {
|
|
1527
|
+
try {
|
|
1528
|
+
const raw = await page.evaluate(PUBLIC_DRAFT_SNAPSHOT_SCRIPT);
|
|
1529
|
+
const parsed = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
|
1530
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
1531
|
+
const value = parsed as Record<string, unknown>;
|
|
1532
|
+
if (
|
|
1533
|
+
typeof value.title !== 'string' ||
|
|
1534
|
+
typeof value.body !== 'string' ||
|
|
1535
|
+
!Array.isArray(value.media) ||
|
|
1536
|
+
value.media.some((item) => typeof item !== 'string') ||
|
|
1537
|
+
typeof value.cover !== 'string'
|
|
1538
|
+
)
|
|
1539
|
+
return null;
|
|
1540
|
+
return {
|
|
1541
|
+
title: normalizeSnapshotText(value.title),
|
|
1542
|
+
body: normalizeSnapshotText(value.body),
|
|
1543
|
+
media: value.media.map((item) => normalizeSnapshotText(item)),
|
|
1544
|
+
cover: normalizeSnapshotText(value.cover),
|
|
1545
|
+
originalUrl: typeof value.originalUrl === 'string' ? normalizeSnapshotText(value.originalUrl) : '',
|
|
1546
|
+
};
|
|
1547
|
+
} catch {
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function publicSnapshotDifferences(before: PublicDraftSnapshot, after: PublicDraftSnapshot): string[] {
|
|
1553
|
+
const differences: string[] = [];
|
|
1554
|
+
if (before.title !== after.title) differences.push('title');
|
|
1555
|
+
if (before.body !== after.body) differences.push('body');
|
|
1556
|
+
// WeChat re-encodes/resizes uploaded body images on save. Its CDN preserves the opaque asset
|
|
1557
|
+
// path but may switch the terminal size variant (`/0` → `/640`), so compare that stable asset
|
|
1558
|
+
// identity rather than a transport URL that the platform is expected to rewrite. Audio IDs,
|
|
1559
|
+
// local paths, different opaque keys, order, and cardinality remain strict mismatches.
|
|
1560
|
+
const beforeMedia = before.media.map(snapshotMediaIdentity);
|
|
1561
|
+
const afterMedia = after.media.map(snapshotMediaIdentity);
|
|
1562
|
+
if (JSON.stringify(beforeMedia) !== JSON.stringify(afterMedia)) differences.push('media');
|
|
1563
|
+
if (before.cover !== after.cover) differences.push('cover');
|
|
1564
|
+
if (before.originalUrl !== after.originalUrl) differences.push('originalUrl');
|
|
1565
|
+
return differences;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Preserve every media identity component except WeChat's known CDN size suffix. In particular,
|
|
1570
|
+
* query parameters remain part of the comparison so a different format/transform cannot pass as a
|
|
1571
|
+
* resize merely because it shares a path prefix.
|
|
1572
|
+
*/
|
|
1573
|
+
function snapshotMediaIdentity(value: string): string {
|
|
1574
|
+
try {
|
|
1575
|
+
const url = new URL(value);
|
|
1576
|
+
if (url.hostname !== 'mmbiz.qpic.cn') return value;
|
|
1577
|
+
const path = url.pathname.replace(/\/(?:0|300|640|1080)$/, '');
|
|
1578
|
+
return `${url.origin}${path}${url.search}${url.hash}`;
|
|
1579
|
+
} catch {
|
|
1580
|
+
return value;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function snapshotValueIsEmpty(value: string): boolean {
|
|
1585
|
+
const normalized = normalizeSnapshotText(value).toLowerCase();
|
|
1586
|
+
return normalized === '' || normalized === 'url("")' || normalized === "url('')" || normalized === 'url()';
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Partial Vue/Tiptap hydration is retryable; a non-empty changed identity is not. */
|
|
1590
|
+
function publicSnapshotIsIncomplete(expected: PublicDraftSnapshot, actual: PublicDraftSnapshot): boolean {
|
|
1591
|
+
if (!snapshotValueIsEmpty(expected.title) && snapshotValueIsEmpty(actual.title)) return true;
|
|
1592
|
+
if (!snapshotValueIsEmpty(expected.body) && snapshotValueIsEmpty(actual.body)) return true;
|
|
1593
|
+
// The editor renders a long body progressively; a strict prefix is still hydrating, not a
|
|
1594
|
+
// different version (20261104: 查看新草稿 failed on its first non-empty read).
|
|
1595
|
+
if (actual.body.length < expected.body.length && expected.body.startsWith(actual.body)) return true;
|
|
1596
|
+
if (expected.media.length > actual.media.length) return true;
|
|
1597
|
+
if (!snapshotValueIsEmpty(expected.cover) && snapshotValueIsEmpty(actual.cover)) return true;
|
|
1598
|
+
if (expected.originalUrl !== '' && actual.originalUrl === '') return true;
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* The live console can acknowledge 保存为草稿 before media/cover hydration reaches the canonical
|
|
1604
|
+
* editor (measured 2026-11-03: five partial reads before the two-media snapshot returned). Reloads
|
|
1605
|
+
* are read-only; publication remains impossible until the coherent snapshot matches.
|
|
1606
|
+
*/
|
|
1607
|
+
const PUBLIC_SAVED_EDITOR_RELOAD_LIMIT = 6;
|
|
1608
|
+
|
|
1609
|
+
function appmsgIdFromCanonical(value: string): string | null {
|
|
1610
|
+
const canonical = canonicalSavedEditorUrl(value);
|
|
1611
|
+
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
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** Require two consecutive native readiness observations after any editor-version transition. */
|
|
1621
|
+
async function waitForPublicEditorReadiness(page: WechatPage, deps: ModeRunDeps): Promise<boolean> {
|
|
1622
|
+
let consecutiveSaved = 0;
|
|
1623
|
+
return waitUntil(
|
|
1624
|
+
async () => {
|
|
1625
|
+
const state = await page.evaluate(nativeLayoutSaveStateScript()).catch(() => 'unknown');
|
|
1626
|
+
const legacyState =
|
|
1627
|
+
state === 'saved'
|
|
1628
|
+
? 'not-needed'
|
|
1629
|
+
: await page.evaluate(nativeLegacyEditorReadyScript()).catch(() => 'unknown');
|
|
1630
|
+
if (state === 'saved' || legacyState === 'ready') {
|
|
1631
|
+
consecutiveSaved += 1;
|
|
1632
|
+
return consecutiveSaved >= 2;
|
|
1633
|
+
}
|
|
1634
|
+
// A fresh save transition resets the quiet window; an unreadable state is never idle
|
|
1635
|
+
// evidence and must not be allowed to carry a prior saved probe across the gap.
|
|
1636
|
+
consecutiveSaved = 0;
|
|
1637
|
+
return false;
|
|
1638
|
+
},
|
|
1639
|
+
Math.min(deps.stepTimeoutMs, 15_000),
|
|
1640
|
+
deps.pollMs,
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Save the composed editor once, require its canonical persisted URL, reopen that URL, and compare
|
|
1646
|
+
* the persisted surfaces before the one and only public click. A refusal or missing native signal
|
|
1647
|
+
* stops before `发表`; no draft-box lookup or blind save retry is safe for the public rail.
|
|
1648
|
+
*/
|
|
1649
|
+
async function savePublicDraft(deps: ModeRunDeps, entry: ModeEntry): Promise<Result | PublicSaveEvidence> {
|
|
1650
|
+
const composed = await readPublicDraftSnapshot(entry.editor);
|
|
1651
|
+
if (composed === null)
|
|
1652
|
+
return fail(
|
|
1653
|
+
'public save readiness: the composed editor snapshot (title/body/media/cover) was unreadable; no publish click attempted',
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
const platformMetadata = deps.content.metadata?.wechatmp;
|
|
1657
|
+
const requestedUrl =
|
|
1658
|
+
platformMetadata !== null && typeof platformMetadata === 'object' && 'originalUrl' in platformMetadata
|
|
1659
|
+
? platformMetadata.originalUrl
|
|
1660
|
+
: undefined;
|
|
1661
|
+
if (typeof requestedUrl === 'string' && normalizeSnapshotText(requestedUrl) !== composed.originalUrl)
|
|
1662
|
+
return fail('public save readiness: requested 原文链接 was not committed; no publish click attempted');
|
|
1663
|
+
|
|
1664
|
+
const canonicalUrlBeforeSave = canonicalSavedEditorUrl(entry.editor.url());
|
|
1665
|
+
await sleep(deps.submitSettleMs);
|
|
1666
|
+
if ((await closeOpenDialogs(entry.editor)) !== 'closed')
|
|
1667
|
+
return fail('public save readiness: a residual editor dialog blocked 保存为草稿; no publish click attempted');
|
|
1668
|
+
const saveControl = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
|
|
1669
|
+
if (!entry.editor.saveDraftAndWait)
|
|
1670
|
+
return fail(
|
|
1671
|
+
'public save readiness: the native 保存为草稿 acknowledgement probe was unavailable; no publish click attempted',
|
|
1672
|
+
);
|
|
1673
|
+
try {
|
|
1674
|
+
await entry.editor.saveDraftAndWait(saveControl, deps.draftTimeoutMs);
|
|
1675
|
+
} catch {
|
|
1676
|
+
return fail(
|
|
1677
|
+
'public save readiness: native 保存为草稿 was not acknowledged successfully; no publish click attempted',
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
let canonicalUrl: string | null = null;
|
|
1682
|
+
let refusal: string | null = null;
|
|
1683
|
+
const saved = await waitUntil(
|
|
1684
|
+
async () => {
|
|
1685
|
+
refusal = await readStaleVersionRefusal(asPlaywrightPage(entry.editor)).catch(() => null);
|
|
1686
|
+
if (refusal !== null) return true;
|
|
1687
|
+
canonicalUrl = canonicalSavedEditorUrl(entry.editor.url());
|
|
1688
|
+
return canonicalUrl !== null;
|
|
1689
|
+
},
|
|
1690
|
+
deps.draftTimeoutMs,
|
|
1691
|
+
deps.pollMs,
|
|
1692
|
+
);
|
|
1693
|
+
if (refusal !== null) return fail(`public save refused by ${TARGET}: ${refusal}; no publish click attempted`);
|
|
1694
|
+
if (!saved || canonicalUrl === null) {
|
|
1695
|
+
return fail(
|
|
1696
|
+
`public save readiness: 保存为草稿 did not expose a canonical saved editor URL (action=edit with appmsgid, not isNew=1) within ${deps.draftTimeoutMs}ms; no publish click attempted`,
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
const savedCanonicalUrl = canonicalUrl;
|
|
1700
|
+
|
|
1701
|
+
// A saved editor can already be on an existing canonical URL. That URL is navigation evidence,
|
|
1702
|
+
// not proof that this click completed; the persisted snapshot below is the independent save proof.
|
|
1703
|
+
await sleep(Math.min(Math.max(deps.pollMs, 25), 500));
|
|
1704
|
+
await entry.editor.goto(savedCanonicalUrl, { timeout: deps.stepTimeoutMs });
|
|
1705
|
+
let reloaded: PublicDraftSnapshot | null = null;
|
|
1706
|
+
let reloadRefusal: string | null = null;
|
|
1707
|
+
let reloads = 0;
|
|
1708
|
+
let lastReloadAt = Date.now();
|
|
1709
|
+
const reloadIntervalMs = Math.min(Math.max(deps.pollMs * 4, 50), 2_000);
|
|
1710
|
+
const canonicalUrlWasPreexisting = canonicalUrlBeforeSave === savedCanonicalUrl;
|
|
1711
|
+
await waitUntil(
|
|
1712
|
+
async () => {
|
|
1713
|
+
reloadRefusal = await readStaleVersionRefusal(asPlaywrightPage(entry.editor)).catch(() => null);
|
|
1714
|
+
if (reloadRefusal !== null) return true;
|
|
1715
|
+
if (canonicalSavedEditorUrl(entry.editor.url()) === null) return false;
|
|
1716
|
+
reloaded = await readPublicDraftSnapshot(entry.editor);
|
|
1717
|
+
if (reloaded === null) return false;
|
|
1718
|
+
const differences = publicSnapshotDifferences(composed, reloaded);
|
|
1719
|
+
if (differences.length === 0) return true;
|
|
1720
|
+
if (!publicSnapshotIsIncomplete(composed, reloaded)) return true;
|
|
1721
|
+
if (reloads >= PUBLIC_SAVED_EDITOR_RELOAD_LIMIT) return true;
|
|
1722
|
+
if (Date.now() - lastReloadAt < reloadIntervalMs) return false;
|
|
1723
|
+
reloads += 1;
|
|
1724
|
+
lastReloadAt = Date.now();
|
|
1725
|
+
await entry.editor.goto(savedCanonicalUrl, { timeout: deps.stepTimeoutMs });
|
|
1726
|
+
return false;
|
|
1727
|
+
},
|
|
1728
|
+
deps.draftTimeoutMs,
|
|
1729
|
+
deps.pollMs,
|
|
1730
|
+
);
|
|
1731
|
+
if (reloadRefusal !== null)
|
|
1732
|
+
return fail(`public saved-editor refusal by ${TARGET}: ${reloadRefusal}; no publish click attempted`);
|
|
1733
|
+
if (reloaded === null)
|
|
1734
|
+
return fail(
|
|
1735
|
+
`public saved-editor readiness: the canonical editor reopened but its title/body/media/cover snapshot was unreadable within ${deps.draftTimeoutMs}ms; no publish click attempted`,
|
|
1736
|
+
);
|
|
1737
|
+
const differences = publicSnapshotDifferences(composed, reloaded);
|
|
1738
|
+
if (differences.length > 0)
|
|
1739
|
+
return fail(
|
|
1740
|
+
`public saved editor content mismatch before publication (${differences.join(', ')}); no publish click attempted`,
|
|
1741
|
+
);
|
|
1742
|
+
|
|
1743
|
+
// The canonical snapshot can become readable before the editor's own autosave finishes. A
|
|
1744
|
+
// mass-send click in that interval is rejected by WeChat as a stale draft even though every
|
|
1745
|
+
// persisted field already matches; require two consecutive native readiness probes before handing
|
|
1746
|
+
// control to publishContent. The v2 rail accepts 已保存; the measured legacy editor has its own
|
|
1747
|
+
// explicit manual-save markers. A saving → saved transition and an already-idle editor are accepted;
|
|
1748
|
+
// this is a readiness guard, not a publish retry.
|
|
1749
|
+
const autoSaveSettled = await waitForPublicEditorReadiness(entry.editor, deps);
|
|
1750
|
+
if (!autoSaveSettled)
|
|
1751
|
+
return fail(
|
|
1752
|
+
`public saved-editor readiness: native 保存状态 did not provide two consecutive recognized readiness probes within ${Math.min(deps.stepTimeoutMs, 15_000)}ms; no publish click attempted`,
|
|
1753
|
+
);
|
|
1754
|
+
return {
|
|
1755
|
+
saveControl,
|
|
1756
|
+
canonicalUrl: savedCanonicalUrl,
|
|
1757
|
+
canonicalUrlForMetadata: savedEditorUrlForMetadata(savedCanonicalUrl),
|
|
1758
|
+
saveSignal: canonicalUrlWasPreexisting ? 'persisted-snapshot' : 'canonical-url-transition+snapshot',
|
|
1759
|
+
snapshot: composed,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
interface StaleVersionRecovery {
|
|
1764
|
+
editor: WechatPage;
|
|
1765
|
+
canonicalUrl: string;
|
|
1766
|
+
canonicalUrlForMetadata: string;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Follow only the platform's known pre-confirmation stale-version escape hatch. This performs no
|
|
1771
|
+
* save and never retries an unknown outcome: 查看新草稿 is the one measured action that switches the
|
|
1772
|
+
* editor to the platform's latest persisted version before a second initial 发表 click.
|
|
1773
|
+
*/
|
|
1774
|
+
async function recoverStaleVersion(
|
|
1775
|
+
deps: ModeRunDeps,
|
|
1776
|
+
entry: ModeEntry,
|
|
1777
|
+
saved: PublicSaveEvidence,
|
|
1778
|
+
): Promise<Result | StaleVersionRecovery> {
|
|
1779
|
+
const expectedAppmsgId = appmsgIdFromCanonical(saved.canonicalUrl);
|
|
1780
|
+
if (expectedAppmsgId === null)
|
|
1781
|
+
return fail('public stale-version recovery: the saved editor identity was not canonical; no retry attempted');
|
|
1782
|
+
|
|
1783
|
+
const latest = await findUniqueVisibleSelector(
|
|
1784
|
+
entry.editor,
|
|
1785
|
+
PUBLISH_VIEW_LATEST,
|
|
1786
|
+
Math.min(CONFIRM_TIMEOUT_MS, deps.stepTimeoutMs),
|
|
1787
|
+
deps.pollMs,
|
|
1788
|
+
);
|
|
1789
|
+
if (latest.selector === undefined)
|
|
1790
|
+
return fail(`public stale-version recovery: 查看新草稿 was ${latest.reason}; no second 发表 click attempted`);
|
|
1791
|
+
|
|
1792
|
+
const before = deps.pages().length;
|
|
1793
|
+
try {
|
|
1794
|
+
await clickSelector(entry.editor, latest.selector, deps.stepTimeoutMs);
|
|
1795
|
+
} catch {
|
|
1796
|
+
return fail('public stale-version recovery: 查看新草稿 could not be clicked; no second 发表 click attempted');
|
|
1797
|
+
}
|
|
1798
|
+
const latestEditor =
|
|
1799
|
+
(await followNewPage(deps.pages, before, Math.min(deps.stepTimeoutMs, CONFIRM_TIMEOUT_MS))) ?? entry.editor;
|
|
1800
|
+
|
|
1801
|
+
let latestCanonicalUrl: string | null = null;
|
|
1802
|
+
const canonicalReady = await waitUntil(
|
|
1803
|
+
async () => {
|
|
1804
|
+
latestCanonicalUrl = canonicalSavedEditorUrl(latestEditor.url());
|
|
1805
|
+
if (latestCanonicalUrl === null) return false;
|
|
1806
|
+
// A click that leaves the original stale dialog visible is not a version transition. Do
|
|
1807
|
+
// not accept the unchanged editor's matching snapshot as latest-version evidence.
|
|
1808
|
+
const refusal = await readStaleVersionRefusal(asPlaywrightPage(latestEditor)).catch(() => '__unreadable__');
|
|
1809
|
+
return refusal === null;
|
|
1810
|
+
},
|
|
1811
|
+
deps.draftTimeoutMs,
|
|
1812
|
+
deps.pollMs,
|
|
1813
|
+
);
|
|
1814
|
+
if (!canonicalReady || latestCanonicalUrl === null)
|
|
1815
|
+
return fail(
|
|
1816
|
+
'public stale-version recovery: 查看新草稿 did not expose a canonical saved editor route; no second 发表 click attempted',
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
const actualAppmsgId = appmsgIdFromCanonical(latestCanonicalUrl);
|
|
1820
|
+
if (actualAppmsgId !== expectedAppmsgId)
|
|
1821
|
+
return fail(
|
|
1822
|
+
`public stale-version recovery: 查看新草稿 opened appmsgid=${actualAppmsgId ?? 'unknown'}, expected appmsgid=${expectedAppmsgId}; no second 发表 click attempted`,
|
|
1823
|
+
);
|
|
1824
|
+
|
|
1825
|
+
let latestSnapshot: PublicDraftSnapshot | null = null;
|
|
1826
|
+
let differences: string[] = [];
|
|
1827
|
+
const snapshotSettled = await waitUntil(
|
|
1828
|
+
async () => {
|
|
1829
|
+
latestSnapshot = await readPublicDraftSnapshot(latestEditor);
|
|
1830
|
+
if (latestSnapshot === null) return false;
|
|
1831
|
+
differences = publicSnapshotDifferences(saved.snapshot, latestSnapshot);
|
|
1832
|
+
// Partial hydration may still fill empty media/body/cover fields. A non-empty changed
|
|
1833
|
+
// identity is final evidence of a different latest version and must stop immediately.
|
|
1834
|
+
return differences.length === 0 || !publicSnapshotIsIncomplete(saved.snapshot, latestSnapshot);
|
|
1835
|
+
},
|
|
1836
|
+
deps.draftTimeoutMs,
|
|
1837
|
+
deps.pollMs,
|
|
1838
|
+
);
|
|
1839
|
+
if (!snapshotSettled || latestSnapshot === null)
|
|
1840
|
+
return fail(
|
|
1841
|
+
'public stale-version recovery: latest editor snapshot was unreadable; no second 发表 click attempted',
|
|
1842
|
+
);
|
|
1843
|
+
// TS narrows the closure-assigned `let` to never after the null guard; re-read it through a cast.
|
|
1844
|
+
const latestBody = (latestSnapshot as PublicDraftSnapshot).body;
|
|
1845
|
+
if (differences.length > 0)
|
|
1846
|
+
return fail(
|
|
1847
|
+
`public stale-version recovery: latest editor content mismatch (${differences.join(', ')}; body ${latestBody.length}/${saved.snapshot.body.length} chars); no second 发表 click attempted`,
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
if (!(await waitForPublicEditorReadiness(latestEditor, deps)))
|
|
1851
|
+
return fail(
|
|
1852
|
+
'public stale-version recovery: latest editor did not provide two consecutive recognized readiness probes; no second 发表 click attempted',
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
return {
|
|
1856
|
+
editor: latestEditor,
|
|
1857
|
+
canonicalUrl: latestCanonicalUrl,
|
|
1858
|
+
canonicalUrlForMetadata: savedEditorUrlForMetadata(latestCanonicalUrl),
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1082
1862
|
/**
|
|
1083
1863
|
* The platform's own answer after 发表: its success marker, or one of the recorded refusal/hint lines
|
|
1084
1864
|
* (§5, §5.3) which are relayed as the actionable message the operator needs.
|
|
@@ -1091,11 +1871,142 @@ async function publishOutcome(page: WechatPage): Promise<string | null> {
|
|
|
1091
1871
|
return null;
|
|
1092
1872
|
}
|
|
1093
1873
|
|
|
1874
|
+
interface VisibleSelectorProbe {
|
|
1875
|
+
selector?: string;
|
|
1876
|
+
reason: 'missing' | 'ambiguous';
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
interface PublicConfirmation {
|
|
1880
|
+
finalSelector: string;
|
|
1881
|
+
notifySubscribers: boolean | null;
|
|
1882
|
+
notifySubscribersPolicy: 'preserve' | 'disabled';
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
interface PreconfirmationStaleVersion {
|
|
1886
|
+
kind: 'stale-version';
|
|
1887
|
+
refusal: string;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Resolve one visible control without allowing a hidden/duplicate match to become a click target.
|
|
1892
|
+
* The live console can leave old dialog nodes mounted, so a first-match helper is unsafe here.
|
|
1893
|
+
*/
|
|
1894
|
+
async function findUniqueVisibleSelector(
|
|
1895
|
+
page: WechatPage,
|
|
1896
|
+
selectors: readonly string[],
|
|
1897
|
+
timeoutMs: number,
|
|
1898
|
+
pollMs: number,
|
|
1899
|
+
): Promise<VisibleSelectorProbe> {
|
|
1900
|
+
let reason: VisibleSelectorProbe['reason'] = 'missing';
|
|
1901
|
+
let selector: string | undefined;
|
|
1902
|
+
const found = await waitUntil(
|
|
1903
|
+
async () => {
|
|
1904
|
+
for (const candidate of selectors) {
|
|
1905
|
+
const locator = page.locator(candidate);
|
|
1906
|
+
const count = await locator.count().catch(() => 0);
|
|
1907
|
+
if (count > 1) {
|
|
1908
|
+
reason = 'ambiguous';
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
if (count !== 1) continue;
|
|
1912
|
+
const visible = await locator
|
|
1913
|
+
.first()
|
|
1914
|
+
.isVisible()
|
|
1915
|
+
.catch(() => false);
|
|
1916
|
+
if (!visible) continue;
|
|
1917
|
+
selector = candidate;
|
|
1918
|
+
return true;
|
|
1919
|
+
}
|
|
1920
|
+
return false;
|
|
1921
|
+
},
|
|
1922
|
+
timeoutMs,
|
|
1923
|
+
pollMs,
|
|
1924
|
+
);
|
|
1925
|
+
return found && selector !== undefined ? { selector, reason } : { reason };
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
async function readPublishNotifySubscribers(page: WechatPage): Promise<boolean | null> {
|
|
1929
|
+
try {
|
|
1930
|
+
const value = await page.evaluate(PUBLISH_NOTIFY_STATE_SCRIPT);
|
|
1931
|
+
return typeof value === 'boolean' ? value : null;
|
|
1932
|
+
} catch {
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function shouldNotifySubscribers(content: Content): boolean {
|
|
1938
|
+
const wechatmp = content.metadata?.wechatmp;
|
|
1939
|
+
if (wechatmp === null || typeof wechatmp !== 'object' || Array.isArray(wechatmp)) return true;
|
|
1940
|
+
return (wechatmp as Record<string, unknown>).notifySubscribers !== false;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Complete the measured mass-send dialog exactly once. The opt-out is deliberately stricter than
|
|
1945
|
+
* the default policy: if 群发通知 cannot be uniquely observed as ON and then OFF, stop before the
|
|
1946
|
+
* final 发表 click so a daily run cannot silently consume the next notification quota. The returned
|
|
1947
|
+
* state is the platform's actual pre-click switch value; the policy is recorded separately because
|
|
1948
|
+
* the default preserves an already-OFF platform setting rather than enabling notifications.
|
|
1949
|
+
*/
|
|
1950
|
+
async function confirmPublicPublish(
|
|
1951
|
+
page: WechatPage,
|
|
1952
|
+
notifySubscribers: boolean,
|
|
1953
|
+
stepTimeoutMs: number,
|
|
1954
|
+
pollMs: number,
|
|
1955
|
+
): Promise<Result | PublicConfirmation | PreconfirmationStaleVersion> {
|
|
1956
|
+
// Not CONFIRM_TIMEOUT_MS: this dialog runs a server-side check before it mounts (3.6s on an idle
|
|
1957
|
+
// editor, 2026-11-04), so a 5s cap reported it missing on 2026-11-03/04. Waiting costs nothing
|
|
1958
|
+
// irreversible — no click happens until the dialog is uniquely observed.
|
|
1959
|
+
const confirmationTimeoutMs = stepTimeoutMs;
|
|
1960
|
+
const dialog = await findUniqueVisibleSelector(page, PUBLISH_DIALOG, confirmationTimeoutMs, pollMs);
|
|
1961
|
+
if (dialog.selector === undefined) {
|
|
1962
|
+
if (dialog.reason === 'missing') {
|
|
1963
|
+
const refusal = await readStaleVersionRefusal(asPlaywrightPage(page)).catch(() => null);
|
|
1964
|
+
if (refusal !== null) return { kind: 'stale-version', refusal };
|
|
1965
|
+
}
|
|
1966
|
+
return fail(`public confirmation: the 发表 dialog was ${dialog.reason}; no final publish click attempted`);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
let observedNotifySubscribers: boolean | null = null;
|
|
1970
|
+
if (!notifySubscribers) {
|
|
1971
|
+
const toggle = await findUniqueVisibleSelector(page, PUBLISH_NOTIFY_TOGGLE, confirmationTimeoutMs, pollMs);
|
|
1972
|
+
if (toggle.selector === undefined)
|
|
1973
|
+
return fail(
|
|
1974
|
+
`public confirmation: 群发通知 switch was ${toggle.reason === 'missing' ? 'unavailable' : 'ambiguous'}; no final publish click attempted`,
|
|
1975
|
+
);
|
|
1976
|
+
const before = await readPublishNotifySubscribers(page);
|
|
1977
|
+
if (before === null)
|
|
1978
|
+
return fail('public confirmation: 群发通知 switch state was unreadable; no final publish click attempted');
|
|
1979
|
+
if (before) await clickSelector(page, toggle.selector, stepTimeoutMs);
|
|
1980
|
+
const off = await waitUntil(
|
|
1981
|
+
async () => (await readPublishNotifySubscribers(page)) === false,
|
|
1982
|
+
confirmationTimeoutMs,
|
|
1983
|
+
pollMs,
|
|
1984
|
+
);
|
|
1985
|
+
if (!off)
|
|
1986
|
+
return fail('public confirmation: 群发通知 switch did not verify OFF; no final publish click attempted');
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const final = await findUniqueVisibleSelector(page, PUBLISH_FINAL, confirmationTimeoutMs, pollMs);
|
|
1990
|
+
if (final.selector === undefined)
|
|
1991
|
+
return fail(`public confirmation: final 发表 button was ${final.reason}; no final publish click attempted`);
|
|
1992
|
+
observedNotifySubscribers = await readPublishNotifySubscribers(page);
|
|
1993
|
+
if (!notifySubscribers && observedNotifySubscribers !== false)
|
|
1994
|
+
return fail(
|
|
1995
|
+
'public confirmation: 群发通知 switch did not remain verifiably OFF; no final publish click attempted',
|
|
1996
|
+
);
|
|
1997
|
+
await clickSelector(page, final.selector, stepTimeoutMs);
|
|
1998
|
+
return {
|
|
1999
|
+
finalSelector: final.selector,
|
|
2000
|
+
notifySubscribers: observedNotifySubscribers,
|
|
2001
|
+
notifySubscribersPolicy: notifySubscribers ? 'preserve' : 'disabled',
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1094
2005
|
/**
|
|
1095
2006
|
* Publish. Reached only for an explicit `publishStatus: 'public'` — the transport refuses every
|
|
1096
|
-
* other value before a browser opens
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
2007
|
+
* other value before a browser opens. The final confirmation is irreversible and is clicked once;
|
|
2008
|
+
* the only pre-confirmation recovery is the bounded, native 查看新草稿 transition handled below.
|
|
2009
|
+
* An unobserved final send is never repeated because a second click could duplicate the message.
|
|
1099
2010
|
*/
|
|
1100
2011
|
async function publishContent(
|
|
1101
2012
|
input: ModeInput,
|
|
@@ -1103,12 +2014,44 @@ async function publishContent(
|
|
|
1103
2014
|
entry: ModeEntry,
|
|
1104
2015
|
composed: ComposeReport,
|
|
1105
2016
|
): Promise<Result> {
|
|
2017
|
+
// The persisted editor snapshot is an article-only readiness contract. The other console
|
|
2018
|
+
// operations historically publish directly after composition; routing them through the article
|
|
2019
|
+
// save probe would reject valid media posts when that probe is unavailable or intentionally fails.
|
|
2020
|
+
const saved = input.operation === '文章' ? await savePublicDraft(deps, entry) : undefined;
|
|
2021
|
+
if (saved !== undefined && 'ok' in saved) return saved;
|
|
2022
|
+
const notifySubscribers = shouldNotifySubscribers(deps.content);
|
|
2023
|
+
let staleVersionRecoveryCount = 0;
|
|
2024
|
+
let finalSavedEditorUrl = saved?.canonicalUrlForMetadata;
|
|
1106
2025
|
await sleep(deps.submitSettleMs);
|
|
1107
2026
|
const control = await findSelector(entry.editor, PUBLISH_SEND, '发表 control', deps.stepTimeoutMs);
|
|
1108
2027
|
const urlBefore = entry.editor.url();
|
|
1109
2028
|
await clickSelector(entry.editor, control, deps.stepTimeoutMs);
|
|
1110
|
-
|
|
1111
|
-
if (
|
|
2029
|
+
let confirmation = await confirmPublicPublish(entry.editor, notifySubscribers, deps.stepTimeoutMs, deps.pollMs);
|
|
2030
|
+
if ('ok' in confirmation) return confirmation;
|
|
2031
|
+
if ('kind' in confirmation) {
|
|
2032
|
+
if (saved === undefined)
|
|
2033
|
+
return fail(
|
|
2034
|
+
`public confirmation: ${confirmation.refusal}; no canonical saved-editor evidence was available for recovery; no second 发表 click attempted`,
|
|
2035
|
+
);
|
|
2036
|
+
const recovered = await recoverStaleVersion(deps, entry, saved);
|
|
2037
|
+
if ('ok' in recovered) return recovered;
|
|
2038
|
+
entry.editor = recovered.editor;
|
|
2039
|
+
finalSavedEditorUrl = recovered.canonicalUrlForMetadata;
|
|
2040
|
+
staleVersionRecoveryCount = 1;
|
|
2041
|
+
const retryControl = await findSelector(
|
|
2042
|
+
entry.editor,
|
|
2043
|
+
PUBLISH_SEND,
|
|
2044
|
+
'发表 control after 查看新草稿',
|
|
2045
|
+
deps.stepTimeoutMs,
|
|
2046
|
+
);
|
|
2047
|
+
await clickSelector(entry.editor, retryControl, deps.stepTimeoutMs);
|
|
2048
|
+
confirmation = await confirmPublicPublish(entry.editor, notifySubscribers, deps.stepTimeoutMs, deps.pollMs);
|
|
2049
|
+
if ('ok' in confirmation) return confirmation;
|
|
2050
|
+
if ('kind' in confirmation)
|
|
2051
|
+
return fail(
|
|
2052
|
+
`public confirmation: ${confirmation.refusal} after bounded 查看新草稿 recovery; no final publish click attempted`,
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
1112
2055
|
|
|
1113
2056
|
const outcome = await expectOutcome(() => publishOutcome(entry.editor), {
|
|
1114
2057
|
timeoutMs: deps.stepTimeoutMs,
|
|
@@ -1129,7 +2072,18 @@ async function publishContent(
|
|
|
1129
2072
|
message: `${TARGET} ${input.operation} published`,
|
|
1130
2073
|
metadata: {
|
|
1131
2074
|
...baseMetadata(input, deps, entry),
|
|
2075
|
+
...(saved === undefined
|
|
2076
|
+
? {}
|
|
2077
|
+
: {
|
|
2078
|
+
draftSaveControl: saved.saveControl,
|
|
2079
|
+
savedEditorUrl: finalSavedEditorUrl ?? saved.canonicalUrlForMetadata,
|
|
2080
|
+
draftSaveSignal: saved.saveSignal,
|
|
2081
|
+
staleVersionRecoveryCount,
|
|
2082
|
+
}),
|
|
1132
2083
|
publishControl: control,
|
|
2084
|
+
finalPublishControl: confirmation.finalSelector,
|
|
2085
|
+
notifySubscribers: confirmation.notifySubscribers,
|
|
2086
|
+
notifySubscribersPolicy: confirmation.notifySubscribersPolicy,
|
|
1133
2087
|
publishSignal: outcome.evidence,
|
|
1134
2088
|
titlePath: composed.titlePath,
|
|
1135
2089
|
...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
|
|
@@ -1263,9 +2217,14 @@ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Resu
|
|
|
1263
2217
|
// the Playwright timeout, so the stuck step had to be rediscovered by hand. Ride them on the error.
|
|
1264
2218
|
const diagnostics = [...composed.warnings, ...composed.enrichWarnings];
|
|
1265
2219
|
try {
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
2220
|
+
const result =
|
|
2221
|
+
deps.publishStatus === 'public'
|
|
2222
|
+
? await publishContent(input, deps, entry, composed)
|
|
2223
|
+
: await saveDraft(input, deps, entry, composed);
|
|
2224
|
+
if (!result.ok && diagnostics.length > 0) {
|
|
2225
|
+
return { ...result, error: `${result.error} — compose diagnostics: ${diagnostics.join(' | ')}` };
|
|
2226
|
+
}
|
|
2227
|
+
return result;
|
|
1269
2228
|
} catch (error) {
|
|
1270
2229
|
const message = error instanceof Error ? error.message : String(error);
|
|
1271
2230
|
const blocked = await openDialogInventory(entry.editor);
|
|
@@ -1283,7 +2242,7 @@ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Resu
|
|
|
1283
2242
|
* TypeScript only — no Python, no theme CLI.
|
|
1284
2243
|
*
|
|
1285
2244
|
* Kept instead of the shared `markdownToHtml` because the two differ on safety, measured on Bun
|
|
1286
|
-
* 1.3.14: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
|
|
2245
|
+
* 1.3.14 and again on 1.4.3: `Bun.markdown.html` passes raw HTML straight through (`<script>` included) and renders
|
|
1287
2246
|
* `javascript:` link destinations, while this renderer escapes text and refuses non-http(s) link
|
|
1288
2247
|
* schemes (R9). The *insertion* is the shared `pasteHtml` either way, so the ADR-029 rail holds.
|
|
1289
2248
|
*/
|
|
@@ -1386,15 +2345,20 @@ export async function insertAudioMaterial(
|
|
|
1386
2345
|
input: { title: string; audioPath: string; coverPath: string },
|
|
1387
2346
|
): Promise<string> {
|
|
1388
2347
|
const { title, audioPath, coverPath } = input;
|
|
1389
|
-
const CARET = `(() => {
|
|
2348
|
+
const CARET = `(async () => {
|
|
1390
2349
|
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
1391
2350
|
if (!pm || !pm.firstChild) return;
|
|
2351
|
+
pm.focus();
|
|
1392
2352
|
const r = document.createRange();
|
|
1393
|
-
|
|
2353
|
+
// The first child can be a non-editable image atom. Select the boundary before it,
|
|
2354
|
+
// not a position inside that atom which ProseMirror discards on toolbar focus.
|
|
2355
|
+
r.setStart(pm, 0);
|
|
1394
2356
|
r.collapse(true);
|
|
1395
2357
|
const sel = window.getSelection();
|
|
1396
2358
|
sel?.removeAllRanges();
|
|
1397
2359
|
sel?.addRange(r);
|
|
2360
|
+
document.dispatchEvent(new Event('selectionchange'));
|
|
2361
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1398
2362
|
})()`;
|
|
1399
2363
|
/** Trim + collapse whitespace/NBSP — MP titles carry ` ` entities (0272 capture). */
|
|
1400
2364
|
const norm = (value: unknown): string =>
|
|
@@ -1421,8 +2385,8 @@ export async function insertAudioMaterial(
|
|
|
1421
2385
|
})()`;
|
|
1422
2386
|
/** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
|
|
1423
2387
|
const AGREE = `(() => {
|
|
1424
|
-
const node = [...document.querySelectorAll('label,
|
|
1425
|
-
.find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.
|
|
2388
|
+
const node = [...document.querySelectorAll('.weui-desktop-dialog label, .audio_dialog label')]
|
|
2389
|
+
.find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.querySelector('input[type=checkbox]'));
|
|
1426
2390
|
if (!node) return 'no-label';
|
|
1427
2391
|
const scope = node.closest('label') ?? node.parentElement;
|
|
1428
2392
|
const cb = scope?.querySelector('input[type=checkbox]') ?? document.querySelector('input[type=checkbox].js_agree');
|
|
@@ -1445,9 +2409,10 @@ export async function insertAudioMaterial(
|
|
|
1445
2409
|
if (!pm) return JSON.stringify({ state: 'noeditor' });
|
|
1446
2410
|
const first = pm.firstElementChild;
|
|
1447
2411
|
if (!first) return JSON.stringify({ state: 'absent' });
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
2412
|
+
const selector = 'mp-common-mpaudio, mpvoice, [data-mediaid]';
|
|
2413
|
+
const node = first.matches(selector) ? first : first.querySelector(selector);
|
|
2414
|
+
if (!node) return JSON.stringify({ state: pm.querySelector(selector) ? 'not-top' : 'absent' });
|
|
2415
|
+
const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('cover') ?? node.getAttribute('voice_encode_fileid') ?? node.getAttribute('data-appid') ??
|
|
1451
2416
|
node.getAttribute('data-src') ?? node.getAttribute('src') ?? '';
|
|
1452
2417
|
return JSON.stringify({ state: 'top', identity });
|
|
1453
2418
|
})()`;
|
|
@@ -1459,7 +2424,7 @@ export async function insertAudioMaterial(
|
|
|
1459
2424
|
// Click the winner, never a joined union: `text=…, css…` mixes selector engines and
|
|
1460
2425
|
// Playwright parses the whole string as CSS — zero matches (DOM fixture 0260).
|
|
1461
2426
|
await clickSelector(page, uploadControl.selector ?? UPLOAD_AUDIO[0] ?? 'text=上传音频', 8_000);
|
|
1462
|
-
const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
|
|
2427
|
+
const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT, { visible: false });
|
|
1463
2428
|
if (!audioInput.found) return '音频: no audio file input appeared';
|
|
1464
2429
|
await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
|
|
1465
2430
|
timeoutMs: 60_000,
|
|
@@ -1468,24 +2433,105 @@ export async function insertAudioMaterial(
|
|
|
1468
2433
|
const coverControl = await trySelectors(page, UPLOAD_COVER);
|
|
1469
2434
|
if (coverControl.found) {
|
|
1470
2435
|
await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
|
|
1471
|
-
const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
|
|
1472
|
-
if (coverInput.found)
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
2436
|
+
const coverInput = await trySelectors(page, AUDIO_COVER_INPUT, { visible: false });
|
|
2437
|
+
if (coverInput.found) {
|
|
2438
|
+
const chooser = '.weui-desktop-dialog:visible:has(h3:has-text("选择图片"))';
|
|
2439
|
+
const hasChooser = (await page.locator(chooser).count()) > 0;
|
|
2440
|
+
const coverName = hasChooser ? await coverLibraryName(coverPath) : baseName(coverPath);
|
|
2441
|
+
let existing = 0;
|
|
2442
|
+
if (hasChooser) {
|
|
2443
|
+
const libraryReady = await waitUntil(
|
|
2444
|
+
async () =>
|
|
2445
|
+
Boolean(
|
|
2446
|
+
await page.evaluate(
|
|
2447
|
+
`!document.querySelector('#js_image_dialog_list_wrp .weui-desktop-loading')`,
|
|
2448
|
+
),
|
|
2449
|
+
),
|
|
2450
|
+
5_000,
|
|
2451
|
+
);
|
|
2452
|
+
if (!libraryReady) return '音频: 封面素材库加载未完成,未上传重复封面';
|
|
2453
|
+
existing = Number(
|
|
2454
|
+
await page.evaluate(
|
|
2455
|
+
`([...document.querySelectorAll('.weui-desktop-img-picker__item')].filter(row => row.offsetWidth > 0 && row.querySelector('.weui-desktop-img-picker__img-title')?.textContent?.trim() === ${JSON.stringify(coverName)})).length`,
|
|
2456
|
+
),
|
|
2457
|
+
);
|
|
2458
|
+
if (existing > 1) return '音频: 内容绑定封面存在多个同名素材,未猜测选择';
|
|
2459
|
+
}
|
|
2460
|
+
let uploadPath = coverPath;
|
|
2461
|
+
let temporaryDir: string | undefined;
|
|
2462
|
+
if (hasChooser && existing === 0) {
|
|
2463
|
+
temporaryDir = join(tmpdir(), `kk-wechat-cover-${randomUUID()}`);
|
|
2464
|
+
if (!mkdirExclusive(temporaryDir)) throw new Error('Private cover upload directory already exists');
|
|
2465
|
+
uploadPath = join(temporaryDir, coverName);
|
|
2466
|
+
}
|
|
2467
|
+
try {
|
|
2468
|
+
if (temporaryDir !== undefined) await createNodeFileSystem().copy(coverPath, uploadPath);
|
|
2469
|
+
if (existing === 0) {
|
|
2470
|
+
await uploadFile(
|
|
2471
|
+
asPlaywrightPage(page),
|
|
2472
|
+
coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
|
|
2473
|
+
uploadPath,
|
|
2474
|
+
{
|
|
2475
|
+
timeoutMs: 60_000,
|
|
2476
|
+
},
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
if (hasChooser) {
|
|
2480
|
+
const selectedName = JSON.stringify(coverName);
|
|
2481
|
+
const selected = await waitUntil(
|
|
2482
|
+
async () =>
|
|
2483
|
+
Boolean(
|
|
2484
|
+
await page.evaluate(`(() => {
|
|
2485
|
+
const rows = [...document.querySelectorAll('.weui-desktop-img-picker__item')]
|
|
2486
|
+
.filter(row => row.offsetWidth > 0 && row.querySelector('.weui-desktop-img-picker__img-title')?.textContent?.trim() === ${selectedName});
|
|
2487
|
+
if (rows.length !== 1) return false;
|
|
2488
|
+
const row = rows[0];
|
|
2489
|
+
if (!row.classList.contains('selected')) row.click();
|
|
2490
|
+
return row.classList.contains('selected');
|
|
2491
|
+
})()`),
|
|
2492
|
+
),
|
|
2493
|
+
30_000,
|
|
2494
|
+
);
|
|
2495
|
+
if (!selected) return '音频: 上传封面未出现唯一已选中的同名图片,未提交封面';
|
|
2496
|
+
const next = await findSelector(
|
|
2497
|
+
page,
|
|
2498
|
+
[`${chooser} button:has-text("下一步"):not(.weui-desktop-btn_disabled)`],
|
|
2499
|
+
'音频封面 下一步',
|
|
2500
|
+
30_000,
|
|
2501
|
+
);
|
|
2502
|
+
await clickSelector(page, next, 8_000);
|
|
2503
|
+
const finish = await findSelector(
|
|
2504
|
+
page,
|
|
2505
|
+
[`${chooser} button:has-text("完成"):not(.weui-desktop-btn_disabled)`],
|
|
2506
|
+
'音频封面 完成',
|
|
2507
|
+
30_000,
|
|
2508
|
+
);
|
|
2509
|
+
await clickSelector(page, finish, 8_000);
|
|
2510
|
+
const closed = await waitUntil(
|
|
2511
|
+
async () => !(await page.locator(chooser).first().isVisible()),
|
|
2512
|
+
8_000,
|
|
2513
|
+
);
|
|
2514
|
+
if (!closed) return '音频: 封面选择器完成后未关闭,未保存音频';
|
|
2515
|
+
}
|
|
2516
|
+
} finally {
|
|
2517
|
+
if (temporaryDir !== undefined) {
|
|
2518
|
+
await createNodeFileSystem().deleteFile(uploadPath);
|
|
2519
|
+
rmdir(temporaryDir);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
1481
2523
|
}
|
|
1482
2524
|
const titleInput = await trySelectors(page, AUDIO_TITLE_INPUT);
|
|
1483
2525
|
if (titleInput.found) await fillSelector(page, titleInput.selector ?? AUDIO_TITLE_INPUT.join(', '), title);
|
|
1484
2526
|
const agreed = String(await page.evaluate(AGREE));
|
|
1485
2527
|
if (agreed !== 'checked') return `音频: 服务规则勾选失败 (${agreed})`;
|
|
1486
|
-
const save = await
|
|
1487
|
-
|
|
1488
|
-
|
|
2528
|
+
const save = await findSelector(
|
|
2529
|
+
page,
|
|
2530
|
+
UPLOAD_SAVE.map((selector) => `${selector}:not(.weui-desktop-btn_disabled):not([disabled])`),
|
|
2531
|
+
'音频上传完成且封面已提交后的保存按钮',
|
|
2532
|
+
90_000,
|
|
2533
|
+
);
|
|
2534
|
+
await clickSelector(page, save, 8_000);
|
|
1489
2535
|
return null;
|
|
1490
2536
|
};
|
|
1491
2537
|
|
|
@@ -1508,6 +2554,8 @@ export async function insertAudioMaterial(
|
|
|
1508
2554
|
};
|
|
1509
2555
|
try {
|
|
1510
2556
|
await page.evaluate(CARET);
|
|
2557
|
+
// Native navigation updates ProseMirror's selection bookmark, which toolbar dialogs restore.
|
|
2558
|
+
await page.press?.(process.platform === 'darwin' ? 'Meta+ArrowUp' : 'Control+Home');
|
|
1511
2559
|
// Open the toolbar dropdown first (measured 2026-10-13): the menu item is hidden until its
|
|
1512
2560
|
// trigger is clicked, so the old direct `js_insertaudio` click timed out.
|
|
1513
2561
|
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_TOOLBAR_TRIGGER)})?.click()`);
|