@gobing-ai/knowledge-kit 0.0.34 → 0.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/dist/board/collect/index.css +42 -0
- package/dist/board/collect/index.js +787 -0
- package/dist/board/daily-voice/index.css +13 -0
- package/dist/board/daily-voice/index.js +902 -0
- package/dist/board/itc/index.css +14 -0
- package/dist/board/itc/index.js +1026 -0
- package/dist/index.js +11769 -9250
- package/package.json +10 -9
- package/plugins/generations/content-gen/dist/index.js +1015 -10814
- package/plugins/generations/content-gen/package.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
- package/plugins/generations/core-facts-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/src/index.ts +32 -3
- package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
- package/plugins/generations/dailynews-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
- package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
- package/plugins/generations/episode-plan-gen/package.json +1 -1
- package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
- package/plugins/generations/gemini-tts/package.json +1 -1
- package/plugins/generations/image-gen/dist/index.js +1239 -11035
- package/plugins/generations/image-gen/package.json +1 -1
- package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
- package/plugins/generations/news-report-gen/package.json +1 -1
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +1066 -10864
- package/plugins/generations/voice-gen/package.json +1 -1
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/generations/voxcpm-tts/README.md +72 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
- package/plugins/generations/voxcpm-tts/package.json +16 -0
- package/plugins/generations/voxcpm-tts/plugin.json +6 -0
- package/plugins/generations/voxcpm-tts/profiles.json +10 -0
- package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
- package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
- package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
- package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
- package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
- package/plugins/ingestions/aihot-ingest/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
- package/plugins/ingestions/horizon-ingest/package.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
- package/plugins/ingestions/karakeep-local/package.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
- package/plugins/ingestions/last30days-ingest/package.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +1250 -11033
- package/plugins/ingestions/web-search/package.json +1 -1
- package/plugins/kk/README.md +29 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +9 -1
- package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
- package/plugins/kk/scripts/daily-stages.ts +702 -63
- package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/skills/publish/SKILL.md +8 -4
- package/plugins/kk/workflows/feature-verification.yaml +93 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +43 -11
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
- package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
- package/plugins/kk/workflows/kk-itc.yaml +8 -1
- package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
- package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
- package/plugins/publishings/emdash-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
- package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +6 -2
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +6 -2
- package/plugins/publishings/medium-pub/dist/index.js +978 -10805
- package/plugins/publishings/medium-pub/package.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
- package/plugins/publishings/podcast-pub/package.json +1 -1
- package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
- package/plugins/publishings/qiita-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
- package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +5 -2
- package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
- package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
- package/plugins/publishings/surfdash-pub/package.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
- package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
- package/plugins/publishings/threads-pub/dist/index.js +12394 -0
- package/plugins/publishings/threads-pub/package.json +17 -0
- package/plugins/publishings/threads-pub/plugin.json +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +340 -0
- package/plugins/publishings/threads-pub/tsconfig.json +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
- package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
- package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
- package/plugins/publishings/weibo-pub/package.json +19 -0
- package/plugins/publishings/weibo-pub/plugin.json +12 -0
- package/plugins/publishings/weibo-pub/src/index.ts +868 -0
- package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
- package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +1948 -11440
- package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +6 -2
- package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
- package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
- package/plugins/publishings/zenn-pub/package.json +1 -1
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -3,7 +3,7 @@ import { ResultSchema } from '@gobing-ai/kk-core';
|
|
|
3
3
|
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
4
4
|
import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
|
|
5
5
|
import type { Page } from 'playwright';
|
|
6
|
-
import { enrichArticle
|
|
6
|
+
import { enrichArticle } from './enrich';
|
|
7
7
|
import {
|
|
8
8
|
artifactPaths,
|
|
9
9
|
CREATE_TYPES,
|
|
@@ -21,8 +21,10 @@ import {
|
|
|
21
21
|
findAttachedSelector,
|
|
22
22
|
findSelector,
|
|
23
23
|
followNewPage,
|
|
24
|
+
modalState,
|
|
24
25
|
tryFindSelector,
|
|
25
26
|
type WechatPage,
|
|
27
|
+
waitForVisible,
|
|
26
28
|
} from './page';
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -65,23 +67,92 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
|
|
68
|
-
* (`a.js_selectCoverFromContent`) /
|
|
69
|
-
*
|
|
70
|
+
* (`a.js_selectCoverFromContent`) / `从图片库选择` (`a.js_imagedialog`) / 微信扫码上传 / AI 配图.
|
|
71
|
+
* `从图片库选择` is the default because the daily flow uploads the episode cover there as the audio
|
|
72
|
+
* material's cover, so the library holds exactly the image the operator wants (2026-10-10);
|
|
73
|
+
* `从正文选择` remains selectable via `metadata.wechatmp.coverSource`.
|
|
70
74
|
*/
|
|
71
75
|
const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
|
|
72
76
|
const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
|
|
77
|
+
const COVER_FROM_LIBRARY = ['a.js_imagedialog', 'text=从图片库选择'];
|
|
78
|
+
/** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
|
|
79
|
+
const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
|
|
73
80
|
/**
|
|
74
81
|
* The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
|
|
75
82
|
* confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
|
|
76
83
|
* to do). A miss is recorded as a warning, never as a false cover claim.
|
|
77
84
|
*/
|
|
78
|
-
const COVER_ITEM = [
|
|
79
|
-
'div[class*="cover" i] li',
|
|
80
|
-
'div[class*="imgList" i] li',
|
|
81
|
-
'div[class*="weui-desktop-img-picker" i] li',
|
|
82
|
-
];
|
|
83
85
|
const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
|
|
84
86
|
|
|
87
|
+
/** The controls that close whatever modal the cover step left open, most specific first. */
|
|
88
|
+
const DIALOG_DISMISS = [
|
|
89
|
+
'.weui-desktop-dialog:visible button:has-text("取消")',
|
|
90
|
+
'.weui-desktop-dialog__close',
|
|
91
|
+
'button:has-text("取消")',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Close an open console dialog, best-effort. Measured 2026-10-12: `setCover` returned its warning
|
|
96
|
+
* while the cover panel stayed open, and an open panel's footer (`div.weui-desktop-dialog__ft`)
|
|
97
|
+
* covers the editor — 赞赏 / 原文链接 / 创作来源 / 音频 each click-timed out behind it and the final
|
|
98
|
+
* `保存为草稿` was blocked for 30 s, so the run saved no draft at all. The same sweep shape the
|
|
99
|
+
* settings rail already uses (`./enrich`): the dialog's own 取消, else Escape.
|
|
100
|
+
*/
|
|
101
|
+
async function dismissOpenDialog(page: WechatPage): Promise<void> {
|
|
102
|
+
for (const selector of DIALOG_DISMISS) {
|
|
103
|
+
const control = await tryFindSelector(page, [selector], 2_000);
|
|
104
|
+
if (control === undefined) continue;
|
|
105
|
+
try {
|
|
106
|
+
await clickSelector(page, control, 2_000);
|
|
107
|
+
} catch {
|
|
108
|
+
// Gone between the probe and the click — nothing left to dismiss.
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
await page.press?.('Escape');
|
|
113
|
+
await page.waitForTimeout?.(400);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The close controls a console modal exposes, most specific first (the audio picker shows no 取消).
|
|
117
|
+
* `.weui-desktop-dialog__close-btn` is the 0272-captured audio-dialog variant the bare `__close`
|
|
118
|
+
* probe missed. */
|
|
119
|
+
const DIALOG_CLOSE = [
|
|
120
|
+
'.weui-desktop-dialog__wrp .weui-desktop-dialog__close',
|
|
121
|
+
'.weui-desktop-dialog__close',
|
|
122
|
+
'.weui-desktop-dialog__close-btn',
|
|
123
|
+
'.weui-desktop-dialog__wrp button:has-text("取消")',
|
|
124
|
+
'button:has-text("取消")',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Close every open console modal before a submit click. Measured 2026-10-12: the 插入音频 dialog
|
|
129
|
+
* (`weui-desktop-dialog__wrp align_edge audio_dialog js_audio_music_dialog`) stayed open after its
|
|
130
|
+
* own 上传音频 click timed out, its footer covered `保存为草稿` for 30 s, and the run saved no draft
|
|
131
|
+
* at all. A failed enrichment is a warning by contract, never a gate — so nothing may outlive it.
|
|
132
|
+
*
|
|
133
|
+
* Returns `'residual'` when a dialog is still open — or its state is UNREADABLE — after all passes:
|
|
134
|
+
* an unknown overlay must never read as cleaned up (task 0260 R3/AC5).
|
|
135
|
+
*/
|
|
136
|
+
async function closeOpenDialogs(page: WechatPage): Promise<'closed' | 'residual'> {
|
|
137
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
138
|
+
const state = await modalState(page);
|
|
139
|
+
if (state === 'closed') return 'closed';
|
|
140
|
+
for (const selector of DIALOG_CLOSE) {
|
|
141
|
+
const control = await tryFindSelector(page, [selector], 1_500);
|
|
142
|
+
if (control === undefined) continue;
|
|
143
|
+
try {
|
|
144
|
+
await clickSelector(page, control, 2_000);
|
|
145
|
+
} catch {
|
|
146
|
+
// Gone between the probe and the click.
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
await page.press?.('Escape');
|
|
151
|
+
await page.waitForTimeout?.(400);
|
|
152
|
+
}
|
|
153
|
+
return (await modalState(page)) === 'closed' ? 'closed' : 'residual';
|
|
154
|
+
}
|
|
155
|
+
|
|
85
156
|
/** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
|
|
86
157
|
const VIDEO_LOCAL_UPLOAD = ['text=本地上传', 'button:has-text("本地上传")'];
|
|
87
158
|
const VIDEO_FILE_INPUT = [
|
|
@@ -111,6 +182,40 @@ const PODCAST_COVER_INPUT = [
|
|
|
111
182
|
];
|
|
112
183
|
const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'button:has-text("插入")'];
|
|
113
184
|
|
|
185
|
+
/** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
|
|
186
|
+
const AUDIO_MENU = 'li.js_insertaudio';
|
|
187
|
+
/** The toolbar dropdown that reveals the 音频 menu item (measured 2026-10-13): the item lives in a
|
|
188
|
+
* `display:none` menu under `#audio_music_plugin_btn` until that trigger is clicked. */
|
|
189
|
+
const AUDIO_TOOLBAR_TRIGGER = '#audio_music_plugin_btn';
|
|
190
|
+
const AUDIO_DIALOG = '.audio_dialog';
|
|
191
|
+
/** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
|
|
192
|
+
const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
|
|
193
|
+
const AUDIO_ROW_RETRIES = 2;
|
|
194
|
+
const AUDIO_ROW_SETTLE_MS = 1_200;
|
|
195
|
+
/**
|
|
196
|
+
* The upload form (operator walk 2026-10-10: `上传音频` → file → `上传封面` → file → title → agree →
|
|
197
|
+
* 保存). The form lives inside the audio dialog, so the file inputs stay scoped to it.
|
|
198
|
+
*/
|
|
199
|
+
const UPLOAD_AUDIO = ['text=上传音频', 'button:has-text("上传音频")'];
|
|
200
|
+
const AUDIO_UPLOAD_INPUT = [
|
|
201
|
+
'.weui-desktop-dialog input[type="file"][accept*="audio"]',
|
|
202
|
+
'.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
|
|
203
|
+
'.audio_dialog input[type="file"][accept*="audio"]',
|
|
204
|
+
'input[type="file"][accept*=".mp3"]',
|
|
205
|
+
];
|
|
206
|
+
const UPLOAD_COVER = ['text=上传封面', 'button:has-text("上传封面")'];
|
|
207
|
+
const AUDIO_COVER_INPUT = [
|
|
208
|
+
'.weui-desktop-dialog input[type="file"][accept*="image/"]',
|
|
209
|
+
'.audio_dialog input[type="file"][accept*="image/"]',
|
|
210
|
+
'input[type="file"][accept*=".png"]',
|
|
211
|
+
];
|
|
212
|
+
const AUDIO_TITLE_INPUT = [
|
|
213
|
+
'.weui-desktop-dialog input[placeholder*="标题"]',
|
|
214
|
+
'.audio_dialog input[placeholder*="标题"]',
|
|
215
|
+
'.audio_dialog input.frm_input',
|
|
216
|
+
];
|
|
217
|
+
const UPLOAD_SAVE = ['.weui-desktop-dialog button:has-text("保存")', 'button:has-text("保存")'];
|
|
218
|
+
|
|
114
219
|
/** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
|
|
115
220
|
const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
|
|
116
221
|
const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
|
|
@@ -452,20 +557,33 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
|
452
557
|
}
|
|
453
558
|
|
|
454
559
|
/**
|
|
455
|
-
* `一键排版` applies the layout
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
560
|
+
* `一键排版` applies the platform's own layout to the pasted body. The operator walk of 2026-10-10 shows
|
|
561
|
+
* the control is present and expected to be applied, so a click that leads nowhere is now REPORTED
|
|
562
|
+
* rather than swallowed (the earlier version failed silently, which is why 正文 came out unformatted).
|
|
563
|
+
*
|
|
564
|
+
* The format set (`保留源格式` / `匹配目标格式` / `仅保留文本`) is what we want when it renders as a menu;
|
|
565
|
+
* `匹配目标格式` is the correct choice for markdown-derived content. A build where the trio is only a
|
|
566
|
+
* label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
|
|
460
567
|
*/
|
|
461
|
-
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<
|
|
568
|
+
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
|
|
569
|
+
// Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
|
|
570
|
+
// control is the platform's layout — silently skip; only a found-but-failing control warns.
|
|
571
|
+
const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
|
|
572
|
+
if (control === undefined) return null;
|
|
462
573
|
try {
|
|
463
|
-
const control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
|
|
464
574
|
await clickSelector(page, control, deps.stepTimeoutMs);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return `一键排版: click failed — ${error instanceof Error ? error.message : String(error)}`;
|
|
577
|
+
}
|
|
578
|
+
const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
|
|
579
|
+
if (option === undefined) {
|
|
580
|
+
return '一键排版: clicked, but no 匹配目标格式 option appeared — the body may be unformatted; check the draft';
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
await clickSelector(page, option, 5_000);
|
|
584
|
+
return null;
|
|
585
|
+
} catch (error) {
|
|
586
|
+
return `一键排版: 匹配目标格式 not applied — ${error instanceof Error ? error.message : String(error)}`;
|
|
469
587
|
}
|
|
470
588
|
}
|
|
471
589
|
|
|
@@ -489,47 +607,207 @@ async function fillOptional(
|
|
|
489
607
|
}
|
|
490
608
|
}
|
|
491
609
|
|
|
610
|
+
/** Local path basename — the only piece of a requested file a remote asset could ever expose. */
|
|
611
|
+
function baseName(path: string): string {
|
|
612
|
+
return path.split(/[\\/]/).pop() ?? path;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The identities `setCover` can accept for a candidate: the requested article image and/or the
|
|
616
|
+
* upload binding actually observed going into the audio form. Either is optional; at least one
|
|
617
|
+
* must produce a UNIQUE exposed match before anything is clicked (task 0260 R1). */
|
|
618
|
+
interface CoverIdentity {
|
|
619
|
+
/** `coverPath(input)` — the article's first body image, requested as the cover. */
|
|
620
|
+
requested?: string;
|
|
621
|
+
/** The cover file the audio upload just bound into the form/library (when audio ran). */
|
|
622
|
+
binding?: string;
|
|
623
|
+
}
|
|
624
|
+
|
|
492
625
|
/**
|
|
493
|
-
* 封面
|
|
494
|
-
* body image.
|
|
495
|
-
*
|
|
626
|
+
* 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover, `从正文选择`
|
|
627
|
+
* the first body image. A candidate is identified ONLY by an exposed exact filename (`title` /
|
|
628
|
+
* `data-name` / its own filename text) uniquely equal to one of the requested identities; a miss or
|
|
629
|
+
* an ambiguity warns WITHOUT clicking first/newest. After the confirm, the committed preview under
|
|
630
|
+
* `#js_cover_area` is read back: the empty state (`add_cover` icon still visible) or an unreadable
|
|
631
|
+
* preview is a named warning, never a success claim (task 0260 R1).
|
|
496
632
|
*/
|
|
497
|
-
async function
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
633
|
+
async function setCover(
|
|
634
|
+
page: WechatPage,
|
|
635
|
+
source: 'library' | 'body',
|
|
636
|
+
deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
|
|
637
|
+
identity: CoverIdentity,
|
|
638
|
+
): Promise<string | null> {
|
|
639
|
+
const picker = source === 'library' ? COVER_FROM_LIBRARY : COVER_FROM_CONTENT;
|
|
640
|
+
const what = source === 'library' ? '从图片库选择' : '从正文选择';
|
|
641
|
+
const wanted = [
|
|
642
|
+
...(identity.requested ? [baseName(identity.requested)] : []),
|
|
643
|
+
...(identity.binding ? [baseName(identity.binding)] : []),
|
|
644
|
+
]
|
|
645
|
+
.map((name) => name.trim())
|
|
646
|
+
.filter((name) => name.length > 0);
|
|
647
|
+
// A cover miss is a warning, never a gate — so the panel must not outlive the step that opened
|
|
648
|
+
// it (see {@link dismissOpenDialog} for what its footer blocked on 2026-10-12).
|
|
649
|
+
const warn = async (message: string): Promise<string> => {
|
|
650
|
+
await dismissOpenDialog(page);
|
|
651
|
+
return message;
|
|
652
|
+
};
|
|
501
653
|
try {
|
|
654
|
+
const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
|
|
655
|
+
if (open === undefined)
|
|
656
|
+
return await warn(
|
|
657
|
+
'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
|
|
658
|
+
);
|
|
502
659
|
await clickSelector(page, open, deps.stepTimeoutMs);
|
|
503
|
-
const pick = await tryFindSelector(page,
|
|
504
|
-
if (pick === undefined) return
|
|
660
|
+
const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
|
|
661
|
+
if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
|
|
505
662
|
await clickSelector(page, pick, deps.stepTimeoutMs);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
663
|
+
if (wanted.length === 0)
|
|
664
|
+
return await warn(`cover: no requested cover file identity to match against — ${what} not driven`);
|
|
665
|
+
// Identify before clicking: the item list is not <img>-based (§5.2) and never sorted the way
|
|
666
|
+
// a first-item assumption needs, so the only honest pick is an exposed unique exact name.
|
|
667
|
+
const IDENTIFY = `(() => {
|
|
668
|
+
const wanted = ${JSON.stringify(wanted)};
|
|
669
|
+
const rows = [...document.querySelectorAll('.weui-desktop-img-picker__item, div[class*="img_picker" i] li, div[class*="imgList" i] li, div[class*="cover" i] li')]
|
|
670
|
+
.filter((row) => row.offsetWidth > 0);
|
|
671
|
+
const nameOf = (row) => {
|
|
672
|
+
const el = row.querySelector('[title], [data-name], [data-file_name]') ?? row;
|
|
673
|
+
const attrs = [el.getAttribute?.('title'), el.getAttribute?.('data-name'), el.getAttribute?.('data-file_name')];
|
|
674
|
+
const text = [...row.querySelectorAll('[class*="name" i], [class*="title" i]')].map((n) => n.textContent ?? '');
|
|
675
|
+
return [...attrs, ...text].map((v) => (v ?? '').trim()).find((v) => v.length > 0) ?? '';
|
|
676
|
+
};
|
|
677
|
+
const hits = rows.filter((row) => wanted.some((w) => nameOf(row) === w));
|
|
678
|
+
if (hits.length === 0) return 'missing';
|
|
679
|
+
if (hits.length > 1) return 'ambiguous';
|
|
680
|
+
const row = hits[0];
|
|
681
|
+
row.querySelector('input[type=checkbox], [role=checkbox]')?.click();
|
|
682
|
+
row.click();
|
|
683
|
+
const checked = row.querySelector('input[type=checkbox], [role=checkbox]');
|
|
684
|
+
const src = row.querySelector('img')?.src ?? (row.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '');
|
|
685
|
+
return JSON.stringify({ picked: true, src });
|
|
686
|
+
})()`;
|
|
687
|
+
const rawIdentify = String(await page.evaluate(IDENTIFY));
|
|
688
|
+
if (rawIdentify === 'missing')
|
|
689
|
+
return await warn(`cover: ${what} 未标出与请求文件同名的素材,未选择(不按首图猜测)`);
|
|
690
|
+
if (rawIdentify === 'ambiguous') return await warn(`cover: ${what} 匹配到多个同名素材,身份不唯一,未选择`);
|
|
691
|
+
let selected: { picked?: boolean; src?: string } = {};
|
|
692
|
+
try {
|
|
693
|
+
selected = JSON.parse(rawIdentify) as { picked?: boolean; src?: string };
|
|
694
|
+
} catch {
|
|
695
|
+
return await warn('cover: 素材身份探测返回未知结果,未选择');
|
|
696
|
+
}
|
|
697
|
+
// The library picker stages the choice: 下一步 advances it, then the dialog confirms.
|
|
698
|
+
const next = await tryFindSelector(page, COVER_LIBRARY_NEXT, Math.min(deps.stepTimeoutMs, 4_000));
|
|
699
|
+
if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
|
|
700
|
+
const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
|
|
701
|
+
if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
|
|
512
702
|
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
703
|
+
// Committed readback (observed capture: the preview lives under #js_cover_area; the empty
|
|
704
|
+
// state keeps the `add_cover` icon visible). Poll briefly — the preview renders late.
|
|
705
|
+
const READBACK = `(() => {
|
|
706
|
+
const area = document.querySelector('#js_cover_area');
|
|
707
|
+
if (!area) return 'noarea';
|
|
708
|
+
if (area.querySelector('.icon20_common.add_cover') && area.querySelector('.icon20_common.add_cover').offsetWidth > 0) return 'empty';
|
|
709
|
+
const preview = area.querySelector('.js_cover_preview_new');
|
|
710
|
+
if (!preview || preview.offsetWidth === 0) return 'nopreview';
|
|
711
|
+
const src = preview.querySelector('img')?.src ??
|
|
712
|
+
(preview.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '') ||
|
|
713
|
+
(preview.querySelector('input[type=hidden]')?.value ?? '');
|
|
714
|
+
return src ? src : 'unknown';
|
|
715
|
+
})()`;
|
|
716
|
+
let committed = '';
|
|
717
|
+
for (let read = 0; read < 5 && committed === ''; read += 1) {
|
|
718
|
+
const raw = String(await page.evaluate(READBACK).catch(() => 'error'));
|
|
719
|
+
if (raw !== '' && raw !== 'nopreview' && raw !== 'noarea') {
|
|
720
|
+
committed = raw;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
await page.waitForTimeout?.(600);
|
|
724
|
+
}
|
|
725
|
+
if (committed === 'empty') return await warn('cover: 确认后面板仍显示未设置封面,提交未生效');
|
|
726
|
+
if (committed === '' || committed === 'noarea' || committed === 'error')
|
|
727
|
+
return await warn('cover: 已确认但无法读取已提交封面,请手动核对');
|
|
728
|
+
// Identity comparison: both sides are remote asset references from the same widget. Only an
|
|
729
|
+
// exact match proves the requested asset committed; an unreadable/edited preview warns
|
|
730
|
+
// rather than inventing equivalence (local paths are never compared to asset URLs).
|
|
731
|
+
const selectedSrc = (selected.src ?? '').trim();
|
|
732
|
+
if (selectedSrc === '' || (committed !== selectedSrc && !committed.includes(selectedSrc)))
|
|
733
|
+
return await warn(
|
|
734
|
+
`cover: 已提交封面的素材身份与所选不一致(selected=${selectedSrc || '未读到'} committed=${committed.slice(0, 80)})`,
|
|
735
|
+
);
|
|
513
736
|
return null;
|
|
514
737
|
} catch (error) {
|
|
515
|
-
return `cover: ${error instanceof Error ? error.message : String(error)}
|
|
738
|
+
return await warn(`cover: ${error instanceof Error ? error.message : String(error)}`);
|
|
516
739
|
}
|
|
517
740
|
}
|
|
518
741
|
|
|
519
|
-
/**
|
|
520
|
-
|
|
742
|
+
/** `metadata.wechatmp.coverSource` — `library` (default) is the uploaded episode cover. */
|
|
743
|
+
function coverSourceOf(content: Content): 'library' | 'body' {
|
|
744
|
+
const meta = content.metadata?.wechatmp;
|
|
745
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return 'library';
|
|
746
|
+
return (meta as Record<string, unknown>).coverSource === 'body' ? 'body' : 'library';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The audio-embed block of `metadata.wechatmp`, or null when this run embeds no audio. Shared by
|
|
750
|
+
* the cover step (the library only holds the episode cover once audio uploaded — task 0272). */
|
|
751
|
+
function audioBlockOf(content: Content): { title: string; audioPath: string; coverPath: string } | null {
|
|
752
|
+
const meta = content.metadata?.wechatmp;
|
|
753
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
|
754
|
+
const m = meta as Record<string, unknown>;
|
|
755
|
+
if (
|
|
756
|
+
typeof m.audioTitle !== 'string' ||
|
|
757
|
+
m.audioTitle.trim() === '' ||
|
|
758
|
+
typeof m.audioPath !== 'string' ||
|
|
759
|
+
m.audioPath === ''
|
|
760
|
+
)
|
|
761
|
+
return null;
|
|
762
|
+
return {
|
|
763
|
+
title: m.audioTitle.trim(),
|
|
764
|
+
audioPath: m.audioPath,
|
|
765
|
+
coverPath: typeof m.audioCoverPath === 'string' ? m.audioCoverPath : '',
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
|
|
770
|
+
async function enrichArticleDraft(
|
|
771
|
+
page: WechatPage,
|
|
772
|
+
content: Content,
|
|
773
|
+
): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
|
|
521
774
|
const enrichMeta = content.metadata?.wechatmp;
|
|
522
|
-
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
775
|
+
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
|
|
776
|
+
return { enrichWarnings: [], coverWarnings: [] };
|
|
777
|
+
const meta = enrichMeta as Record<string, unknown>;
|
|
778
|
+
const enrichWarnings = await enrichArticle(page, meta);
|
|
779
|
+
const audio = audioBlockOf(content);
|
|
780
|
+
let audioFailed = false;
|
|
781
|
+
if (audio !== null) {
|
|
782
|
+
const audioWarning = await insertAudioMaterial(page, audio);
|
|
783
|
+
if (audioWarning !== '') {
|
|
784
|
+
enrichWarnings.push(audioWarning);
|
|
785
|
+
audioFailed = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Deferred library cover (task 0272): the audio upload just put the episode cover in the 图片库 —
|
|
789
|
+
// but ONLY a committed upload guarantees that (task 0260 R1: a failed/uncertain audio upload
|
|
790
|
+
// leaves the library without the requested cover, so the dependent picker must not run).
|
|
791
|
+
const coverWarnings: string[] = [];
|
|
792
|
+
if (coverSourceOf(content) === 'library' && audio !== null) {
|
|
793
|
+
if (audioFailed) {
|
|
794
|
+
coverWarnings.push('cover: 音频上传未成功,图片库不保证已有请求封面,已跳过图片库封面');
|
|
795
|
+
} else {
|
|
796
|
+
const cover = await setCover(
|
|
797
|
+
page,
|
|
798
|
+
'library',
|
|
799
|
+
{ stepTimeoutMs: 8_000 },
|
|
800
|
+
// The upload binding is the image the audio form just bound (the episode cover
|
|
801
|
+
// file); `coverPath(input)` is the article's own first body image, not this.
|
|
802
|
+
{ binding: audio.coverPath === '' ? undefined : audio.coverPath },
|
|
803
|
+
);
|
|
804
|
+
if (cover !== null) coverWarnings.push(cover);
|
|
805
|
+
}
|
|
528
806
|
}
|
|
529
|
-
return
|
|
807
|
+
return { enrichWarnings, coverWarnings };
|
|
530
808
|
}
|
|
531
809
|
|
|
532
|
-
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover
|
|
810
|
+
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover, settings. */
|
|
533
811
|
async function composeArticle(
|
|
534
812
|
input: { operation: '文章'; images: string[]; digest?: string },
|
|
535
813
|
deps: ModeRunDeps,
|
|
@@ -540,16 +818,28 @@ async function composeArticle(
|
|
|
540
818
|
await attachImages(page, input.images, deps);
|
|
541
819
|
const titlePath = await typeTitle(page, deps);
|
|
542
820
|
await insertBody(page, deps);
|
|
543
|
-
await applyLayout(page, deps);
|
|
821
|
+
const layout = await applyLayout(page, deps);
|
|
822
|
+
if (layout !== null) warnings.push(layout);
|
|
544
823
|
for (const warning of [
|
|
545
824
|
await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
|
|
546
825
|
await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
|
|
547
826
|
]) {
|
|
548
827
|
if (warning !== null) warnings.push(warning);
|
|
549
828
|
}
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
829
|
+
const coverSource = coverSourceOf(deps.content);
|
|
830
|
+
const audioRuns = audioBlockOf(deps.content) !== null;
|
|
831
|
+
if (!(coverSource === 'library' && audioRuns)) {
|
|
832
|
+
const cover = await setCover(page, coverSource, deps, {
|
|
833
|
+
// Requested identity = the article's first body image (the 默认首图为封面 semantics of
|
|
834
|
+
// modes.coverPath). No audio ran, so the library binding is unknown — only an exposed
|
|
835
|
+
// exact filename can match.
|
|
836
|
+
requested: input.images[0],
|
|
837
|
+
});
|
|
838
|
+
if (cover !== null) warnings.push(cover);
|
|
839
|
+
}
|
|
840
|
+
const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content);
|
|
841
|
+
warnings.push(...coverWarnings);
|
|
842
|
+
return { warnings, titlePath, enrichWarnings };
|
|
553
843
|
}
|
|
554
844
|
|
|
555
845
|
/** Compose 贴图 (createType 8): ≥1 image is the message; title and body are 选填. */
|
|
@@ -647,8 +937,18 @@ async function compose(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): P
|
|
|
647
937
|
}
|
|
648
938
|
|
|
649
939
|
function authorOf(content: Content): string {
|
|
650
|
-
|
|
651
|
-
|
|
940
|
+
// The 作者 field is the byline readers see. The daily stage carries it as `metadata.wechatmp.author`
|
|
941
|
+
// (with the other MP preferences); `metadata.author` is the generic Content field and wins when a
|
|
942
|
+
// producer sets it directly. Reading only the generic one left 请输入作者 empty on every daily run
|
|
943
|
+
// (operator report 2026-10-10).
|
|
944
|
+
const wechatmp = content.metadata?.wechatmp;
|
|
945
|
+
const scoped =
|
|
946
|
+
wechatmp !== null && typeof wechatmp === 'object' && !Array.isArray(wechatmp)
|
|
947
|
+
? (wechatmp as Record<string, unknown>).author
|
|
948
|
+
: undefined;
|
|
949
|
+
const generic = content.metadata?.author;
|
|
950
|
+
if (typeof generic === 'string' && generic.trim() !== '') return generic.trim();
|
|
951
|
+
return typeof scoped === 'string' ? scoped.trim() : '';
|
|
652
952
|
}
|
|
653
953
|
|
|
654
954
|
function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Record<string, unknown> {
|
|
@@ -685,10 +985,20 @@ async function saveDraft(
|
|
|
685
985
|
let control: string | null = null;
|
|
686
986
|
let signal: string | null = null;
|
|
687
987
|
let attempts = 0;
|
|
988
|
+
let residuals = 0;
|
|
989
|
+
const residualDialogs: string[] = [];
|
|
688
990
|
let stampAfter: string | null = deps.draftBaseline.stamp;
|
|
689
991
|
while (signal === null && attempts < deps.submitAttempts) {
|
|
690
992
|
attempts += 1;
|
|
691
993
|
await sleep(deps.submitSettleMs);
|
|
994
|
+
if ((await closeOpenDialogs(entry.editor)) !== 'closed') {
|
|
995
|
+
residuals += 1;
|
|
996
|
+
residualDialogs.push(await openDialogInventory(entry.editor).catch(() => 'unreadable'));
|
|
997
|
+
// Refuse to click through an unresolved overlay (0260 R3/AC5): the click would land on
|
|
998
|
+
// the dialog footer again, not 保存为草稿. Retry the cleanup on the next attempt.
|
|
999
|
+
if (attempts < deps.submitAttempts) continue;
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
692
1002
|
control = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
|
|
693
1003
|
await clickSelector(entry.editor, control, deps.stepTimeoutMs);
|
|
694
1004
|
// The editor lives in its own tab, so re-read the console entry page — and ENTER the 草稿箱
|
|
@@ -711,6 +1021,10 @@ async function saveDraft(
|
|
|
711
1021
|
}
|
|
712
1022
|
|
|
713
1023
|
if (signal === null || control === null) {
|
|
1024
|
+
if (control === null)
|
|
1025
|
+
return fail(
|
|
1026
|
+
`draft confirmation: a residual modal refused cleanup after ${attempts} attempt(s), ${residuals} refused cleanup(s) (${residualDialogs.join(' | ') || 'no inventory'}) — '保存为草稿' was not clicked through an unresolved overlay (inventory: ${await openDialogInventory(entry.editor).catch(() => 'unreadable')})`,
|
|
1027
|
+
);
|
|
714
1028
|
return fail(
|
|
715
1029
|
`draft confirmation: '${SAVE_DRAFT[0]}' was clicked ${attempts}× but no fresh 草稿箱 row for '${title}' (its ${DRAFT_STAMP} stamp) was observed within ${deps.draftTimeoutMs}ms (before=${deps.draftBaseline.stamp ?? 'none'}, after=${stampAfter ?? 'none'}, page=${deps.entry.url()}, frames=${await describeFrames(deps.entry, 5)})`,
|
|
716
1030
|
);
|
|
@@ -727,6 +1041,13 @@ async function saveDraft(
|
|
|
727
1041
|
draftStampBefore: deps.draftBaseline.stamp,
|
|
728
1042
|
draftStampAfter: signal,
|
|
729
1043
|
titlePath: composed.titlePath,
|
|
1044
|
+
...(residuals > 0
|
|
1045
|
+
? {
|
|
1046
|
+
warnings: [
|
|
1047
|
+
`保存前清理了 ${residuals} 个残留弹层(${residualDialogs.join(' | ')}),草稿仍保存成功`,
|
|
1048
|
+
],
|
|
1049
|
+
}
|
|
1050
|
+
: {}),
|
|
730
1051
|
...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
|
|
731
1052
|
...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
|
|
732
1053
|
},
|
|
@@ -792,6 +1113,23 @@ async function publishContent(
|
|
|
792
1113
|
});
|
|
793
1114
|
}
|
|
794
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Text inventory of whatever modal is still open on `page` — the diagnostic a blocked submit click
|
|
1118
|
+
* needs (2026-10-12: `保存为草稿` was covered by a dialog footer for 30 s and the failure named only
|
|
1119
|
+
* the Playwright timeout). Covers the inner dialog and the overlay wrapper, both of which can be the
|
|
1120
|
+
* element that intercepts pointer events.
|
|
1121
|
+
*/
|
|
1122
|
+
async function openDialogInventory(page: WechatPage): Promise<string> {
|
|
1123
|
+
const raw = await page
|
|
1124
|
+
.evaluate(
|
|
1125
|
+
`[...document.querySelectorAll('.weui-desktop-dialog, .weui-desktop-dialog__wrp')]` +
|
|
1126
|
+
`.filter((d) => d.offsetWidth).map((d) => (d.className || '') + ' :: ' + ` +
|
|
1127
|
+
`(d.innerText || '').replace(/\\s+/g, ' ').slice(0, 160)).join(' || ')`,
|
|
1128
|
+
)
|
|
1129
|
+
.catch(() => '');
|
|
1130
|
+
return typeof raw === 'string' ? raw.slice(0, 600) : '';
|
|
1131
|
+
}
|
|
1132
|
+
|
|
795
1133
|
/**
|
|
796
1134
|
* Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
|
|
797
1135
|
* or — only on an explicit live request — publish it.
|
|
@@ -799,9 +1137,24 @@ async function publishContent(
|
|
|
799
1137
|
export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
|
|
800
1138
|
const entry = await enterMode(deps, input.operation);
|
|
801
1139
|
const composed = await compose(input, deps, entry);
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1140
|
+
// 2026-10-12: a throw from the submit step drops every diagnostic the compose phase collected —
|
|
1141
|
+
// a leaked settings dialog blocked 保存为草稿 on the daily run and the failure Result carried only
|
|
1142
|
+
// the Playwright timeout, so the stuck step had to be rediscovered by hand. Ride them on the error.
|
|
1143
|
+
const diagnostics = [...composed.warnings, ...composed.enrichWarnings];
|
|
1144
|
+
try {
|
|
1145
|
+
return deps.publishStatus === 'public'
|
|
1146
|
+
? await publishContent(input, deps, entry, composed)
|
|
1147
|
+
: await saveDraft(input, deps, entry, composed);
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1150
|
+
const blocked = await openDialogInventory(entry.editor);
|
|
1151
|
+
const carried = [
|
|
1152
|
+
...(diagnostics.length > 0 ? [`compose diagnostics: ${diagnostics.join(' | ')}`] : []),
|
|
1153
|
+
...(blocked !== '' ? [`open dialogs at submit: ${blocked}`] : []),
|
|
1154
|
+
];
|
|
1155
|
+
if (carried.length === 0) throw error;
|
|
1156
|
+
throw new Error(`${message} — ${carried.join(' — ')}`);
|
|
1157
|
+
}
|
|
805
1158
|
}
|
|
806
1159
|
|
|
807
1160
|
/**
|
|
@@ -894,3 +1247,207 @@ export function markdownToWechatHtml(markdown: string): string {
|
|
|
894
1247
|
function escapeHtml(text: string): string {
|
|
895
1248
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
896
1249
|
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Audio at the article top (operator request 2026-09-29; upload path added 2026-10-10).
|
|
1253
|
+
*
|
|
1254
|
+
* Reuses a same-title 素材库 material when one exists (UNIQUE exposed-title match, identity
|
|
1255
|
+
* captured); otherwise UPLOADS it. The operator's measured upload shape: 音频 menu → 上传音频 → pick
|
|
1256
|
+
* the audio file → 上传封面 → pick the cover → type the title → tick 我已阅读并同意《公众平台音频上传服务规则》
|
|
1257
|
+
* → 保存 → the material list reopens → select the row whose exposed title matches uniquely → 插入.
|
|
1258
|
+
* 插入 places it at the caret, so the caret is put at the body start first.
|
|
1259
|
+
*
|
|
1260
|
+
* Fail-soft — every non-committed outcome returns a warning string; `''` means the inserted node is
|
|
1261
|
+
* the body's first content AND carries the selected material's identity (task 0260 R2).
|
|
1262
|
+
*/
|
|
1263
|
+
export async function insertAudioMaterial(
|
|
1264
|
+
page: WechatPage,
|
|
1265
|
+
input: { title: string; audioPath: string; coverPath: string },
|
|
1266
|
+
): Promise<string> {
|
|
1267
|
+
const { title, audioPath, coverPath } = input;
|
|
1268
|
+
const CARET = `(() => {
|
|
1269
|
+
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
1270
|
+
if (!pm || !pm.firstChild) return;
|
|
1271
|
+
const r = document.createRange();
|
|
1272
|
+
r.setStart(pm.firstChild, 0);
|
|
1273
|
+
r.collapse(true);
|
|
1274
|
+
const sel = window.getSelection();
|
|
1275
|
+
sel?.removeAllRanges();
|
|
1276
|
+
sel?.addRange(r);
|
|
1277
|
+
})()`;
|
|
1278
|
+
/** Trim + collapse whitespace/NBSP — MP titles carry ` ` entities (0272 capture). */
|
|
1279
|
+
const norm = (value: unknown): string =>
|
|
1280
|
+
typeof value === 'string' ? value.replace(/[\s\u00a0]+/g, ' ').trim() : '';
|
|
1281
|
+
/** Unique exposed-title match; ticks only an unticked row and captures its stable identity
|
|
1282
|
+
* (data-mediaid/data-id, else the thumb's media URL) for the body readback. Never first-item. */
|
|
1283
|
+
const MATCH = `(() => {
|
|
1284
|
+
const norm = (v) => (v ?? '').replace(/[\\s\\u00a0]+/g, ' ').trim();
|
|
1285
|
+
const want = ${JSON.stringify(norm(title))};
|
|
1286
|
+
const rows = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
|
|
1287
|
+
.filter((l) => l.offsetWidth > 0);
|
|
1288
|
+
const hits = rows.filter((l) => norm((l.querySelector('.audio_title') ?? l).textContent) === want);
|
|
1289
|
+
if (hits.length === 0) return 'missing';
|
|
1290
|
+
if (hits.length > 1) return 'ambiguous';
|
|
1291
|
+
const hit = hits[0];
|
|
1292
|
+
const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
|
|
1293
|
+
if (!cb) return 'nobox';
|
|
1294
|
+
if (!cb.checked) cb.click();
|
|
1295
|
+
const wrp = hit.closest('.audio_item_wrp') ?? hit.parentElement;
|
|
1296
|
+
const thumb = wrp?.querySelector('.audio_item_thumb');
|
|
1297
|
+
const identity = wrp?.getAttribute('data-mediaid') ?? wrp?.getAttribute('data-id') ??
|
|
1298
|
+
(thumb ? (thumb.style.backgroundImage.match(/url\\("?([^")]+)"?\\)/)?.[1] ?? '') : '');
|
|
1299
|
+
return JSON.stringify({ state: 'checked', identity });
|
|
1300
|
+
})()`;
|
|
1301
|
+
/** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
|
|
1302
|
+
const AGREE = `(() => {
|
|
1303
|
+
const node = [...document.querySelectorAll('label, span, div')]
|
|
1304
|
+
.find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.children.length === 0);
|
|
1305
|
+
if (!node) return 'no-label';
|
|
1306
|
+
const scope = node.closest('label') ?? node.parentElement;
|
|
1307
|
+
const cb = scope?.querySelector('input[type=checkbox]') ?? document.querySelector('input[type=checkbox].js_agree');
|
|
1308
|
+
if (!cb) return 'no-box';
|
|
1309
|
+
if (!cb.checked) cb.click();
|
|
1310
|
+
return cb.checked ? 'checked' : 'unchecked';
|
|
1311
|
+
})()`;
|
|
1312
|
+
const INSERT = `(() => {
|
|
1313
|
+
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
|
|
1314
|
+
if (!btn) return false;
|
|
1315
|
+
btn.click();
|
|
1316
|
+
return true;
|
|
1317
|
+
})()`;
|
|
1318
|
+
const CANCEL = `(() => {
|
|
1319
|
+
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
|
|
1320
|
+
btn?.click();
|
|
1321
|
+
})()`;
|
|
1322
|
+
const VERIFY = `(() => {
|
|
1323
|
+
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
1324
|
+
if (!pm) return JSON.stringify({ state: 'noeditor' });
|
|
1325
|
+
const first = pm.firstElementChild;
|
|
1326
|
+
if (!first) return JSON.stringify({ state: 'absent' });
|
|
1327
|
+
const node = first.matches('mpvoice, [data-mediaid]') ? first : first.querySelector('mpvoice, [data-mediaid]');
|
|
1328
|
+
if (!node) return JSON.stringify({ state: 'absent' });
|
|
1329
|
+
const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('data-appid') ??
|
|
1330
|
+
node.getAttribute('data-src') ?? node.getAttribute('src') ?? '';
|
|
1331
|
+
return JSON.stringify({ state: 'top', identity });
|
|
1332
|
+
})()`;
|
|
1333
|
+
|
|
1334
|
+
/** Upload the audio + its cover, acknowledge the rules, and 保存. Returns a warning or null. */
|
|
1335
|
+
const upload = async (): Promise<string | null> => {
|
|
1336
|
+
const uploadControl = await trySelectors(page, UPLOAD_AUDIO);
|
|
1337
|
+
if (!uploadControl.found) return '音频: 上传音频 control not found in the audio dialog';
|
|
1338
|
+
// Click the winner, never a joined union: `text=…, css…` mixes selector engines and
|
|
1339
|
+
// Playwright parses the whole string as CSS — zero matches (DOM fixture 0260).
|
|
1340
|
+
await clickSelector(page, uploadControl.selector ?? UPLOAD_AUDIO[0] ?? 'text=上传音频', 8_000);
|
|
1341
|
+
const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
|
|
1342
|
+
if (!audioInput.found) return '音频: no audio file input appeared';
|
|
1343
|
+
await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
|
|
1344
|
+
timeoutMs: 60_000,
|
|
1345
|
+
});
|
|
1346
|
+
// The form requires a cover before it will save.
|
|
1347
|
+
const coverControl = await trySelectors(page, UPLOAD_COVER);
|
|
1348
|
+
if (coverControl.found) {
|
|
1349
|
+
await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
|
|
1350
|
+
const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
|
|
1351
|
+
if (coverInput.found)
|
|
1352
|
+
await uploadFile(
|
|
1353
|
+
asPlaywrightPage(page),
|
|
1354
|
+
coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
|
|
1355
|
+
coverPath,
|
|
1356
|
+
{
|
|
1357
|
+
timeoutMs: 60_000,
|
|
1358
|
+
},
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
const titleInput = await trySelectors(page, AUDIO_TITLE_INPUT);
|
|
1362
|
+
if (titleInput.found) await fillSelector(page, titleInput.selector ?? AUDIO_TITLE_INPUT.join(', '), title);
|
|
1363
|
+
const agreed = String(await page.evaluate(AGREE));
|
|
1364
|
+
if (agreed !== 'checked') return `音频: 服务规则勾选失败 (${agreed})`;
|
|
1365
|
+
const save = await trySelectors(page, UPLOAD_SAVE);
|
|
1366
|
+
if (!save.found) return '音频: 上传表单的保存按钮未找到';
|
|
1367
|
+
await clickSelector(page, UPLOAD_SAVE.join(', '), 8_000);
|
|
1368
|
+
return null;
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
/** One MATCH read; a throwing probe stays 'error' (unknown, never treated as missing). */
|
|
1372
|
+
const matchOnce = async (): Promise<string> => {
|
|
1373
|
+
try {
|
|
1374
|
+
return String(await page.evaluate(MATCH));
|
|
1375
|
+
} catch {
|
|
1376
|
+
return 'error';
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
/** Poll the picker while rows stream in; 'missing' keeps polling, JSON answers settle it. */
|
|
1380
|
+
const awaitMatch = async (): Promise<string> => {
|
|
1381
|
+
let match = await matchOnce();
|
|
1382
|
+
for (let i = 0; match === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
|
|
1383
|
+
await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
|
|
1384
|
+
match = await matchOnce();
|
|
1385
|
+
}
|
|
1386
|
+
return match;
|
|
1387
|
+
};
|
|
1388
|
+
try {
|
|
1389
|
+
await page.evaluate(CARET);
|
|
1390
|
+
// Open the toolbar dropdown first (measured 2026-10-13): the menu item is hidden until its
|
|
1391
|
+
// trigger is clicked, so the old direct `js_insertaudio` click timed out.
|
|
1392
|
+
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_TOOLBAR_TRIGGER)})?.click()`);
|
|
1393
|
+
await page.waitForTimeout?.(300);
|
|
1394
|
+
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
|
|
1395
|
+
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1396
|
+
let match = await awaitMatch();
|
|
1397
|
+
if (match === 'missing') {
|
|
1398
|
+
// No reusable material: upload one, then POLL until the new row actually exists — never
|
|
1399
|
+
// assume the upload auto-selected a default row (task 0260 AC2).
|
|
1400
|
+
const uploadWarning = await upload();
|
|
1401
|
+
if (uploadWarning !== null) {
|
|
1402
|
+
await page.evaluate(CANCEL);
|
|
1403
|
+
return uploadWarning;
|
|
1404
|
+
}
|
|
1405
|
+
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1406
|
+
match = await awaitMatch();
|
|
1407
|
+
if (match === 'missing') {
|
|
1408
|
+
await page.evaluate(CANCEL);
|
|
1409
|
+
return `音频: 保存后素材列表未出现「${title}」,未插入音频`;
|
|
1410
|
+
}
|
|
1411
|
+
if (match === 'error') {
|
|
1412
|
+
await page.evaluate(CANCEL);
|
|
1413
|
+
return '音频: 保存后素材匹配探测失败,未插入音频';
|
|
1414
|
+
}
|
|
1415
|
+
} else if (match === 'error') {
|
|
1416
|
+
await page.evaluate(CANCEL);
|
|
1417
|
+
return '音频: 素材匹配探测失败,未插入音频';
|
|
1418
|
+
}
|
|
1419
|
+
// Parse the match; anything but a unique ticked row stays a warning (never first-item).
|
|
1420
|
+
let identity = '';
|
|
1421
|
+
try {
|
|
1422
|
+
const parsed = JSON.parse(match) as { state?: string; identity?: string };
|
|
1423
|
+
if (parsed.state !== 'checked') {
|
|
1424
|
+
await page.evaluate(CANCEL);
|
|
1425
|
+
return `音频: 素材「${title}」${parsed.state === 'ambiguous' ? '列表匹配到多个同名条目,身份不唯一' : parsed.state === 'nobox' ? '行没有勾选框' : '未选中'},未插入音频`;
|
|
1426
|
+
}
|
|
1427
|
+
identity = parsed.identity ?? '';
|
|
1428
|
+
} catch {
|
|
1429
|
+
await page.evaluate(CANCEL);
|
|
1430
|
+
return '音频: 素材匹配探测返回未知结果,未插入音频';
|
|
1431
|
+
}
|
|
1432
|
+
if ((await page.evaluate(INSERT)) !== true) {
|
|
1433
|
+
await page.evaluate(CANCEL);
|
|
1434
|
+
return '音频: 插入按钮未找到,请手动插入';
|
|
1435
|
+
}
|
|
1436
|
+
await page.waitForTimeout?.(2_000);
|
|
1437
|
+
let verdict: { state?: string; identity?: string };
|
|
1438
|
+
try {
|
|
1439
|
+
verdict = JSON.parse(String(await page.evaluate(VERIFY))) as { state?: string; identity?: string };
|
|
1440
|
+
} catch {
|
|
1441
|
+
return '音频: 已插入但无法读取正文状态,请手动检查';
|
|
1442
|
+
}
|
|
1443
|
+
if (verdict.state === 'absent' || verdict.state === 'noeditor') return '音频: 插入未生效,请手动检查';
|
|
1444
|
+
if (verdict.state !== 'top') return '音频: 已插入但未在文首,请手动调整位置';
|
|
1445
|
+
// Identity readback (0260 R2): success requires the selected identity to reappear on the
|
|
1446
|
+
// inserted node; an unreadable identity is an unverifiable warning, never a silent pass.
|
|
1447
|
+
if (identity === '' || (verdict.identity ?? '') === '' || verdict.identity !== identity)
|
|
1448
|
+
return `音频: 已插入文首但无法核对素材身份(selected=${identity || '未读到'} node=${(verdict.identity ?? '').slice(0, 60) || '未读到'}),请手动核对`;
|
|
1449
|
+
return '';
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
return `音频: ${error instanceof Error ? error.message.slice(0, 120) : '自动插入失败'},请手动插入`;
|
|
1452
|
+
}
|
|
1453
|
+
}
|