@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import type { Content } from '@gobing-ai/kk-core';
|
|
3
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The xhs channel's publishing TYPES, declared and validated (ADR-029).
|
|
7
|
+
*
|
|
8
|
+
* One plugin per channel: the four console modes a single authenticated session offers are
|
|
9
|
+
* `operations` of this plugin — selected from `options.operation ?? content.metadata.operation`,
|
|
10
|
+
* resolved against {@link XHS_OPERATIONS}, and refused by name **before** a browser is launched or
|
|
11
|
+
* a profile is resolved. Each operation owns its own entry step, artifact set, limits and flow
|
|
12
|
+
* (`./flow`); the channel owns the profile dir, session, preflight and `Content → Result` mapping
|
|
13
|
+
* (`./index`).
|
|
14
|
+
*
|
|
15
|
+
* Labels and constraints come from the live console walk (`docs/design/xhs-ui-discovery.md`).
|
|
16
|
+
* Everything here is pre-browser: it decides whether the run is allowed to open a session at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const TARGET = 'xhs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Channel-wide console facts, shared by the session gate (`./index`) and the flows (`./flow`):
|
|
23
|
+
* every mode is reached from this one creator-console entry, and a completed publish is marked in
|
|
24
|
+
* the console's own URL (measured live 2026-09-26).
|
|
25
|
+
*/
|
|
26
|
+
export const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish';
|
|
27
|
+
export const PUBLISHED_MARKER = 'published=true';
|
|
28
|
+
|
|
29
|
+
export const XHS_OPERATIONS = ['上传图文', '上传视频', '写长文', '发播客'] as const;
|
|
30
|
+
|
|
31
|
+
export type XhsOperation = (typeof XHS_OPERATIONS)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* No operation requested ⇒ the note/composer mode this channel shipped with. The daily pipeline's
|
|
35
|
+
* card-series stage (`kk-workflow-stages.ts` E8) still dispatches xhs-pub without one, and its
|
|
36
|
+
* packaged `Content` is exactly an image-text note.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_XHS_OPERATION: XhsOperation = '上传图文';
|
|
39
|
+
|
|
40
|
+
/** Exit-early resolution of the requested operation (ADR-029 dispatch). */
|
|
41
|
+
export function resolveXhsOperation(
|
|
42
|
+
options?: Record<string, unknown>,
|
|
43
|
+
content?: Content,
|
|
44
|
+
): { operation: XhsOperation; error?: undefined } | { operation?: undefined; error: string } {
|
|
45
|
+
const raw = options?.operation ?? content?.metadata?.operation;
|
|
46
|
+
if (raw === undefined) return { operation: DEFAULT_XHS_OPERATION };
|
|
47
|
+
if (typeof raw !== 'string' || !(XHS_OPERATIONS as readonly string[]).includes(raw)) {
|
|
48
|
+
return {
|
|
49
|
+
error: `unsupported operation '${String(raw)}' for ${TARGET}: expected one of ${XHS_OPERATIONS.join(', ')}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { operation: raw as XhsOperation };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Image extensions the 图文 grid accepts (`accept=".jpg,.jpeg,.png,.webp"`). */
|
|
56
|
+
const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp)$/i;
|
|
57
|
+
const IMAGE_LIST = 'png|jpg|jpeg|webp';
|
|
58
|
+
/** Video extensions the dropzone accepts (`accept=".mp4,.mov,…,.ts"`). */
|
|
59
|
+
const VIDEO_EXTENSIONS = /\.(mp4|mov|flv|f4v|mkv|rm|rmvb|m4v|mpg|mpeg|ts)$/i;
|
|
60
|
+
const VIDEO_LIST = 'mp4|mov|flv|f4v|mkv|rm|rmvb|m4v|mpg|mpeg|ts';
|
|
61
|
+
/** Audio extensions the 发播客 modal's picker accepts (`accept=".mp3,.wav,.aac,.flac,.m4a"`). */
|
|
62
|
+
const AUDIO_EXTENSIONS = /\.(mp3|wav|aac|flac|m4a)$/i;
|
|
63
|
+
const AUDIO_LIST = 'mp3|wav|aac|flac|m4a';
|
|
64
|
+
|
|
65
|
+
/** Images the 图文 grid accepts before it refuses the batch (discovery §2: ≤18). */
|
|
66
|
+
export const XHS_IMAGE_MAX = 18;
|
|
67
|
+
/**
|
|
68
|
+
* Note-title characters. The daily card-series stage enforces the same cap on the plan it packages
|
|
69
|
+
* for this channel (`kk-workflow-stages.ts` XHS_TITLE_CAP), which is exactly the content this
|
|
70
|
+
* plugin receives.
|
|
71
|
+
*/
|
|
72
|
+
export const XHS_NOTE_TITLE_MAX = 20;
|
|
73
|
+
/** Note-body characters, read off the 图文 editor's own `0/1000` counter (discovery §2). */
|
|
74
|
+
export const XHS_NOTE_BODY_MAX = 1000;
|
|
75
|
+
|
|
76
|
+
/** Per-mode field ceilings, as recorded by the console walk. */
|
|
77
|
+
export interface ModeLimits {
|
|
78
|
+
/** Images the 图文 grid accepts before it refuses the batch. */
|
|
79
|
+
imageMax?: number;
|
|
80
|
+
/** Title characters; the same cap the daily card-series stage already enforces on the plan. */
|
|
81
|
+
titleMax?: number;
|
|
82
|
+
/** Note-body characters, read off the editor's own `0/1000` counter. */
|
|
83
|
+
bodyMax?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const MODE_LIMITS: Record<XhsOperation, ModeLimits> = {
|
|
87
|
+
// 图文: image grid ≤18, note title 20, note body 0/1000 (discovery §2).
|
|
88
|
+
上传图文: { imageMax: XHS_IMAGE_MAX, titleMax: XHS_NOTE_TITLE_MAX, bodyMax: XHS_NOTE_BODY_MAX },
|
|
89
|
+
// 视频: one video file; the same note composer carries the title. No body counter was recorded
|
|
90
|
+
// for the video note, so none is enforced.
|
|
91
|
+
上传视频: { titleMax: XHS_NOTE_TITLE_MAX },
|
|
92
|
+
// 写长文: no upload and no recorded ceiling — it is the long-form surface.
|
|
93
|
+
写长文: {},
|
|
94
|
+
// 发播客: the artifacts are the audio and its cover; no text ceiling was recorded.
|
|
95
|
+
发播客: {},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A validated mode input: the operation plus the artifacts and limits that mode requires.
|
|
100
|
+
* Discriminated on `operation` so every flow narrows to exactly the artifacts it may use.
|
|
101
|
+
*/
|
|
102
|
+
export type ModeInput =
|
|
103
|
+
| { operation: '上传图文'; images: string[] }
|
|
104
|
+
| { operation: '上传视频'; video: string }
|
|
105
|
+
| { operation: '写长文' }
|
|
106
|
+
| { operation: '发播客'; audio: string; cover: string };
|
|
107
|
+
|
|
108
|
+
/** Validated artifacts in upload order — the evidence a Result reports. */
|
|
109
|
+
export function artifactPaths(input: ModeInput): string[] {
|
|
110
|
+
switch (input.operation) {
|
|
111
|
+
case '上传图文':
|
|
112
|
+
return input.images;
|
|
113
|
+
case '上传视频':
|
|
114
|
+
return [input.video];
|
|
115
|
+
case '写长文':
|
|
116
|
+
return [];
|
|
117
|
+
case '发播客':
|
|
118
|
+
return [input.audio, input.cover];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface MediaSpec {
|
|
123
|
+
/** `metadata` key the artifact is read from. */
|
|
124
|
+
key: 'video' | 'audio' | 'coverImage';
|
|
125
|
+
/** Error prefix, e.g. `video validation`. */
|
|
126
|
+
label: string;
|
|
127
|
+
extensions: RegExp;
|
|
128
|
+
/** Accepted extensions for the error text. */
|
|
129
|
+
list: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reads one local media artifact out of `Content.metadata`. The repo spells a single artifact
|
|
134
|
+
* either bare (`metadata.coverImage`, `metadata.audioFile`) or as a `{ path }` descriptor
|
|
135
|
+
* (`metadata.images[]`), so both are accepted here; the error always names the `metadata` key.
|
|
136
|
+
*/
|
|
137
|
+
async function resolveMediaPath(content: Content, spec: MediaSpec): Promise<{ path?: string; error?: string }> {
|
|
138
|
+
const raw = content.metadata?.[spec.key];
|
|
139
|
+
const path =
|
|
140
|
+
typeof raw === 'string'
|
|
141
|
+
? raw
|
|
142
|
+
: typeof (raw as { path?: unknown } | undefined)?.path === 'string'
|
|
143
|
+
? ((raw as { path: string }).path as string)
|
|
144
|
+
: undefined;
|
|
145
|
+
if (path === undefined || path.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
error: `${spec.label}: Content.metadata.${spec.key} is required (absolute path to an existing ${spec.list} file)`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!isAbsolute(path)) {
|
|
151
|
+
return { error: `${spec.label}: Content.metadata.${spec.key} must be absolute, got '${path}'` };
|
|
152
|
+
}
|
|
153
|
+
if (!spec.extensions.test(path)) {
|
|
154
|
+
return {
|
|
155
|
+
error: `${spec.label}: Content.metadata.${spec.key} has unsupported extension (expected ${spec.list}): '${path}'`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!(await createNodeFileSystem().exists(path))) {
|
|
159
|
+
return { error: `${spec.label}: Content.metadata.${spec.key} does not exist: '${path}'` };
|
|
160
|
+
}
|
|
161
|
+
return { path };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pure pre-browser validation of one image-text note: `publishStatus`, the ordered image batch and
|
|
166
|
+
* the mode's caps. Kept as the 图文 entry point (and exported for its own tests).
|
|
167
|
+
*/
|
|
168
|
+
export async function validatePublishInput(content: Content): Promise<{ paths?: string[]; error?: string }> {
|
|
169
|
+
const status = validatePublishStatus(content);
|
|
170
|
+
if (status.error) return { error: status.error };
|
|
171
|
+
const images = content.metadata?.images;
|
|
172
|
+
if (!Array.isArray(images) || images.length === 0) {
|
|
173
|
+
return { error: 'images validation: metadata.images must be a non-empty ordered array' };
|
|
174
|
+
}
|
|
175
|
+
const max = XHS_IMAGE_MAX;
|
|
176
|
+
if (images.length > max) {
|
|
177
|
+
return {
|
|
178
|
+
error: `images validation: metadata.images holds ${images.length} entries but the 上传图文 grid accepts ≤${max}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const paths: string[] = [];
|
|
182
|
+
for (const [i, image] of images.entries()) {
|
|
183
|
+
const path = (image as { path?: unknown })?.path;
|
|
184
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
185
|
+
return { error: `images validation: images[${i}].path is missing` };
|
|
186
|
+
}
|
|
187
|
+
if (!isAbsolute(path)) {
|
|
188
|
+
return { error: `images validation: images[${i}].path must be absolute, got '${path}'` };
|
|
189
|
+
}
|
|
190
|
+
if (!IMAGE_EXTENSIONS.test(path)) {
|
|
191
|
+
return {
|
|
192
|
+
error: `images validation: images[${i}].path has unsupported extension (expected ${IMAGE_LIST}): '${path}'`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!(await createNodeFileSystem().exists(path))) {
|
|
196
|
+
return { error: `images validation: images[${i}].path does not exist: '${path}'` };
|
|
197
|
+
}
|
|
198
|
+
paths.push(path);
|
|
199
|
+
}
|
|
200
|
+
return { paths };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `publishStatus` is the operator's explicit live-publish request: absent means draft, `public`
|
|
205
|
+
* means the flow may click the console's 发布 control, and anything else is refused here — before
|
|
206
|
+
* a browser opens, so a typo can never reach a real publish control.
|
|
207
|
+
*/
|
|
208
|
+
function validatePublishStatus(content: Content): { error?: string } {
|
|
209
|
+
const status = content.metadata?.publishStatus;
|
|
210
|
+
if (status !== undefined && status !== 'draft' && status !== 'public') {
|
|
211
|
+
return {
|
|
212
|
+
error: `publishStatus validation: unknown publishStatus '${String(status)}' (expected 'draft' or 'public')`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The note title cap is shared by the two note-composer modes. */
|
|
219
|
+
function validateTitle(content: Content, operation: XhsOperation): { error?: string } {
|
|
220
|
+
const max = MODE_LIMITS[operation].titleMax;
|
|
221
|
+
if (max === undefined) return {};
|
|
222
|
+
const title = content.title ?? '';
|
|
223
|
+
if (title.length > max) {
|
|
224
|
+
return {
|
|
225
|
+
error: `title validation: Content.title is ${title.length} chars but ${operation} accepts ≤${max}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateBody(content: Content, operation: XhsOperation): { error?: string } {
|
|
232
|
+
const max = MODE_LIMITS[operation].bodyMax;
|
|
233
|
+
if (max === undefined) return {};
|
|
234
|
+
if (content.body.length > max) {
|
|
235
|
+
return {
|
|
236
|
+
error: `body validation: Content.body is ${content.body.length} chars but ${operation} accepts ≤${max}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export type ModeValidation = { input: ModeInput; error?: undefined } | { input?: undefined; error: string };
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pre-browser validation for the requested operation: what it needs, and whether it fits.
|
|
246
|
+
* A failure here is returned as `ok:false` naming the missing artifact, field or limit — the run
|
|
247
|
+
* never opens a browser or resolves a profile for input the console cannot accept.
|
|
248
|
+
*/
|
|
249
|
+
export async function validateModeInput(operation: XhsOperation, content: Content): Promise<ModeValidation> {
|
|
250
|
+
const status = validatePublishStatus(content);
|
|
251
|
+
if (status.error) return { error: status.error };
|
|
252
|
+
|
|
253
|
+
switch (operation) {
|
|
254
|
+
case '上传图文': {
|
|
255
|
+
const title = validateTitle(content, operation);
|
|
256
|
+
if (title.error) return { error: title.error };
|
|
257
|
+
const body = validateBody(content, operation);
|
|
258
|
+
if (body.error) return { error: body.error };
|
|
259
|
+
const images = await validatePublishInput(content);
|
|
260
|
+
if (images.error) return { error: images.error };
|
|
261
|
+
return { input: { operation, images: images.paths ?? [] } };
|
|
262
|
+
}
|
|
263
|
+
case '上传视频': {
|
|
264
|
+
const title = validateTitle(content, operation);
|
|
265
|
+
if (title.error) return { error: title.error };
|
|
266
|
+
const video = await resolveMediaPath(content, {
|
|
267
|
+
key: 'video',
|
|
268
|
+
label: 'video validation',
|
|
269
|
+
extensions: VIDEO_EXTENSIONS,
|
|
270
|
+
list: VIDEO_LIST,
|
|
271
|
+
});
|
|
272
|
+
if (video.error) return { error: video.error };
|
|
273
|
+
return { input: { operation, video: video.path ?? '' } };
|
|
274
|
+
}
|
|
275
|
+
case '写长文': {
|
|
276
|
+
// No upload: the body IS the artifact, authored in place in the console editor.
|
|
277
|
+
if (content.body.trim().length === 0) {
|
|
278
|
+
return { error: 'body validation: Content.body must be non-empty for 写长文 (authored in place)' };
|
|
279
|
+
}
|
|
280
|
+
return { input: { operation } };
|
|
281
|
+
}
|
|
282
|
+
case '发播客': {
|
|
283
|
+
const audio = await resolveMediaPath(content, {
|
|
284
|
+
key: 'audio',
|
|
285
|
+
label: 'audio validation',
|
|
286
|
+
extensions: AUDIO_EXTENSIONS,
|
|
287
|
+
list: AUDIO_LIST,
|
|
288
|
+
});
|
|
289
|
+
if (audio.error) return { error: audio.error };
|
|
290
|
+
const cover = await resolveMediaPath(content, {
|
|
291
|
+
key: 'coverImage',
|
|
292
|
+
label: 'cover validation',
|
|
293
|
+
extensions: IMAGE_EXTENSIONS,
|
|
294
|
+
list: IMAGE_LIST,
|
|
295
|
+
});
|
|
296
|
+
if (cover.error) return { error: cover.error };
|
|
297
|
+
return { input: { operation, audio: audio.path ?? '', cover: cover.path ?? '' } };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The console's page seam (ADR-029) and the helpers every xhs operation shares.
|
|
5
|
+
*
|
|
6
|
+
* `XhsPage` is deliberately a structural subset of the Playwright `Page`: the flows are driven
|
|
7
|
+
* through it so the offline tests can script the console (fake page) instead of launching a
|
|
8
|
+
* browser. Anything the shared `@gobing-ai/publish-harness/browser` helpers need on a real
|
|
9
|
+
* Playwright page is part of this seam, never reached around it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const POLL_MS = 500;
|
|
13
|
+
export const STEP_TIMEOUT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
/** The console's mode tab bar; it mounts late and renders every tab several times over. */
|
|
16
|
+
export const CREATOR_TAB_BAR = 'div.creator-tab';
|
|
17
|
+
/** 上传视频 / 上传图文 / 写长文 / 发播客 — the bar is only usable once all four are laid out. */
|
|
18
|
+
export const CREATOR_TAB_COUNT = 4;
|
|
19
|
+
|
|
20
|
+
/** A control resolved inside a closed shadow root, in viewport coordinates. */
|
|
21
|
+
export interface ShadowControl {
|
|
22
|
+
label: string;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Locates a labelled control inside a closed shadow root (CDP-backed in production). */
|
|
28
|
+
export interface ShadowProbe {
|
|
29
|
+
findControl(labels: readonly string[]): Promise<ShadowControl | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface XhsLocator {
|
|
33
|
+
count(): Promise<number>;
|
|
34
|
+
isVisible(options?: { timeout?: number }): Promise<boolean>;
|
|
35
|
+
fill(text: string, options?: { timeout?: number }): Promise<void>;
|
|
36
|
+
click(options?: { timeout?: number; force?: boolean }): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Type into the element. The XHS note body is a TipTap/ProseMirror editor: `fill` sets
|
|
39
|
+
* the DOM text without the editor registering content, so a later save has nothing to
|
|
40
|
+
* save and produces no observable draft.
|
|
41
|
+
*/
|
|
42
|
+
type?(text: string, options?: { timeout?: number; delay?: number }): Promise<void>;
|
|
43
|
+
textContent?(): Promise<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Structural subset of the Playwright page the script driver uses. */
|
|
47
|
+
export interface XhsPage {
|
|
48
|
+
goto(url: string, options?: { timeout?: number }): Promise<void>;
|
|
49
|
+
locator(selector: string): { first(): XhsLocator; count(): Promise<number> };
|
|
50
|
+
/** Playwright accepts one path or a list; the flows use both (one video, or an image batch). */
|
|
51
|
+
setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
|
|
52
|
+
waitForSelector(selector: string, options?: { timeout?: number }): Promise<unknown>;
|
|
53
|
+
url(): string;
|
|
54
|
+
/** Page-side script evaluation; the shared paste helper runs its clipboard script through it. */
|
|
55
|
+
evaluate(expression: string): Promise<unknown>;
|
|
56
|
+
/** How many matches are laid out at an on-screen box (the tab-clone filter). */
|
|
57
|
+
countOnScreen(selector: string): Promise<number>;
|
|
58
|
+
/** Click the first on-screen match — the console renders the tabs 3×. */
|
|
59
|
+
clickInViewport?(selector: string): Promise<boolean>;
|
|
60
|
+
/** Resolve a labelled control inside a closed shadow root. */
|
|
61
|
+
findShadowControl?(labels: readonly string[]): Promise<ShadowControl | null>;
|
|
62
|
+
/** Click at viewport coordinates. */
|
|
63
|
+
clickAt?(x: number, y: number): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OpenedPage {
|
|
67
|
+
page: XhsPage;
|
|
68
|
+
close(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type OpenPage = (profileDir: string) => Promise<OpenedPage>;
|
|
72
|
+
|
|
73
|
+
/** The `div.creator-tab` carrying one mode's label. */
|
|
74
|
+
export function creatorTabSelector(label: string): string {
|
|
75
|
+
return `${CREATOR_TAB_BAR}:has-text("${label}")`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Enter one console mode by clicking the tab bar's **on-screen** instance.
|
|
80
|
+
*
|
|
81
|
+
* Hazard (measured 2026-10-05, `docs/design/xhs-ui-discovery.md` §1): every tab is rendered at
|
|
82
|
+
* least three times — an `aria-hidden="true"` clone, a clone laid out far off-viewport (measured
|
|
83
|
+
* `x=-9726, y=-9918`), and the live node — and the whole bar mounts late and varies. A
|
|
84
|
+
* `...:has-text(label).first()` binds to a clone and hangs for the full step timeout (measured:
|
|
85
|
+
* 30 s, reported as "no real node"), and `:not([aria-hidden="true"])` alone still matches an
|
|
86
|
+
* off-screen clone. So the bar is waited on first (≥4 laid-out, on-screen tabs) and the click
|
|
87
|
+
* then targets the first candidate whose box is inside the viewport.
|
|
88
|
+
*/
|
|
89
|
+
export async function clickCreatorTab(
|
|
90
|
+
page: XhsPage,
|
|
91
|
+
label: string,
|
|
92
|
+
opts: { timeoutMs?: number; pollMs?: number } = {},
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const timeoutMs = opts.timeoutMs ?? STEP_TIMEOUT_MS;
|
|
95
|
+
const pollMs = opts.pollMs ?? POLL_MS;
|
|
96
|
+
// A count read that throws (a detached node while the bar is still mounting) is "not yet",
|
|
97
|
+
// not a broken rail: the deadline below is what reports the observed state.
|
|
98
|
+
const onScreenCount = () => page.countOnScreen(CREATOR_TAB_BAR).catch(() => 0);
|
|
99
|
+
const mounted = await waitUntil(
|
|
100
|
+
() => onScreenCount().then((count) => count >= CREATOR_TAB_COUNT),
|
|
101
|
+
timeoutMs,
|
|
102
|
+
pollMs,
|
|
103
|
+
);
|
|
104
|
+
if (!mounted) {
|
|
105
|
+
const observed = await onScreenCount();
|
|
106
|
+
throw new Error(
|
|
107
|
+
`mode entry: the console tab bar did not mount within ${timeoutMs}ms (${observed} of ${CREATOR_TAB_COUNT} on-screen '${CREATOR_TAB_BAR}' nodes) — every tab renders aria-hidden and off-viewport clones`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!page.clickInViewport) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`mode entry: this driver cannot read viewport geometry, so the on-screen '${label}' tab cannot be told apart from its off-screen clones`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const clicked = await page.clickInViewport(creatorTabSelector(label));
|
|
116
|
+
if (!clicked) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`mode entry: no in-viewport '${label}' tab found (only aria-hidden or off-screen clones matched) — the mode was not entered`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Poll `predicate` every `pollMs` until the deadline, then evaluate it one final time. The trailing
|
|
125
|
+
* call matters: the condition often becomes true exactly at the boundary.
|
|
126
|
+
*/
|
|
127
|
+
export async function waitUntil(
|
|
128
|
+
predicate: () => Promise<boolean>,
|
|
129
|
+
timeoutMs: number,
|
|
130
|
+
pollMs = POLL_MS,
|
|
131
|
+
): Promise<boolean> {
|
|
132
|
+
const deadline = Date.now() + timeoutMs;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
if (await predicate()) return true;
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
136
|
+
}
|
|
137
|
+
return predicate();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage {
|
|
141
|
+
const pw = raw as {
|
|
142
|
+
goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
143
|
+
locator(selector: string): {
|
|
144
|
+
count(): Promise<number>;
|
|
145
|
+
nth(index: number): {
|
|
146
|
+
boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null>;
|
|
147
|
+
click(options?: Record<string, unknown>): Promise<void>;
|
|
148
|
+
};
|
|
149
|
+
first(): Record<string, (...args: unknown[]) => unknown> & Promise<unknown>;
|
|
150
|
+
};
|
|
151
|
+
setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
|
|
152
|
+
waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
153
|
+
url(): string;
|
|
154
|
+
viewportSize?(): { width: number; height: number } | null;
|
|
155
|
+
evaluate(expression: string): Promise<unknown>;
|
|
156
|
+
mouse: { click(x: number, y: number): Promise<void> };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The matches of `selector` that are laid out at a readable box inside the viewport. A
|
|
161
|
+
* `display:none` node reports no box at all (`boundingBox()` → null), which is the non-hidden
|
|
162
|
+
* half of the tab-bar rule; an off-screen clone keeps its box and is rejected on coordinates.
|
|
163
|
+
* Without a readable viewport nothing qualifies — the duplicates cannot be told apart, so the
|
|
164
|
+
* caller fails loud instead of clicking a maybe-off-screen copy.
|
|
165
|
+
*/
|
|
166
|
+
const onScreen = async (
|
|
167
|
+
selector: string,
|
|
168
|
+
): Promise<Array<{ index: number; box: { x: number; y: number; width: number; height: number } }>> => {
|
|
169
|
+
const viewport = pw.viewportSize?.() ?? null;
|
|
170
|
+
if (viewport === null) return [];
|
|
171
|
+
const total = await pw.locator(selector).count();
|
|
172
|
+
const found: Array<{ index: number; box: { x: number; y: number; width: number; height: number } }> = [];
|
|
173
|
+
for (let index = 0; index < total; index += 1) {
|
|
174
|
+
const candidate = pw.locator(selector).nth(index);
|
|
175
|
+
const box = await candidate.boundingBox().catch(() => null);
|
|
176
|
+
if (!box || box.width === 0 || box.height === 0) continue;
|
|
177
|
+
const outside = box.x < 0 || box.y < 0 || box.x >= viewport.width || box.y >= viewport.height;
|
|
178
|
+
if (outside) continue;
|
|
179
|
+
found.push({ index, box });
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
goto: (url, options) => pw.goto(url, options) as Promise<void>,
|
|
186
|
+
locator: (selector) => ({
|
|
187
|
+
count: () => pw.locator(selector).count(),
|
|
188
|
+
first: () => {
|
|
189
|
+
const loc = pw.locator(selector).first() as unknown as {
|
|
190
|
+
count(): Promise<number>;
|
|
191
|
+
isVisible(o?: Record<string, unknown>): Promise<boolean>;
|
|
192
|
+
fill(t: string, o?: Record<string, unknown>): Promise<void>;
|
|
193
|
+
click(o?: Record<string, unknown>): Promise<void>;
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
count: () => loc.count(),
|
|
197
|
+
isVisible: (options) => loc.isVisible(options),
|
|
198
|
+
fill: (text, options) => loc.fill(text, options),
|
|
199
|
+
click: (options) => loc.click(options),
|
|
200
|
+
type: (text, options) =>
|
|
201
|
+
(
|
|
202
|
+
loc as unknown as {
|
|
203
|
+
pressSequentially(text: string, options?: { delay?: number }): Promise<void>;
|
|
204
|
+
}
|
|
205
|
+
).pressSequentially(text, options),
|
|
206
|
+
textContent: () => (loc as unknown as { textContent(): Promise<string> }).textContent(),
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
setInputFiles: (selector, paths) => pw.setInputFiles(selector, paths),
|
|
211
|
+
waitForSelector: (selector, options) => pw.waitForSelector(selector, options),
|
|
212
|
+
url: () => pw.url(),
|
|
213
|
+
evaluate: (expression) => pw.evaluate(expression),
|
|
214
|
+
countOnScreen: async (selector) => (await onScreen(selector)).length,
|
|
215
|
+
clickInViewport: async (selector) => {
|
|
216
|
+
const [candidate] = await onScreen(selector);
|
|
217
|
+
if (candidate === undefined) return false;
|
|
218
|
+
// The tab's inner span intercepts the hit test, so the click must be forced.
|
|
219
|
+
await pw.locator(selector).nth(candidate.index).click({ force: true });
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
findShadowControl: async (labels) => (shadow ? shadow.findControl(labels) : null),
|
|
223
|
+
clickAt: (x, y) => pw.mouse.click(x, y),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Polls the candidate list until one selector has an attached match; throws naming `what`. */
|
|
228
|
+
export async function findSelector(
|
|
229
|
+
page: XhsPage,
|
|
230
|
+
selectors: readonly string[],
|
|
231
|
+
what: string,
|
|
232
|
+
timeoutMs = STEP_TIMEOUT_MS,
|
|
233
|
+
): Promise<string> {
|
|
234
|
+
const deadline = Date.now() + timeoutMs;
|
|
235
|
+
while (Date.now() < deadline) {
|
|
236
|
+
const found = await trySelectors(page, selectors);
|
|
237
|
+
if (found.found && found.selector) return found.selector;
|
|
238
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
239
|
+
}
|
|
240
|
+
throw new Error(`${what}: no matching element observed`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Find the first selector that is ATTACHED, ignoring visibility. Upload widgets hide the
|
|
245
|
+
* real `<input type="file">` behind a styled drop zone, and `trySelectors` filters on
|
|
246
|
+
* visibility by default — so a hidden input needs this probe. Uploading into the wrong
|
|
247
|
+
* input is still caught: the thumbnail count must match the payload afterwards.
|
|
248
|
+
*/
|
|
249
|
+
export async function findAttachedSelector(
|
|
250
|
+
page: XhsPage,
|
|
251
|
+
selectors: readonly string[],
|
|
252
|
+
what: string,
|
|
253
|
+
timeoutMs = STEP_TIMEOUT_MS,
|
|
254
|
+
): Promise<string> {
|
|
255
|
+
const deadline = Date.now() + timeoutMs;
|
|
256
|
+
while (Date.now() < deadline) {
|
|
257
|
+
for (const selector of selectors) {
|
|
258
|
+
const attached = await page
|
|
259
|
+
.locator(selector)
|
|
260
|
+
.count()
|
|
261
|
+
.catch(() => 0);
|
|
262
|
+
if (attached > 0) return selector;
|
|
263
|
+
}
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`${what}: no matching element observed`);
|
|
267
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* the full recipe lives in docs/design/xhs-image-cards.md.
|
|
22
22
|
*/
|
|
23
23
|
import { parseArgs } from 'node:util';
|
|
24
|
-
import {
|
|
24
|
+
import { resolveChannelProfileDir } from '@gobing-ai/publish-harness/browser';
|
|
25
25
|
import { type BrowserLauncher, loginWithProfile } from '@gobing-ai/ts-browser-automation';
|
|
26
26
|
import { echo, echoError } from '@gobing-ai/ts-utils';
|
|
27
27
|
import { type AutomationGuard, createAutomationGuard } from './automation-guard';
|
|
@@ -86,8 +86,7 @@ export async function main(
|
|
|
86
86
|
throw new Error(`--timeout must be a non-negative number of ms, got '${values.timeout}'`);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
profileDir = resolveBrowserProfileDir(CHANNEL, override);
|
|
89
|
+
profileDir = resolveChannelProfileDir(CHANNEL, values.profile);
|
|
91
90
|
} catch (error) {
|
|
92
91
|
echoError(
|
|
93
92
|
`profile-check: ${error instanceof Error ? error.message : String(error)} — usage: profile-check.ts [login|check] [--profile <dir>] [--timeout <ms>]`,
|
|
@@ -14642,7 +14642,7 @@ var init_zod = __esm(() => {
|
|
|
14642
14642
|
init_external();
|
|
14643
14643
|
});
|
|
14644
14644
|
|
|
14645
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14645
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/errors.js
|
|
14646
14646
|
var ErrorCode;
|
|
14647
14647
|
var init_errors3 = __esm(() => {
|
|
14648
14648
|
ErrorCode = {
|
|
@@ -14653,7 +14653,7 @@ var init_errors3 = __esm(() => {
|
|
|
14653
14653
|
};
|
|
14654
14654
|
});
|
|
14655
14655
|
|
|
14656
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14656
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/api-response.js
|
|
14657
14657
|
var API_ERROR_CODES, ERROR_CODE_TO_HTTP, CLIENT_SAFE_CODES;
|
|
14658
14658
|
var init_api_response = __esm(() => {
|
|
14659
14659
|
init_errors3();
|
|
@@ -14676,13 +14676,13 @@ var init_api_response = __esm(() => {
|
|
|
14676
14676
|
CLIENT_SAFE_CODES = new Set([ErrorCode.NotFound, ErrorCode.Validation, ErrorCode.Conflict]);
|
|
14677
14677
|
});
|
|
14678
14678
|
|
|
14679
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14679
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/cursor.js
|
|
14680
14680
|
var init_cursor = () => {};
|
|
14681
14681
|
|
|
14682
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14682
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/event-severity.js
|
|
14683
14683
|
var init_event_severity = () => {};
|
|
14684
14684
|
|
|
14685
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14685
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/output.js
|
|
14686
14686
|
function processStream(name) {
|
|
14687
14687
|
const proc = globalThis.process;
|
|
14688
14688
|
const stream = proc?.[name];
|
|
@@ -14700,7 +14700,7 @@ function echoError(message, target = defaultStderrTarget ?? processStream("stder
|
|
|
14700
14700
|
}
|
|
14701
14701
|
var defaultStderrTarget;
|
|
14702
14702
|
|
|
14703
|
-
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.
|
|
14703
|
+
// ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.15/node_modules/@gobing-ai/ts-utils/dist/index.js
|
|
14704
14704
|
var init_dist = __esm(() => {
|
|
14705
14705
|
init_api_response();
|
|
14706
14706
|
init_cursor();
|
|
@@ -21698,7 +21698,7 @@ var init_dist2 = __esm(() => {
|
|
|
21698
21698
|
$visitAsync = visit.visitAsync;
|
|
21699
21699
|
});
|
|
21700
21700
|
|
|
21701
|
-
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.
|
|
21701
|
+
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.15/node_modules/@gobing-ai/ts-runtime/dist/config.js
|
|
21702
21702
|
var configSchema;
|
|
21703
21703
|
var init_config = __esm(() => {
|
|
21704
21704
|
init_dist2();
|
|
@@ -21722,7 +21722,7 @@ var init_config = __esm(() => {
|
|
|
21722
21722
|
});
|
|
21723
21723
|
});
|
|
21724
21724
|
|
|
21725
|
-
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.
|
|
21725
|
+
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.15/node_modules/@gobing-ai/ts-runtime/dist/file-system-node.js
|
|
21726
21726
|
import { appendFileSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
21727
21727
|
import { dirname, resolve as resolvePath } from "path";
|
|
21728
21728
|
function createNodeFileSystem(root, paths) {
|
|
@@ -21901,7 +21901,7 @@ var PluginManifestSchema = exports_external.looseObject({
|
|
|
21901
21901
|
description: exports_external.string().optional(),
|
|
21902
21902
|
operations: exports_external.record(exports_external.string(), PluginOperationSchema).optional()
|
|
21903
21903
|
});
|
|
21904
|
-
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.
|
|
21904
|
+
// ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.15/node_modules/@gobing-ai/ts-runtime/dist/index.js
|
|
21905
21905
|
init_file_system_node();
|
|
21906
21906
|
init_config();
|
|
21907
21907
|
|