@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +10 -10
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +9 -9
- package/plugins/generations/core-facts-gen/dist/index.js +12 -12
- package/plugins/generations/daily-article-gen/dist/index.js +12 -12
- package/plugins/generations/dailynews-gen/dist/index.js +9 -9
- package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
- package/plugins/generations/gemini-tts/dist/index.js +9 -9
- package/plugins/generations/image-gen/dist/index.js +9 -9
- package/plugins/generations/news-report-gen/dist/index.js +12 -12
- package/plugins/generations/voice-gen/dist/index.js +9 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
- package/plugins/ingestions/web-search/dist/index.js +10 -10
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
- package/plugins/kk/skills/content-judge/SKILL.md +12 -2
- package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
- package/plugins/kk/skills/content-safety/SKILL.md +24 -13
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
- package/plugins/publishings/emdash-pub/dist/index.js +9 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +1 -1
- package/plugins/publishings/infoq-pub/src/index.ts +246 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +1 -1
- package/plugins/publishings/juejin-pub/src/index.ts +305 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +9 -9
- package/plugins/publishings/podcast-pub/dist/index.js +9 -9
- package/plugins/publishings/qiita-pub/dist/index.js +9 -9
- package/plugins/publishings/substack-pub/dist/index.js +8610 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +1 -1
- package/plugins/publishings/substack-pub/src/index.ts +271 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +186 -516
- package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
- package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -5
- package/plugins/publishings/x-pub/dist/index.js +8626 -221
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +1 -1
- package/plugins/publishings/x-pub/src/index.ts +258 -20
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +843 -346
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +101 -593
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +267 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
- package/plugins/publishings/zenn-pub/dist/index.js +9 -9
|
@@ -1,313 +1,95 @@
|
|
|
1
|
-
import { dirname
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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<
|
|
77
|
+
context: { newCDPSession?(page: unknown): Promise<ShadowCdpSession> },
|
|
292
78
|
page: unknown,
|
|
293
79
|
): Promise<ShadowProbe | undefined> {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
306
|
-
*
|
|
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,
|
|
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<
|
|
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
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* neither fabricate a session nor leave a
|
|
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),
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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}:
|
|
223
|
+
`unsupported driver '${this.driver}' for ${TARGET}: the console modes (${XHS_OPERATIONS.join(', ')}) require the script driver (Playwright setInputFiles)`,
|
|
608
224
|
);
|
|
609
225
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
283
|
+
// baseline is captured before the mode is entered (the editor replaces the header).
|
|
668
284
|
const draftBaseline =
|
|
669
|
-
publishStatus === 'draft' ? await
|
|
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
|
-
|
|
287
|
+
return await runMode(input, {
|
|
680
288
|
page,
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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));
|