@gobing-ai/knowledge-kit 0.0.31 → 0.0.32

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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +10 -10
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +9 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +12 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +12 -12
  7. package/plugins/generations/dailynews-gen/dist/index.js +9 -9
  8. package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
  9. package/plugins/generations/gemini-tts/dist/index.js +9 -9
  10. package/plugins/generations/image-gen/dist/index.js +9 -9
  11. package/plugins/generations/news-report-gen/dist/index.js +12 -12
  12. package/plugins/generations/voice-gen/dist/index.js +9 -9
  13. package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
  14. package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
  15. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  16. package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
  17. package/plugins/ingestions/web-search/dist/index.js +10 -10
  18. package/plugins/kk/plugin.json +1 -1
  19. package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
  20. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  21. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  22. package/plugins/kk/skills/content-safety/SKILL.md +24 -13
  23. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
  24. package/plugins/publishings/emdash-pub/dist/index.js +9 -9
  25. package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
  26. package/plugins/publishings/infoq-pub/package.json +3 -1
  27. package/plugins/publishings/infoq-pub/plugin.json +1 -1
  28. package/plugins/publishings/infoq-pub/src/index.ts +246 -29
  29. package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
  30. package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
  31. package/plugins/publishings/juejin-pub/package.json +3 -1
  32. package/plugins/publishings/juejin-pub/plugin.json +1 -1
  33. package/plugins/publishings/juejin-pub/src/index.ts +305 -29
  34. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  35. package/plugins/publishings/medium-pub/dist/index.js +9 -9
  36. package/plugins/publishings/podcast-pub/dist/index.js +9 -9
  37. package/plugins/publishings/qiita-pub/dist/index.js +9 -9
  38. package/plugins/publishings/substack-pub/dist/index.js +8610 -211
  39. package/plugins/publishings/substack-pub/package.json +3 -1
  40. package/plugins/publishings/substack-pub/plugin.json +1 -1
  41. package/plugins/publishings/substack-pub/src/index.ts +271 -29
  42. package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
  43. package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
  44. package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
  45. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  46. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  47. package/plugins/publishings/wechatmp-pub/src/index.ts +186 -516
  48. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  49. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  50. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -5
  51. package/plugins/publishings/x-pub/dist/index.js +8626 -221
  52. package/plugins/publishings/x-pub/package.json +3 -1
  53. package/plugins/publishings/x-pub/plugin.json +1 -1
  54. package/plugins/publishings/x-pub/src/index.ts +258 -20
  55. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  56. package/plugins/publishings/xhs-pub/dist/index.js +843 -346
  57. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  58. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  59. package/plugins/publishings/xhs-pub/src/index.ts +101 -593
  60. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  61. package/plugins/publishings/xhs-pub/src/page.ts +267 -0
  62. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
  63. package/plugins/publishings/zenn-pub/dist/index.js +9 -9
@@ -6,172 +6,64 @@ import {
6
6
  type PublishTransportPayload,
7
7
  resolveBrowserProfileDir,
8
8
  } from '@gobing-ai/publish-harness';
9
+ import { waitUntil } from '@gobing-ai/publish-harness/browser';
9
10
  import { BrowserProfileMissingError, openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
10
11
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
11
12
  import { echoError } from '@gobing-ai/ts-utils';
12
13
  import type { BrowserContext, Cookie, Page } from 'playwright';
14
+ import { markdownToWechatHtml, readDraftBaseline, runMode } from './flow';
15
+ import {
16
+ CONSOLE_HOME_FRAGMENT,
17
+ CONSOLE_URL,
18
+ readConsoleToken,
19
+ resolveWechatmpOperation,
20
+ TARGET,
21
+ validateModeInput,
22
+ WECHATMP_OPERATIONS,
23
+ } from './modes';
24
+ import { type OpenConsole, type OpenedConsole, POLL_MS, STEP_TIMEOUT_MS, wrapPlaywrightContext } from './page';
13
25
 
14
- const TARGET = 'wechatmp';
15
- const CONSOLE_URL = 'https://mp.weixin.qq.com/';
26
+ /**
27
+ * The wechatmp publish CHANNEL (ADR-029, ADR-026): one plugin, four mode `operations`.
28
+ *
29
+ * This module owns what is channel-level — the profile dir, the session preflight (ADR-026: the
30
+ * preflight plus the draft default ARE the automation guard, so no ADR-025 cap is ported), the
31
+ * ADR-029 operation dispatch and pre-browser validation, the browser launch seam and the
32
+ * `Content → Result` mapping. The modes themselves (entry, artifacts, selectors, save/publish flow)
33
+ * live in `./modes`, `./flow` and `./enrich`.
34
+ */
16
35
 
17
- /** Proven console selectors (ported from the wt-publish-to-wechatmp Playwright flow). */
18
- const SEL = {
19
- // Console home: an authenticated session lands on /cgi-bin/home; logged-out shows the QR scan page.
20
- loggedInFragment: '/cgi-bin/home',
21
- menuItem: '.new-creation__menu .new-creation__menu-item',
22
- // The 2024+ console editor renders title/body as two ProseMirror instances; the legacy
23
- // textarea#title is a hidden mirror. The title PM carries a 标题 placeholder — the only
24
- // reliable discriminator between the two editors.
25
- titleInput: '.ProseMirror[data-placeholder*="标题"]',
26
- authorInput: '#author',
27
- editor: '.ProseMirror:not([data-placeholder])',
28
- summaryInput: '#js_description',
29
- saveDraftBtn: 'button:has-text("保存为草稿")',
30
- originalSwitch: '.setting-group__switch.js_edit_ori',
31
- tagRow: '.allow_click_opr.js_article_tags_label',
32
- tagInput: 'input[placeholder*="合集"]',
33
- claimSourceCheckbox: '#js_claim_source_area .frm_checkbox_label',
34
- coverBtn: '.js_cover_btn_area',
35
- publishBtn: '.js_publish, button:has-text("发表"), button:has-text("发布")',
36
- confirmBtn: '.weui-desktop-dialog .weui-desktop-btn_primary, button:has-text("确定")',
37
- toast: '.weui-desktop-toast',
38
- } as const;
36
+ export const CHANNEL = TARGET;
37
+ export type { ModeInput, WechatmpOperation } from './modes';
38
+ export type { OpenConsole, OpenedConsole, WechatLocator, WechatPage } from './page';
39
+ export { wrapPlaywrightPage } from './page';
40
+ export { CONSOLE_URL, markdownToWechatHtml, runMode, WECHATMP_OPERATIONS };
39
41
 
42
+ /** Console readiness deadline: the headless-visit scale, not the 180s interactive login wait. */
40
43
  const CONSOLE_READY_TIMEOUT_MS = 30_000;
41
- const TOAST_TIMEOUT_MS = 15_000;
42
-
43
- function fail(message: string): never {
44
- throw new Error(message);
44
+ /** Settle before the save click: the editor must have registered the paste before it can be saved. */
45
+ const SUBMIT_SETTLE_MS = 1_500;
46
+ /** The observed-draft-signal window after a save click (the pre-ADR-029 toast deadline was 15s). */
47
+ const DRAFT_SIGNAL_TIMEOUT_MS = 15_000;
48
+ /** The save click is retried — the console can drop one that lands while the editor is settling. */
49
+ const SUBMIT_ATTEMPTS = 2;
50
+
51
+ function fail(error: string): Result {
52
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
45
53
  }
46
54
 
47
- function escapeHtml(text: string): string {
48
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
55
+ /** Console home readiness: an authenticated route carries the session token (discovery §1/§9-W9). */
56
+ export function isConsoleReady(page: { url(): string }): boolean {
57
+ return readConsoleToken(page.url()) !== undefined || page.url().includes(CONSOLE_HOME_FRAGMENT);
49
58
  }
50
59
 
51
60
  /**
52
- * Harvested subset of wt md-to-wechat: headings, paragraphs, fenced code, lists, inline.
53
- * TypeScript only — no Python, no theme CLI.
61
+ * The recorded login signal for this channel (ADR-026): an authenticated session lands on
62
+ * `/cgi-bin/home`. Body text is unusable — `登录` also appears on a live console, which
63
+ * false-negatived a successful login (W9) — so the URL is the only safe read.
54
64
  */
55
- export function markdownToWechatHtml(markdown: string): string {
56
- const lines = markdown.replace(/\r\n/g, '\n').split('\n');
57
- const out: string[] = [];
58
- let inCode = false;
59
- let code: string[] = [];
60
- let listType: 'ul' | 'ol' | null = null;
61
-
62
- const closeList = (): void => {
63
- if (listType) {
64
- out.push(`</${listType}>`);
65
- listType = null;
66
- }
67
- };
68
-
69
- const inline = (text: string): string =>
70
- escapeHtml(text)
71
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
72
- .replace(/\*([^*]+)\*/g, '<em>$1</em>')
73
- .replace(/`([^`]+)`/g, '<code>$1</code>')
74
- // `href` is already HTML-escaped (inline() escapes before replacing), so a `"` can no
75
- // longer close the attribute. The scheme allowlist is still required: escaping does not
76
- // neutralise `javascript:`. Non-http(s) links degrade to their label text.
77
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text: string, href: string) =>
78
- /^https?:\/\//i.test(href) ? `<a href="${href}">${text}</a>` : text,
79
- );
80
-
81
- for (const line of lines) {
82
- if (line.startsWith('```')) {
83
- if (inCode) {
84
- out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
85
- code = [];
86
- inCode = false;
87
- } else {
88
- closeList();
89
- inCode = true;
90
- }
91
- continue;
92
- }
93
- if (inCode) {
94
- code.push(line);
95
- continue;
96
- }
97
- const heading = line.match(/^(#{1,6})\s+(.+)$/);
98
- if (heading?.[1] && heading[2]) {
99
- closeList();
100
- const level = Math.min(heading[1].length, 3);
101
- out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
102
- continue;
103
- }
104
- const ul = line.match(/^[-*]\s+(.+)$/);
105
- if (ul?.[1]) {
106
- if (listType !== 'ul') {
107
- closeList();
108
- listType = 'ul';
109
- out.push('<ul>');
110
- }
111
- out.push(`<li>${inline(ul[1])}</li>`);
112
- continue;
113
- }
114
- const ol = line.match(/^\d+\.\s+(.+)$/);
115
- if (ol?.[1]) {
116
- if (listType !== 'ol') {
117
- closeList();
118
- listType = 'ol';
119
- out.push('<ol>');
120
- }
121
- out.push(`<li>${inline(ol[1])}</li>`);
122
- continue;
123
- }
124
- closeList();
125
- if (line.trim().length === 0) continue;
126
- out.push(`<p>${inline(line.trim())}</p>`);
127
- }
128
- closeList();
129
- if (inCode) out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
130
- return out.join('');
131
- }
132
-
133
- function resolveTitle(content: Content): string {
134
- if (typeof content.title === 'string' && content.title.trim() !== '') return content.title.trim();
135
- return fail('wechatmp-pub: content.title is required');
136
- }
137
-
138
- function resolveDigest(content: Content): string | undefined {
139
- const body = typeof content.body === 'string' ? content.body : '';
140
- if (body.trim() === '') return undefined;
141
- const first = body
142
- .split('\n')
143
- .map((line) => line.trim())
144
- .find((line) => line !== '');
145
- return first ? first.slice(0, 54) : undefined;
146
- }
147
-
148
- // ---------------------------------------------------------------------------
149
- // Browser flow (ts-browser-automation; pattern mirrors xhs-pub)
150
- // ---------------------------------------------------------------------------
151
-
152
- export interface WechatPage {
153
- goto(url: string): Promise<void>;
154
- url(): string;
155
- fill(selector: string, value: string): Promise<void>;
156
- click(selector: string, timeoutMs?: number): Promise<void>;
157
- press?(key: string): Promise<void>;
158
- setInnerHtml(selector: string, html: string): Promise<void>;
159
- waitFor(selector: string, timeoutMs: number): Promise<void>;
160
- waitForAny(selectors: readonly string[], timeoutMs: number): Promise<string>;
161
- /** Resolves when the page URL contains the fragment (real browsers); absent on test fakes. */
162
- waitForUrlFragment?(fragment: string, timeoutMs: number): Promise<void>;
163
- setInputFiles?(selector: string, files: string): Promise<void>;
164
- waitForTimeout?(ms: number): Promise<void>;
165
- /** Evaluate a JS expression in the page (real browsers) - used for commit verification; absent on fakes. */
166
- evalJs?(expression: string): Promise<unknown>;
167
- /** Click and adopt the tab the console opens for the editor; absent on test fakes (same-page click). */
168
- clickAndAwaitNewPage?(selector: string): Promise<WechatPage>;
169
- /** Present on real browser pages; absent on test fakes. publish() closes it in finally. */
170
- close?(): Promise<void>;
171
- }
172
-
173
- export function isWechatLoggedIn(page: Page): boolean {
174
- return page.url().includes(SEL.loggedInFragment);
65
+ export function isWechatLoggedIn(page: { url(): string }): boolean {
66
+ return page.url().includes(CONSOLE_HOME_FRAGMENT);
175
67
  }
176
68
 
177
69
  export type SessionCheck = (profileDir: string) => Promise<void>;
@@ -193,81 +85,12 @@ export async function checkWechatSession(profileDir: string): Promise<void> {
193
85
  }
194
86
 
195
87
  async function pollLogin(page: Page): Promise<void> {
196
- const deadline = Date.now() + CONSOLE_READY_TIMEOUT_MS;
197
- while (!isWechatLoggedIn(page)) {
198
- if (Date.now() > deadline) {
199
- throw new Error(
200
- `Authentication was not confirmed within ${CONSOLE_READY_TIMEOUT_MS}ms — the saved session state has ` +
201
- 'likely expired: re-run `bun src/profile-check.ts login` to sign in again.',
202
- );
203
- }
204
- await new Promise((resolve) => setTimeout(resolve, 1000));
205
- }
206
- }
207
-
208
- export interface WechatmpTransportOptions {
209
- openPage?: (profileDir: string) => Promise<WechatPage>;
210
- checkSession?: SessionCheck;
211
- }
212
-
213
- export function wrapPlaywrightPage(page: Page, context?: BrowserContext): WechatPage {
214
- return {
215
- goto: (url) => page.goto(url, { waitUntil: 'domcontentloaded' }).then(() => undefined),
216
- url: () => page.url(),
217
- fill: (selector, value) => page.locator(selector).first().fill(value),
218
- click: (selector, timeoutMs) =>
219
- page
220
- .locator(selector)
221
- .first()
222
- .click(timeoutMs ? { timeout: timeoutMs } : undefined),
223
- setInnerHtml: (selector, html) =>
224
- page
225
- .locator(selector)
226
- .first()
227
- .evaluate((node, value) => {
228
- node.innerHTML = value;
229
- node.dispatchEvent(new Event('input', { bubbles: true }));
230
- }, html),
231
- waitFor: (selector, timeoutMs) =>
232
- page
233
- .locator(selector)
234
- .first()
235
- .waitFor({ state: 'visible', timeout: timeoutMs })
236
- .then(() => undefined),
237
- waitForAny: async (selectors, timeoutMs) => {
238
- const deadline = Date.now() + timeoutMs;
239
- while (Date.now() < deadline) {
240
- for (const selector of selectors) {
241
- try {
242
- await page.locator(selector).first().waitFor({ state: 'visible', timeout: 250 });
243
- return selector;
244
- } catch {
245
- // try the next candidate
246
- }
247
- }
248
- }
249
- return '';
250
- },
251
- waitForUrlFragment: (fragment, timeoutMs) =>
252
- page
253
- .waitForURL(new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: timeoutMs })
254
- .then(() => undefined),
255
- setInputFiles: (selector, files) => page.locator(selector).first().setInputFiles(files),
256
- waitForTimeout: (ms) => page.waitForTimeout(ms),
257
- evalJs: (expression) => page.evaluate(expression),
258
- press: (key) => page.keyboard.press(key),
259
- clickAndAwaitNewPage: context
260
- ? async (selector) => {
261
- const popup = context.waitForEvent('page', { timeout: 15000 }).catch(() => undefined);
262
- await page.locator(selector).first().click();
263
- const opened = await popup;
264
- if (!opened) return wrapPlaywrightPage(page, context);
265
- await opened.waitForLoadState('domcontentloaded').catch(() => {});
266
- return wrapPlaywrightPage(opened, context);
267
- }
268
- : undefined,
269
- close: context ? () => context.close() : undefined,
270
- };
88
+ const ready = await waitUntil(() => Promise.resolve(isConsoleReady(page)), CONSOLE_READY_TIMEOUT_MS, 1000);
89
+ if (ready) return;
90
+ throw new Error(
91
+ `Authentication was not confirmed within ${CONSOLE_READY_TIMEOUT_MS}ms — the saved session state has ` +
92
+ 'likely expired: re-run `bun src/profile-check.ts login` to sign in again.',
93
+ );
271
94
  }
272
95
 
273
96
  /** Launch, inject the saved login cookies, and land on the console. Caller owns closing the context. */
@@ -275,7 +98,9 @@ async function openWithState(profileDir: string, headless: boolean): Promise<{ p
275
98
  const context = await openPersistentBrowser({ profileDir, headless });
276
99
  try {
277
100
  const stateFile = storageStatePath(profileDir);
278
- let state: { cookies?: Cookie[] } | undefined;
101
+ let state:
102
+ | { cookies?: Cookie[]; origins?: { origin?: string; localStorage?: { name: string; value: string }[] }[] }
103
+ | undefined;
279
104
  try {
280
105
  state = JSON.parse(await createNodeFileSystem().readFile(stateFile));
281
106
  } catch {
@@ -289,6 +114,23 @@ async function openWithState(profileDir: string, headless: boolean): Promise<{ p
289
114
  }
290
115
  await context.addCookies(cookies);
291
116
  const page = context.pages()[0] ?? (await context.newPage());
117
+ // `addCookies` alone is NOT the session: the captured state also carries localStorage for
118
+ // mp.weixin.qq.com (its `origins` half), and discarding it lands the console on its login page
119
+ // even though every cookie was accepted — observed live 2026-10-05, where `check` reported OK
120
+ // while the flow sat on a QR page. Replay each origin's storage before the console navigation.
121
+ const origins = state?.origins ?? [];
122
+ for (const entry of origins) {
123
+ const items = entry.localStorage ?? [];
124
+ if (entry.origin === undefined || entry.origin.length === 0 || items.length === 0) continue;
125
+ await page.goto(entry.origin).catch(() => undefined);
126
+ await page
127
+ .evaluate(
128
+ // A string expression, not a callback: this plugin's tsconfig has no DOM lib.
129
+ '(pairs) => { for (const pair of pairs) { window.localStorage.setItem(pair.name, pair.value); } }',
130
+ items,
131
+ )
132
+ .catch(() => undefined);
133
+ }
292
134
  await page.goto(CONSOLE_URL);
293
135
  return { page, context };
294
136
  } catch (error: unknown) {
@@ -297,37 +139,86 @@ async function openWithState(profileDir: string, headless: boolean): Promise<{ p
297
139
  }
298
140
  }
299
141
 
300
- async function defaultOpenPage(profileDir: string): Promise<WechatPage> {
301
- // Headless works end-to-end for the draft flow (probed 2026-09-29); no window is popped.
142
+ /**
143
+ * Default launcher: the console home wrapped as the `OpenConsole` seam (entry page + the sibling
144
+ * pages a creation-type click opens). Headless works end-to-end for the draft flow (probed
145
+ * 2026-09-29), so no window is popped — the preflight is what keeps a sessionless run from even
146
+ * trying.
147
+ */
148
+ async function defaultOpenConsole(profileDir: string): Promise<OpenedConsole> {
302
149
  const { page, context } = await openWithState(profileDir, true);
303
- return wrapPlaywrightPage(page, context);
150
+ return wrapPlaywrightContext(context, page);
151
+ }
152
+
153
+ type BrowserDriverName = 'script' | 'agent';
154
+
155
+ export interface WechatmpTransportOptions {
156
+ /** ADR-029 dispatch: the console modes need the Playwright script driver. */
157
+ driver?: BrowserDriverName;
158
+ openPage?: OpenConsole;
159
+ checkSession?: SessionCheck;
160
+ /** Deadline for the console to read ready after launch (default 30s). */
161
+ consoleReadyTimeoutMs?: number;
162
+ stepTimeoutMs?: number;
163
+ /** Re-poll interval for observed-signal waits (default 500ms); tests pass 0-5ms. */
164
+ pollMs?: number;
165
+ submitSettleMs?: number;
166
+ /** How long an observed draft signal is waited for after a save click (default 15s). */
167
+ draftTimeoutMs?: number;
168
+ submitAttempts?: number;
304
169
  }
305
170
 
306
171
  export class WechatmpBrowserTransport implements PublishTransport {
307
172
  readonly kind = 'browser';
308
- private readonly openPage: (profileDir: string) => Promise<WechatPage>;
173
+ readonly driver: BrowserDriverName;
174
+ private readonly openPage: OpenConsole;
309
175
  private readonly checkSession: SessionCheck;
176
+ private readonly consoleReadyTimeoutMs: number;
177
+ private readonly stepTimeoutMs: number;
178
+ private readonly pollMs: number;
179
+ private readonly submitSettleMs: number;
180
+ private readonly draftTimeoutMs: number;
181
+ private readonly submitAttempts: number;
310
182
 
311
183
  constructor(options: WechatmpTransportOptions = {}) {
312
- this.openPage = options.openPage ?? defaultOpenPage;
184
+ this.driver = options.driver ?? 'script';
185
+ this.openPage = options.openPage ?? defaultOpenConsole;
313
186
  this.checkSession = options.checkSession ?? ((profileDir) => checkWechatSession(profileDir));
187
+ this.consoleReadyTimeoutMs = options.consoleReadyTimeoutMs ?? CONSOLE_READY_TIMEOUT_MS;
188
+ this.stepTimeoutMs = options.stepTimeoutMs ?? STEP_TIMEOUT_MS;
189
+ this.pollMs = options.pollMs ?? POLL_MS;
190
+ this.submitSettleMs = options.submitSettleMs ?? SUBMIT_SETTLE_MS;
191
+ this.draftTimeoutMs = options.draftTimeoutMs ?? DRAFT_SIGNAL_TIMEOUT_MS;
192
+ this.submitAttempts = options.submitAttempts ?? SUBMIT_ATTEMPTS;
314
193
  }
315
194
 
195
+ /**
196
+ * Publish through the console. Everything decidable without a browser is decided first (driver,
197
+ * operation, artifacts, field limits, publishStatus, session), so an impossible or misrouted run
198
+ * never opens — let alone touches — a real console.
199
+ */
316
200
  async publish(payload: PublishTransportPayload): Promise<Result> {
317
201
  const { content, options } = payload;
318
- const want =
319
- options?.operation === 'live'
320
- ? 'public'
321
- : ((content.metadata?.publishStatus as string | undefined) ?? 'draft');
322
- if (want !== 'draft' && want !== 'public') {
323
- return this.failed(`publishStatus "${want}" is not draft|public`);
324
- }
325
- const title = resolveTitle(content);
326
- if (typeof content.body !== 'string' || content.body.trim() === '') {
327
- return this.failed('wechatmp-pub: content.body is required');
202
+ if (this.driver !== 'script') {
203
+ return fail(
204
+ `unsupported driver '${this.driver}' for ${TARGET}: the console modes (${WECHATMP_OPERATIONS.join(', ')}) require the script driver (Playwright setInputFiles)`,
205
+ );
328
206
  }
207
+ // ADR-029 dispatch: an undeclared operation is refused by name before a browser is launched
208
+ // or a profile is resolved, so a misrouted run cannot open a session it will not use.
209
+ const requested = resolveWechatmpOperation(options, content);
210
+ if (requested.error !== undefined) return fail(requested.error);
211
+
212
+ // Mode validation: a missing artifact (which the 视频/播客 media modal would otherwise hide
213
+ // the save bar for), an over-limit field or an unknown publishStatus all fail here.
214
+ const validation = await validateModeInput(requested.operation, content);
215
+ if (validation.error !== undefined) return fail(validation.error);
216
+
329
217
  const profileDir = resolveBrowserProfileDir(TARGET, process.env.WECHATMP_PROFILE_DIR);
218
+ const publishStatus = content.metadata?.publishStatus === 'public' ? 'public' : 'draft';
330
219
 
220
+ // ADR-026 rail: a headless preflight refuses a sessionless run fast, so no console visit is
221
+ // ever attempted with a dead session (and the draft default is this channel's guard).
331
222
  try {
332
223
  await this.checkSession(profileDir);
333
224
  } catch (error: unknown) {
@@ -335,296 +226,75 @@ export class WechatmpBrowserTransport implements PublishTransport {
335
226
  error instanceof BrowserProfileMissingError
336
227
  ? 'no saved console profile — log in once via `bun src/profile-check.ts login`'
337
228
  : `console session preflight failed (${error instanceof Error ? error.message : String(error)})`;
338
- // Non-blocking contract (mirrors xhs-pub): a sessionless run refuses fast and stays draftless —
339
- // it must never open a headed login wall inside an unattended run.
340
- return this.failed(`wechatmp-pub: ${why}`);
229
+ // Non-blocking contract (mirrors xhs-pub): a sessionless run refuses fast and stays
230
+ // draftless — it must never open a login wall inside an unattended run.
231
+ return fail(`${TARGET}-pub: ${why}`);
341
232
  }
342
233
 
343
- const page = await this.openPage(profileDir);
344
- let enrichWarnings: string[] = [];
234
+ let opened: OpenedConsole;
345
235
  try {
346
- await page.waitFor(SEL.menuItem, CONSOLE_READY_TIMEOUT_MS);
347
- // The console opens the article editor in a new tab — adopt it when it appears.
348
- const editor = page.clickAndAwaitNewPage
349
- ? await page.clickAndAwaitNewPage(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`)
350
- : page;
351
- if (editor === page) {
352
- await page.click(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`);
353
- }
354
- await editor.waitFor(SEL.titleInput, CONSOLE_READY_TIMEOUT_MS);
355
- await editor.fill(SEL.titleInput, title);
356
- await this.tryFill(editor, SEL.authorInput, String(content.metadata?.author ?? ''));
357
- await editor.waitFor(SEL.editor, CONSOLE_READY_TIMEOUT_MS);
358
- await editor.setInnerHtml(SEL.editor, markdownToWechatHtml(content.body));
359
- const digest = resolveDigest(content);
360
- if (digest) await this.tryFill(editor, SEL.summaryInput, digest);
361
-
362
- const enrichMeta = content.metadata?.wechatmp;
363
- if (enrichMeta && typeof enrichMeta === 'object' && !Array.isArray(enrichMeta)) {
364
- enrichWarnings = await this.enrichDraft(editor, enrichMeta as Record<string, unknown>);
365
- const audioTitle = (enrichMeta as Record<string, unknown>).audioTitle;
366
- if (typeof audioTitle === 'string' && audioTitle.trim() !== '') {
367
- const audioWarn = await this.insertAudio(editor, audioTitle.trim());
368
- if (audioWarn !== '') enrichWarnings.push(audioWarn);
369
- }
370
- }
371
-
372
- if (want === 'draft') {
373
- await editor.click(SEL.saveDraftBtn);
374
- } else {
375
- await editor.click(SEL.publishBtn);
376
- await this.tryClick(editor, SEL.confirmBtn);
377
- }
378
- // Real consoles confirm the draft save by navigating from the isNew editor
379
- // (appmsg_edit_v2) to the saved-draft edit page carrying the new appmsgid.
380
- if (editor.waitForUrlFragment) {
381
- await editor.waitForUrlFragment('appmsg_edit&action=edit', CONSOLE_READY_TIMEOUT_MS);
382
- } else {
383
- const seen = await editor.waitForAny([SEL.toast], TOAST_TIMEOUT_MS);
384
- if (seen === '') {
385
- return this.failed(
386
- `save was clicked but no confirmation toast appeared within ${TOAST_TIMEOUT_MS}ms — verify the console manually`,
387
- );
388
- }
389
- }
390
- const appmsgid = /appmsgid=(\d+)/.exec(editor.url())?.[1] ?? '';
391
- return {
392
- ok: true,
393
- target: TARGET,
394
- metadata: {
395
- publishStatus: want,
396
- note:
397
- want === 'draft'
398
- ? `saved to the WeChat MP draft box (草稿箱)${appmsgid ? ` as appmsgid ${appmsgid}` : ''}`
399
- : 'published via the console 发表 flow',
400
- ...(enrichWarnings.length > 0 ? { enrichWarnings } : {}),
401
- },
402
- };
236
+ opened = await this.openPage(profileDir);
403
237
  } catch (error: unknown) {
404
- const why = error instanceof Error ? error.message : String(error);
405
- const enrich = enrichWarnings.length > 0 ? ` (enrich warnings: ${enrichWarnings.join(' | ')})` : '';
406
- return this.failed(`console flow failed: ${why}${enrich}`);
407
- } finally {
408
- await page.close?.();
238
+ return fail(error instanceof Error ? error.message : String(error));
409
239
  }
410
- }
411
-
412
- /**
413
- * Set the operator's draft metadata (原创/作者/合集/创作来源/封面) in the v2 editor.
414
- * Runs on a FRESH draft only - the toggles are not state-aware (no idempotency for re-runs).
415
- * Per-field failures are collected into warnings and surfaced in the Result; the draft still saves.
416
- * Dialog mapping evidence (probes 2026-09-29, drafts 100000805/808): 原创 + 赞赏 share ONE dialog
417
- * (声明类型 radios, 作者 input, 确定) - the 赞赏 switch stays hidden for this account, so 赞赏 is a manual step;
418
- * 合集 dialog = 请选择合集 input + 确认; 创作来源 is an inline checkbox (no dialog); the 原文链接 row has
419
- * no reachable URL input in headless - also manual; cover accepts a direct input[type=file] set.
420
- * Commit verification (evalJs): the original dialog only closes when WeChat's validation passes - the
421
- * author is the reward (shang) account and must be picked from its search, which does not resolve for
422
- * this account headless; a non-closing dialog is swept and surfaced as a warning. Same for the
423
- * creation-source checkbox (DOM re-check).
424
- */
425
- private async enrichDraft(editor: WechatPage, meta: Record<string, unknown>): Promise<string[]> {
426
- const DIALOG = '.weui-desktop-dialog:visible';
427
- const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
428
- const warnings: string[] = [];
429
- const str = (key: string) => (typeof meta[key] === 'string' ? (meta[key] as string) : '');
430
- const on = (key: string) => meta[key] === true;
431
- // Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
432
- const sweep = async () => {
433
- await this.tryClick(editor, 'button:has-text("取消")', 2000);
434
- await editor.press?.('Escape');
435
- await editor.waitForTimeout?.(400);
436
- };
437
- // One retry per step: the settings panel re-renders after dialog confirms and can briefly
438
- // detach rows (observed on the tags row right after the original-declaration confirm).
439
- const step = async (name: string, fn: () => Promise<void>) => {
440
- try {
441
- await fn();
442
- return;
443
- } catch {
444
- // retry once below
445
- }
446
- await sweep();
447
- await editor.waitForTimeout?.(1000);
448
- try {
449
- await fn();
450
- } catch (error) {
451
- warnings.push(`${name}: ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`);
452
- await sweep();
453
- }
454
- };
455
- if (on('original')) {
456
- await step('原创声明', async () => {
457
- await editor.click(SEL.originalSwitch, 10000);
458
- await editor.click(`${DIALOG} :text("文字原创")`, 8000);
459
- const author = str('author');
460
- if (author) await this.tryFill(editor, `${DIALOG} input[placeholder*="作者"]`, author);
461
- await editor.click(CONFIRM, 8000);
462
- });
463
- const committed = await editor.evalJs?.(
464
- `[...document.querySelectorAll('.weui-desktop-dialog')].every((d) => !d.offsetWidth)`,
240
+ try {
241
+ const ready = await waitUntil(
242
+ () => Promise.resolve(isConsoleReady(opened.page)),
243
+ this.consoleReadyTimeoutMs,
465
244
  );
466
- if (committed === false) {
467
- warnings.push('原创声明: 未生效 - 作者须为赞赏账户(搜索选择),请手动设置');
468
- await sweep();
469
- }
470
- }
471
- const collection = str('collection');
472
- if (collection) {
473
- await step('合集', async () => {
474
- await editor.click(SEL.tagRow, 8000);
475
- await editor.waitFor(SEL.tagInput, 8000);
476
- await editor.fill(SEL.tagInput, collection);
477
- await editor.waitForTimeout?.(1200);
478
- await editor.click(`${DIALOG} :text("${collection}")`, 8000);
479
- await editor.click(CONFIRM, 8000);
480
- });
481
- }
482
- if (on('aiGenerated')) {
483
- await step('创作来源', async () => {
484
- await editor.click(SEL.claimSourceCheckbox, 8000);
485
- // Probes show no dialog on current builds — the checkbox alone declares AI generation.
486
- const dialog = await editor.waitForAny([DIALOG], 3000);
487
- if (dialog) {
488
- await editor.click(`${DIALOG} :text("内容由AI生成")`, 8000);
489
- await editor.click(CONFIRM, 8000);
490
- }
491
- });
492
- const checked = await editor.evalJs?.(`!!document.querySelector('#js_claim_source_area input:checked')`);
493
- if (checked === false) warnings.push('创作来源: 勾选未生效,请手动设置');
494
- }
495
- const coverPath = str('coverPath');
496
- const putFile = editor.setInputFiles?.bind(editor);
497
- if (coverPath && putFile) {
498
- await step('封面', async () => {
499
- try {
500
- await putFile('input[type="file"]', coverPath);
501
- } catch {
502
- await editor.click(SEL.coverBtn, 8000);
503
- await editor.waitForTimeout?.(1500);
504
- await putFile('input[type="file"]', coverPath);
505
- }
506
- const cropBtn = await editor.waitForAny(
507
- [`${DIALOG} button:has-text("完成")`, `${DIALOG} button:has-text("确定")`],
508
- 5000,
245
+ if (!ready) {
246
+ return fail(
247
+ `no authenticated ${TARGET} console within ${this.consoleReadyTimeoutMs}ms — the preflight passed but the console did not report a session route (profile ${profileDir})`,
509
248
  );
510
- if (cropBtn) await editor.click(cropBtn, 8000);
511
- });
512
- }
513
- await sweep();
514
- return warnings;
515
- }
516
-
517
- /**
518
- * Audio embed at the article top (operator request 2026-09-29): open the editor's audio menu
519
- * (a hover dropdown - JS click bypasses the hover gate), reuse an existing same-title 素材库
520
- * material, and insert at the caret (placed at the article start first). Upload of a missing
521
- * material is deliberately manual for now: the 上传音频 form is iframe-based and unmapped.
522
- * Fail-soft: every non-committed outcome returns a warning string; '' means placed at top.
523
- */
524
- private async insertAudio(editor: WechatPage, title: string): Promise<string> {
525
- const evalJs = editor.evalJs;
526
- if (typeof evalJs !== 'function') return '';
527
- const CARET = `(() => {
528
- const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
529
- if (!pm || !pm.firstChild) return;
530
- const r = document.createRange();
531
- r.setStart(pm.firstChild, 0);
532
- r.collapse(true);
533
- const sel = window.getSelection();
534
- sel?.removeAllRanges();
535
- sel?.addRange(r);
536
- })()`;
537
- const DEDUPE = `(() => {
538
- const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
539
- const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
540
- .find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
541
- if (!hit) return 'missing';
542
- const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
543
- if (!cb) return 'nobox';
544
- cb.click();
545
- return 'checked';
546
- })()`;
547
- const INSERT = `(() => {
548
- const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
549
- if (!btn) return false;
550
- btn.click();
551
- return true;
552
- })()`;
553
- const CANCEL = `(() => {
554
- const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
555
- btn?.click();
556
- })()`;
557
- const VERIFY = `(() => {
558
- const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
559
- if (!pm) return 'noeditor';
560
- const node = pm.querySelector('mpvoice, [class*="audio"]');
561
- if (!node) return 'absent';
562
- const first = pm.firstElementChild;
563
- return first && (first === node || first.contains(node)) ? 'top' : 'notop';
564
- })()`;
565
- try {
566
- const run = (expression: string) => evalJs.call(editor, expression);
567
- await run(CARET);
568
- await run("document.querySelector('li.js_insertaudio')?.click()");
569
- await editor.waitFor('.audio_dialog', TOAST_TIMEOUT_MS);
570
- // The 素材库 list loads async after the dialog opens - retry while rows stream in.
571
- let dedupe = String(await run(DEDUPE));
572
- for (let i = 0; dedupe === 'missing' && i < 2; i++) {
573
- await editor.waitForTimeout?.(1200);
574
- dedupe = String(await run(DEDUPE));
575
- }
576
- if (dedupe !== 'checked') {
577
- await run(CANCEL);
578
- return dedupe === 'missing'
579
- ? '音频: 素材库无同名音频,请手动上传后插入'
580
- : '音频: 同名素材行未找到勾选框,请手动插入';
581
- }
582
- if ((await run(INSERT)) !== true) {
583
- await run(CANCEL);
584
- return '音频: 插入按钮未找到,请手动插入';
585
249
  }
586
- await editor.waitForTimeout?.(2000);
587
- const state = String(await run(VERIFY));
588
- if (state === 'absent' || state === 'noeditor') return '音频: 插入未生效,请手动检查';
589
- if (state === 'notop') return '音频: 已插入但未在文首,请手动调整位置';
590
- return '';
591
- } catch {
592
- return '音频: 自动插入失败,请手动插入';
593
- }
594
- }
595
-
596
- private async tryFill(page: WechatPage, selector: string, value: string): Promise<void> {
597
- if (value === '') return;
598
- try {
599
- await page.fill(selector, value);
600
- } catch {
601
- // optional field — the console hides it for some account types
602
- }
603
- }
604
-
605
- private async tryClick(page: WechatPage, selector: string, timeoutMs?: number): Promise<void> {
606
- try {
607
- await page.click(selector, timeoutMs);
608
- } catch {
609
- // confirm dialogs are not shown for every account type
250
+ // The 草稿箱 baseline is read before the editor opens: MP auto-saves continuously, so a
251
+ // row that already carried a stamp says nothing about this run's save (discovery §5.1).
252
+ const title = typeof content.title === 'string' ? content.title.trim() : '';
253
+ const draftBaseline =
254
+ publishStatus === 'draft' ? await readDraftBaseline(opened.page, title) : { stamp: null };
255
+
256
+ return await runMode(validation.input, {
257
+ entry: opened.page,
258
+ pages: opened.pages,
259
+ newPage: opened.newPage,
260
+ content,
261
+ publishStatus,
262
+ profileDir,
263
+ driver: this.driver,
264
+ stepTimeoutMs: this.stepTimeoutMs,
265
+ pollMs: this.pollMs,
266
+ submitSettleMs: this.submitSettleMs,
267
+ draftTimeoutMs: this.draftTimeoutMs,
268
+ submitAttempts: this.submitAttempts,
269
+ draftBaseline,
270
+ });
271
+ } catch (error: unknown) {
272
+ return fail(error instanceof Error ? error.message : String(error));
273
+ } finally {
274
+ await opened.close();
610
275
  }
611
276
  }
612
-
613
- private failed(error: string): Result {
614
- return { ok: false, target: TARGET, error, metadata: { publishStatus: 'draft' } };
615
- }
616
277
  }
617
278
 
618
- export function getPublishTransport(): PublishTransport {
279
+ export function getPublishTransport(driver: BrowserDriverName = 'script'): PublishTransport {
619
280
  if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeFileCliTransport();
620
- return new WechatmpBrowserTransport();
281
+ return new WechatmpBrowserTransport({ driver });
621
282
  }
622
283
 
284
+ /**
285
+ * `--in`/`--out` IO. A top-level `options` object in the input file is forwarded, which is how a
286
+ * caller selects the ADR-029 operation (`options.operation`) without touching `Content`.
287
+ */
623
288
  export async function processPublishIO(inPath: string, outPath: string, transport?: PublishTransport): Promise<void> {
624
289
  const fs = createNodeFileSystem();
625
- const raw = JSON.parse(await fs.readFile(inPath));
626
- const content = ContentSchema.parse(raw);
627
- const result = await (transport ?? getPublishTransport()).publish({ content, options: { target: TARGET } });
290
+ const raw = JSON.parse(await fs.readFile(inPath)) as Record<string, unknown>;
291
+ const content: Content = ContentSchema.parse(raw);
292
+ const provided = raw.options;
293
+ const options =
294
+ provided !== undefined && typeof provided === 'object' && provided !== null && !Array.isArray(provided)
295
+ ? { target: TARGET, ...(provided as Record<string, unknown>) }
296
+ : { target: TARGET };
297
+ const result = await (transport ?? getPublishTransport()).publish({ content, options });
628
298
  await fs.writeFile(outPath, JSON.stringify(ResultSchema.parse(result), null, 2));
629
299
  }
630
300