@gobing-ai/knowledge-kit 0.0.31 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +32320 -15021
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +10 -9
- package/plugins/generations/core-facts-gen/dist/index.js +13 -12
- package/plugins/generations/daily-article-gen/dist/index.js +14 -13
- package/plugins/generations/daily-article-gen/src/index.ts +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +10 -9
- package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
- package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
- package/plugins/generations/gemini-tts/README.md +11 -0
- package/plugins/generations/gemini-tts/dist/index.js +10 -9
- package/plugins/generations/image-gen/dist/index.js +154 -151
- package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
- package/plugins/generations/news-report-gen/dist/index.js +13 -12
- package/plugins/generations/voice-gen/dist/index.js +10 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
- package/plugins/ingestions/web-search/dist/index.js +11 -10
- package/plugins/kk/README.md +38 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
- package/plugins/kk/decisions/kk-items.yaml +113 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +15 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
- package/plugins/kk/scripts/daily-stages.ts +1533 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
- 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 +60 -18
- package/plugins/kk/skills/web-automation/SKILL.md +100 -0
- package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
- package/plugins/kk/workflows/kk-collect.yaml +258 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
- package/plugins/publishings/emdash-pub/dist/index.js +10 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +2 -1
- package/plugins/publishings/infoq-pub/src/index.ts +358 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +2 -1
- package/plugins/publishings/juejin-pub/src/index.ts +345 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +10 -9
- package/plugins/publishings/podcast-pub/dist/index.js +10 -9
- package/plugins/publishings/qiita-pub/dist/index.js +10 -9
- package/plugins/publishings/substack-pub/dist/index.js +8687 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +2 -1
- package/plugins/publishings/substack-pub/src/index.ts +302 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
- 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 +223 -520
- 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 +84 -38
- package/plugins/publishings/x-pub/dist/index.js +8707 -223
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +2 -1
- package/plugins/publishings/x-pub/src/index.ts +315 -23
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +897 -354
- package/plugins/publishings/xhs-pub/plugin.json +2 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +108 -596
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +254 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
- package/plugins/publishings/zenn-pub/dist/index.js +10 -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,254 @@
|
|
|
1
|
+
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
2
|
+
import { waitUntil } from '@gobing-ai/publish-harness/browser';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The console's page seam (ADR-029) and the helpers every xhs operation shares.
|
|
6
|
+
*
|
|
7
|
+
* `XhsPage` is deliberately a structural subset of the Playwright `Page`: the flows are driven
|
|
8
|
+
* through it so the offline tests can script the console (fake page) instead of launching a
|
|
9
|
+
* browser. Anything the shared `@gobing-ai/publish-harness/browser` helpers need on a real
|
|
10
|
+
* Playwright page is part of this seam, never reached around it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const POLL_MS = 500;
|
|
14
|
+
|
|
15
|
+
// The shared poll (0184 R2); re-exported so flow/index keep importing it from here.
|
|
16
|
+
export { waitUntil };
|
|
17
|
+
export const STEP_TIMEOUT_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/** The console's mode tab bar; it mounts late and renders every tab several times over. */
|
|
20
|
+
export const CREATOR_TAB_BAR = 'div.creator-tab';
|
|
21
|
+
/** 上传视频 / 上传图文 / 写长文 / 发播客 — the bar is only usable once all four are laid out. */
|
|
22
|
+
export const CREATOR_TAB_COUNT = 4;
|
|
23
|
+
|
|
24
|
+
/** A control resolved inside a closed shadow root, in viewport coordinates. */
|
|
25
|
+
export interface ShadowControl {
|
|
26
|
+
label: string;
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Locates a labelled control inside a closed shadow root (CDP-backed in production). */
|
|
32
|
+
export interface ShadowProbe {
|
|
33
|
+
findControl(labels: readonly string[]): Promise<ShadowControl | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface XhsLocator {
|
|
37
|
+
count(): Promise<number>;
|
|
38
|
+
isVisible(options?: { timeout?: number }): Promise<boolean>;
|
|
39
|
+
fill(text: string, options?: { timeout?: number }): Promise<void>;
|
|
40
|
+
click(options?: { timeout?: number; force?: boolean }): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Type into the element. The XHS note body is a TipTap/ProseMirror editor: `fill` sets
|
|
43
|
+
* the DOM text without the editor registering content, so a later save has nothing to
|
|
44
|
+
* save and produces no observable draft.
|
|
45
|
+
*/
|
|
46
|
+
type?(text: string, options?: { timeout?: number; delay?: number }): Promise<void>;
|
|
47
|
+
textContent?(): Promise<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Structural subset of the Playwright page the script driver uses. */
|
|
51
|
+
export interface XhsPage {
|
|
52
|
+
goto(url: string, options?: { timeout?: number }): Promise<void>;
|
|
53
|
+
locator(selector: string): { first(): XhsLocator; count(): Promise<number> };
|
|
54
|
+
/** Playwright accepts one path or a list; the flows use both (one video, or an image batch). */
|
|
55
|
+
setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
|
|
56
|
+
waitForSelector(selector: string, options?: { timeout?: number }): Promise<unknown>;
|
|
57
|
+
url(): string;
|
|
58
|
+
/** Page-side script evaluation; the shared paste helper runs its clipboard script through it. */
|
|
59
|
+
evaluate(expression: string): Promise<unknown>;
|
|
60
|
+
/** How many matches are laid out at an on-screen box (the tab-clone filter). */
|
|
61
|
+
countOnScreen(selector: string): Promise<number>;
|
|
62
|
+
/** Click the first on-screen match — the console renders the tabs 3×. */
|
|
63
|
+
clickInViewport?(selector: string): Promise<boolean>;
|
|
64
|
+
/** Resolve a labelled control inside a closed shadow root. */
|
|
65
|
+
findShadowControl?(labels: readonly string[]): Promise<ShadowControl | null>;
|
|
66
|
+
/** Click at viewport coordinates. */
|
|
67
|
+
clickAt?(x: number, y: number): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OpenedPage {
|
|
71
|
+
page: XhsPage;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type OpenPage = (profileDir: string) => Promise<OpenedPage>;
|
|
76
|
+
|
|
77
|
+
/** The `div.creator-tab` carrying one mode's label. */
|
|
78
|
+
export function creatorTabSelector(label: string): string {
|
|
79
|
+
return `${CREATOR_TAB_BAR}:has-text("${label}")`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Enter one console mode by clicking the tab bar's **on-screen** instance.
|
|
84
|
+
*
|
|
85
|
+
* Hazard (measured 2026-10-05, `docs/design/xhs-ui-discovery.md` §1): every tab is rendered at
|
|
86
|
+
* least three times — an `aria-hidden="true"` clone, a clone laid out far off-viewport (measured
|
|
87
|
+
* `x=-9726, y=-9918`), and the live node — and the whole bar mounts late and varies. A
|
|
88
|
+
* `...:has-text(label).first()` binds to a clone and hangs for the full step timeout (measured:
|
|
89
|
+
* 30 s, reported as "no real node"), and `:not([aria-hidden="true"])` alone still matches an
|
|
90
|
+
* off-screen clone. So the bar is waited on first (≥4 laid-out, on-screen tabs) and the click
|
|
91
|
+
* then targets the first candidate whose box is inside the viewport.
|
|
92
|
+
*/
|
|
93
|
+
export async function clickCreatorTab(
|
|
94
|
+
page: XhsPage,
|
|
95
|
+
label: string,
|
|
96
|
+
opts: { timeoutMs?: number; pollMs?: number } = {},
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const timeoutMs = opts.timeoutMs ?? STEP_TIMEOUT_MS;
|
|
99
|
+
const pollMs = opts.pollMs ?? POLL_MS;
|
|
100
|
+
// A count read that throws (a detached node while the bar is still mounting) is "not yet",
|
|
101
|
+
// not a broken rail: the deadline below is what reports the observed state.
|
|
102
|
+
const onScreenCount = () => page.countOnScreen(CREATOR_TAB_BAR).catch(() => 0);
|
|
103
|
+
const mounted = await waitUntil(
|
|
104
|
+
() => onScreenCount().then((count) => count >= CREATOR_TAB_COUNT),
|
|
105
|
+
timeoutMs,
|
|
106
|
+
pollMs,
|
|
107
|
+
);
|
|
108
|
+
if (!mounted) {
|
|
109
|
+
const observed = await onScreenCount();
|
|
110
|
+
throw new Error(
|
|
111
|
+
`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`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!page.clickInViewport) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`mode entry: this driver cannot read viewport geometry, so the on-screen '${label}' tab cannot be told apart from its off-screen clones`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const clicked = await page.clickInViewport(creatorTabSelector(label));
|
|
120
|
+
if (!clicked) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`mode entry: no in-viewport '${label}' tab found (only aria-hidden or off-screen clones matched) — the mode was not entered`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage {
|
|
128
|
+
const pw = raw as {
|
|
129
|
+
goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
130
|
+
locator(selector: string): {
|
|
131
|
+
count(): Promise<number>;
|
|
132
|
+
nth(index: number): {
|
|
133
|
+
boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null>;
|
|
134
|
+
click(options?: Record<string, unknown>): Promise<void>;
|
|
135
|
+
};
|
|
136
|
+
first(): Record<string, (...args: unknown[]) => unknown> & Promise<unknown>;
|
|
137
|
+
};
|
|
138
|
+
setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
|
|
139
|
+
waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
140
|
+
url(): string;
|
|
141
|
+
viewportSize?(): { width: number; height: number } | null;
|
|
142
|
+
evaluate(expression: string): Promise<unknown>;
|
|
143
|
+
mouse: { click(x: number, y: number): Promise<void> };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The matches of `selector` that are laid out at a readable box inside the viewport. A
|
|
148
|
+
* `display:none` node reports no box at all (`boundingBox()` → null), which is the non-hidden
|
|
149
|
+
* half of the tab-bar rule; an off-screen clone keeps its box and is rejected on coordinates.
|
|
150
|
+
* Without a readable viewport nothing qualifies — the duplicates cannot be told apart, so the
|
|
151
|
+
* caller fails loud instead of clicking a maybe-off-screen copy.
|
|
152
|
+
*/
|
|
153
|
+
const onScreen = async (
|
|
154
|
+
selector: string,
|
|
155
|
+
): Promise<Array<{ index: number; box: { x: number; y: number; width: number; height: number } }>> => {
|
|
156
|
+
const viewport = pw.viewportSize?.() ?? null;
|
|
157
|
+
if (viewport === null) return [];
|
|
158
|
+
const total = await pw.locator(selector).count();
|
|
159
|
+
const found: Array<{ index: number; box: { x: number; y: number; width: number; height: number } }> = [];
|
|
160
|
+
for (let index = 0; index < total; index += 1) {
|
|
161
|
+
const candidate = pw.locator(selector).nth(index);
|
|
162
|
+
const box = await candidate.boundingBox().catch(() => null);
|
|
163
|
+
if (!box || box.width === 0 || box.height === 0) continue;
|
|
164
|
+
const outside = box.x < 0 || box.y < 0 || box.x >= viewport.width || box.y >= viewport.height;
|
|
165
|
+
if (outside) continue;
|
|
166
|
+
found.push({ index, box });
|
|
167
|
+
}
|
|
168
|
+
return found;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
goto: (url, options) => pw.goto(url, options) as Promise<void>,
|
|
173
|
+
locator: (selector) => ({
|
|
174
|
+
count: () => pw.locator(selector).count(),
|
|
175
|
+
first: () => {
|
|
176
|
+
const loc = pw.locator(selector).first() as unknown as {
|
|
177
|
+
count(): Promise<number>;
|
|
178
|
+
isVisible(o?: Record<string, unknown>): Promise<boolean>;
|
|
179
|
+
fill(t: string, o?: Record<string, unknown>): Promise<void>;
|
|
180
|
+
click(o?: Record<string, unknown>): Promise<void>;
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
count: () => loc.count(),
|
|
184
|
+
isVisible: (options) => loc.isVisible(options),
|
|
185
|
+
fill: (text, options) => loc.fill(text, options),
|
|
186
|
+
click: (options) => loc.click(options),
|
|
187
|
+
type: (text, options) =>
|
|
188
|
+
(
|
|
189
|
+
loc as unknown as {
|
|
190
|
+
pressSequentially(text: string, options?: { delay?: number }): Promise<void>;
|
|
191
|
+
}
|
|
192
|
+
).pressSequentially(text, options),
|
|
193
|
+
textContent: () => (loc as unknown as { textContent(): Promise<string> }).textContent(),
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
setInputFiles: (selector, paths) => pw.setInputFiles(selector, paths),
|
|
198
|
+
waitForSelector: (selector, options) => pw.waitForSelector(selector, options),
|
|
199
|
+
url: () => pw.url(),
|
|
200
|
+
evaluate: (expression) => pw.evaluate(expression),
|
|
201
|
+
countOnScreen: async (selector) => (await onScreen(selector)).length,
|
|
202
|
+
clickInViewport: async (selector) => {
|
|
203
|
+
const [candidate] = await onScreen(selector);
|
|
204
|
+
if (candidate === undefined) return false;
|
|
205
|
+
// The tab's inner span intercepts the hit test, so the click must be forced.
|
|
206
|
+
await pw.locator(selector).nth(candidate.index).click({ force: true });
|
|
207
|
+
return true;
|
|
208
|
+
},
|
|
209
|
+
findShadowControl: async (labels) => (shadow ? shadow.findControl(labels) : null),
|
|
210
|
+
clickAt: (x, y) => pw.mouse.click(x, y),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Polls the candidate list until one selector has an attached match; throws naming `what`. */
|
|
215
|
+
export async function findSelector(
|
|
216
|
+
page: XhsPage,
|
|
217
|
+
selectors: readonly string[],
|
|
218
|
+
what: string,
|
|
219
|
+
timeoutMs = STEP_TIMEOUT_MS,
|
|
220
|
+
): Promise<string> {
|
|
221
|
+
const deadline = Date.now() + timeoutMs;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
const found = await trySelectors(page, selectors);
|
|
224
|
+
if (found.found && found.selector) return found.selector;
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`${what}: no matching element observed`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Find the first selector that is ATTACHED, ignoring visibility. Upload widgets hide the
|
|
232
|
+
* real `<input type="file">` behind a styled drop zone, and `trySelectors` filters on
|
|
233
|
+
* visibility by default — so a hidden input needs this probe. Uploading into the wrong
|
|
234
|
+
* input is still caught: the thumbnail count must match the payload afterwards.
|
|
235
|
+
*/
|
|
236
|
+
export async function findAttachedSelector(
|
|
237
|
+
page: XhsPage,
|
|
238
|
+
selectors: readonly string[],
|
|
239
|
+
what: string,
|
|
240
|
+
timeoutMs = STEP_TIMEOUT_MS,
|
|
241
|
+
): Promise<string> {
|
|
242
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
|
+
while (Date.now() < deadline) {
|
|
244
|
+
for (const selector of selectors) {
|
|
245
|
+
const attached = await page
|
|
246
|
+
.locator(selector)
|
|
247
|
+
.count()
|
|
248
|
+
.catch(() => 0);
|
|
249
|
+
if (attached > 0) return selector;
|
|
250
|
+
}
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`${what}: no matching element observed`);
|
|
254
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - `login` recovers an absent/expired session headed via the upstream
|
|
7
7
|
* `loginWithProfile`, polled with the same XHS-owned readiness gate the publish
|
|
8
8
|
* flow uses;
|
|
9
|
-
* - `check` reopens the existing profile headless
|
|
9
|
+
* - `check` reopens the existing profile (headless by default; `--headed` skips the headless
|
|
10
|
+
* pass and probes headed only, 0216) and reports the session as
|
|
10
11
|
* `verified` or `refused` — a site refusal, expired session, or missing profile
|
|
11
12
|
* is an explicit failed readiness, never a success. The upstream launcher never
|
|
12
13
|
* creates a profile headless, so a check cannot fabricate a session from a
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
* the full recipe lives in docs/design/xhs-image-cards.md.
|
|
22
23
|
*/
|
|
23
24
|
import { parseArgs } from 'node:util';
|
|
24
|
-
import {
|
|
25
|
+
import { kkBrowserLauncher, resolveChannelProfileDir } from '@gobing-ai/publish-harness/browser';
|
|
25
26
|
import { type BrowserLauncher, loginWithProfile } from '@gobing-ai/ts-browser-automation';
|
|
26
27
|
import { echo, echoError } from '@gobing-ai/ts-utils';
|
|
27
28
|
import { type AutomationGuard, createAutomationGuard } from './automation-guard';
|
|
@@ -60,11 +61,20 @@ export async function loginProfile(opts: ProfileLoginOptions): Promise<void> {
|
|
|
60
61
|
url: PUBLISH_URL,
|
|
61
62
|
isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
|
|
62
63
|
timeoutMs: opts.timeoutMs ?? LOGIN_TIMEOUT_MS,
|
|
63
|
-
launcher: opts.launcher,
|
|
64
|
+
launcher: opts.launcher ?? kkBrowserLauncher,
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/** Exit 2 with the human usage on stderr and the ADR-032 `error` verdict as the last stdout line. */
|
|
69
|
+
function usageError(message: string): number {
|
|
70
|
+
echoError(
|
|
71
|
+
`profile-check: ${message} — usage: profile-check.ts [login|check] [--profile <dir>] [--timeout <ms>] [--headed]`,
|
|
72
|
+
);
|
|
73
|
+
echo(JSON.stringify({ status: 'error', detail: message }));
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `login|check [--profile <dir>] [--timeout <ms>] [--headed]`; profile precedence matches the publish flow. */
|
|
68
78
|
export async function main(
|
|
69
79
|
argv: string[] = process.argv.slice(2),
|
|
70
80
|
launcher?: BrowserLauncher,
|
|
@@ -73,26 +83,28 @@ export async function main(
|
|
|
73
83
|
let command: string;
|
|
74
84
|
let profileDir: string;
|
|
75
85
|
let timeoutMs: number | undefined;
|
|
86
|
+
let headed = false;
|
|
76
87
|
try {
|
|
77
88
|
const { positionals, values } = parseArgs({
|
|
78
89
|
args: argv,
|
|
79
90
|
allowPositionals: true,
|
|
80
|
-
options: {
|
|
91
|
+
options: {
|
|
92
|
+
profile: { type: 'string' },
|
|
93
|
+
timeout: { type: 'string' },
|
|
94
|
+
headed: { type: 'boolean' },
|
|
95
|
+
},
|
|
81
96
|
});
|
|
82
97
|
command = positionals[0] ?? 'check';
|
|
98
|
+
headed = values.headed === true;
|
|
83
99
|
if (values.timeout !== undefined) {
|
|
84
100
|
timeoutMs = Number(values.timeout);
|
|
85
101
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
86
102
|
throw new Error(`--timeout must be a non-negative number of ms, got '${values.timeout}'`);
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
|
-
|
|
90
|
-
profileDir = resolveBrowserProfileDir(CHANNEL, override);
|
|
105
|
+
profileDir = resolveChannelProfileDir(CHANNEL, values.profile);
|
|
91
106
|
} catch (error) {
|
|
92
|
-
|
|
93
|
-
`profile-check: ${error instanceof Error ? error.message : String(error)} — usage: profile-check.ts [login|check] [--profile <dir>] [--timeout <ms>]`,
|
|
94
|
-
);
|
|
95
|
-
return 1;
|
|
107
|
+
return usageError(error instanceof Error ? error.message : String(error));
|
|
96
108
|
}
|
|
97
109
|
if (command === 'login') {
|
|
98
110
|
try {
|
|
@@ -105,21 +117,21 @@ export async function main(
|
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
if (command !== 'check') {
|
|
108
|
-
|
|
109
|
-
`profile-check: unknown command '${command}' — usage: profile-check.ts [login|check] [--profile <dir>] [--timeout <ms>]`,
|
|
110
|
-
);
|
|
111
|
-
return 1;
|
|
120
|
+
return usageError(`unknown command '${command}'`);
|
|
112
121
|
}
|
|
113
122
|
// ADR-025: a manual session check is a real console visit, so it draws on the shared 2h
|
|
114
123
|
// automation budget rather than a separate one. `login` is deliberately uncapped above: it is
|
|
115
124
|
// operator-interactive, so capping it would only block recovery from an expired session.
|
|
116
125
|
const cap = guard.allow('check');
|
|
117
126
|
if (!cap.allowed) {
|
|
118
|
-
|
|
127
|
+
// The budget refusal is still a readiness verdict (0216 contract): emit the JSON line so
|
|
128
|
+
// `kk plugin check` reports a refusal with the operator hint instead of a malformed probe.
|
|
129
|
+
echo(JSON.stringify({ status: 'refused', profileDir, detail: `xhs readiness refused: ${cap.reason}` }));
|
|
130
|
+
echoError(cap.reason);
|
|
119
131
|
return 1;
|
|
120
132
|
}
|
|
121
133
|
guard.record('check');
|
|
122
|
-
const outcome = await checkXhsProfileSession({ profileDir, timeoutMs, launcher });
|
|
134
|
+
const outcome = await checkXhsProfileSession({ profileDir, timeoutMs, launcher, headedOnly: headed });
|
|
123
135
|
echo(JSON.stringify(outcome));
|
|
124
136
|
if (outcome.status !== 'verified') {
|
|
125
137
|
echoError(outcome.detail);
|