@gobing-ai/knowledge-kit 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +137 -59
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +97 -45
- package/plugins/generations/core-facts-gen/dist/index.js +106 -57
- package/plugins/generations/daily-article-gen/dist/index.js +105 -56
- package/plugins/generations/dailynews-gen/dist/index.js +235 -57
- package/plugins/generations/dailynews-gen/src/index.ts +67 -2
- package/plugins/generations/dailynews-gen/src/script-builder.ts +36 -15
- package/plugins/generations/dailynews-gen/src/script-plan.ts +142 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +115 -66
- package/plugins/generations/gemini-tts/README.md +24 -0
- package/plugins/generations/gemini-tts/dist/index.js +326 -260
- package/plugins/generations/gemini-tts/src/gemini-client.ts +41 -11
- package/plugins/generations/image-gen/dist/index.js +338 -117
- package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +19 -0
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +55 -0
- package/plugins/generations/image-gen/presets/styles/daily-news.yaml +9 -0
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +7 -0
- package/plugins/generations/image-gen/src/index.ts +148 -14
- package/plugins/generations/image-gen/src/job.ts +30 -0
- package/plugins/generations/image-gen/src/providers/errors.ts +91 -0
- package/plugins/generations/image-gen/src/providers/types.ts +79 -0
- package/plugins/generations/news-report-gen/dist/index.js +136 -55
- package/plugins/generations/news-report-gen/src/index.ts +57 -1
- package/plugins/generations/voice-gen/dist/index.js +119 -67
- package/plugins/ingestions/aihot-ingest/dist/index.js +103 -53
- package/plugins/ingestions/horizon-ingest/dist/index.js +102 -53
- package/plugins/ingestions/karakeep-local/dist/index.js +273 -224
- package/plugins/ingestions/last30days-ingest/dist/index.js +97 -48
- package/plugins/ingestions/web-search/dist/index.js +121 -72
- package/plugins/kk/config.example.yaml +13 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +52 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +433 -16
- package/plugins/kk/skills/publish/SKILL.md +24 -1
- package/plugins/kk/skills/xhs-images/SKILL.md +89 -0
- package/plugins/kk/skills/xhs-images/references/card-layouts.md +35 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +162 -11
- package/plugins/publishings/emdash-pub/dist/index.js +94 -45
- package/plugins/publishings/infoq-pub/dist/index.js +94 -45
- package/plugins/publishings/juejin-pub/dist/index.js +94 -45
- package/plugins/publishings/medium-pub/dist/index.js +94 -45
- package/plugins/publishings/podcast-pub/dist/index.js +115 -55
- package/plugins/publishings/podcast-pub/src/map.ts +30 -10
- package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -1
- package/plugins/publishings/qiita-pub/dist/index.js +94 -45
- package/plugins/publishings/substack-pub/dist/index.js +94 -45
- package/plugins/publishings/surfdash-pub/dist/index.js +99 -63
- package/plugins/publishings/surfdash-pub/src/index.ts +10 -1
- package/plugins/publishings/wechatmp-pub/dist/index.js +94 -45
- package/plugins/publishings/x-pub/dist/index.js +94 -45
- package/plugins/publishings/xhs-pub/dist/index.js +8908 -229
- package/plugins/publishings/xhs-pub/package.json +1 -0
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/src/automation-guard.ts +128 -0
- package/plugins/publishings/xhs-pub/src/index.ts +753 -24
- package/plugins/publishings/xhs-pub/src/profile-check.ts +133 -0
- package/plugins/publishings/zenn-pub/dist/index.js +94 -45
- package/plugins/sp/lib/env.ts +46 -0
- package/plugins/sp/lib/inline-run.generated.mjs +1489 -0
- package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
- package/plugins/sp/scripts/inline-run-setup.ts +571 -47
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +190 -0
- package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +77 -6
- package/plugins/sp/scripts/wrapup-steps.ts +91 -5
|
@@ -1,66 +1,795 @@
|
|
|
1
|
-
import { dirname } from 'node:path';
|
|
1
|
+
import { dirname, isAbsolute } 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
|
-
type BrowserDriver,
|
|
6
5
|
FakeBrowserTransport,
|
|
7
6
|
type PublishTransport,
|
|
8
7
|
type PublishTransportPayload,
|
|
9
8
|
resolveBrowserProfileDir,
|
|
9
|
+
trySelectors,
|
|
10
10
|
} from '@gobing-ai/publish-harness';
|
|
11
|
+
import { type BrowserLauncher, openPersistentBrowser, withProfilePage } from '@gobing-ai/ts-browser-automation';
|
|
11
12
|
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
12
13
|
import { echoError } from '@gobing-ai/ts-utils';
|
|
14
|
+
import { type AutomationGuard, createAutomationGuard } from './automation-guard';
|
|
13
15
|
|
|
14
|
-
const CHANNEL = 'xhs';
|
|
16
|
+
export const CHANNEL = 'xhs';
|
|
15
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';
|
|
21
|
+
export const LOGIN_TIMEOUT_MS = 120_000;
|
|
22
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
23
|
+
/**
|
|
24
|
+
* Headless session-preflight deadline (ADR-025): the step scale, not the 120s interactive login
|
|
25
|
+
* wait. A preflight must fail fast — the point is to avoid a headed visit entirely.
|
|
26
|
+
*/
|
|
27
|
+
const PREFLIGHT_TIMEOUT_MS = 30_000;
|
|
28
|
+
const POLL_MS = 500;
|
|
29
|
+
/** The console drops a submit click that lands while the note is still settling (live run 2026-09-25). */
|
|
30
|
+
const SUBMIT_SETTLE_MS = 2_500;
|
|
31
|
+
const SUBMIT_ATTEMPTS = 3;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Selectors measured against the live XHS creator UI (2026-09-25 dogfood). Each list is
|
|
35
|
+
* tried in order via the harness `trySelectors`.
|
|
36
|
+
*/
|
|
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
|
+
}
|
|
16
81
|
|
|
17
82
|
function fail(error: string): Result {
|
|
18
83
|
return ResultSchema.parse({ ok: false, target: TARGET, error });
|
|
19
84
|
}
|
|
20
85
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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>;
|
|
24
98
|
}
|
|
25
99
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
/** CDP is Chromium-only; when unavailable the submit step fails loud instead of guessing. */
|
|
290
|
+
export async function createShadowProbe(
|
|
291
|
+
context: { newCDPSession?(page: unknown): Promise<CdpSession> },
|
|
292
|
+
page: unknown,
|
|
293
|
+
): 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
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
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
|
|
307
|
+
* (src/profile-check.ts) so both spell the authentication signal exactly once.
|
|
308
|
+
*/
|
|
309
|
+
export async function isXhsConsoleReady(page: XhsPage): Promise<boolean> {
|
|
310
|
+
return trySelectors(page, SELECTORS.imageTab).then((r) => r.found);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Production launcher: the upstream typed persistent-context opener (0177). Named so the
|
|
315
|
+
* migration is provable offline — tests assert this binding, never launch a real browser.
|
|
316
|
+
*/
|
|
317
|
+
export const defaultLauncher = openPersistentBrowser;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Build the default page opener. The launcher is injected so the seam (page wrapping, shadow
|
|
321
|
+
* 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).
|
|
323
|
+
*/
|
|
324
|
+
export function makeDefaultOpenPage(
|
|
325
|
+
launch: (options: { profileDir: string; headless: boolean }) => Promise<unknown> = defaultLauncher,
|
|
326
|
+
): OpenPage {
|
|
327
|
+
return async (profileDir) => {
|
|
328
|
+
let context: unknown;
|
|
329
|
+
try {
|
|
330
|
+
context = await launch({ profileDir, headless: false });
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`browser launch: ${error instanceof Error ? error.message : String(error)} — install with: bunx playwright install chromium`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const ctx = context as {
|
|
337
|
+
pages(): unknown[];
|
|
338
|
+
newPage(): Promise<unknown>;
|
|
339
|
+
close(): Promise<void>;
|
|
340
|
+
newCDPSession?(page: unknown): Promise<CdpSession>;
|
|
341
|
+
};
|
|
342
|
+
try {
|
|
343
|
+
const raw = ctx.pages()[0] ?? (await ctx.newPage());
|
|
344
|
+
const shadow = await createShadowProbe(ctx, raw);
|
|
345
|
+
return { page: wrapPlaywrightPage(raw, shadow), close: () => ctx.close() };
|
|
346
|
+
} catch (error) {
|
|
347
|
+
await ctx.close();
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Default seam: persistent Chromium on the channel profile, headed for first login. */
|
|
354
|
+
export const defaultOpenPage: OpenPage = makeDefaultOpenPage();
|
|
355
|
+
|
|
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
|
+
/** Explicit session verdict: only an observed authenticated console is `verified`. */
|
|
407
|
+
export interface ProfileSessionCheck {
|
|
408
|
+
status: 'verified' | 'refused';
|
|
409
|
+
profileDir: string;
|
|
410
|
+
/** Verdict evidence: what was observed, or the explicit refusal reason. */
|
|
411
|
+
detail: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface SessionCheckOptions {
|
|
415
|
+
profileDir: string;
|
|
416
|
+
timeoutMs?: number;
|
|
417
|
+
launcher?: BrowserLauncher;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
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.
|
|
425
|
+
*
|
|
426
|
+
* ADR-025: this is the publish preflight. The headed visit is only attempted once it says
|
|
427
|
+
* `verified`, which is what turns "no session" from a 120s headed login wait into a fast refusal.
|
|
428
|
+
*/
|
|
429
|
+
export async function checkXhsProfileSession(opts: SessionCheckOptions): Promise<ProfileSessionCheck> {
|
|
430
|
+
try {
|
|
431
|
+
await withProfilePage(
|
|
432
|
+
{
|
|
433
|
+
profileDir: opts.profileDir,
|
|
434
|
+
headless: true,
|
|
435
|
+
url: PUBLISH_URL,
|
|
436
|
+
isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
|
|
437
|
+
timeoutMs: opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS,
|
|
438
|
+
launcher: opts.launcher,
|
|
439
|
+
},
|
|
440
|
+
async () => undefined,
|
|
441
|
+
);
|
|
442
|
+
return {
|
|
443
|
+
status: 'verified',
|
|
444
|
+
profileDir: opts.profileDir,
|
|
445
|
+
detail: `authenticated ${CHANNEL} creator console observed headlessly via profile ${opts.profileDir} (read-only)`,
|
|
446
|
+
};
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return {
|
|
449
|
+
status: 'refused',
|
|
450
|
+
profileDir: opts.profileDir,
|
|
451
|
+
detail: `headless readiness refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
34
454
|
}
|
|
35
455
|
|
|
36
456
|
export class XhsBrowserTransport implements PublishTransport {
|
|
37
457
|
public readonly kind = 'browser';
|
|
38
|
-
public readonly driver:
|
|
458
|
+
public readonly driver: BrowserDriverName;
|
|
39
459
|
private readonly loginTimeoutMs: number;
|
|
40
|
-
private readonly
|
|
460
|
+
private readonly stepTimeoutMs: number;
|
|
461
|
+
private readonly submitSettleMs: number;
|
|
462
|
+
private readonly submitAttempts: number;
|
|
463
|
+
private readonly openPage: OpenPage;
|
|
464
|
+
private readonly automation: AutomationGuard;
|
|
465
|
+
private readonly preflight: (profileDir: string) => Promise<ProfileSessionCheck>;
|
|
41
466
|
|
|
42
|
-
constructor(
|
|
467
|
+
constructor(
|
|
468
|
+
driver: BrowserDriverName = 'script',
|
|
469
|
+
opts?: {
|
|
470
|
+
loginTimeoutMs?: number;
|
|
471
|
+
stepTimeoutMs?: number;
|
|
472
|
+
submitSettleMs?: number;
|
|
473
|
+
submitAttempts?: number;
|
|
474
|
+
openPage?: OpenPage;
|
|
475
|
+
/** ADR-025 automation-risk guard; defaults to the real 24h-capped state file. */
|
|
476
|
+
automation?: AutomationGuard;
|
|
477
|
+
/** ADR-025 headless session preflight; defaults to `checkXhsProfileSession`. */
|
|
478
|
+
preflight?: (profileDir: string) => Promise<ProfileSessionCheck>;
|
|
479
|
+
},
|
|
480
|
+
) {
|
|
43
481
|
this.driver = driver;
|
|
44
|
-
this.loginTimeoutMs = opts?.loginTimeoutMs ??
|
|
45
|
-
this.
|
|
482
|
+
this.loginTimeoutMs = opts?.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
483
|
+
this.stepTimeoutMs = opts?.stepTimeoutMs ?? STEP_TIMEOUT_MS;
|
|
484
|
+
this.submitSettleMs = opts?.submitSettleMs ?? SUBMIT_SETTLE_MS;
|
|
485
|
+
this.submitAttempts = opts?.submitAttempts ?? SUBMIT_ATTEMPTS;
|
|
486
|
+
this.openPage = opts?.openPage ?? defaultOpenPage;
|
|
487
|
+
this.automation = opts?.automation ?? createAutomationGuard();
|
|
488
|
+
this.preflight = opts?.preflight ?? ((profileDir) => checkXhsProfileSession({ profileDir }));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Read the console's draft counter (`草稿箱(N)`). It exists only on the landing view: the
|
|
493
|
+
* image editor replaces the header, so this must be read before entering the tab. Returns
|
|
494
|
+
* null when the label carries no number yet.
|
|
495
|
+
*/
|
|
496
|
+
private async readDraftCount(page: XhsPage): Promise<number | null> {
|
|
497
|
+
for (const selector of SELECTORS.draftBox) {
|
|
498
|
+
// Attachment, not visibility: the label lives in a panel that may be collapsed.
|
|
499
|
+
const attached = await page
|
|
500
|
+
.locator(selector)
|
|
501
|
+
.count()
|
|
502
|
+
.catch(() => 0);
|
|
503
|
+
if (attached === 0) continue;
|
|
504
|
+
const locator = page.locator(selector).first();
|
|
505
|
+
if (!locator.textContent) continue;
|
|
506
|
+
const text = await locator.textContent().catch(() => '');
|
|
507
|
+
const match = /草稿箱\s*[((]\s*(\d+)\s*[))]/.exec(text);
|
|
508
|
+
if (match?.[1] !== undefined) return Number(match[1]);
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Timestamps the console prints for a saved draft, e.g. `保存于2026-09-25 21:37`.
|
|
515
|
+
* The current and previous two minutes are accepted so a save near a minute boundary
|
|
516
|
+
* still confirms.
|
|
517
|
+
*/
|
|
518
|
+
private static draftSavedStamps(now = new Date()): string[] {
|
|
519
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
520
|
+
return [0, 1, 2].map((minutesAgo) => {
|
|
521
|
+
const stamp = new Date(now.getTime() - minutesAgo * 60_000);
|
|
522
|
+
return `保存于${stamp.getFullYear()}-${pad(stamp.getMonth() + 1)}-${pad(stamp.getDate())} ${pad(stamp.getHours())}:${pad(stamp.getMinutes())}`;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A draft counts as observed when the console prints a fresh save timestamp, or the draft
|
|
528
|
+
* counter (readable again once the save dismisses the editor) has moved past its pre-entry
|
|
529
|
+
* value. Anything else stays an explicit failure — XHS autosave means "no signal" is not
|
|
530
|
+
* evidence of a saved draft.
|
|
531
|
+
*/
|
|
532
|
+
/**
|
|
533
|
+
* Which candidate save stamps are already on the page — the baseline a later check diffs
|
|
534
|
+
* against, so a pre-existing entry can never be read as this run's save.
|
|
535
|
+
*/
|
|
536
|
+
private async readDraftStamps(page: XhsPage): Promise<string[]> {
|
|
537
|
+
const present: string[] = [];
|
|
538
|
+
for (const stamp of XhsBrowserTransport.draftSavedStamps()) {
|
|
539
|
+
const attached = await page
|
|
540
|
+
.locator(`text=${stamp}`)
|
|
541
|
+
.count()
|
|
542
|
+
.catch(() => 0);
|
|
543
|
+
if (attached > 0) present.push(stamp);
|
|
544
|
+
}
|
|
545
|
+
return present;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Draft evidence captured before the editor opens (the counter only exists on that view). */
|
|
549
|
+
private async readDraftBaseline(page: XhsPage): Promise<{ count: number | null; stamps: string[] }> {
|
|
550
|
+
return { count: await this.readDraftCount(page), stamps: await this.readDraftStamps(page) };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A draft counts as observed only from a DELTA against the pre-entry baseline: the console's
|
|
555
|
+
* draft counter moved, or a save timestamp appeared that was not already there. A readable
|
|
556
|
+
* counter with no usable baseline is NOT evidence — XHS autosaves uploads, so "the counter
|
|
557
|
+
* exists" says nothing about this save. No delta ⇒ the caller fails loud.
|
|
558
|
+
*/
|
|
559
|
+
private async isDraftSaved(
|
|
560
|
+
page: XhsPage,
|
|
561
|
+
baseline: { count: number | null; stamps: readonly string[] },
|
|
562
|
+
): Promise<boolean> {
|
|
563
|
+
const stamps = await this.readDraftStamps(page);
|
|
564
|
+
if (stamps.some((stamp) => !baseline.stamps.includes(stamp))) return true;
|
|
565
|
+
const after = await this.readDraftCount(page);
|
|
566
|
+
if (baseline.count === null || after === null) return false;
|
|
567
|
+
return after > baseline.count;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Click a labelled control inside the CLOSED shadow publish bar. The label and box come
|
|
572
|
+
* from CDP, so the click is aimed at a read target rather than a guessed coordinate; a
|
|
573
|
+
* driver without that capability fails loud instead of clicking blind.
|
|
574
|
+
*/
|
|
575
|
+
private async clickShadowControl(
|
|
576
|
+
page: XhsPage,
|
|
577
|
+
labels: readonly string[],
|
|
578
|
+
): Promise<{ ok: true; label: string; x: number; y: number } | { ok: false; error: string }> {
|
|
579
|
+
if (!page.findShadowControl || !page.clickAt) {
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
error: `submit control: the ${CHANNEL} publish bar lives in a closed shadow root and this driver has no CDP access to resolve it`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const control = await page.findShadowControl(labels);
|
|
586
|
+
if (!control) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
error: `submit control: none of [${labels.join(', ')}] found in the ${CHANNEL} publish bar (closed shadow root)`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
await page.clickAt(control.x, control.y);
|
|
593
|
+
return { ok: true, label: control.label, x: control.x, y: control.y };
|
|
46
594
|
}
|
|
47
595
|
|
|
48
596
|
public async publish(payload: PublishTransportPayload): Promise<Result> {
|
|
597
|
+
if (this.driver !== 'script') {
|
|
598
|
+
return fail(
|
|
599
|
+
`unsupported driver '${this.driver}' for ${TARGET}: image posts require the script driver (Playwright setInputFiles)`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
49
602
|
const override =
|
|
50
603
|
typeof payload.options?.profileDir === 'string'
|
|
51
604
|
? payload.options.profileDir
|
|
52
605
|
: (process.env.XHS_PROFILE_DIR ?? process.env.KK_BROWSER_PROFILE_DIR);
|
|
53
606
|
const profileDir = resolveBrowserProfileDir(CHANNEL, override);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
607
|
+
const validation = await validatePublishInput(payload.content);
|
|
608
|
+
if (validation.error) return fail(validation.error);
|
|
609
|
+
const paths = validation.paths ?? [];
|
|
610
|
+
const publishStatus = payload.content.metadata?.publishStatus === 'public' ? 'public' : 'draft';
|
|
611
|
+
const base = { driver: this.driver, publishStatus, profileDir, imageCount: paths.length };
|
|
612
|
+
|
|
613
|
+
// ADR-025 automation guard — both gates run before any browser opens.
|
|
614
|
+
// 1. Frequency cap: the operator's platform warning makes a repeated console visit an
|
|
615
|
+
// account-level risk, not merely a failed run.
|
|
616
|
+
const cap = this.automation.allow('publish');
|
|
617
|
+
if (!cap.allowed) return fail(`automation cap: ${cap.reason}`);
|
|
618
|
+
// 2. Headless session preflight: an unauthenticated profile must never produce a headed
|
|
619
|
+
// visit. The upstream launcher refuses a missing profile headless, so this replaces the
|
|
620
|
+
// previous behaviour — a headed window waiting out the 120s login gate.
|
|
621
|
+
const session = await this.preflight(profileDir);
|
|
622
|
+
if (session.status !== 'verified') {
|
|
623
|
+
return fail(`session preflight refused (no headed visit attempted): ${session.detail}`);
|
|
624
|
+
}
|
|
625
|
+
// Consumed at the headed launch, not at the attempt: a refusal above leaves the window
|
|
626
|
+
// intact so the operator can log in and retry the same day.
|
|
627
|
+
this.automation.record('publish');
|
|
628
|
+
|
|
629
|
+
let opened: OpenedPage;
|
|
630
|
+
try {
|
|
631
|
+
opened = await this.openPage(profileDir);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
634
|
+
}
|
|
635
|
+
const { page, close } = opened;
|
|
636
|
+
const act = async <T>(what: string, fn: () => Promise<T>): Promise<T> => {
|
|
637
|
+
try {
|
|
638
|
+
return await fn();
|
|
639
|
+
} catch (error) {
|
|
640
|
+
throw new Error(`${what}: ${error instanceof Error ? error.message : String(error)}`);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
try {
|
|
644
|
+
await act('creator page goto', () => page.goto(PUBLISH_URL, { timeout: this.loginTimeoutMs }));
|
|
645
|
+
|
|
646
|
+
// Login/UI gate: the image-post tab is rendered only for an authenticated
|
|
647
|
+
// creator console, and it is what the driver must switch to before the note
|
|
648
|
+
// editor exists (the console opens on the video tab).
|
|
649
|
+
const gated = await waitUntil(() => isXhsConsoleReady(page), this.loginTimeoutMs);
|
|
650
|
+
if (!gated) {
|
|
651
|
+
return fail(
|
|
652
|
+
`login timeout after ${this.loginTimeoutMs}ms: no authenticated ${CHANNEL} creator console in ${profileDir}`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const step = (selectors: readonly string[], what: string) =>
|
|
657
|
+
findSelector(page, selectors, what, this.stepTimeoutMs);
|
|
658
|
+
// The draft counter and any save timestamps exist only on the landing view, so the
|
|
659
|
+
// baseline is captured before the tab switch replaces the header with the editor.
|
|
660
|
+
const draftBaseline =
|
|
661
|
+
publishStatus === 'draft' ? await this.readDraftBaseline(page) : { count: null, stamps: [] };
|
|
662
|
+
const tabSelector = await step(SELECTORS.imageTab, 'image-post tab');
|
|
663
|
+
const clickInViewport = page.clickInViewport;
|
|
664
|
+
if (clickInViewport) {
|
|
665
|
+
const switched = await act('image-post tab click', () => clickInViewport.call(page, tabSelector));
|
|
666
|
+
if (!switched) return fail('image-post tab click: no in-viewport image tab found');
|
|
667
|
+
} else {
|
|
668
|
+
await act('image-post tab click', () => page.locator(tabSelector).first().click({ force: true }));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const fileSelector = await findAttachedSelector(
|
|
672
|
+
page,
|
|
673
|
+
SELECTORS.fileInput,
|
|
674
|
+
'image file input',
|
|
675
|
+
this.stepTimeoutMs,
|
|
676
|
+
);
|
|
677
|
+
await act('image upload', () => page.setInputFiles(fileSelector, paths));
|
|
678
|
+
|
|
679
|
+
const thumbSelector = await step(SELECTORS.thumbnail, 'thumbnail wait');
|
|
680
|
+
const countMatches = await waitUntil(
|
|
681
|
+
() =>
|
|
682
|
+
act('thumbnail count read', () => page.locator(thumbSelector).count()).then(
|
|
683
|
+
(n) => n >= paths.length,
|
|
684
|
+
),
|
|
685
|
+
this.stepTimeoutMs,
|
|
57
686
|
);
|
|
687
|
+
const thumbCount = await act('thumbnail count read', () => page.locator(thumbSelector).count());
|
|
688
|
+
if (!countMatches || thumbCount !== paths.length) {
|
|
689
|
+
return fail(`thumbnail count: expected ${paths.length}, observed ${thumbCount}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const titleSelector = await step(SELECTORS.title, 'title input');
|
|
693
|
+
await act('title fill', () =>
|
|
694
|
+
page
|
|
695
|
+
.locator(titleSelector)
|
|
696
|
+
.first()
|
|
697
|
+
.fill(payload.content.title ?? ''),
|
|
698
|
+
);
|
|
699
|
+
const bodySelector = await step(SELECTORS.body, 'note body');
|
|
700
|
+
// Type rather than fill: TipTap ignores a synthetic value set, so a filled body
|
|
701
|
+
// leaves the note unsaved and the draft never becomes observable.
|
|
702
|
+
await act('body fill', async () => {
|
|
703
|
+
const body = page.locator(bodySelector).first();
|
|
704
|
+
const text = payload.content.body ?? '';
|
|
705
|
+
if (typeof body.type === 'function') {
|
|
706
|
+
await body.click();
|
|
707
|
+
await body.type(text, { delay: 5 });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
await body.fill(text);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (publishStatus === 'draft') {
|
|
714
|
+
// "暂存离开" needs the image editor's content to have registered; a click that
|
|
715
|
+
// lands mid-settle is dropped, so settle, click, then give the save a window in
|
|
716
|
+
// which to become observable — bounded, and never a blind coordinate guess.
|
|
717
|
+
let submitted: { label: string; x: number; y: number } | null = null;
|
|
718
|
+
let confirmed = false;
|
|
719
|
+
let attempts = 0;
|
|
720
|
+
while (!confirmed && attempts < this.submitAttempts) {
|
|
721
|
+
attempts += 1;
|
|
722
|
+
await new Promise((resolve) => setTimeout(resolve, this.submitSettleMs));
|
|
723
|
+
const clicked = await this.clickShadowControl(page, SHADOW_CONTROLS.draft);
|
|
724
|
+
if (!clicked.ok) return fail(clicked.error);
|
|
725
|
+
submitted = clicked;
|
|
726
|
+
confirmed = await waitUntil(() => this.isDraftSaved(page, draftBaseline), this.submitSettleMs);
|
|
727
|
+
}
|
|
728
|
+
const draftCountAfter = await this.readDraftCount(page);
|
|
729
|
+
if (!confirmed || submitted === null) {
|
|
730
|
+
return fail(
|
|
731
|
+
`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)})`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
return ResultSchema.parse({
|
|
735
|
+
ok: true,
|
|
736
|
+
target: TARGET,
|
|
737
|
+
message: `${TARGET} draft saved (unpublished) in profile ${profileDir} via '${submitted.label}'`,
|
|
738
|
+
metadata: {
|
|
739
|
+
...base,
|
|
740
|
+
draftControl: submitted.label,
|
|
741
|
+
draftClick: `${submitted.x},${submitted.y}`,
|
|
742
|
+
draftAttempts: attempts,
|
|
743
|
+
draftCountBefore: draftBaseline.count,
|
|
744
|
+
draftCountAfter,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Publish: ONE click, no retry — an unobserved publish must never be repeated, and the
|
|
750
|
+
// console drops a click that lands while the note is still settling (measured live).
|
|
751
|
+
await new Promise((resolve) => setTimeout(resolve, this.submitSettleMs));
|
|
752
|
+
const submitted = await this.clickShadowControl(page, SHADOW_CONTROLS.publish);
|
|
753
|
+
if (!submitted.ok) return fail(submitted.error);
|
|
754
|
+
|
|
755
|
+
const urlBeforeSubmit = page.url();
|
|
756
|
+
const confirmed = await waitUntil(async () => {
|
|
757
|
+
// The console rewrites its URL to `…published=true` once the post is accepted;
|
|
758
|
+
// the text hints stay as fallbacks for console variants.
|
|
759
|
+
if (page.url().includes(PUBLISHED_MARKER)) return true;
|
|
760
|
+
return trySelectors(page, SELECTORS.publishConfirm)
|
|
761
|
+
.then((r) => r.found)
|
|
762
|
+
.catch(() => false);
|
|
763
|
+
}, this.stepTimeoutMs);
|
|
764
|
+
if (!confirmed) {
|
|
765
|
+
return fail(
|
|
766
|
+
`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.`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const url = page.url();
|
|
770
|
+
return ResultSchema.parse({
|
|
771
|
+
ok: true,
|
|
772
|
+
target: TARGET,
|
|
773
|
+
...(url !== PUBLISH_URL ? { url } : {}),
|
|
774
|
+
message: `${TARGET} post published`,
|
|
775
|
+
metadata: {
|
|
776
|
+
...base,
|
|
777
|
+
publishControl: submitted.label,
|
|
778
|
+
publishClick: `${submitted.x},${submitted.y}`,
|
|
779
|
+
publishSignal: url.includes(PUBLISHED_MARKER) ? `url:${PUBLISHED_MARKER}` : 'text',
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
} catch (error) {
|
|
783
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
784
|
+
} finally {
|
|
785
|
+
await close();
|
|
58
786
|
}
|
|
59
|
-
return draftResult(payload.content, this.driver, profileDir);
|
|
60
787
|
}
|
|
61
788
|
}
|
|
62
789
|
|
|
63
|
-
|
|
790
|
+
type BrowserDriverName = 'script' | 'agent';
|
|
791
|
+
|
|
792
|
+
export function getPublishTransport(driver: BrowserDriverName = 'script'): PublishTransport {
|
|
64
793
|
if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
|
|
65
794
|
return new FakeBrowserTransport(driver);
|
|
66
795
|
}
|