@gobing-ai/knowledge-kit 0.0.31 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +32320 -15021
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +10 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +13 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +14 -13
  7. package/plugins/generations/daily-article-gen/src/index.ts +1 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +10 -9
  9. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  10. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  11. package/plugins/generations/gemini-tts/README.md +11 -0
  12. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  13. package/plugins/generations/image-gen/dist/index.js +154 -151
  14. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  15. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  16. package/plugins/generations/voice-gen/dist/index.js +10 -9
  17. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  18. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  19. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  20. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  21. package/plugins/ingestions/web-search/dist/index.js +11 -10
  22. package/plugins/kk/README.md +38 -0
  23. package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
  24. package/plugins/kk/decisions/kk-items.yaml +113 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/schedules/cron.tmpl +15 -0
  27. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  28. package/plugins/kk/scripts/daily-stages.ts +1533 -0
  29. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
  30. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  31. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  32. package/plugins/kk/skills/content-safety/SKILL.md +60 -18
  33. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  34. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  35. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  39. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
  41. package/plugins/publishings/infoq-pub/package.json +3 -1
  42. package/plugins/publishings/infoq-pub/plugin.json +2 -1
  43. package/plugins/publishings/infoq-pub/src/index.ts +358 -29
  44. package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
  45. package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
  46. package/plugins/publishings/juejin-pub/package.json +3 -1
  47. package/plugins/publishings/juejin-pub/plugin.json +2 -1
  48. package/plugins/publishings/juejin-pub/src/index.ts +345 -29
  49. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  51. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  52. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  53. package/plugins/publishings/substack-pub/dist/index.js +8687 -211
  54. package/plugins/publishings/substack-pub/package.json +3 -1
  55. package/plugins/publishings/substack-pub/plugin.json +2 -1
  56. package/plugins/publishings/substack-pub/src/index.ts +302 -29
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  62. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  63. package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
  64. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  65. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  66. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
  67. package/plugins/publishings/x-pub/dist/index.js +8707 -223
  68. package/plugins/publishings/x-pub/package.json +3 -1
  69. package/plugins/publishings/x-pub/plugin.json +2 -1
  70. package/plugins/publishings/x-pub/src/index.ts +315 -23
  71. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +897 -354
  73. package/plugins/publishings/xhs-pub/plugin.json +2 -1
  74. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +108 -596
  76. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  77. package/plugins/publishings/xhs-pub/src/page.ts +254 -0
  78. package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
  79. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -2,6 +2,7 @@
2
2
  "name": "xhs-pub",
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
+ "check": "./src/profile-check.ts",
5
6
  "version": "1.0.0",
6
- "description": "Publish plugin for Xiaohongshu (xhs): ordered card images to draft or live post via Playwright script transport"
7
+ "description": "Publish plugin for Xiaohongshu (xhs): one creator console, four validated operations (上传图文 ≤18 images | 上传视频 | 写长文 | 发播客 audio+cover) saved as a draft via 暂存离开 or published live via 发布, over the Playwright script transport"
7
8
  }
@@ -0,0 +1,572 @@
1
+ import type { Content, Result } from '@gobing-ai/kk-core';
2
+ import { ResultSchema } from '@gobing-ai/kk-core';
3
+ import { trySelectors } from '@gobing-ai/publish-harness';
4
+ import {
5
+ expectOutcome,
6
+ markdownToHtml,
7
+ pasteHtml,
8
+ readScopedSignal,
9
+ uploadFile,
10
+ } from '@gobing-ai/publish-harness/browser';
11
+ import type { Page } from 'playwright';
12
+ import { artifactPaths, type ModeInput, PUBLISH_URL, PUBLISHED_MARKER, TARGET, type XhsOperation } from './modes';
13
+ import { clickCreatorTab, findAttachedSelector, findSelector, POLL_MS, waitUntil, type XhsPage } from './page';
14
+
15
+ /**
16
+ * The four xhs flows (ADR-029: the operations own their flow, selector table and success signal)
17
+ * plus the rails they share — mode entry, the scoped signal probe, the 暂存离开 click and publish.
18
+ *
19
+ * `docs/design/xhs-ui-discovery.md` is the evidence for every selector here; where the recorded
20
+ * evidence does not cover a detail the code says so rather than inventing one.
21
+ */
22
+
23
+ const NOTE_TITLE = [
24
+ 'input.d-text[placeholder="填写标题会有更多赞哦"]',
25
+ 'input[placeholder*="标题"]',
26
+ '#title-textarea input',
27
+ ];
28
+ // The note body is TipTap ProseMirror since 2026-09 (it was Quill `.ql-editor`).
29
+ const NOTE_BODY = [
30
+ 'div.tiptap[contenteditable="true"]',
31
+ 'div[contenteditable="true"].ql-editor',
32
+ '#post-textarea',
33
+ '.ql-editor',
34
+ ];
35
+ // Uploaded previews carry XHS's own class spelling (`preivew`, not `preview`).
36
+ const NOTE_THUMBNAIL = ['img.preivew-image', '.img-preview img'];
37
+ // The image input is distinguishable from the video input by its accept list (`.jpg` first).
38
+ const IMAGE_FILE_INPUT = [
39
+ 'input[type="file"][accept*=".jpg"]',
40
+ 'input[type="file"].upload-input',
41
+ 'input[type="file"]',
42
+ ];
43
+ const VIDEO_FILE_INPUT = ['input[type="file"][accept*=".mp4"]', 'input[type="file"].upload-input'];
44
+ const VIDEO_EDITOR = ['div.tiptap.ProseMirror', 'div.tiptap[contenteditable="true"]', '.ProseMirror'];
45
+
46
+ // 写长文: the landing offers 新的创作 (author in place) and 导入链接 (excluded — it is fed from a
47
+ // 公众号 source, which would put another platform's publishing and censorship in the critical
48
+ // path), then an in-page ProseMirror editor with 暂存离开 and 一键排版.
49
+ const ARTICLE_NEW = ['text=新的创作', 'button:has-text("新的创作")'];
50
+ const ARTICLE_EDITOR = ['div.tiptap.ProseMirror.ProseMirror-focused', 'div.tiptap.ProseMirror', '.ProseMirror'];
51
+ // The clickable `一键排版` is the button (measured: `button.d-button…` at the editor's action bar);
52
+ // the `ailayout-*` divs are its label wrappers.
53
+ const ARTICLE_LAYOUT = ['button:has-text("一键排版")', 'div.ailayout-btn_wrp', 'div.ailayout-btn_label'];
54
+
55
+ // 发播客: 上传音频 opens a modal carrying the audio picker AND the cover picker; the two inputs
56
+ // are told apart by their accept lists.
57
+ const PODCAST_OPEN = ['text=上传音频', 'button:has-text("上传音频")'];
58
+ const PODCAST_MODAL = ['div.creator-modal-sty', '.d-modal.d-modal-centered', '.d-modal'];
59
+ const PODCAST_AUDIO_INPUT = ['input[type="file"][accept*=".mp3"]', 'input[type="file"][accept*=".wav"]'];
60
+ const PODCAST_COVER_INPUT = ['input[type="file"][accept*=".jpg"]', 'input[type="file"][accept*=".png"]'];
61
+ const PODCAST_MODAL_ADVANCE = ['去发布'];
62
+
63
+ /**
64
+ * The `暂存离开` labels, newest first: XHS renamed the control, and the older labels stay as
65
+ * fallbacks. The control is a `BUTTON.ce-btn.white` inside the publish bar.
66
+ */
67
+ const DRAFT_LABELS = ['暂存离开', '存草稿', '保存草稿'];
68
+ const PUBLISH_LABELS = ['发布'];
69
+ /** The console's publish confirmation text, read as a scoped signal (the URL marker is primary). */
70
+ const PUBLISH_CONFIRM = ['发布成功', '已发布'];
71
+ /** The draft toast measured on save (`保存成功`); read only from notification containers. */
72
+ const DRAFT_TOASTS = ['保存成功'];
73
+
74
+ /** The submit bar is the custom element `<XHS-PUBLISH-BTN>`, whose shadow root is CLOSED. */
75
+ const SHADOW_BAR = 'the xhs publish bar (closed shadow root)';
76
+
77
+ /**
78
+ * Where each mode's action-bar controls (`暂存离开` and `发布`) live, in the order the flow tries it.
79
+ *
80
+ * Measured (discovery §3): the 图文 action bar is the closed-shadow `<XHS-PUBLISH-BTN>` — a
81
+ * light-DOM search over every open shadow root finds **zero** matches, so a text selector there
82
+ * could only ever hit a mirrored clone. The same labels ARE in the light DOM in 写长文, which is why
83
+ * that mode tries the normal locator first and keeps the CDP path as the fallback; the three bar
84
+ * modes go straight to CDP, where the label and box are read rather than guessed.
85
+ */
86
+ const MODE_ACTION_PATHS: Record<XhsOperation, readonly ActionPath[]> = {
87
+ 上传图文: ['shadow'],
88
+ 上传视频: ['shadow'],
89
+ 写长文: ['light', 'shadow'],
90
+ 发播客: ['shadow'],
91
+ };
92
+
93
+ type ActionPath = 'shadow' | 'light';
94
+
95
+ /** Everything the flows need besides the validated mode input. */
96
+ export interface ModeRunDeps {
97
+ page: XhsPage;
98
+ content: Content;
99
+ publishStatus: 'draft' | 'public';
100
+ profileDir: string;
101
+ driver: string;
102
+ stepTimeoutMs: number;
103
+ submitSettleMs: number;
104
+ submitAttempts: number;
105
+ /** Draft evidence captured before the mode was entered (the counter only exists on the landing view). */
106
+ draftBaseline: DraftBaseline;
107
+ }
108
+
109
+ function fail(error: string): Result {
110
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
111
+ }
112
+
113
+ function sleep(ms: number): Promise<void> {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+
117
+ /**
118
+ * The shared helpers are declared against Playwright's `Page`, while the flows drive the
119
+ * structural `XhsPage` seam (which is what lets the offline tests script the console). Every
120
+ * member those helpers touch — `locator`, `evaluate`, `setInputFiles` — is implemented by
121
+ * `wrapPlaywrightPage`, so this is a type-level bridge over the same object, not a second page.
122
+ */
123
+ function asPlaywrightPage(page: XhsPage): Page {
124
+ return page as unknown as Page;
125
+ }
126
+
127
+ /** Enter a console mode: the tab bar must be mounted, then the ON-SCREEN tab is clicked. */
128
+ async function enterMode(deps: ModeRunDeps, label: string): Promise<void> {
129
+ await clickCreatorTab(deps.page, label, { timeoutMs: deps.stepTimeoutMs });
130
+ }
131
+
132
+ /**
133
+ * Click a labelled control inside the CLOSED shadow publish bar. The label and box come from CDP,
134
+ * so the click is aimed at a read target rather than a guessed coordinate; a driver without that
135
+ * capability fails loud instead of clicking blind.
136
+ */
137
+ async function clickShadowControl(
138
+ page: XhsPage,
139
+ labels: readonly string[],
140
+ ): Promise<{ ok: true; label: string; x: number; y: number } | { ok: false; error: string }> {
141
+ if (!page.findShadowControl || !page.clickAt) {
142
+ return {
143
+ ok: false,
144
+ error: `submit control: ${SHADOW_BAR} and this driver has no CDP access to resolve it`,
145
+ };
146
+ }
147
+ const control = await page.findShadowControl(labels);
148
+ if (!control) {
149
+ return {
150
+ ok: false,
151
+ error: `submit control: none of [${labels.join(', ')}] found in ${SHADOW_BAR}`,
152
+ };
153
+ }
154
+ await page.clickAt(control.x, control.y);
155
+ return { ok: true, label: control.label, x: control.x, y: control.y };
156
+ }
157
+
158
+ /** Click a labelled control that lives in the light DOM (写长文's action bar). */
159
+ async function clickLightControl(
160
+ page: XhsPage,
161
+ labels: readonly string[],
162
+ timeoutMs: number,
163
+ what: string,
164
+ ): Promise<{ ok: true; label: string; selector: string } | { ok: false; error: string }> {
165
+ const candidates = labels.flatMap((label) => [`button:has-text("${label}")`, `text=${label}`]);
166
+ let selector: string;
167
+ try {
168
+ selector = await findSelector(
169
+ page,
170
+ candidates,
171
+ `${what} (light DOM) none of [${labels.join(', ')}]`,
172
+ timeoutMs,
173
+ );
174
+ } catch (error) {
175
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
176
+ }
177
+ const label = labels.find((candidate) => selector.includes(candidate)) ?? labels[0];
178
+ // No `force`: the actionability check is what keeps a covered or detached control from being
179
+ // clicked, and the site signal afterwards is what decides whether the click did anything.
180
+ await page.locator(selector).first().click({ timeout: timeoutMs });
181
+ return { ok: true, label: label ?? '', selector };
182
+ }
183
+
184
+ interface ActionClick {
185
+ ok: true;
186
+ path: ActionPath;
187
+ label: string;
188
+ /** Coordinates for the shadow path, the resolved selector for the light path. */
189
+ click: string;
190
+ }
191
+
192
+ /** Click an action-bar control of this mode, on the path its evidence says that bar lives on. */
193
+ async function clickActionControl(
194
+ operation: XhsOperation,
195
+ deps: ModeRunDeps,
196
+ labels: readonly string[],
197
+ what: string,
198
+ ): Promise<ActionClick | { ok: false; error: string }> {
199
+ let lastError = '';
200
+ for (const path of MODE_ACTION_PATHS[operation]) {
201
+ if (path === 'shadow') {
202
+ const clicked = await clickShadowControl(deps.page, labels);
203
+ if (clicked.ok) return { ok: true, path, label: clicked.label, click: `${clicked.x},${clicked.y}` };
204
+ lastError = clicked.error;
205
+ continue;
206
+ }
207
+ const clicked = await clickLightControl(deps.page, labels, deps.stepTimeoutMs, what);
208
+ if (clicked.ok) return { ok: true, path, label: clicked.label, click: `selector:${clicked.selector}` };
209
+ lastError = clicked.error;
210
+ }
211
+ return { ok: false, error: lastError };
212
+ }
213
+
214
+ /** Draft evidence captured before the editor opens (the counter only exists on that view). */
215
+ export interface DraftBaseline {
216
+ count: number | null;
217
+ stamps: string[];
218
+ }
219
+
220
+ /**
221
+ * Read the console's draft counter (`草稿箱(N)`). It exists only on the landing view: the image
222
+ * editor replaces the header, so this must be read before entering the mode. Returns null when the
223
+ * label carries no number yet.
224
+ */
225
+ async function readDraftCount(page: XhsPage): Promise<number | null> {
226
+ for (const selector of ['text=/草稿箱\\(\\d+\\)/', 'text=草稿箱']) {
227
+ // Attachment, not visibility: the label lives in a panel that may be collapsed.
228
+ const attached = await page
229
+ .locator(selector)
230
+ .count()
231
+ .catch(() => 0);
232
+ if (attached === 0) continue;
233
+ const locator = page.locator(selector).first();
234
+ if (!locator.textContent) continue;
235
+ const text = await locator.textContent().catch(() => '');
236
+ const match = /草稿箱\s*[((]\s*(\d+)\s*[))]/.exec(text);
237
+ if (match?.[1] !== undefined) return Number(match[1]);
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Timestamps the console prints for a saved draft, e.g. `保存于2026-09-25 21:37`. The current and
244
+ * previous two minutes are accepted so a save near a minute boundary still confirms.
245
+ */
246
+ function draftSavedStamps(now = new Date()): string[] {
247
+ const pad = (value: number) => String(value).padStart(2, '0');
248
+ return [0, 1, 2].map((minutesAgo) => {
249
+ const stamp = new Date(now.getTime() - minutesAgo * 60_000);
250
+ return `保存于${stamp.getFullYear()}-${pad(stamp.getMonth() + 1)}-${pad(stamp.getDate())} ${pad(stamp.getHours())}:${pad(stamp.getMinutes())}`;
251
+ });
252
+ }
253
+
254
+ /** Which candidate save stamps are already on the page — the baseline a later check diffs against. */
255
+ async function readDraftStamps(page: XhsPage): Promise<string[]> {
256
+ const present: string[] = [];
257
+ for (const stamp of draftSavedStamps()) {
258
+ const attached = await page
259
+ .locator(`text=${stamp}`)
260
+ .count()
261
+ .catch(() => 0);
262
+ if (attached > 0) present.push(stamp);
263
+ }
264
+ return present;
265
+ }
266
+
267
+ /** Draft evidence captured before the mode is entered (the counter only exists on the landing view). */
268
+ export async function readDraftBaseline(page: XhsPage): Promise<DraftBaseline> {
269
+ return { count: await readDraftCount(page), stamps: await readDraftStamps(page) };
270
+ }
271
+
272
+ /**
273
+ * A draft counts as observed only from a DELTA against the pre-entry baseline: the console's draft
274
+ * counter moved, or a save timestamp appeared that was not already there. A readable counter with
275
+ * no usable baseline is NOT evidence — XHS autosaves uploads, so "the counter exists" says nothing
276
+ * about this save.
277
+ */
278
+ async function isDraftRowSaved(page: XhsPage, baseline: DraftBaseline): Promise<boolean> {
279
+ const stamps = await readDraftStamps(page);
280
+ if (stamps.some((stamp) => !baseline.stamps.includes(stamp))) return true;
281
+ const after = await readDraftCount(page);
282
+ if (baseline.count === null || after === null) return false;
283
+ return after > baseline.count;
284
+ }
285
+
286
+ /**
287
+ * The observed draft signal, or null (rail: success only from a signal the site actually showed).
288
+ *
289
+ * The URL does not change on save (measured: `保存成功` + `草稿箱(N)` with the URL untouched), so
290
+ * the signals are the toast and the counter/timestamp delta. The toast is read through
291
+ * `readScopedSignal`, scoped to the page's notification containers — never an unscoped
292
+ * `:text(...)`, which the note body this flow just pasted would satisfy.
293
+ */
294
+ async function draftSignalEvidence(page: XhsPage, baseline: DraftBaseline): Promise<string | null> {
295
+ const toast = await readScopedSignal(asPlaywrightPage(page), DRAFT_TOASTS);
296
+ if (toast !== null) return `toast:${toast}`;
297
+ if (await isDraftRowSaved(page, baseline)) return 'draft-row';
298
+ return null;
299
+ }
300
+
301
+ async function applyNoteTitle(deps: ModeRunDeps): Promise<void> {
302
+ const selector = await findSelector(deps.page, NOTE_TITLE, 'title input', deps.stepTimeoutMs);
303
+ await deps.page
304
+ .locator(selector)
305
+ .first()
306
+ .fill(deps.content.title ?? '');
307
+ }
308
+
309
+ async function typeNoteBody(deps: ModeRunDeps, selectors: readonly string[], what: string): Promise<void> {
310
+ const selector = await findSelector(deps.page, selectors, what, deps.stepTimeoutMs);
311
+ const body = deps.page.locator(selector).first();
312
+ const text = deps.content.body ?? '';
313
+ // Type rather than fill: TipTap ignores a synthetic value set, so a filled body leaves the
314
+ // note unsaved and the draft never becomes observable.
315
+ if (typeof body.type === 'function') {
316
+ await body.click();
317
+ await body.type(text, { delay: 5 });
318
+ return;
319
+ }
320
+ await body.fill(text);
321
+ }
322
+
323
+ /** The uploaded-preview count must match the payload: it is how a wrong input is caught. */
324
+ async function confirmThumbnails(deps: ModeRunDeps, expected: number): Promise<void> {
325
+ const selector = await findSelector(deps.page, NOTE_THUMBNAIL, 'thumbnail wait', deps.stepTimeoutMs);
326
+ const matches = await waitUntil(
327
+ () =>
328
+ deps.page
329
+ .locator(selector)
330
+ .count()
331
+ .then((count) => count >= expected),
332
+ deps.stepTimeoutMs,
333
+ );
334
+ const observed = await deps.page.locator(selector).count();
335
+ if (!matches || observed !== expected) {
336
+ throw new Error(`thumbnail count: expected ${expected}, observed ${observed}`);
337
+ }
338
+ }
339
+
340
+ async function composeImageText(input: { operation: '上传图文'; images: string[] }, deps: ModeRunDeps): Promise<void> {
341
+ await enterMode(deps, input.operation);
342
+ const fileSelector = await findAttachedSelector(
343
+ deps.page,
344
+ IMAGE_FILE_INPUT,
345
+ 'image file input',
346
+ deps.stepTimeoutMs,
347
+ );
348
+ // One ordered call: the console keeps the batch in payload order, and that order is the post.
349
+ await deps.page.setInputFiles(fileSelector, input.images);
350
+ await confirmThumbnails(deps, input.images.length);
351
+ await applyNoteTitle(deps);
352
+ await typeNoteBody(deps, NOTE_BODY, 'note body');
353
+ }
354
+
355
+ async function composeVideo(input: { operation: '上传视频'; video: string }, deps: ModeRunDeps): Promise<void> {
356
+ await enterMode(deps, input.operation);
357
+ const fileSelector = await findAttachedSelector(
358
+ deps.page,
359
+ VIDEO_FILE_INPUT,
360
+ 'video file input',
361
+ deps.stepTimeoutMs,
362
+ );
363
+ await uploadFile(asPlaywrightPage(deps.page), fileSelector, input.video, { timeoutMs: deps.stepTimeoutMs });
364
+ // The note editor only exists once the video is attached (discovery §2).
365
+ await findSelector(deps.page, VIDEO_EDITOR, 'video note editor', deps.stepTimeoutMs);
366
+ // The video note is the same note composer as 图文 (`话题` / `用户` / `表情` / `关联`), so the
367
+ // title field is the shared note title. Not re-measured for the video tab in the walk.
368
+ await applyNoteTitle(deps);
369
+ await typeNoteBody(deps, NOTE_BODY, 'note body');
370
+ }
371
+
372
+ async function composeArticle(deps: ModeRunDeps): Promise<void> {
373
+ await enterMode(deps, '写长文');
374
+ const newArticle = await findAttachedSelector(deps.page, ARTICLE_NEW, '新的创作 control', deps.stepTimeoutMs);
375
+ await deps.page.locator(newArticle).first().click();
376
+ const editor = await findSelector(deps.page, ARTICLE_EDITOR, 'long-article editor', deps.stepTimeoutMs);
377
+ // Authored IN PLACE: the raw markdown is the input (no 导入链接 — that is fed from a 公众号
378
+ // source), and the insertion is a real paste through the shared E91-D4 helper. It is pasted as
379
+ // rendered HTML because ProseMirror would otherwise keep the markdown syntax as literal text;
380
+ // `一键排版` is what then formats the pasted content for the platform.
381
+ await pasteHtml(asPlaywrightPage(deps.page), editor, markdownToHtml(deps.content.body), {
382
+ timeoutMs: deps.stepTimeoutMs,
383
+ });
384
+ // 一键排版 applies the layout DIRECTLY on xhs. Measured live (dogfood 2026-10-05): a
385
+ // hit-test-verified click produced no option menu — the only new text was `自动保存于 …` — and a
386
+ // CDP-pierced scan found none either. The `保留源格式 / 匹配目标格式 / 仅保留文本` trio is WeChat
387
+ // MP's option set, not xhs's, and requiring it here failed a real draft run. So: click it when
388
+ // the control exists, and treat its absence as a non-event — the layout step is an enhancement,
389
+ // never a gate on saving.
390
+ try {
391
+ const layout = await findSelector(deps.page, ARTICLE_LAYOUT, '一键排版 control', deps.stepTimeoutMs);
392
+ await deps.page.locator(layout).first().click();
393
+ } catch {
394
+ // No layout control on this editor build — the draft still saves.
395
+ }
396
+ }
397
+
398
+ async function isPodcastModalOpen(page: XhsPage): Promise<boolean> {
399
+ return trySelectors(page, PODCAST_MODAL)
400
+ .then((found) => found.found)
401
+ .catch(() => false);
402
+ }
403
+
404
+ /**
405
+ * Leave the 发播客 modal. The recorded advance control is `去发布`, which is publish-adjacent, so a
406
+ * draft run never clicks it: it waits for the platform to move on by itself and fails loud naming
407
+ * the control it refused to click. Only an explicit `publishStatus: 'public'` may click it.
408
+ */
409
+ async function leavePodcastModal(deps: ModeRunDeps): Promise<void> {
410
+ const closed = () => isPodcastModalOpen(deps.page).then((open) => !open);
411
+ if (await waitUntil(closed, deps.stepTimeoutMs)) return;
412
+ if (deps.publishStatus === 'public') {
413
+ const advance = await clickLightControl(
414
+ deps.page,
415
+ PODCAST_MODAL_ADVANCE,
416
+ deps.stepTimeoutMs,
417
+ 'podcast modal advance',
418
+ );
419
+ if (!advance.ok) throw new Error(advance.error);
420
+ if (await waitUntil(closed, deps.stepTimeoutMs)) return;
421
+ }
422
+ throw new Error(
423
+ `podcast modal: the 发播客 modal was still open ${deps.stepTimeoutMs}ms after the audio and cover were attached; the only recorded advance control is '${PODCAST_MODAL_ADVANCE[0]}' and a draft run does not click it (it is publish-adjacent)`,
424
+ );
425
+ }
426
+
427
+ async function composePodcast(
428
+ input: { operation: '发播客'; audio: string; cover: string },
429
+ deps: ModeRunDeps,
430
+ ): Promise<void> {
431
+ await enterMode(deps, input.operation);
432
+ const open = await findAttachedSelector(deps.page, PODCAST_OPEN, '上传音频 control', deps.stepTimeoutMs);
433
+ await deps.page.locator(open).first().click();
434
+ await findSelector(deps.page, PODCAST_MODAL, 'podcast modal', deps.stepTimeoutMs);
435
+ const audioInput = await findAttachedSelector(
436
+ deps.page,
437
+ PODCAST_AUDIO_INPUT,
438
+ 'podcast audio input',
439
+ deps.stepTimeoutMs,
440
+ );
441
+ await uploadFile(asPlaywrightPage(deps.page), audioInput, input.audio, { timeoutMs: deps.stepTimeoutMs });
442
+ const coverInput = await findAttachedSelector(
443
+ deps.page,
444
+ PODCAST_COVER_INPUT,
445
+ 'podcast cover input',
446
+ deps.stepTimeoutMs,
447
+ );
448
+ await uploadFile(asPlaywrightPage(deps.page), coverInput, input.cover, { timeoutMs: deps.stepTimeoutMs });
449
+ await leavePodcastModal(deps);
450
+ }
451
+
452
+ /** Compose the requested mode's post. Exhaustive over {@link ModeInput}. */
453
+ async function compose(input: ModeInput, deps: ModeRunDeps): Promise<void> {
454
+ switch (input.operation) {
455
+ case '上传图文':
456
+ await composeImageText(input, deps);
457
+ return;
458
+ case '上传视频':
459
+ await composeVideo(input, deps);
460
+ return;
461
+ case '写长文':
462
+ await composeArticle(deps);
463
+ return;
464
+ case '发播客':
465
+ await composePodcast(input, deps);
466
+ return;
467
+ }
468
+ }
469
+
470
+ function baseMetadata(input: ModeInput, deps: ModeRunDeps): Record<string, unknown> {
471
+ return {
472
+ operation: input.operation,
473
+ driver: deps.driver,
474
+ publishStatus: deps.publishStatus,
475
+ profileDir: deps.profileDir,
476
+ artifactPaths: artifactPaths(input),
477
+ ...(input.operation === '上传图文' ? { imageCount: input.images.length } : {}),
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Save the post as a draft: settle, click this mode's 暂存离开, then require an OBSERVED signal.
483
+ * The click is retried (the console drops one that lands while the note is still settling) but the
484
+ * signal is never assumed: an unobserved save is `ok:false` naming the label and the deadline.
485
+ */
486
+ async function saveDraft(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
487
+ let clicked: ActionClick | null = null;
488
+ let signal: string | null = null;
489
+ let attempts = 0;
490
+ while (signal === null && attempts < deps.submitAttempts) {
491
+ attempts += 1;
492
+ await sleep(deps.submitSettleMs);
493
+ const control = await clickActionControl(input.operation, deps, DRAFT_LABELS, 'draft control');
494
+ if (!control.ok) return fail(control.error);
495
+ clicked = control;
496
+ const outcome = await expectOutcome(() => draftSignalEvidence(deps.page, deps.draftBaseline), {
497
+ timeoutMs: deps.submitSettleMs,
498
+ label: `draft confirmation '${control.label}'`,
499
+ pollMs: POLL_MS,
500
+ });
501
+ if (outcome.ok) signal = outcome.evidence;
502
+ }
503
+ const draftCountAfter = await readDraftCount(deps.page);
504
+ if (signal === null || clicked === null) {
505
+ return fail(
506
+ `draft confirmation: '${clicked?.label ?? DRAFT_LABELS[0]}' was clicked ${attempts}× but neither a fresh ${TARGET} save timestamp, a moved draft counter, nor a scoped '${DRAFT_TOASTS[0]}' toast was observed within ${deps.submitSettleMs}ms (before=${String(deps.draftBaseline.count)}, after=${String(draftCountAfter)})`,
507
+ );
508
+ }
509
+ return ResultSchema.parse({
510
+ ok: true,
511
+ target: TARGET,
512
+ message: `${TARGET} draft saved (unpublished) in profile ${deps.profileDir} via '${clicked.label}' (${input.operation})`,
513
+ metadata: {
514
+ ...baseMetadata(input, deps),
515
+ draftControl: clicked.label,
516
+ draftClick: clicked.click,
517
+ draftPath: clicked.path,
518
+ draftAttempts: attempts,
519
+ draftSignal: signal,
520
+ draftCountBefore: deps.draftBaseline.count,
521
+ draftCountAfter,
522
+ },
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Publish the post. Reached only for an explicit `publishStatus: 'public'` — the transport refuses
528
+ * every other value before a browser opens, so no other path can click a publish control. ONE
529
+ * click, no retry: an unobserved publish must never be repeated, and the console drops a click that
530
+ * lands while the note is still settling.
531
+ */
532
+ async function publishNote(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
533
+ await sleep(deps.submitSettleMs);
534
+ const submitted = await clickActionControl(input.operation, deps, PUBLISH_LABELS, 'publish control');
535
+ if (!submitted.ok) return fail(submitted.error);
536
+
537
+ const urlBeforeSubmit = deps.page.url();
538
+ const confirmed = await waitUntil(async () => {
539
+ // The console rewrites its URL to `…published=true` once the post is accepted; the toast is
540
+ // the fallback, read scoped (never from the document at large).
541
+ if (deps.page.url().includes(PUBLISHED_MARKER)) return true;
542
+ return (await readScopedSignal(asPlaywrightPage(deps.page), PUBLISH_CONFIRM)) !== null;
543
+ }, deps.stepTimeoutMs);
544
+ if (!confirmed) {
545
+ return fail(
546
+ `publish submission: state unknown — the '${submitted.label}' click (${submitted.click}) was sent from ${urlBeforeSubmit} but no success state was observed (now ${deps.page.url()}). Check the ${TARGET} creator note manager before retrying: a retry could duplicate the post.`,
547
+ );
548
+ }
549
+ const url = deps.page.url();
550
+ return ResultSchema.parse({
551
+ ok: true,
552
+ target: TARGET,
553
+ ...(url !== PUBLISH_URL ? { url } : {}),
554
+ message: `${TARGET} ${input.operation} post published`,
555
+ metadata: {
556
+ ...baseMetadata(input, deps),
557
+ publishControl: submitted.label,
558
+ publishPath: submitted.path,
559
+ publishClick: submitted.click,
560
+ publishSignal: url.includes(PUBLISHED_MARKER) ? `url:${PUBLISHED_MARKER}` : 'toast',
561
+ },
562
+ });
563
+ }
564
+
565
+ /**
566
+ * Run one validated operation end to end: compose the post, then take it to a draft or (only on an
567
+ * explicit live request) publish it.
568
+ */
569
+ export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
570
+ await compose(input, deps);
571
+ return deps.publishStatus === 'public' ? publishNote(input, deps) : saveDraft(input, deps);
572
+ }