@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
@@ -1,325 +1,108 @@
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
+ kkBrowserLauncher,
14
+ resolveChannelProfileDir,
15
+ type ShadowCdpSession,
16
+ } from '@gobing-ai/publish-harness/browser';
17
+ import type { BrowserLauncher } from '@gobing-ai/ts-browser-automation';
12
18
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
13
19
  import { echoError } from '@gobing-ai/ts-utils';
14
20
  import { type AutomationGuard, createAutomationGuard } from './automation-guard';
21
+ import { readDraftBaseline, runMode } from './flow';
22
+ import { type ModeInput, PUBLISH_URL, resolveXhsOperation, TARGET, validateModeInput, XHS_OPERATIONS } from './modes';
23
+ import {
24
+ type OpenedPage,
25
+ type OpenPage,
26
+ type ShadowProbe,
27
+ STEP_TIMEOUT_MS,
28
+ waitUntil,
29
+ wrapPlaywrightPage,
30
+ type XhsPage,
31
+ } from './page';
32
+
33
+ /**
34
+ * The xhs publish CHANNEL (ADR-029): one plugin, four mode `operations`.
35
+ *
36
+ * This module owns what is channel-level — the profile dir, the session preflight and its
37
+ * ADR-025 automation guard, the ADR-029 operation dispatch and pre-browser validation, the
38
+ * browser launch seam and the `Content → Result` mapping. The modes themselves (entry, artifacts,
39
+ * selectors, save/publish flow) live in `./modes` and `./flow`.
40
+ */
15
41
 
16
42
  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';
43
+ export { PUBLISH_URL };
21
44
  export const LOGIN_TIMEOUT_MS = 120_000;
22
- const STEP_TIMEOUT_MS = 30_000;
23
45
  /**
24
46
  * Headless session-preflight deadline (ADR-025): the step scale, not the 120s interactive login
25
47
  * wait. A preflight must fail fast — the point is to avoid a headed visit entirely.
26
48
  */
27
49
  const PREFLIGHT_TIMEOUT_MS = 30_000;
28
- const POLL_MS = 500;
29
50
  /** The console drops a submit click that lands while the note is still settling (live run 2026-09-25). */
30
51
  const SUBMIT_SETTLE_MS = 2_500;
31
52
  const SUBMIT_ATTEMPTS = 3;
32
53
 
33
54
  /**
34
- * Selectors measured against the live XHS creator UI (2026-09-25 dogfood). Each list is
35
- * tried in order via the harness `trySelectors`.
55
+ * Login/UI gate. The creator console renders its mode tab bar only for an authenticated session,
56
+ * and it opens on 上传视频 — which is why every operation enters its own mode explicitly instead of
57
+ * assuming the console's landing mode.
36
58
  */
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
- }
59
+ const AUTH_GATE = ['div.creator-tab', 'text=上传图文'];
60
+
61
+ export {
62
+ MODE_LIMITS,
63
+ type ModeInput,
64
+ type ModeLimits,
65
+ validatePublishInput,
66
+ XHS_OPERATIONS,
67
+ type XhsOperation,
68
+ } from './modes';
69
+ export type { OpenedPage, OpenPage, ShadowControl, ShadowProbe, XhsLocator, XhsPage } from './page';
70
+ export { CREATOR_TAB_BAR, CREATOR_TAB_COUNT, clickCreatorTab, creatorTabSelector, wrapPlaywrightPage } from './page';
81
71
 
82
72
  function fail(error: string): Result {
83
73
  return ResultSchema.parse({ ok: false, target: TARGET, error });
84
74
  }
85
75
 
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
76
  /** CDP is Chromium-only; when unavailable the submit step fails loud instead of guessing. */
290
77
  export async function createShadowProbe(
291
- context: { newCDPSession?(page: unknown): Promise<CdpSession> },
78
+ context: { newCDPSession?(page: unknown): Promise<ShadowCdpSession> },
292
79
  page: unknown,
293
80
  ): 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
- }
81
+ // The closed-shadow walk itself lives in the shared harness (ADR-029 rail); this stays a
82
+ // plugin-shaped adapter so the page wrapper's contract is unchanged.
83
+ const find = await createShadowFinder(context, page);
84
+ return find === undefined ? undefined : { findControl: (labels) => find(labels) };
302
85
  }
303
86
 
304
87
  /**
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
88
+ * XHS-owned readiness predicate: the mode tab bar renders only for an authenticated creator
89
+ * console. Shared by the publish login gate and the profile-session capability
307
90
  * (src/profile-check.ts) so both spell the authentication signal exactly once.
308
91
  */
309
92
  export async function isXhsConsoleReady(page: XhsPage): Promise<boolean> {
310
- return trySelectors(page, SELECTORS.imageTab).then((r) => r.found);
93
+ return trySelectors(page, AUTH_GATE).then((r) => r.found);
311
94
  }
312
95
 
313
96
  /**
314
- * Production launcher: the upstream typed persistent-context opener (0177). Named so the
97
+ * Production launcher: the harness kk launcher (0214; full-Chromium channel + stealth). Named so the
315
98
  * migration is provable offline — tests assert this binding, never launch a real browser.
316
99
  */
317
- export const defaultLauncher = openPersistentBrowser;
100
+ export const defaultLauncher = kkBrowserLauncher.launch;
318
101
 
319
102
  /**
320
103
  * Build the default page opener. The launcher is injected so the seam (page wrapping, shadow
321
104
  * probe wiring, context close, launch-failure message) is testable without launching a browser;
322
- * production uses the upstream typed `openPersistentBrowser` (@gobing-ai/ts-browser-automation).
105
+ * production uses the harness `kkBrowserLauncher` (@gobing-ai/publish-harness/browser, 0214).
323
106
  */
324
107
  export function makeDefaultOpenPage(
325
108
  launch: (options: { profileDir: string; headless: boolean }) => Promise<unknown> = defaultLauncher,
@@ -337,7 +120,7 @@ export function makeDefaultOpenPage(
337
120
  pages(): unknown[];
338
121
  newPage(): Promise<unknown>;
339
122
  close(): Promise<void>;
340
- newCDPSession?(page: unknown): Promise<CdpSession>;
123
+ newCDPSession?(page: unknown): Promise<ShadowCdpSession>;
341
124
  };
342
125
  try {
343
126
  const raw = ctx.pages()[0] ?? (await ctx.newPage());
@@ -353,56 +136,6 @@ export function makeDefaultOpenPage(
353
136
  /** Default seam: persistent Chromium on the channel profile, headed for first login. */
354
137
  export const defaultOpenPage: OpenPage = makeDefaultOpenPage();
355
138
 
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
139
  /** Explicit session verdict: only an observed authenticated console is `verified`. */
407
140
  export interface ProfileSessionCheck {
408
141
  status: 'verified' | 'refused';
@@ -415,50 +148,37 @@ export interface SessionCheckOptions {
415
148
  profileDir: string;
416
149
  timeoutMs?: number;
417
150
  launcher?: BrowserLauncher;
151
+ /** 0216: skip the headless pass and probe headed only (manual `check --headed`). */
152
+ headedOnly?: boolean;
418
153
  }
419
154
 
420
155
  /**
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.
156
+ * Read-only login gate over the shared session check: XHS owns the console URL and the readiness
157
+ * predicate, `@gobing-ai/publish-harness/browser` owns the single headless probe, the headed retry
158
+ * and the verdict text. `withProfilePage` closes the context on every exit path, and the upstream
159
+ * launcher never creates a profile headless, so a check can neither fabricate a session nor leave a
160
+ * browser behind.
425
161
  *
426
162
  * ADR-025: this is the publish preflight. The headed visit is only attempted once it says
427
163
  * `verified`, which is what turns "no session" from a 120s headed login wait into a fast refusal.
428
164
  */
429
165
  export async function checkXhsProfileSession(opts: SessionCheckOptions): Promise<ProfileSessionCheck> {
430
- const timeoutMs = opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
431
166
  // 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');
167
+ // confirms while the same profile reads ready in 27s headed), which is why the shared check
168
+ // retries a headless refusal once headed — still read-only, before any publish action.
169
+ return checkChannelSession(
170
+ {
171
+ channel: CHANNEL,
172
+ homeUrl: PUBLISH_URL,
173
+ isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
174
+ },
175
+ {
176
+ profileDir: opts.profileDir,
177
+ launcher: opts.launcher,
178
+ timeoutMs: opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS,
179
+ headedOnly: opts.headedOnly === true,
180
+ },
181
+ );
462
182
  }
463
183
 
464
184
  export class XhsBrowserTransport implements PublishTransport {
@@ -497,126 +217,31 @@ export class XhsBrowserTransport implements PublishTransport {
497
217
  }
498
218
 
499
219
  /**
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.
220
+ * Publish through the console. Everything that can be decided without a browser is decided
221
+ * first (driver, operation, artifacts, limits, publishStatus, budget, session), so an
222
+ * impossible or misrouted run never opens — let alone touches — a real console.
566
223
  */
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
224
  public async publish(payload: PublishTransportPayload): Promise<Result> {
605
225
  if (this.driver !== 'script') {
606
226
  return fail(
607
- `unsupported driver '${this.driver}' for ${TARGET}: image posts require the script driver (Playwright setInputFiles)`,
227
+ `unsupported driver '${this.driver}' for ${TARGET}: the console modes (${XHS_OPERATIONS.join(', ')}) require the script driver (Playwright setInputFiles)`,
608
228
  );
609
229
  }
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 ?? [];
230
+ // ADR-029 dispatch: an undeclared operation is refused by name before a browser is launched
231
+ // or a profile is resolved, so a misrouted run cannot open a session it will not use.
232
+ const requested = resolveXhsOperation(payload.options, payload.content);
233
+ if (requested.error !== undefined) return fail(requested.error);
234
+ const operation = requested.operation;
235
+
236
+ // Mode validation: missing artifact, over-limit field, or an unknown publishStatus all fail
237
+ // here, naming what is missing and the mode's limit.
238
+ const validation = await validateModeInput(operation, payload.content);
239
+ if (validation.error !== undefined) return fail(validation.error);
240
+ const input: ModeInput = validation.input;
241
+
242
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
243
+ const profileDir = resolveChannelProfileDir(CHANNEL, override);
618
244
  const publishStatus = payload.content.metadata?.publishStatus === 'public' ? 'public' : 'draft';
619
- const base = { driver: this.driver, publishStatus, profileDir, imageCount: paths.length };
620
245
 
621
246
  // ADR-025 automation guard — both gates run before any browser opens.
622
247
  // 1. Frequency cap: the operator's platform warning makes a repeated console visit an
@@ -651,9 +276,6 @@ export class XhsBrowserTransport implements PublishTransport {
651
276
  try {
652
277
  await act('creator page goto', () => page.goto(PUBLISH_URL, { timeout: this.loginTimeoutMs }));
653
278
 
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
279
  const gated = await waitUntil(() => isXhsConsoleReady(page), this.loginTimeoutMs);
658
280
  if (!gated) {
659
281
  return fail(
@@ -661,131 +283,21 @@ export class XhsBrowserTransport implements PublishTransport {
661
283
  );
662
284
  }
663
285
 
664
- const step = (selectors: readonly string[], what: string) =>
665
- findSelector(page, selectors, what, this.stepTimeoutMs);
666
286
  // 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.
287
+ // baseline is captured before the mode is entered (the editor replaces the header).
668
288
  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
- }
289
+ publishStatus === 'draft' ? await readDraftBaseline(page) : { count: null, stamps: [] };
678
290
 
679
- const fileSelector = await findAttachedSelector(
291
+ return await runMode(input, {
680
292
  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
- },
293
+ content: payload.content,
294
+ publishStatus,
295
+ profileDir,
296
+ driver: this.driver,
297
+ stepTimeoutMs: this.stepTimeoutMs,
298
+ submitSettleMs: this.submitSettleMs,
299
+ submitAttempts: this.submitAttempts,
300
+ draftBaseline,
789
301
  });
790
302
  } catch (error) {
791
303
  return fail(error instanceof Error ? error.message : String(error));