@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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +10 -10
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +9 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +12 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +12 -12
  7. package/plugins/generations/dailynews-gen/dist/index.js +9 -9
  8. package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
  9. package/plugins/generations/gemini-tts/dist/index.js +9 -9
  10. package/plugins/generations/image-gen/dist/index.js +9 -9
  11. package/plugins/generations/news-report-gen/dist/index.js +12 -12
  12. package/plugins/generations/voice-gen/dist/index.js +9 -9
  13. package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
  14. package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
  15. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  16. package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
  17. package/plugins/ingestions/web-search/dist/index.js +10 -10
  18. package/plugins/kk/plugin.json +1 -1
  19. package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
  20. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  21. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  22. package/plugins/kk/skills/content-safety/SKILL.md +24 -13
  23. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
  24. package/plugins/publishings/emdash-pub/dist/index.js +9 -9
  25. package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
  26. package/plugins/publishings/infoq-pub/package.json +3 -1
  27. package/plugins/publishings/infoq-pub/plugin.json +1 -1
  28. package/plugins/publishings/infoq-pub/src/index.ts +246 -29
  29. package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
  30. package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
  31. package/plugins/publishings/juejin-pub/package.json +3 -1
  32. package/plugins/publishings/juejin-pub/plugin.json +1 -1
  33. package/plugins/publishings/juejin-pub/src/index.ts +305 -29
  34. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  35. package/plugins/publishings/medium-pub/dist/index.js +9 -9
  36. package/plugins/publishings/podcast-pub/dist/index.js +9 -9
  37. package/plugins/publishings/qiita-pub/dist/index.js +9 -9
  38. package/plugins/publishings/substack-pub/dist/index.js +8610 -211
  39. package/plugins/publishings/substack-pub/package.json +3 -1
  40. package/plugins/publishings/substack-pub/plugin.json +1 -1
  41. package/plugins/publishings/substack-pub/src/index.ts +271 -29
  42. package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
  43. package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
  44. package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
  45. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  46. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  47. package/plugins/publishings/wechatmp-pub/src/index.ts +186 -516
  48. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  49. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  50. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -5
  51. package/plugins/publishings/x-pub/dist/index.js +8626 -221
  52. package/plugins/publishings/x-pub/package.json +3 -1
  53. package/plugins/publishings/x-pub/plugin.json +1 -1
  54. package/plugins/publishings/x-pub/src/index.ts +258 -20
  55. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  56. package/plugins/publishings/xhs-pub/dist/index.js +843 -346
  57. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  58. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  59. package/plugins/publishings/xhs-pub/src/index.ts +101 -593
  60. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  61. package/plugins/publishings/xhs-pub/src/page.ts +267 -0
  62. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
  63. 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 { resolveBrowserProfileDir } from '@gobing-ai/publish-harness';
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
- const override = values.profile ?? process.env.XHS_PROFILE_DIR ?? process.env.KK_BROWSER_PROFILE_DIR;
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.11/node_modules/@gobing-ai/ts-utils/dist/errors.js
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.11/node_modules/@gobing-ai/ts-utils/dist/api-response.js
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.11/node_modules/@gobing-ai/ts-utils/dist/cursor.js
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.11/node_modules/@gobing-ai/ts-utils/dist/event-severity.js
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.11/node_modules/@gobing-ai/ts-utils/dist/output.js
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.11/node_modules/@gobing-ai/ts-utils/dist/index.js
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.11/node_modules/@gobing-ai/ts-runtime/dist/config.js
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.11/node_modules/@gobing-ai/ts-runtime/dist/file-system-node.js
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.11/node_modules/@gobing-ai/ts-runtime/dist/index.js
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