@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.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +32320 -15021
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +10 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +13 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +14 -13
  7. package/plugins/generations/daily-article-gen/src/index.ts +1 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +10 -9
  9. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  10. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  11. package/plugins/generations/gemini-tts/README.md +11 -0
  12. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  13. package/plugins/generations/image-gen/dist/index.js +154 -151
  14. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  15. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  16. package/plugins/generations/voice-gen/dist/index.js +10 -9
  17. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  18. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  19. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  20. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  21. package/plugins/ingestions/web-search/dist/index.js +11 -10
  22. package/plugins/kk/README.md +38 -0
  23. package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
  24. package/plugins/kk/decisions/kk-items.yaml +113 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/schedules/cron.tmpl +15 -0
  27. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  28. package/plugins/kk/scripts/daily-stages.ts +1533 -0
  29. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
  30. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  31. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  32. package/plugins/kk/skills/content-safety/SKILL.md +60 -18
  33. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  34. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  35. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  39. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
  41. package/plugins/publishings/infoq-pub/package.json +3 -1
  42. package/plugins/publishings/infoq-pub/plugin.json +2 -1
  43. package/plugins/publishings/infoq-pub/src/index.ts +358 -29
  44. package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
  45. package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
  46. package/plugins/publishings/juejin-pub/package.json +3 -1
  47. package/plugins/publishings/juejin-pub/plugin.json +2 -1
  48. package/plugins/publishings/juejin-pub/src/index.ts +345 -29
  49. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  51. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  52. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  53. package/plugins/publishings/substack-pub/dist/index.js +8687 -211
  54. package/plugins/publishings/substack-pub/package.json +3 -1
  55. package/plugins/publishings/substack-pub/plugin.json +2 -1
  56. package/plugins/publishings/substack-pub/src/index.ts +302 -29
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  62. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  63. package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
  64. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  65. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  66. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
  67. package/plugins/publishings/x-pub/dist/index.js +8707 -223
  68. package/plugins/publishings/x-pub/package.json +3 -1
  69. package/plugins/publishings/x-pub/plugin.json +2 -1
  70. package/plugins/publishings/x-pub/src/index.ts +315 -23
  71. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +897 -354
  73. package/plugins/publishings/xhs-pub/plugin.json +2 -1
  74. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +108 -596
  76. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  77. package/plugins/publishings/xhs-pub/src/page.ts +254 -0
  78. package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
  79. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -0,0 +1,403 @@
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 wechatmp channel's publishing TYPES, declared and validated (ADR-029).
7
+ *
8
+ * One plugin per channel: the four console modes a single authenticated MP session offers
9
+ * (文章 / 贴图 / 视频 / 播客, `createType` 0/8/5/7) are `operations` of this plugin — selected from
10
+ * `options.operation ?? content.metadata.operation`, resolved against {@link WECHATMP_OPERATIONS},
11
+ * and refused by name **before** a browser is launched or a profile is resolved. Each operation owns
12
+ * its own entry, artifact set, limits and flow (`./flow`); the channel owns the profile dir,
13
+ * session, preflight and `Content → Result` mapping (`./index`).
14
+ *
15
+ * Labels, routes and constraints come from the live console walk
16
+ * (`docs/design/wechatmp-ui-discovery.md`). Everything here is pre-browser: it decides whether the
17
+ * run is allowed to open a session at all.
18
+ */
19
+
20
+ export const TARGET = 'wechatmp';
21
+
22
+ /** Console entry; it redirects to `/cgi-bin/home?…&token=<N>` for an authenticated session. */
23
+ export const CONSOLE_URL = 'https://mp.weixin.qq.com/';
24
+ /** The authenticated console home fragment — the readiness signal the preflight and flow share. */
25
+ export const CONSOLE_HOME_FRAGMENT = '/cgi-bin/home';
26
+
27
+ export const WECHATMP_OPERATIONS = ['文章', '贴图', '视频', '播客'] as const;
28
+
29
+ export type WechatmpOperation = (typeof WECHATMP_OPERATIONS)[number];
30
+
31
+ /**
32
+ * No operation requested ⇒ 文章. The daily workflow's `wechat-publish` state dispatches this plugin
33
+ * without one (`kk-workflow-stages.ts` `daily-publish-wechat`), and its packaged `Content` is the
34
+ * episode article.
35
+ */
36
+ export const DEFAULT_WECHATMP_OPERATION: WechatmpOperation = '文章';
37
+
38
+ /**
39
+ * `createType` per mode. Verified for all four on the live console (discovery §2): each creation
40
+ * type lands on the same editor route with its own value.
41
+ */
42
+ export const CREATE_TYPES: Record<WechatmpOperation, number> = {
43
+ 文章: 0,
44
+ 贴图: 8,
45
+ 视频: 5,
46
+ 播客: 7,
47
+ };
48
+
49
+ /**
50
+ * The editor route template (discovery §2), minus `createType` / `token` / `timestamp`. A stored
51
+ * editor URL is NOT reusable (W2): the token is per-session and the timestamp is per-visit.
52
+ */
53
+ export const EDITOR_ROUTE = '/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77';
54
+
55
+ /** Every console route carries the per-session token (discovery §1), so it is read, never stored. */
56
+ const CONSOLE_TOKEN_PATTERN = /[?&]token=([0-9]+)/;
57
+
58
+ /** The token carried by a console URL, or `undefined` when the URL is not an authenticated route. */
59
+ export function readConsoleToken(url: string): string | undefined {
60
+ return CONSOLE_TOKEN_PATTERN.exec(url)?.[1];
61
+ }
62
+
63
+ /**
64
+ * The editor deep-link for one mode, built at run time from the console's own session token
65
+ * (discovery §1: "the token is per-session, so stored console URLs cannot be reused verbatim") and
66
+ * a freshly generated `timestamp` — never a baked-in one.
67
+ */
68
+ export function editorUrl(operation: WechatmpOperation, token: string, timestamp: number = Date.now()): string {
69
+ const base = `${CONSOLE_URL.replace(/\/$/, '')}${EDITOR_ROUTE}`;
70
+ return `${base}&createType=${CREATE_TYPES[operation]}&token=${encodeURIComponent(token)}&lang=zh_CN&timestamp=${timestamp}`;
71
+ }
72
+
73
+ /** The `createType` an editor URL reports, or `undefined` when it does not carry the field. */
74
+ export function readCreateType(url: string): number | undefined {
75
+ const found = /[?&]createType=([0-9]+)/.exec(url)?.[1];
76
+ return found === undefined ? undefined : Number(found);
77
+ }
78
+
79
+ /** Exit-early resolution of the requested operation (ADR-029 dispatch). */
80
+ export function resolveWechatmpOperation(
81
+ options?: Record<string, unknown>,
82
+ content?: Content,
83
+ ): { operation: WechatmpOperation; error?: undefined } | { operation?: undefined; error: string } {
84
+ const raw = options?.operation ?? content?.metadata?.operation;
85
+ if (raw === undefined) return { operation: DEFAULT_WECHATMP_OPERATION };
86
+ if (typeof raw !== 'string' || !(WECHATMP_OPERATIONS as readonly string[]).includes(raw)) {
87
+ return {
88
+ error: `unsupported operation '${String(raw)}' for ${TARGET}: expected one of ${WECHATMP_OPERATIONS.join(', ')}`,
89
+ };
90
+ }
91
+ return { operation: raw as WechatmpOperation };
92
+ }
93
+
94
+ /** Extensions the editor's image input accepts (`accept="image/gif,image/jpeg,image/jpg,image/png,image/svg,image/webp"`). */
95
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg)$/i;
96
+ const IMAGE_LIST = 'png|jpg|jpeg|gif|webp|svg';
97
+ /**
98
+ * Video extensions for the 视频 media modal. The walk recorded the modal's controls (`本地上传`,
99
+ * `确定`) but not its picker's `accept` list, so this set is the union MP's own video surfaces
100
+ * accept — the live dogfood confirms it.
101
+ */
102
+ const VIDEO_EXTENSIONS = /\.(mp4|mov|flv|f4v|mkv|m4v|mpg|mpeg|ts)$/i;
103
+ const VIDEO_LIST = 'mp4|mov|flv|f4v|mkv|m4v|mpg|mpeg|ts';
104
+ /** Audio extensions for the 播客 media modal (`上传音频`; the walk recorded no `accept` list). */
105
+ const AUDIO_EXTENSIONS = /\.(mp3|wav|aac|flac|m4a)$/i;
106
+ const AUDIO_LIST = 'mp3|wav|aac|flac|m4a';
107
+
108
+ /**
109
+ * The 贴图 image ceiling. The walk recorded **no** cap (discovery §3/§5.2 name none) and this
110
+ * repository does not invent platform limits, so none is enforced — the constant is the recorded
111
+ * absence, and the live dogfood is where a real cap would be entered.
112
+ */
113
+ export const STICKER_IMAGE_MAX: number | undefined = undefined;
114
+
115
+ /** The 摘要 derivation's cap (the pre-ADR-029 heuristic: the body's first non-empty line, ≤54 chars). */
116
+ const DERIVED_DIGEST_MAX = 54;
117
+
118
+ /** Per-mode field ceilings, as recorded by the console walk (discovery §3/§5.2). */
119
+ export interface ModeLimits {
120
+ /** Title characters: 0/64 for 文章, 0/20 for 视频/播客. Absent = no recorded ceiling. */
121
+ titleMax?: number;
122
+ /** The mode refuses an untitled draft: the title placeholder is 请在这里输入标题, not (选填). */
123
+ titleRequired?: boolean;
124
+ /** 摘要 characters (`textarea#js_description`, 0/120). */
125
+ digestMax?: number;
126
+ /** 作者 characters (`input#author`, 0/8, labelled 原创 and gated on 原创 verification). */
127
+ authorMax?: number;
128
+ }
129
+
130
+ export const MODE_LIMITS: Record<WechatmpOperation, ModeLimits> = {
131
+ // 文章: title 0/64 required, 摘要 0/120 选填, 作者 0/8 (discovery §5.2).
132
+ 文章: { titleRequired: true, titleMax: 64, digestMax: 120, authorMax: 8 },
133
+ // 贴图: the title block is `请在这里输入标题(选填)` (no required flag, no recorded ceiling).
134
+ 贴图: { authorMax: 8 },
135
+ // 视频 / 播客: the same editor route with a 0/20 title limit (discovery §5.2).
136
+ 视频: { titleMax: 20, authorMax: 8 },
137
+ 播客: { titleMax: 20, authorMax: 8 },
138
+ };
139
+
140
+ /**
141
+ * A validated mode input: the operation plus the artifacts and fields that mode requires.
142
+ * Discriminated on `operation` so every flow narrows to exactly the artifacts it may use.
143
+ */
144
+ export type ModeInput =
145
+ | { operation: '文章'; images: string[]; digest?: string }
146
+ | { operation: '贴图'; images: string[] }
147
+ | { operation: '视频'; video: string }
148
+ | { operation: '播客'; audio: string; cover: string };
149
+
150
+ /** Validated artifacts in attachment order — the evidence a Result reports. */
151
+ export function artifactPaths(input: ModeInput): string[] {
152
+ switch (input.operation) {
153
+ case '文章':
154
+ return input.images;
155
+ case '贴图':
156
+ return input.images;
157
+ case '视频':
158
+ return [input.video];
159
+ case '播客':
160
+ return [input.audio, input.cover];
161
+ }
162
+ }
163
+
164
+ /** The body image the cover panel's 默认首图为封面 rule turns into the mode's cover. */
165
+ export function coverPath(input: ModeInput): string | undefined {
166
+ return input.operation === '文章' || input.operation === '贴图' ? input.images[0] : undefined;
167
+ }
168
+
169
+ interface MediaSpec {
170
+ /** `metadata` key the artifact is read from. */
171
+ key: 'video' | 'audio' | 'coverImage';
172
+ /** Error prefix, e.g. `video validation`. */
173
+ label: string;
174
+ extensions: RegExp;
175
+ /** Accepted extensions for the error text. */
176
+ list: string;
177
+ }
178
+
179
+ /**
180
+ * Reads one local media artifact out of `Content.metadata`. The repo spells a single artifact
181
+ * either bare (`metadata.coverImage`) or as a `{ path }` descriptor, so both are accepted here;
182
+ * the error always names the `metadata` key.
183
+ */
184
+ async function resolveMediaPath(content: Content, spec: MediaSpec): Promise<{ path?: string; error?: string }> {
185
+ const raw = content.metadata?.[spec.key];
186
+ const path =
187
+ typeof raw === 'string'
188
+ ? raw
189
+ : typeof (raw as { path?: unknown } | undefined)?.path === 'string'
190
+ ? ((raw as { path: string }).path as string)
191
+ : undefined;
192
+ if (path === undefined || path.length === 0) {
193
+ return {
194
+ error: `${spec.label}: Content.metadata.${spec.key} is required (absolute path to an existing ${spec.list} file)`,
195
+ };
196
+ }
197
+ if (!isAbsolute(path)) {
198
+ return { error: `${spec.label}: Content.metadata.${spec.key} must be absolute, got '${path}'` };
199
+ }
200
+ if (!spec.extensions.test(path)) {
201
+ return {
202
+ error: `${spec.label}: Content.metadata.${spec.key} has unsupported extension (expected ${spec.list}): '${path}'`,
203
+ };
204
+ }
205
+ if (!(await createNodeFileSystem().exists(path))) {
206
+ return { error: `${spec.label}: Content.metadata.${spec.key} does not exist: '${path}'` };
207
+ }
208
+ return { path };
209
+ }
210
+
211
+ /**
212
+ * The body-image set for the two image-carrying modes, in attachment order.
213
+ *
214
+ * `metadata.images` is the repo's ordered convention (xhs reads the same key). The daily MP stage
215
+ * instead supplies `metadata.wechatmp.coverPath` — the operator's cover — which is attached FIRST
216
+ * because the cover panel states 默认首图为封面 (discovery §5.2): the first body image is the cover.
217
+ */
218
+ async function resolveBodyImages(content: Content): Promise<{ paths?: string[]; error?: string }> {
219
+ const ordered = content.metadata?.images;
220
+ if (ordered !== undefined && !Array.isArray(ordered)) {
221
+ return { error: 'images validation: metadata.images must be an array' };
222
+ }
223
+ const descriptors = Array.isArray(ordered) ? ordered : [];
224
+ const cover =
225
+ typeof content.metadata?.wechatmp === 'object' && content.metadata.wechatmp !== null
226
+ ? (content.metadata.wechatmp as Record<string, unknown>).coverPath
227
+ : undefined;
228
+ const candidates: Array<{ path: string; what: string }> = [];
229
+ if (typeof cover === 'string' && cover.length > 0)
230
+ candidates.push({ path: cover, what: 'metadata.wechatmp.coverPath' });
231
+ for (const [index, image] of descriptors.entries()) {
232
+ const path = typeof image === 'string' ? image : (image as { path?: unknown })?.path;
233
+ if (typeof path !== 'string' || path.length === 0) {
234
+ return { error: `images validation: images[${index}] carries no path` };
235
+ }
236
+ candidates.push({ path, what: `metadata.images[${index}]` });
237
+ }
238
+
239
+ const paths: string[] = [];
240
+ for (const { path, what } of candidates) {
241
+ if (!isAbsolute(path)) {
242
+ return { error: `images validation: ${what} must be absolute, got '${path}'` };
243
+ }
244
+ if (!IMAGE_EXTENSIONS.test(path)) {
245
+ return {
246
+ error: `images validation: ${what} has unsupported extension (expected ${IMAGE_LIST}): '${path}'`,
247
+ };
248
+ }
249
+ if (!(await createNodeFileSystem().exists(path))) {
250
+ return { error: `images validation: ${what} does not exist: '${path}'` };
251
+ }
252
+ paths.push(path);
253
+ }
254
+ return { paths };
255
+ }
256
+
257
+ /**
258
+ * The 摘要 for 文章: `metadata.digest` when the operator sets one, else the body's first non-empty
259
+ * line (the pre-ADR-029 derivation, kept). `textarea#js_description` is 0/120 and 选填.
260
+ */
261
+ export function deriveDigest(content: Content): string | undefined {
262
+ const explicit = content.metadata?.digest;
263
+ if (typeof explicit === 'string') return explicit;
264
+ const body = content.body ?? '';
265
+ const first = body
266
+ .split('\n')
267
+ .map((line) => line.trim())
268
+ .find((line) => line !== '');
269
+ return first === undefined ? undefined : first.slice(0, DERIVED_DIGEST_MAX);
270
+ }
271
+
272
+ /**
273
+ * `publishStatus` is the operator's explicit live-publish request: absent means draft, `public`
274
+ * means the flow may click the console's 发表 control, and anything else is refused here — before a
275
+ * browser opens, so a typo can never reach a real publish control (ADR-022/ADR-026).
276
+ */
277
+ function validatePublishStatus(content: Content): { error?: string } {
278
+ const status = content.metadata?.publishStatus;
279
+ if (status !== undefined && status !== 'draft' && status !== 'public') {
280
+ return {
281
+ error: `publishStatus validation: unknown publishStatus '${String(status)}' (expected 'draft' or 'public')`,
282
+ };
283
+ }
284
+ return {};
285
+ }
286
+
287
+ function validateTitle(content: Content, operation: WechatmpOperation): { error?: string } {
288
+ const limits = MODE_LIMITS[operation];
289
+ const title = content.title ?? '';
290
+ if (limits.titleRequired === true && title.trim().length === 0) {
291
+ return {
292
+ error: `title validation: Content.title is required for ${operation} (the editor's placeholder is 请在这里输入标题)`,
293
+ };
294
+ }
295
+ if (limits.titleMax !== undefined && title.length > limits.titleMax) {
296
+ return {
297
+ error: `title validation: Content.title is ${title.length} chars but ${operation} accepts ≤${limits.titleMax}`,
298
+ };
299
+ }
300
+ return {};
301
+ }
302
+
303
+ function validateDigest(content: Content, operation: WechatmpOperation): { digest?: string; error?: string } {
304
+ const max = MODE_LIMITS[operation].digestMax;
305
+ if (max === undefined) return {};
306
+ const digest = deriveDigest(content);
307
+ if (digest !== undefined && digest.length > max) {
308
+ return { error: `digest validation: the 摘要 is ${digest.length} chars but ${operation} accepts ≤${max}` };
309
+ }
310
+ return digest === undefined ? {} : { digest };
311
+ }
312
+
313
+ function validateAuthor(content: Content, operation: WechatmpOperation): { author?: string; error?: string } {
314
+ const max = MODE_LIMITS[operation].authorMax;
315
+ const author = content.metadata?.author;
316
+ if (typeof author !== 'string' || author.length === 0) return {};
317
+ if (max !== undefined && author.length > max) {
318
+ return {
319
+ error: `author validation: Content.metadata.author is ${author.length} chars but ${operation} accepts ≤${max}`,
320
+ };
321
+ }
322
+ return { author };
323
+ }
324
+
325
+ export type ModeValidation = { input: ModeInput; error?: undefined } | { input?: undefined; error: string };
326
+
327
+ /**
328
+ * Pre-browser validation for the requested operation: what it needs, and whether it fits.
329
+ *
330
+ * A failure here is returned as `ok:false` naming the missing artifact, field or limit — the run
331
+ * never opens a browser or resolves a profile for input the console cannot accept. The 视频/播客
332
+ * media modal covers the save bar (discovery §5.1), so a missing artifact must be caught here and
333
+ * not after a console visit.
334
+ */
335
+ export async function validateModeInput(operation: WechatmpOperation, content: Content): Promise<ModeValidation> {
336
+ const status = validatePublishStatus(content);
337
+ if (status.error) return { error: status.error };
338
+ const title = validateTitle(content, operation);
339
+ if (title.error) return { error: title.error };
340
+ const author = validateAuthor(content, operation);
341
+ if (author.error) return { error: author.error };
342
+
343
+ switch (operation) {
344
+ case '文章': {
345
+ if ((content.body ?? '').trim().length === 0) {
346
+ return { error: 'body validation: Content.body is required for 文章' };
347
+ }
348
+ const digest = validateDigest(content, operation);
349
+ if (digest.error) return { error: digest.error };
350
+ const images = await resolveBodyImages(content);
351
+ if (images.error) return { error: images.error };
352
+ if ((images.paths ?? []).length === 0) {
353
+ return {
354
+ error: 'cover validation: 文章 takes its cover from a body image (默认首图为封面) but none was supplied — set metadata.wechatmp.coverPath or metadata.images[]',
355
+ };
356
+ }
357
+ return {
358
+ input: {
359
+ operation,
360
+ images: images.paths ?? [],
361
+ ...(digest.digest === undefined ? {} : { digest: digest.digest }),
362
+ },
363
+ };
364
+ }
365
+ case '贴图': {
366
+ const images = await resolveBodyImages(content);
367
+ if (images.error) return { error: images.error };
368
+ if ((images.paths ?? []).length === 0) {
369
+ return {
370
+ error: 'image validation: 贴图 requires at least one image (metadata.images[] or metadata.wechatmp.coverPath)',
371
+ };
372
+ }
373
+ return { input: { operation, images: images.paths ?? [] } };
374
+ }
375
+ case '视频': {
376
+ const video = await resolveMediaPath(content, {
377
+ key: 'video',
378
+ label: 'video validation',
379
+ extensions: VIDEO_EXTENSIONS,
380
+ list: VIDEO_LIST,
381
+ });
382
+ if (video.error) return { error: video.error };
383
+ return { input: { operation, video: video.path ?? '' } };
384
+ }
385
+ case '播客': {
386
+ const audio = await resolveMediaPath(content, {
387
+ key: 'audio',
388
+ label: 'audio validation',
389
+ extensions: AUDIO_EXTENSIONS,
390
+ list: AUDIO_LIST,
391
+ });
392
+ if (audio.error) return { error: audio.error };
393
+ const cover = await resolveMediaPath(content, {
394
+ key: 'coverImage',
395
+ label: 'cover validation',
396
+ extensions: IMAGE_EXTENSIONS,
397
+ list: IMAGE_LIST,
398
+ });
399
+ if (cover.error) return { error: cover.error };
400
+ return { input: { operation, audio: audio.path ?? '', cover: cover.path ?? '' } };
401
+ }
402
+ }
403
+ }
@@ -0,0 +1,260 @@
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 probes every wechatmp operation shares.
6
+ *
7
+ * `WechatPage` 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, while the shared `@gobing-ai/publish-harness/browser` helpers (pasteHtml, uploadFile,
10
+ * expectOutcome, readScopedSignal) reach the same object through the type-level bridge in `./flow`.
11
+ *
12
+ * `OpenedConsole` is context-level, not page-level, because the console opens the editor in a NEW
13
+ * TAB (discovery §1: `pages()` becomes 2 after a creation-type click) — a flow bound to one page
14
+ * object never sees the editor, so the seam has to expose the sibling pages.
15
+ */
16
+
17
+ export const POLL_MS = 500;
18
+ export const STEP_TIMEOUT_MS = 30_000;
19
+
20
+ /** The playwright-shaped locator surface the flows and the shared helpers read. */
21
+ export interface WechatLocator {
22
+ count(): Promise<number>;
23
+ isVisible(options?: { timeout?: number }): Promise<boolean>;
24
+ fill(text: string, options?: { timeout?: number }): Promise<void>;
25
+ click(options?: { timeout?: number; force?: boolean }): Promise<void>;
26
+ /**
27
+ * Type into the element. Both ProseMirror blocks (title and body) register content only from
28
+ * real input events — a value set leaves the editor empty, so the save has nothing to persist.
29
+ */
30
+ type?(text: string, options?: { timeout?: number; delay?: number }): Promise<void>;
31
+ /** Wait for an element state; the enrichment rail waits on dialogs and pickers. */
32
+ waitFor?(options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden'; timeout?: number }): Promise<void>;
33
+ textContent?(): Promise<string>;
34
+ /** A form control's value — how the `textarea#title` mirror trap is detected (discovery §5.2). */
35
+ inputValue?(): Promise<string>;
36
+ }
37
+
38
+ export interface WechatPage {
39
+ goto(url: string, options?: { timeout?: number }): Promise<void>;
40
+ locator(selector: string): { first(): WechatLocator; count(): Promise<number> };
41
+ setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
42
+ url(): string;
43
+ /** Page-side script evaluation; the enrichment rail's commit checks run through it. */
44
+ evaluate(expression: string): Promise<unknown>;
45
+ waitForTimeout?(ms: number): Promise<void>;
46
+ /** A page-level key press (the enrichment rail's dialog sweep). */
47
+ press?(key: string): Promise<void>;
48
+ /** The page's frames, for naming what a failed read actually contained. */
49
+ frames?(): WechatFrame[];
50
+ }
51
+
52
+ /** One frame of the console page: enough to name it and count a signal inside it. */
53
+ export interface WechatFrame {
54
+ url(): string;
55
+ locator(selector: string): { count(): Promise<number> };
56
+ }
57
+
58
+ /** One browser context: the console home, plus every page it holds (the editor opens in a new tab). */
59
+ export interface OpenedConsole {
60
+ /** The entry page the flows start from — the console home, where the 草稿箱 lives. */
61
+ page: WechatPage;
62
+ /** Every page the context holds, entry first. */
63
+ pages(): WechatPage[];
64
+ /** Opens an extra tab; the token-derived editor fallback uses it so the home stays readable. */
65
+ newPage(): Promise<WechatPage>;
66
+ close(): Promise<void>;
67
+ }
68
+
69
+ export type OpenConsole = (profileDir: string) => Promise<OpenedConsole>;
70
+
71
+ /** A selector with an ATTACHED match (hidden inputs included), or `undefined` at the deadline. */
72
+ async function tryFindAttachedSelector(
73
+ page: WechatPage,
74
+ selectors: readonly string[],
75
+ timeoutMs = STEP_TIMEOUT_MS,
76
+ ): Promise<string | undefined> {
77
+ let found: string | undefined;
78
+ await waitUntil(async () => {
79
+ for (const selector of selectors) {
80
+ const attached = await page
81
+ .locator(selector)
82
+ .count()
83
+ .catch(() => 0);
84
+ if (attached > 0) {
85
+ found = selector;
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }, timeoutMs);
91
+ return found;
92
+ }
93
+
94
+ /** A selector with a VISIBLE match, or `undefined` at the deadline (the menu renders closed). */
95
+ export async function tryFindSelector(
96
+ page: WechatPage,
97
+ selectors: readonly string[],
98
+ timeoutMs = STEP_TIMEOUT_MS,
99
+ ): Promise<string | undefined> {
100
+ let found: string | undefined;
101
+ await waitUntil(async () => {
102
+ const result = await trySelectors(page, selectors).catch(() => ({ found: false, selector: null }));
103
+ if (result.found && result.selector) {
104
+ found = result.selector;
105
+ return true;
106
+ }
107
+ return false;
108
+ }, timeoutMs);
109
+ return found;
110
+ }
111
+
112
+ /** Polls until a visible match exists, then throws naming `what`, the candidates and the deadline. */
113
+ export async function findSelector(
114
+ page: WechatPage,
115
+ selectors: readonly string[],
116
+ what: string,
117
+ timeoutMs = STEP_TIMEOUT_MS,
118
+ ): Promise<string> {
119
+ const found = await tryFindSelector(page, selectors, timeoutMs);
120
+ if (found === undefined) {
121
+ throw new Error(`${what}: none of [${selectors.join(' | ')}] visible within ${timeoutMs}ms`);
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /**
127
+ * Polls until an ATTACHED match exists, then throws naming `what`.
128
+ *
129
+ * Attachment — not visibility — is the rule for upload inputs: the media modals hide the real
130
+ * `input[type=file]` behind a styled control, and `trySelectors` filters on visibility by default.
131
+ */
132
+ export async function findAttachedSelector(
133
+ page: WechatPage,
134
+ selectors: readonly string[],
135
+ what: string,
136
+ timeoutMs = STEP_TIMEOUT_MS,
137
+ ): Promise<string> {
138
+ const found = await tryFindAttachedSelector(page, selectors, timeoutMs);
139
+ if (found === undefined) {
140
+ throw new Error(`${what}: none of [${selectors.join(' | ')}] attached within ${timeoutMs}ms`);
141
+ }
142
+ return found;
143
+ }
144
+
145
+ /**
146
+ * Follow the tab a creation-type click opens (discovery §1: "`ctx.pages()` becomes 2 … any walker
147
+ * must follow the newest page"). Returns `undefined` at the deadline instead of quietly staying on
148
+ * the entry page — a flow that keeps driving the console home would type into nothing.
149
+ */
150
+ export async function followNewPage(
151
+ pages: () => WechatPage[],
152
+ before: number,
153
+ timeoutMs: number,
154
+ ): Promise<WechatPage | undefined> {
155
+ const opened = await waitUntil(async () => pages().length > before, timeoutMs);
156
+ if (!opened) return undefined;
157
+ const all = pages();
158
+ return all[all.length - 1];
159
+ }
160
+
161
+ /** Click the first match of `selector`, waiting for actionability within the step timeout. */
162
+ export async function clickSelector(page: WechatPage, selector: string, timeoutMs = STEP_TIMEOUT_MS): Promise<void> {
163
+ await page.locator(selector).first().click({ timeout: timeoutMs });
164
+ }
165
+
166
+ /** Fill the first match of `selector`; no `force`, so a covered control is never written through. */
167
+ export async function fillSelector(page: WechatPage, selector: string, value: string): Promise<void> {
168
+ await page.locator(selector).first().fill(value);
169
+ }
170
+
171
+ interface PlaywrightLike {
172
+ goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
173
+ locator(selector: string): {
174
+ count(): Promise<number>;
175
+ first(): {
176
+ count(): Promise<number>;
177
+ isVisible(options?: { timeout?: number }): Promise<boolean>;
178
+ fill(text: string, options?: Record<string, unknown>): Promise<void>;
179
+ click(options?: Record<string, unknown>): Promise<void>;
180
+ pressSequentially(text: string, options?: { delay?: number }): Promise<void>;
181
+ waitFor(options?: Record<string, unknown>): Promise<void>;
182
+ textContent(): Promise<string | null>;
183
+ inputValue(): Promise<string>;
184
+ };
185
+ };
186
+ setInputFiles(selector: string, paths: readonly string[] | string): Promise<void>;
187
+ url(): string;
188
+ evaluate(expression: string): Promise<unknown>;
189
+ waitForTimeout(ms: number): Promise<void>;
190
+ keyboard: { press(key: string): Promise<void> };
191
+ }
192
+
193
+ /** The production adapter: the structural seam over a real Playwright page. */
194
+ /** The raw Playwright frame surface the inventory needs (see `wrapPlaywrightPage`). */
195
+ interface RawFrame {
196
+ url(): string;
197
+ locator(selector: string): { count(): Promise<number> };
198
+ }
199
+
200
+ export function wrapPlaywrightPage(raw: unknown): WechatPage {
201
+ const pw = raw as PlaywrightLike;
202
+ return {
203
+ goto: (url, options) => pw.goto(url, { waitUntil: 'domcontentloaded', ...options }).then(() => undefined),
204
+ locator: (selector) => ({
205
+ count: () => pw.locator(selector).count(),
206
+ first: () => {
207
+ const loc = pw.locator(selector).first();
208
+ return {
209
+ count: () => loc.count(),
210
+ isVisible: (options) => loc.isVisible(options),
211
+ fill: (text, options) => loc.fill(text, options),
212
+ click: (options) => loc.click(options),
213
+ type: (text, options) => loc.pressSequentially(text, options),
214
+ waitFor: (options) => loc.waitFor(options),
215
+ textContent: async () => (await loc.textContent()) ?? '',
216
+ inputValue: () => loc.inputValue(),
217
+ };
218
+ },
219
+ }),
220
+ setInputFiles: (selector, paths) => pw.setInputFiles(selector, paths),
221
+ url: () => pw.url(),
222
+ evaluate: (expression) => pw.evaluate(expression),
223
+ waitForTimeout: (ms) => pw.waitForTimeout(ms),
224
+ press: (key) => pw.keyboard.press(key),
225
+ // MP's console renders some lists inside frames, and the flows must be able to NAME what they
226
+ // actually saw when a read comes back empty (2026-10-05: the 草稿箱 row existed and no
227
+ // main-frame read found it). Only the two calls the inventory needs are exposed.
228
+ frames: () => {
229
+ const source = raw as { frames?: () => RawFrame[] };
230
+ return (source.frames?.() ?? []).map((frame) => ({
231
+ url: () => frame.url(),
232
+ locator: (selector: string) => ({ count: () => frame.locator(selector).count() }),
233
+ }));
234
+ },
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Wrap one browser context as the launcher seam. Wrappers are cached per raw page, so
240
+ * `pages()[0]` and `page` are the same object by reference — the flows take the newest page and
241
+ * compare page identity, and a second wrapper would break that check.
242
+ */
243
+ export function wrapPlaywrightContext(raw: unknown, entryPage: unknown): OpenedConsole {
244
+ const context = raw as { pages(): unknown[]; newPage(): Promise<unknown>; close(): Promise<void> };
245
+ const cache = new WeakMap<object, WechatPage>();
246
+ const wrap = (page: unknown): WechatPage => {
247
+ const key = page as object;
248
+ const cached = cache.get(key);
249
+ if (cached !== undefined) return cached;
250
+ const wrapped = wrapPlaywrightPage(page);
251
+ cache.set(key, wrapped);
252
+ return wrapped;
253
+ };
254
+ return {
255
+ page: wrap(entryPage),
256
+ pages: () => context.pages().map(wrap),
257
+ newPage: async () => wrap(await context.newPage()),
258
+ close: () => context.close(),
259
+ };
260
+ }