@gobing-ai/knowledge-kit 0.0.31 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +10 -10
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +9 -9
- package/plugins/generations/core-facts-gen/dist/index.js +12 -12
- package/plugins/generations/daily-article-gen/dist/index.js +12 -12
- package/plugins/generations/dailynews-gen/dist/index.js +9 -9
- package/plugins/generations/episode-plan-gen/dist/index.js +12 -12
- package/plugins/generations/gemini-tts/dist/index.js +9 -9
- package/plugins/generations/image-gen/dist/index.js +9 -9
- package/plugins/generations/news-report-gen/dist/index.js +12 -12
- package/plugins/generations/voice-gen/dist/index.js +9 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +12 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +12 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +12 -12
- package/plugins/ingestions/web-search/dist/index.js +10 -10
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +173 -15
- package/plugins/kk/skills/content-judge/SKILL.md +12 -2
- package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
- package/plugins/kk/skills/content-safety/SKILL.md +24 -13
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +65 -8
- package/plugins/publishings/emdash-pub/dist/index.js +9 -9
- package/plugins/publishings/infoq-pub/dist/index.js +8602 -211
- package/plugins/publishings/infoq-pub/package.json +3 -1
- package/plugins/publishings/infoq-pub/plugin.json +1 -1
- package/plugins/publishings/infoq-pub/src/index.ts +246 -29
- package/plugins/publishings/infoq-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/juejin-pub/dist/index.js +8665 -211
- package/plugins/publishings/juejin-pub/package.json +3 -1
- package/plugins/publishings/juejin-pub/plugin.json +1 -1
- package/plugins/publishings/juejin-pub/src/index.ts +305 -29
- package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/medium-pub/dist/index.js +9 -9
- package/plugins/publishings/podcast-pub/dist/index.js +9 -9
- package/plugins/publishings/qiita-pub/dist/index.js +9 -9
- package/plugins/publishings/substack-pub/dist/index.js +8610 -211
- package/plugins/publishings/substack-pub/package.json +3 -1
- package/plugins/publishings/substack-pub/plugin.json +1 -1
- package/plugins/publishings/substack-pub/src/index.ts +271 -29
- package/plugins/publishings/substack-pub/src/profile-check.ts +74 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +9 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +1227 -348
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +186 -516
- package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
- package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -5
- package/plugins/publishings/x-pub/dist/index.js +8626 -221
- package/plugins/publishings/x-pub/package.json +3 -1
- package/plugins/publishings/x-pub/plugin.json +1 -1
- package/plugins/publishings/x-pub/src/index.ts +258 -20
- package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/xhs-pub/dist/index.js +843 -346
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
- package/plugins/publishings/xhs-pub/src/index.ts +101 -593
- package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
- package/plugins/publishings/xhs-pub/src/page.ts +267 -0
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -3
- package/plugins/publishings/zenn-pub/dist/index.js +9 -9
|
@@ -0,0 +1,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×tamp=${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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveBrowserProfileDir } from '@gobing-ai/publish-harness';
|
|
2
|
+
import { waitUntil } from '@gobing-ai/publish-harness/browser';
|
|
2
3
|
import { openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
|
|
3
4
|
import { echo } from '@gobing-ai/ts-utils';
|
|
4
5
|
import { checkWechatSession, storageStatePath } from './index';
|
|
@@ -19,11 +20,12 @@ try {
|
|
|
19
20
|
try {
|
|
20
21
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
21
22
|
await page.goto(CONSOLE_URL);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const ready = await waitUntil(
|
|
24
|
+
() => Promise.resolve(page.url().includes(LOGGED_IN_FRAGMENT)),
|
|
25
|
+
LOGIN_TIMEOUT_MS,
|
|
26
|
+
1000,
|
|
27
|
+
);
|
|
28
|
+
if (!ready) throw new Error(`QR scan not completed within ${LOGIN_TIMEOUT_MS / 1000}s`);
|
|
27
29
|
await page.waitForTimeout(10_000); // settle: let the console finish its post-auth Set-Cookies
|
|
28
30
|
// The Chromium profile store drops the grant cookies on restart even though they carry
|
|
29
31
|
// multi-day expiries — the durable session artifact is the captured storageState file,
|