@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
@@ -1,313 +1,95 @@
1
- import { dirname, isAbsolute } from 'node:path';
1
+ import { dirname } from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
4
4
  import {
5
5
  FakeBrowserTransport,
6
6
  type PublishTransport,
7
7
  type PublishTransportPayload,
8
- resolveBrowserProfileDir,
9
8
  trySelectors,
10
9
  } from '@gobing-ai/publish-harness';
11
- import { type BrowserLauncher, openPersistentBrowser, withProfilePage } from '@gobing-ai/ts-browser-automation';
10
+ import {
11
+ checkChannelSession,
12
+ createShadowFinder,
13
+ resolveChannelProfileDir,
14
+ type ShadowCdpSession,
15
+ } from '@gobing-ai/publish-harness/browser';
16
+ import { type BrowserLauncher, openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
12
17
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
13
18
  import { echoError } from '@gobing-ai/ts-utils';
14
19
  import { type AutomationGuard, createAutomationGuard } from './automation-guard';
20
+ import { readDraftBaseline, runMode } from './flow';
21
+ import { type ModeInput, PUBLISH_URL, resolveXhsOperation, TARGET, validateModeInput, XHS_OPERATIONS } from './modes';
22
+ import {
23
+ type OpenedPage,
24
+ type OpenPage,
25
+ type ShadowProbe,
26
+ STEP_TIMEOUT_MS,
27
+ waitUntil,
28
+ wrapPlaywrightPage,
29
+ type XhsPage,
30
+ } from './page';
31
+
32
+ /**
33
+ * The xhs publish CHANNEL (ADR-029): one plugin, four mode `operations`.
34
+ *
35
+ * This module owns what is channel-level — the profile dir, the session preflight and its
36
+ * ADR-025 automation guard, the ADR-029 operation dispatch and pre-browser validation, the
37
+ * browser launch seam and the `Content → Result` mapping. The modes themselves (entry, artifacts,
38
+ * selectors, save/publish flow) live in `./modes` and `./flow`.
39
+ */
15
40
 
16
41
  export const CHANNEL = 'xhs';
17
- const TARGET = 'xhs';
18
- export const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish';
19
- /** The console marks a completed publish in its own URL (measured live 2026-09-26). */
20
- const PUBLISHED_MARKER = 'published=true';
42
+ export { PUBLISH_URL };
21
43
  export const LOGIN_TIMEOUT_MS = 120_000;
22
- const STEP_TIMEOUT_MS = 30_000;
23
44
  /**
24
45
  * Headless session-preflight deadline (ADR-025): the step scale, not the 120s interactive login
25
46
  * wait. A preflight must fail fast — the point is to avoid a headed visit entirely.
26
47
  */
27
48
  const PREFLIGHT_TIMEOUT_MS = 30_000;
28
- const POLL_MS = 500;
29
49
  /** The console drops a submit click that lands while the note is still settling (live run 2026-09-25). */
30
50
  const SUBMIT_SETTLE_MS = 2_500;
31
51
  const SUBMIT_ATTEMPTS = 3;
32
52
 
33
53
  /**
34
- * Selectors measured against the live XHS creator UI (2026-09-25 dogfood). Each list is
35
- * tried in order via the harness `trySelectors`.
54
+ * Login/UI gate. The creator console renders its mode tab bar only for an authenticated session,
55
+ * and it opens on 上传视频 — which is why every operation enters its own mode explicitly instead of
56
+ * assuming the console's landing mode.
36
57
  */
37
- const SELECTORS = {
38
- // Login/UI gate. The creator console renders the image-post tab only for an
39
- // authenticated session, and it opens on the VIDEO tab — the note editor does not
40
- // exist until the image tab is entered.
41
- imageTab: ['div.creator-tab:has-text("图文")', 'text=上传图文'],
42
- // The image input is distinguishable from the video input by its accept list.
43
- fileInput: ['input[type="file"][accept*=".jpg"]', 'input[type="file"]'],
44
- // Uploaded previews carry XHS's own class spelling (`preivew`, not `preview`).
45
- thumbnail: ['img.preivew-image', '.img-preview img'],
46
- title: ['input[placeholder*="标题"]', '#title-textarea input'],
47
- // The note body is TipTap ProseMirror since 2026-09 (it was Quill `.ql-editor`).
48
- body: [
49
- 'div.tiptap[contenteditable="true"]',
50
- 'div[contenteditable="true"].ql-editor',
51
- '#post-textarea',
52
- '.ql-editor',
53
- ],
54
- draftBox: ['text=/草稿箱\\(\\d+\\)/', 'text=草稿箱'],
55
- publishConfirm: ['text=发布成功', 'text=已发布'],
56
- } as const;
57
-
58
- /**
59
- * The submit bar is the custom element `<XHS-PUBLISH-BTN>`, whose shadow root is CLOSED
60
- * (`shadowRoot === null`), so no CSS or text selector can reach it — `button:has-text(…)
61
- * and `text=…` can never match. Labels are resolved through CDP (`DOM.getDocument` with
62
- * `pierce: true` plus `DOM.getBoxModel`) and the control is clicked at its measured
63
- * centre, so the target is read, never guessed. XHS renamed the draft control to
64
- * `暂存离开`; the older labels stay as fallbacks.
65
- */
66
- const SHADOW_CONTROLS = {
67
- draft: ['暂存离开', '存草稿', '保存草稿'],
68
- publish: ['发布'],
69
- } as const;
70
-
71
- export interface ShadowControl {
72
- label: string;
73
- x: number;
74
- y: number;
75
- }
76
-
77
- /** Locates a labelled control inside a closed shadow root (CDP-backed in production). */
78
- export interface ShadowProbe {
79
- findControl(labels: readonly string[]): Promise<ShadowControl | null>;
80
- }
58
+ const AUTH_GATE = ['div.creator-tab', 'text=上传图文'];
59
+
60
+ export {
61
+ MODE_LIMITS,
62
+ type ModeInput,
63
+ type ModeLimits,
64
+ validatePublishInput,
65
+ XHS_OPERATIONS,
66
+ type XhsOperation,
67
+ } from './modes';
68
+ export type { OpenedPage, OpenPage, ShadowControl, ShadowProbe, XhsLocator, XhsPage } from './page';
69
+ export { CREATOR_TAB_BAR, CREATOR_TAB_COUNT, clickCreatorTab, creatorTabSelector, wrapPlaywrightPage } from './page';
81
70
 
82
71
  function fail(error: string): Result {
83
72
  return ResultSchema.parse({ ok: false, target: TARGET, error });
84
73
  }
85
74
 
86
- export interface XhsLocator {
87
- count(): Promise<number>;
88
- isVisible(options?: { timeout?: number }): Promise<boolean>;
89
- fill(text: string, options?: { timeout?: number }): Promise<void>;
90
- click(options?: { timeout?: number; force?: boolean }): Promise<void>;
91
- /**
92
- * Type into the element. The XHS note body is a TipTap/ProseMirror editor: `fill` sets
93
- * the DOM text without the editor registering content, so a later save has nothing to
94
- * save and produces no observable draft.
95
- */
96
- type?(text: string, options?: { timeout?: number; delay?: number }): Promise<void>;
97
- textContent?(): Promise<string>;
98
- }
99
-
100
- /** Structural subset of the Playwright page the script driver uses. */
101
- export interface XhsPage {
102
- goto(url: string, options?: { timeout?: number }): Promise<void>;
103
- locator(selector: string): { first(): XhsLocator; count(): Promise<number> };
104
- setInputFiles(selector: string, paths: readonly string[]): Promise<void>;
105
- waitForSelector(selector: string, options?: { timeout?: number }): Promise<unknown>;
106
- url(): string;
107
- /** Click the first match inside the viewport — the console renders the tabs 3×. */
108
- clickInViewport?(selector: string): Promise<boolean>;
109
- /** Resolve a labelled control inside a closed shadow root. */
110
- findShadowControl?(labels: readonly string[]): Promise<ShadowControl | null>;
111
- /** Click at viewport coordinates. */
112
- clickAt?(x: number, y: number): Promise<void>;
113
- }
114
-
115
- export interface OpenedPage {
116
- page: XhsPage;
117
- close(): Promise<void>;
118
- }
119
-
120
- export type OpenPage = (profileDir: string) => Promise<OpenedPage>;
121
-
122
- interface ValidationResult {
123
- paths?: string[];
124
- error?: string;
125
- }
126
-
127
- /** Pure pre-browser validation: image shape/abs/existence/extension + publishStatus. */
128
- export async function validatePublishInput(content: Content): Promise<ValidationResult> {
129
- const status = content.metadata?.publishStatus;
130
- if (status !== undefined && status !== 'draft' && status !== 'public') {
131
- return { error: `images validation: unknown publishStatus '${String(status)}' (expected 'draft' or 'public')` };
132
- }
133
- const images = content.metadata?.images;
134
- if (!Array.isArray(images) || images.length === 0) {
135
- return { error: 'images validation: metadata.images must be a non-empty ordered array' };
136
- }
137
- const fs = createNodeFileSystem();
138
- const paths: string[] = [];
139
- for (const [i, image] of images.entries()) {
140
- const path = (image as { path?: unknown })?.path;
141
- if (typeof path !== 'string' || path.length === 0) {
142
- return { error: `images validation: images[${i}].path is missing` };
143
- }
144
- if (!isAbsolute(path)) {
145
- return { error: `images validation: images[${i}].path must be absolute, got '${path}'` };
146
- }
147
- if (!/\.(png|jpe?g|webp)$/i.test(path)) {
148
- return {
149
- error: `images validation: images[${i}].path has unsupported extension (expected png|jpg|jpeg|webp): '${path}'`,
150
- };
151
- }
152
- if (!(await fs.exists(path))) {
153
- return { error: `images validation: images[${i}].path does not exist: '${path}'` };
154
- }
155
- paths.push(path);
156
- }
157
- return { paths };
158
- }
159
-
160
- export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage {
161
- const pw = raw as {
162
- goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
163
- locator(selector: string): {
164
- count(): Promise<number>;
165
- nth(index: number): {
166
- boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null>;
167
- click(options?: Record<string, unknown>): Promise<void>;
168
- };
169
- first(): Record<string, (...args: unknown[]) => unknown> & Promise<unknown>;
170
- };
171
- setInputFiles(selector: string, paths: readonly string[]): Promise<void>;
172
- waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
173
- url(): string;
174
- viewportSize?(): { width: number; height: number } | null;
175
- mouse: { click(x: number, y: number): Promise<void> };
176
- };
177
- return {
178
- goto: (url, options) => pw.goto(url, options) as Promise<void>,
179
- locator: (selector) => ({
180
- count: () => pw.locator(selector).count(),
181
- first: () => {
182
- const loc = pw.locator(selector).first() as unknown as {
183
- count(): Promise<number>;
184
- isVisible(o?: Record<string, unknown>): Promise<boolean>;
185
- fill(t: string, o?: Record<string, unknown>): Promise<void>;
186
- click(o?: Record<string, unknown>): Promise<void>;
187
- };
188
- return {
189
- count: () => loc.count(),
190
- isVisible: (options) => loc.isVisible(options),
191
- fill: (text, options) => loc.fill(text, options),
192
- click: (options) => loc.click(options),
193
- type: (text, options) =>
194
- (
195
- loc as unknown as {
196
- pressSequentially(text: string, options?: { delay?: number }): Promise<void>;
197
- }
198
- ).pressSequentially(text, options),
199
- textContent: () => (loc as unknown as { textContent(): Promise<string> }).textContent(),
200
- };
201
- },
202
- }),
203
- setInputFiles: (selector, paths) => pw.setInputFiles(selector, paths),
204
- waitForSelector: (selector, options) => pw.waitForSelector(selector, options),
205
- url: () => pw.url(),
206
- clickInViewport: async (selector) => {
207
- // The console renders the image tab several times; only the in-viewport instance is
208
- // the live control. Without a readable viewport those duplicates cannot be told
209
- // apart, so the step fails loud instead of clicking a maybe-off-screen copy.
210
- const viewport = pw.viewportSize?.() ?? null;
211
- if (viewport === null) return false;
212
- const total = await pw.locator(selector).count();
213
- for (let index = 0; index < total; index += 1) {
214
- const candidate = pw.locator(selector).nth(index);
215
- const box = await candidate.boundingBox().catch(() => null);
216
- if (!box || box.width === 0) continue;
217
- const outside = box.x < 0 || box.y < 0 || box.x >= viewport.width || box.y >= viewport.height;
218
- if (outside) continue;
219
- // The tab's inner span intercepts the hit test, so the click must be forced.
220
- await candidate.click({ force: true });
221
- return true;
222
- }
223
- return false;
224
- },
225
- findShadowControl: async (labels) => (shadow ? shadow.findControl(labels) : null),
226
- clickAt: (x, y) => pw.mouse.click(x, y),
227
- };
228
- }
229
-
230
- interface CdpSession {
231
- send(method: string, params?: Record<string, unknown>): Promise<unknown>;
232
- }
233
-
234
- interface CdpNode {
235
- nodeName: string;
236
- nodeValue?: string;
237
- backendNodeId?: number;
238
- children?: CdpNode[];
239
- shadowRoots?: CdpNode[];
240
- }
241
-
242
- /**
243
- * Resolve a labelled control inside a CLOSED shadow root via Chrome DevTools Protocol.
244
- * `pierce: true` is the only way in: the light DOM exposes neither the label nor the node,
245
- * so this is what makes the publish bar reachable without guessing coordinates.
246
- */
247
- async function findControlViaCdp(session: CdpSession, labels: readonly string[]): Promise<ShadowControl | null> {
248
- const document = (await session.send('DOM.getDocument', { depth: -1, pierce: true })) as { root?: CdpNode };
249
- if (!document.root) return null;
250
- const wanted = new Set(labels);
251
- const text = (node: CdpNode): string =>
252
- node.nodeName === '#text' ? (node.nodeValue ?? '') : (node.children ?? []).map(text).join('');
253
- const hits: { label: string; backendNodeId: number }[] = [];
254
- const walk = (node: CdpNode): void => {
255
- if (hits.length > 0) return;
256
- for (const shadowRoot of node.shadowRoots ?? []) {
257
- walk(shadowRoot);
258
- if (hits.length > 0) return;
259
- }
260
- if (node.nodeName === 'BUTTON') {
261
- const label = text(node).replace(/\s+/g, '');
262
- if (wanted.has(label) && typeof node.backendNodeId === 'number') {
263
- hits.push({ label, backendNodeId: node.backendNodeId });
264
- return;
265
- }
266
- }
267
- for (const child of node.children ?? []) {
268
- walk(child);
269
- if (hits.length > 0) return;
270
- }
271
- };
272
- walk(document.root);
273
- const hit = hits[0];
274
- if (!hit) return null;
275
- const box = (await session.send('DOM.getBoxModel', { backendNodeId: hit.backendNodeId })) as {
276
- model?: { content?: number[] };
277
- };
278
- const quad = box.model?.content;
279
- if (!quad || quad.length < 8) return null;
280
- // Quad layout is [x1,y1, x2,y2, x3,y3, x4,y4]; the centre is ((x1+x3)/2, (y1+y4)/2).
281
- const [x1 = 0, y1 = 0, x3 = 0, , , y4 = 0] = quad;
282
- return {
283
- label: hit.label,
284
- x: Math.round((x1 + x3) / 2),
285
- y: Math.round((y1 + y4) / 2),
286
- };
287
- }
288
-
289
75
  /** CDP is Chromium-only; when unavailable the submit step fails loud instead of guessing. */
290
76
  export async function createShadowProbe(
291
- context: { newCDPSession?(page: unknown): Promise<CdpSession> },
77
+ context: { newCDPSession?(page: unknown): Promise<ShadowCdpSession> },
292
78
  page: unknown,
293
79
  ): Promise<ShadowProbe | undefined> {
294
- if (typeof context.newCDPSession !== 'function') return undefined;
295
- try {
296
- const session = await context.newCDPSession(page);
297
- await session.send('DOM.enable');
298
- return { findControl: (labels) => findControlViaCdp(session, labels) };
299
- } catch {
300
- return undefined;
301
- }
80
+ // The closed-shadow walk itself lives in the shared harness (ADR-029 rail); this stays a
81
+ // plugin-shaped adapter so the page wrapper's contract is unchanged.
82
+ const find = await createShadowFinder(context, page);
83
+ return find === undefined ? undefined : { findControl: (labels) => find(labels) };
302
84
  }
303
85
 
304
86
  /**
305
- * XHS-owned readiness predicate: the image-post tab renders only for an authenticated
306
- * creator console. Shared by the publish login gate and the profile-session capability
87
+ * XHS-owned readiness predicate: the mode tab bar renders only for an authenticated creator
88
+ * console. Shared by the publish login gate and the profile-session capability
307
89
  * (src/profile-check.ts) so both spell the authentication signal exactly once.
308
90
  */
309
91
  export async function isXhsConsoleReady(page: XhsPage): Promise<boolean> {
310
- return trySelectors(page, SELECTORS.imageTab).then((r) => r.found);
92
+ return trySelectors(page, AUTH_GATE).then((r) => r.found);
311
93
  }
312
94
 
313
95
  /**
@@ -337,7 +119,7 @@ export function makeDefaultOpenPage(
337
119
  pages(): unknown[];
338
120
  newPage(): Promise<unknown>;
339
121
  close(): Promise<void>;
340
- newCDPSession?(page: unknown): Promise<CdpSession>;
122
+ newCDPSession?(page: unknown): Promise<ShadowCdpSession>;
341
123
  };
342
124
  try {
343
125
  const raw = ctx.pages()[0] ?? (await ctx.newPage());
@@ -353,56 +135,6 @@ export function makeDefaultOpenPage(
353
135
  /** Default seam: persistent Chromium on the channel profile, headed for first login. */
354
136
  export const defaultOpenPage: OpenPage = makeDefaultOpenPage();
355
137
 
356
- async function waitUntil(predicate: () => Promise<boolean>, timeoutMs: number): Promise<boolean> {
357
- const deadline = Date.now() + timeoutMs;
358
- while (Date.now() < deadline) {
359
- if (await predicate()) return true;
360
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
361
- }
362
- return predicate();
363
- }
364
-
365
- async function findSelector(
366
- page: XhsPage,
367
- selectors: readonly string[],
368
- what: string,
369
- timeoutMs = STEP_TIMEOUT_MS,
370
- ): Promise<string> {
371
- const deadline = Date.now() + timeoutMs;
372
- while (Date.now() < deadline) {
373
- const found = await trySelectors(page, selectors);
374
- if (found.found && found.selector) return found.selector;
375
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
376
- }
377
- throw new Error(`${what}: no matching element observed`);
378
- }
379
-
380
- /**
381
- * Find the first selector that is ATTACHED, ignoring visibility. Upload widgets hide the
382
- * real `<input type="file">` behind a styled drop zone, and `trySelectors` filters on
383
- * visibility by default — so a hidden input needs this probe. Uploading into the wrong
384
- * input is still caught: the thumbnail count must match the payload afterwards.
385
- */
386
- async function findAttachedSelector(
387
- page: XhsPage,
388
- selectors: readonly string[],
389
- what: string,
390
- timeoutMs = STEP_TIMEOUT_MS,
391
- ): Promise<string> {
392
- const deadline = Date.now() + timeoutMs;
393
- while (Date.now() < deadline) {
394
- for (const selector of selectors) {
395
- const attached = await page
396
- .locator(selector)
397
- .count()
398
- .catch(() => 0);
399
- if (attached > 0) return selector;
400
- }
401
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
402
- }
403
- throw new Error(`${what}: no matching element observed`);
404
- }
405
-
406
138
  /** Explicit session verdict: only an observed authenticated console is `verified`. */
407
139
  export interface ProfileSessionCheck {
408
140
  status: 'verified' | 'refused';
@@ -418,47 +150,31 @@ export interface SessionCheckOptions {
418
150
  }
419
151
 
420
152
  /**
421
- * Reopen the existing profile headless and apply the login gate read-only: one goto of the creator
422
- * console and selector reads — no upload, no click, no note. `withProfilePage` closes the context on
423
- * every exit path, and the upstream launcher never creates a profile headless, so a check can
424
- * neither fabricate a session nor leave a browser behind.
153
+ * Read-only login gate over the shared session check: XHS owns the console URL and the readiness
154
+ * predicate, `@gobing-ai/publish-harness/browser` owns the single headless probe, the headed retry
155
+ * and the verdict text. `withProfilePage` closes the context on every exit path, and the upstream
156
+ * launcher never creates a profile headless, so a check can neither fabricate a session nor leave a
157
+ * browser behind.
425
158
  *
426
159
  * ADR-025: this is the publish preflight. The headed visit is only attempted once it says
427
160
  * `verified`, which is what turns "no session" from a 120s headed login wait into a fast refusal.
428
161
  */
429
162
  export async function checkXhsProfileSession(opts: SessionCheckOptions): Promise<ProfileSessionCheck> {
430
- const timeoutMs = opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
431
163
  // XHS bot-walls and slow-walls headless loads (observed 2026-09-29: headless auth never
432
- // confirms while the same profile reads ready in 27s headed), so a headless refusal is
433
- // retried once headed — still a read-only readiness check before any publish actions.
434
- for (const headless of [true, false] as const) {
435
- try {
436
- await withProfilePage(
437
- {
438
- profileDir: opts.profileDir,
439
- headless,
440
- url: PUBLISH_URL,
441
- isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
442
- timeoutMs,
443
- launcher: opts.launcher,
444
- },
445
- async () => undefined,
446
- );
447
- return {
448
- status: 'verified',
449
- profileDir: opts.profileDir,
450
- detail: `authenticated ${CHANNEL} creator console observed ${headless ? 'headlessly' : 'via headed fallback'} via profile ${opts.profileDir} (read-only)`,
451
- };
452
- } catch (error) {
453
- if (headless) continue;
454
- return {
455
- status: 'refused',
456
- profileDir: opts.profileDir,
457
- detail: `headless and headed readiness refused: ${error instanceof Error ? error.message : String(error)}`,
458
- };
459
- }
460
- }
461
- throw new Error('unreachable: session check loop must return or throw');
164
+ // confirms while the same profile reads ready in 27s headed), which is why the shared check
165
+ // retries a headless refusal once headed — still read-only, before any publish action.
166
+ return checkChannelSession(
167
+ {
168
+ channel: CHANNEL,
169
+ homeUrl: PUBLISH_URL,
170
+ isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
171
+ },
172
+ {
173
+ profileDir: opts.profileDir,
174
+ launcher: opts.launcher,
175
+ timeoutMs: opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS,
176
+ },
177
+ );
462
178
  }
463
179
 
464
180
  export class XhsBrowserTransport implements PublishTransport {
@@ -497,126 +213,31 @@ export class XhsBrowserTransport implements PublishTransport {
497
213
  }
498
214
 
499
215
  /**
500
- * Read the console's draft counter (`草稿箱(N)`). It exists only on the landing view: the
501
- * image editor replaces the header, so this must be read before entering the tab. Returns
502
- * null when the label carries no number yet.
503
- */
504
- private async readDraftCount(page: XhsPage): Promise<number | null> {
505
- for (const selector of SELECTORS.draftBox) {
506
- // Attachment, not visibility: the label lives in a panel that may be collapsed.
507
- const attached = await page
508
- .locator(selector)
509
- .count()
510
- .catch(() => 0);
511
- if (attached === 0) continue;
512
- const locator = page.locator(selector).first();
513
- if (!locator.textContent) continue;
514
- const text = await locator.textContent().catch(() => '');
515
- const match = /草稿箱\s*[((]\s*(\d+)\s*[))]/.exec(text);
516
- if (match?.[1] !== undefined) return Number(match[1]);
517
- }
518
- return null;
519
- }
520
-
521
- /**
522
- * Timestamps the console prints for a saved draft, e.g. `保存于2026-09-25 21:37`.
523
- * The current and previous two minutes are accepted so a save near a minute boundary
524
- * still confirms.
525
- */
526
- private static draftSavedStamps(now = new Date()): string[] {
527
- const pad = (value: number) => String(value).padStart(2, '0');
528
- return [0, 1, 2].map((minutesAgo) => {
529
- const stamp = new Date(now.getTime() - minutesAgo * 60_000);
530
- return `保存于${stamp.getFullYear()}-${pad(stamp.getMonth() + 1)}-${pad(stamp.getDate())} ${pad(stamp.getHours())}:${pad(stamp.getMinutes())}`;
531
- });
532
- }
533
-
534
- /**
535
- * A draft counts as observed when the console prints a fresh save timestamp, or the draft
536
- * counter (readable again once the save dismisses the editor) has moved past its pre-entry
537
- * value. Anything else stays an explicit failure — XHS autosave means "no signal" is not
538
- * evidence of a saved draft.
539
- */
540
- /**
541
- * Which candidate save stamps are already on the page — the baseline a later check diffs
542
- * against, so a pre-existing entry can never be read as this run's save.
543
- */
544
- private async readDraftStamps(page: XhsPage): Promise<string[]> {
545
- const present: string[] = [];
546
- for (const stamp of XhsBrowserTransport.draftSavedStamps()) {
547
- const attached = await page
548
- .locator(`text=${stamp}`)
549
- .count()
550
- .catch(() => 0);
551
- if (attached > 0) present.push(stamp);
552
- }
553
- return present;
554
- }
555
-
556
- /** Draft evidence captured before the editor opens (the counter only exists on that view). */
557
- private async readDraftBaseline(page: XhsPage): Promise<{ count: number | null; stamps: string[] }> {
558
- return { count: await this.readDraftCount(page), stamps: await this.readDraftStamps(page) };
559
- }
560
-
561
- /**
562
- * A draft counts as observed only from a DELTA against the pre-entry baseline: the console's
563
- * draft counter moved, or a save timestamp appeared that was not already there. A readable
564
- * counter with no usable baseline is NOT evidence — XHS autosaves uploads, so "the counter
565
- * exists" says nothing about this save. No delta ⇒ the caller fails loud.
216
+ * Publish through the console. Everything that can be decided without a browser is decided
217
+ * first (driver, operation, artifacts, limits, publishStatus, budget, session), so an
218
+ * impossible or misrouted run never opens — let alone touches — a real console.
566
219
  */
567
- private async isDraftSaved(
568
- page: XhsPage,
569
- baseline: { count: number | null; stamps: readonly string[] },
570
- ): Promise<boolean> {
571
- const stamps = await this.readDraftStamps(page);
572
- if (stamps.some((stamp) => !baseline.stamps.includes(stamp))) return true;
573
- const after = await this.readDraftCount(page);
574
- if (baseline.count === null || after === null) return false;
575
- return after > baseline.count;
576
- }
577
-
578
- /**
579
- * Click a labelled control inside the CLOSED shadow publish bar. The label and box come
580
- * from CDP, so the click is aimed at a read target rather than a guessed coordinate; a
581
- * driver without that capability fails loud instead of clicking blind.
582
- */
583
- private async clickShadowControl(
584
- page: XhsPage,
585
- labels: readonly string[],
586
- ): Promise<{ ok: true; label: string; x: number; y: number } | { ok: false; error: string }> {
587
- if (!page.findShadowControl || !page.clickAt) {
588
- return {
589
- ok: false,
590
- error: `submit control: the ${CHANNEL} publish bar lives in a closed shadow root and this driver has no CDP access to resolve it`,
591
- };
592
- }
593
- const control = await page.findShadowControl(labels);
594
- if (!control) {
595
- return {
596
- ok: false,
597
- error: `submit control: none of [${labels.join(', ')}] found in the ${CHANNEL} publish bar (closed shadow root)`,
598
- };
599
- }
600
- await page.clickAt(control.x, control.y);
601
- return { ok: true, label: control.label, x: control.x, y: control.y };
602
- }
603
-
604
220
  public async publish(payload: PublishTransportPayload): Promise<Result> {
605
221
  if (this.driver !== 'script') {
606
222
  return fail(
607
- `unsupported driver '${this.driver}' for ${TARGET}: image posts require the script driver (Playwright setInputFiles)`,
223
+ `unsupported driver '${this.driver}' for ${TARGET}: the console modes (${XHS_OPERATIONS.join(', ')}) require the script driver (Playwright setInputFiles)`,
608
224
  );
609
225
  }
610
- const override =
611
- typeof payload.options?.profileDir === 'string'
612
- ? payload.options.profileDir
613
- : (process.env.XHS_PROFILE_DIR ?? process.env.KK_BROWSER_PROFILE_DIR);
614
- const profileDir = resolveBrowserProfileDir(CHANNEL, override);
615
- const validation = await validatePublishInput(payload.content);
616
- if (validation.error) return fail(validation.error);
617
- const paths = validation.paths ?? [];
226
+ // ADR-029 dispatch: an undeclared operation is refused by name before a browser is launched
227
+ // or a profile is resolved, so a misrouted run cannot open a session it will not use.
228
+ const requested = resolveXhsOperation(payload.options, payload.content);
229
+ if (requested.error !== undefined) return fail(requested.error);
230
+ const operation = requested.operation;
231
+
232
+ // Mode validation: missing artifact, over-limit field, or an unknown publishStatus all fail
233
+ // here, naming what is missing and the mode's limit.
234
+ const validation = await validateModeInput(operation, payload.content);
235
+ if (validation.error !== undefined) return fail(validation.error);
236
+ const input: ModeInput = validation.input;
237
+
238
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
239
+ const profileDir = resolveChannelProfileDir(CHANNEL, override);
618
240
  const publishStatus = payload.content.metadata?.publishStatus === 'public' ? 'public' : 'draft';
619
- const base = { driver: this.driver, publishStatus, profileDir, imageCount: paths.length };
620
241
 
621
242
  // ADR-025 automation guard — both gates run before any browser opens.
622
243
  // 1. Frequency cap: the operator's platform warning makes a repeated console visit an
@@ -651,9 +272,6 @@ export class XhsBrowserTransport implements PublishTransport {
651
272
  try {
652
273
  await act('creator page goto', () => page.goto(PUBLISH_URL, { timeout: this.loginTimeoutMs }));
653
274
 
654
- // Login/UI gate: the image-post tab is rendered only for an authenticated
655
- // creator console, and it is what the driver must switch to before the note
656
- // editor exists (the console opens on the video tab).
657
275
  const gated = await waitUntil(() => isXhsConsoleReady(page), this.loginTimeoutMs);
658
276
  if (!gated) {
659
277
  return fail(
@@ -661,131 +279,21 @@ export class XhsBrowserTransport implements PublishTransport {
661
279
  );
662
280
  }
663
281
 
664
- const step = (selectors: readonly string[], what: string) =>
665
- findSelector(page, selectors, what, this.stepTimeoutMs);
666
282
  // The draft counter and any save timestamps exist only on the landing view, so the
667
- // baseline is captured before the tab switch replaces the header with the editor.
283
+ // baseline is captured before the mode is entered (the editor replaces the header).
668
284
  const draftBaseline =
669
- publishStatus === 'draft' ? await this.readDraftBaseline(page) : { count: null, stamps: [] };
670
- const tabSelector = await step(SELECTORS.imageTab, 'image-post tab');
671
- const clickInViewport = page.clickInViewport;
672
- if (clickInViewport) {
673
- const switched = await act('image-post tab click', () => clickInViewport.call(page, tabSelector));
674
- if (!switched) return fail('image-post tab click: no in-viewport image tab found');
675
- } else {
676
- await act('image-post tab click', () => page.locator(tabSelector).first().click({ force: true }));
677
- }
285
+ publishStatus === 'draft' ? await readDraftBaseline(page) : { count: null, stamps: [] };
678
286
 
679
- const fileSelector = await findAttachedSelector(
287
+ return await runMode(input, {
680
288
  page,
681
- SELECTORS.fileInput,
682
- 'image file input',
683
- this.stepTimeoutMs,
684
- );
685
- await act('image upload', () => page.setInputFiles(fileSelector, paths));
686
-
687
- const thumbSelector = await step(SELECTORS.thumbnail, 'thumbnail wait');
688
- const countMatches = await waitUntil(
689
- () =>
690
- act('thumbnail count read', () => page.locator(thumbSelector).count()).then(
691
- (n) => n >= paths.length,
692
- ),
693
- this.stepTimeoutMs,
694
- );
695
- const thumbCount = await act('thumbnail count read', () => page.locator(thumbSelector).count());
696
- if (!countMatches || thumbCount !== paths.length) {
697
- return fail(`thumbnail count: expected ${paths.length}, observed ${thumbCount}`);
698
- }
699
-
700
- const titleSelector = await step(SELECTORS.title, 'title input');
701
- await act('title fill', () =>
702
- page
703
- .locator(titleSelector)
704
- .first()
705
- .fill(payload.content.title ?? ''),
706
- );
707
- const bodySelector = await step(SELECTORS.body, 'note body');
708
- // Type rather than fill: TipTap ignores a synthetic value set, so a filled body
709
- // leaves the note unsaved and the draft never becomes observable.
710
- await act('body fill', async () => {
711
- const body = page.locator(bodySelector).first();
712
- const text = payload.content.body ?? '';
713
- if (typeof body.type === 'function') {
714
- await body.click();
715
- await body.type(text, { delay: 5 });
716
- return;
717
- }
718
- await body.fill(text);
719
- });
720
-
721
- if (publishStatus === 'draft') {
722
- // "暂存离开" needs the image editor's content to have registered; a click that
723
- // lands mid-settle is dropped, so settle, click, then give the save a window in
724
- // which to become observable — bounded, and never a blind coordinate guess.
725
- let submitted: { label: string; x: number; y: number } | null = null;
726
- let confirmed = false;
727
- let attempts = 0;
728
- while (!confirmed && attempts < this.submitAttempts) {
729
- attempts += 1;
730
- await new Promise((resolve) => setTimeout(resolve, this.submitSettleMs));
731
- const clicked = await this.clickShadowControl(page, SHADOW_CONTROLS.draft);
732
- if (!clicked.ok) return fail(clicked.error);
733
- submitted = clicked;
734
- confirmed = await waitUntil(() => this.isDraftSaved(page, draftBaseline), this.submitSettleMs);
735
- }
736
- const draftCountAfter = await this.readDraftCount(page);
737
- if (!confirmed || submitted === null) {
738
- return fail(
739
- `draft confirmation: '${submitted?.label ?? SHADOW_CONTROLS.draft[0]}' was clicked ${attempts}× but neither a fresh ${CHANNEL} save timestamp nor a moved draft counter was observed (before=${String(draftBaseline.count)}, after=${String(draftCountAfter)})`,
740
- );
741
- }
742
- return ResultSchema.parse({
743
- ok: true,
744
- target: TARGET,
745
- message: `${TARGET} draft saved (unpublished) in profile ${profileDir} via '${submitted.label}'`,
746
- metadata: {
747
- ...base,
748
- draftControl: submitted.label,
749
- draftClick: `${submitted.x},${submitted.y}`,
750
- draftAttempts: attempts,
751
- draftCountBefore: draftBaseline.count,
752
- draftCountAfter,
753
- },
754
- });
755
- }
756
-
757
- // Publish: ONE click, no retry — an unobserved publish must never be repeated, and the
758
- // console drops a click that lands while the note is still settling (measured live).
759
- await new Promise((resolve) => setTimeout(resolve, this.submitSettleMs));
760
- const submitted = await this.clickShadowControl(page, SHADOW_CONTROLS.publish);
761
- if (!submitted.ok) return fail(submitted.error);
762
-
763
- const urlBeforeSubmit = page.url();
764
- const confirmed = await waitUntil(async () => {
765
- // The console rewrites its URL to `…published=true` once the post is accepted;
766
- // the text hints stay as fallbacks for console variants.
767
- if (page.url().includes(PUBLISHED_MARKER)) return true;
768
- return trySelectors(page, SELECTORS.publishConfirm)
769
- .then((r) => r.found)
770
- .catch(() => false);
771
- }, this.stepTimeoutMs);
772
- if (!confirmed) {
773
- return fail(
774
- `publish submission: state unknown — the '${submitted.label}' click at ${submitted.x},${submitted.y} was sent from ${urlBeforeSubmit} but no success state was observed (now ${page.url()}). Check the ${CHANNEL} creator note manager before retrying: a retry could duplicate the post.`,
775
- );
776
- }
777
- const url = page.url();
778
- return ResultSchema.parse({
779
- ok: true,
780
- target: TARGET,
781
- ...(url !== PUBLISH_URL ? { url } : {}),
782
- message: `${TARGET} post published`,
783
- metadata: {
784
- ...base,
785
- publishControl: submitted.label,
786
- publishClick: `${submitted.x},${submitted.y}`,
787
- publishSignal: url.includes(PUBLISHED_MARKER) ? `url:${PUBLISHED_MARKER}` : 'text',
788
- },
289
+ content: payload.content,
290
+ publishStatus,
291
+ profileDir,
292
+ driver: this.driver,
293
+ stepTimeoutMs: this.stepTimeoutMs,
294
+ submitSettleMs: this.submitSettleMs,
295
+ submitAttempts: this.submitAttempts,
296
+ draftBaseline,
789
297
  });
790
298
  } catch (error) {
791
299
  return fail(error instanceof Error ? error.message : String(error));