@gobing-ai/knowledge-kit 0.0.24 → 0.0.26

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.
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
10
  "@gobing-ai/publish-harness": "workspace:*",
11
+ "@gobing-ai/ts-browser-automation": "catalog:",
11
12
  "@gobing-ai/ts-runtime": "catalog:",
12
13
  "@gobing-ai/ts-utils": "catalog:"
13
14
  },
@@ -3,5 +3,5 @@
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
5
  "version": "1.0.0",
6
- "description": "Publish plugin for WeChat Official Account drafts via official draft/add"
6
+ "description": "Publish plugin for the WeChat MP console via ts-browser-automation: draft-box first (ADR-022), headed publish behind a session preflight"
7
7
  }
@@ -1,37 +1,39 @@
1
- import { dirname } from 'node:path';
2
1
  import { parseArgs } from 'node:util';
3
2
  import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
4
- import { FakeFileCliTransport, type PublishTransport, type PublishTransportPayload } from '@gobing-ai/publish-harness';
3
+ import {
4
+ FakeFileCliTransport,
5
+ type PublishTransport,
6
+ type PublishTransportPayload,
7
+ resolveBrowserProfileDir,
8
+ } from '@gobing-ai/publish-harness';
9
+ import { BrowserProfileMissingError, withProfilePage } from '@gobing-ai/ts-browser-automation';
5
10
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
6
11
  import { echoError } from '@gobing-ai/ts-utils';
12
+ import type { Page } from 'playwright';
7
13
 
8
14
  const TARGET = 'wechatmp';
9
- const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token';
10
-
11
- /** Per-request timeout (ms) for WeChat HTTP calls — Bun's `fetch` has no default deadline (R5). */
12
- const REQUEST_TIMEOUT_MS = 120_000;
13
- const DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
14
- const APP_ID_ENV = 'WECHAT_APP_ID';
15
- const APP_SECRET_ENV = 'WECHAT_APP_SECRET';
16
-
17
- export interface WechatDraftArticle {
18
- article_type: 'news';
19
- title: string;
20
- author?: string;
21
- digest?: string;
22
- content: string;
23
- content_source_url?: string;
24
- thumb_media_id: string;
25
- need_open_comment?: number;
26
- only_fans_can_comment?: number;
27
- }
15
+ const CONSOLE_URL = 'https://mp.weixin.qq.com/';
28
16
 
29
- export interface WechatDraftArticles {
30
- articles: WechatDraftArticle[];
31
- }
17
+ /** Proven console selectors (ported from the wt-publish-to-wechatmp Playwright flow). */
18
+ const SEL = {
19
+ // Console home: an authenticated session lands on /cgi-bin/home; logged-out shows the QR scan page.
20
+ loggedInFragment: '/cgi-bin/home',
21
+ menuItem: '.new-creation__menu .new-creation__menu-item',
22
+ titleInput: '#title',
23
+ authorInput: '#author',
24
+ editor: '.ProseMirror',
25
+ summaryInput: '#js_description',
26
+ saveDraftBtn: '.js_save_draft, button:has-text("保存为草稿"), button:has-text("保存草稿")',
27
+ publishBtn: '.js_publish, button:has-text("发表"), button:has-text("发布")',
28
+ confirmBtn: '.weui-desktop-dialog .weui-desktop-btn_primary, button:has-text("确定")',
29
+ toast: '.weui-desktop-toast',
30
+ } as const;
32
31
 
33
- function fail(error: string): Result {
34
- return ResultSchema.parse({ ok: false, target: TARGET, error });
32
+ const CONSOLE_READY_TIMEOUT_MS = 30_000;
33
+ const TOAST_TIMEOUT_MS = 15_000;
34
+
35
+ function fail(message: string): never {
36
+ throw new Error(message);
35
37
  }
36
38
 
37
39
  function escapeHtml(text: string): string {
@@ -121,182 +123,225 @@ export function markdownToWechatHtml(markdown: string): string {
121
123
  }
122
124
 
123
125
  function resolveTitle(content: Content): string {
124
- if (content.title && content.title.trim().length > 0) return content.title.trim();
125
- const match = content.body.match(/^#\s+(.+)$/m);
126
- return match?.[1] ? match[1].trim() : 'Untitled';
126
+ if (typeof content.title === 'string' && content.title.trim() !== '') return content.title.trim();
127
+ return fail('wechatmp-pub: content.title is required');
127
128
  }
128
129
 
129
- function resolveDigest(content: Content): string {
130
- if (typeof content.metadata?.description === 'string' && content.metadata.description.trim().length > 0) {
131
- return content.metadata.description.trim().slice(0, 120);
132
- }
133
- const para = content.body
134
- .replace(/^#.+$/m, '')
135
- .split(/\n\n+/)
136
- .map((p) => p.trim())
137
- .find((p) => p.length > 0);
138
- return (para ?? '').slice(0, 120);
130
+ function resolveDigest(content: Content): string | undefined {
131
+ const body = typeof content.body === 'string' ? content.body : '';
132
+ if (body.trim() === '') return undefined;
133
+ const first = body
134
+ .split('\n')
135
+ .map((line) => line.trim())
136
+ .find((line) => line !== '');
137
+ return first ? first.slice(0, 54) : undefined;
139
138
  }
140
139
 
141
- export function mapContentToWechatDraft(content: Content): WechatDraftArticles {
142
- const thumb = content.metadata?.thumbMediaId;
143
- if (typeof thumb !== 'string' || thumb.trim().length === 0) {
144
- throw new Error(
145
- 'metadata.thumbMediaId is required for WeChat news drafts (cover media_id from material/add_material)',
146
- );
147
- }
148
- const title = resolveTitle(content).slice(0, 32);
149
- const author = typeof content.metadata?.author === 'string' ? content.metadata.author.slice(0, 16) : undefined;
150
- const source =
151
- typeof content.metadata?.canonicalUrl === 'string'
152
- ? content.metadata.canonicalUrl
153
- : content.references?.[0]?.url;
140
+ // ---------------------------------------------------------------------------
141
+ // Browser flow (ts-browser-automation; pattern mirrors xhs-pub)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export interface WechatPage {
145
+ goto(url: string): Promise<void>;
146
+ url(): string;
147
+ fill(selector: string, value: string): Promise<void>;
148
+ click(selector: string): Promise<void>;
149
+ setInnerHtml(selector: string, html: string): Promise<void>;
150
+ waitFor(selector: string, timeoutMs: number): Promise<void>;
151
+ waitForAny(selectors: readonly string[], timeoutMs: number): Promise<string>;
152
+ }
153
+
154
+ export function isWechatLoggedIn(page: Page): boolean {
155
+ return page.url().includes(SEL.loggedInFragment);
156
+ }
157
+
158
+ export type SessionCheck = (profileDir: string) => Promise<void>;
159
+
160
+ /** Headless session preflight: throws (incl. BrowserProfileMissingError) when the profile cannot drive the console. */
161
+ export async function checkWechatSession(profileDir: string): Promise<void> {
162
+ await withProfilePage(
163
+ {
164
+ profileDir,
165
+ headless: true,
166
+ url: CONSOLE_URL,
167
+ isAuthenticated: isWechatLoggedIn,
168
+ timeoutMs: CONSOLE_READY_TIMEOUT_MS,
169
+ },
170
+ async () => {},
171
+ );
172
+ }
173
+
174
+ export interface WechatmpTransportOptions {
175
+ openPage?: (profileDir: string) => Promise<WechatPage>;
176
+ checkSession?: SessionCheck;
177
+ }
178
+
179
+ export function wrapPlaywrightPage(page: Page): WechatPage {
154
180
  return {
155
- articles: [
156
- {
157
- article_type: 'news',
158
- title,
159
- ...(author ? { author } : {}),
160
- digest: resolveDigest(content),
161
- content: markdownToWechatHtml(content.body),
162
- ...(source ? { content_source_url: source } : {}),
163
- thumb_media_id: thumb.trim(),
164
- },
165
- ],
181
+ goto: (url) => page.goto(url, { waitUntil: 'domcontentloaded' }).then(() => undefined),
182
+ url: () => page.url(),
183
+ fill: (selector, value) => page.locator(selector).first().fill(value),
184
+ click: (selector) => page.locator(selector).first().click(),
185
+ setInnerHtml: (selector, html) =>
186
+ page
187
+ .locator(selector)
188
+ .first()
189
+ .evaluate((node, value) => {
190
+ node.innerHTML = value;
191
+ node.dispatchEvent(new Event('input', { bubbles: true }));
192
+ }, html),
193
+ waitFor: (selector, timeoutMs) =>
194
+ page
195
+ .locator(selector)
196
+ .first()
197
+ .waitFor({ state: 'visible', timeout: timeoutMs })
198
+ .then(() => undefined),
199
+ waitForAny: async (selectors, timeoutMs) => {
200
+ const deadline = Date.now() + timeoutMs;
201
+ while (Date.now() < deadline) {
202
+ for (const selector of selectors) {
203
+ try {
204
+ await page.locator(selector).first().waitFor({ state: 'visible', timeout: 250 });
205
+ return selector;
206
+ } catch {
207
+ // try the next candidate
208
+ }
209
+ }
210
+ }
211
+ return '';
212
+ },
166
213
  };
167
214
  }
168
215
 
169
- function resolveOperation(options?: Record<string, unknown>, content?: Content): string {
170
- const raw = options?.operation ?? content?.metadata?.operation ?? 'draft';
171
- return typeof raw === 'string' ? raw : 'draft';
216
+ async function defaultOpenPage(profileDir: string): Promise<WechatPage> {
217
+ return withProfilePage({ profileDir, headless: false, url: CONSOLE_URL }, async (handle) =>
218
+ wrapPlaywrightPage(handle.page),
219
+ );
172
220
  }
173
221
 
174
- export class WechatDraftApiTransport implements PublishTransport {
175
- public readonly kind = 'file-cli';
222
+ export class WechatmpBrowserTransport implements PublishTransport {
223
+ readonly kind = 'browser';
224
+ private readonly openPage: (profileDir: string) => Promise<WechatPage>;
225
+ private readonly checkSession: SessionCheck;
176
226
 
177
- public async publish(payload: PublishTransportPayload): Promise<Result> {
178
- const operation = resolveOperation(payload.options, payload.content);
179
- if (operation === 'live' || operation === 'publish' || operation === 'freepublish') {
180
- return fail(
181
- 'WeChat live publish via freepublish/submit is certification-gated; default operation is draft. Confirm with the operator before a live/console path.',
182
- );
183
- }
184
- if (operation !== 'draft') {
185
- return fail(`invalid options.operation "${operation}": expected draft (default) or live`);
186
- }
227
+ constructor(options: WechatmpTransportOptions = {}) {
228
+ this.openPage = options.openPage ?? defaultOpenPage;
229
+ this.checkSession = options.checkSession ?? ((profileDir) => checkWechatSession(profileDir));
230
+ }
187
231
 
188
- const appId = process.env[APP_ID_ENV];
189
- const appSecret = process.env[APP_SECRET_ENV];
190
- const missing: string[] = [];
191
- if (!appId || appId.trim().length === 0) missing.push(APP_ID_ENV);
192
- if (!appSecret || appSecret.trim().length === 0) missing.push(APP_SECRET_ENV);
193
- if (missing.length > 0) {
194
- return fail(
195
- `${missing.join(' and ')} environment variable${missing.length > 1 ? 's are' : ' is'} required: WeChat Official Account AppID/AppSecret`,
196
- );
232
+ async publish(payload: PublishTransportPayload): Promise<Result> {
233
+ const { content, options } = payload;
234
+ const want =
235
+ options?.operation === 'live'
236
+ ? 'public'
237
+ : ((content.metadata?.publishStatus as string | undefined) ?? 'draft');
238
+ if (want !== 'draft' && want !== 'public') {
239
+ return this.failed(`publishStatus "${want}" is not draft|public`);
240
+ }
241
+ const title = resolveTitle(content);
242
+ if (typeof content.body !== 'string' || content.body.trim() === '') {
243
+ return this.failed('wechatmp-pub: content.body is required');
197
244
  }
245
+ const profileDir = resolveBrowserProfileDir(TARGET, process.env.WECHATMP_PROFILE_DIR);
198
246
 
199
- let articles: WechatDraftArticles;
200
247
  try {
201
- articles = mapContentToWechatDraft(payload.content);
248
+ await this.checkSession(profileDir);
202
249
  } catch (error: unknown) {
203
- return fail(error instanceof Error ? error.message : String(error));
250
+ const why =
251
+ error instanceof BrowserProfileMissingError
252
+ ? 'no saved console profile — log in once via `bun src/profile-check.ts login`'
253
+ : `console session preflight failed (${error instanceof Error ? error.message : String(error)})`;
254
+ // Non-blocking contract (mirrors xhs-pub): a sessionless run refuses fast and stays draftless —
255
+ // it must never open a headed login wall inside an unattended run.
256
+ return this.failed(`wechatmp-pub: ${why}`);
204
257
  }
205
258
 
259
+ const page = await this.openPage(profileDir);
206
260
  try {
207
- const tokenUrl = `${TOKEN_URL}?grant_type=client_credential&appid=${encodeURIComponent(appId ?? '')}&secret=${encodeURIComponent(appSecret ?? '')}`;
208
- const tokenResponse = await fetch(tokenUrl, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
209
- const tokenJson = (await tokenResponse.json()) as {
210
- access_token?: unknown;
211
- errmsg?: unknown;
212
- errcode?: unknown;
213
- };
214
- if (typeof tokenJson.access_token !== 'string') {
215
- return fail(
216
- `WeChat token request failed: ${typeof tokenJson.errmsg === 'string' ? tokenJson.errmsg : 'missing access_token'}`,
217
- );
261
+ await page.waitFor(SEL.menuItem, CONSOLE_READY_TIMEOUT_MS);
262
+ await page.click(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`);
263
+ await page.waitFor(SEL.titleInput, CONSOLE_READY_TIMEOUT_MS);
264
+ await page.fill(SEL.titleInput, title);
265
+ await this.tryFill(page, SEL.authorInput, String(content.metadata?.author ?? ''));
266
+ await page.waitFor(SEL.editor, CONSOLE_READY_TIMEOUT_MS);
267
+ await page.setInnerHtml(SEL.editor, markdownToWechatHtml(content.body));
268
+ const digest = resolveDigest(content);
269
+ if (digest) await this.tryFill(page, SEL.summaryInput, digest);
270
+
271
+ if (want === 'draft') {
272
+ await page.click(SEL.saveDraftBtn);
273
+ } else {
274
+ await page.click(SEL.publishBtn);
275
+ await this.tryClick(page, SEL.confirmBtn);
218
276
  }
219
- const draftResponse = await fetch(
220
- `${DRAFT_ADD_URL}?access_token=${encodeURIComponent(tokenJson.access_token)}`,
221
- {
222
- method: 'POST',
223
- headers: { 'Content-Type': 'application/json' },
224
- body: JSON.stringify(articles),
225
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
226
- },
227
- );
228
- const draftJson = (await draftResponse.json()) as {
229
- media_id?: unknown;
230
- errmsg?: unknown;
231
- errcode?: unknown;
232
- };
233
- if (typeof draftJson.media_id !== 'string') {
234
- return fail(
235
- `WeChat draft/add failed: ${typeof draftJson.errmsg === 'string' ? draftJson.errmsg : 'missing media_id'}`,
277
+ const seen = await page.waitForAny([SEL.toast], TOAST_TIMEOUT_MS);
278
+ if (seen === '') {
279
+ return this.failed(
280
+ `save was clicked but no confirmation toast appeared within ${TOAST_TIMEOUT_MS}ms — verify the console manually`,
236
281
  );
237
282
  }
238
- return ResultSchema.parse({
283
+ return {
239
284
  ok: true,
240
285
  target: TARGET,
241
- id: draftJson.media_id,
242
- message: 'WeChat draft created via draft/add',
243
- metadata: { operation: 'draft', publishStatus: 'draft' },
244
- });
245
- } catch (err: unknown) {
246
- return fail(`WeChat transport error: ${err instanceof Error ? err.message : String(err)}`);
286
+ metadata: {
287
+ publishStatus: want,
288
+ note:
289
+ want === 'draft'
290
+ ? 'saved to the WeChat MP draft box (草稿箱)'
291
+ : 'published via the console 发表 flow',
292
+ },
293
+ };
294
+ } catch (error: unknown) {
295
+ const why = error instanceof Error ? error.message : String(error);
296
+ return this.failed(`console flow failed: ${why}`);
247
297
  }
248
298
  }
249
- }
250
299
 
251
- export function getPublishTransport(): PublishTransport {
252
- if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
253
- return new FakeFileCliTransport();
300
+ private async tryFill(page: WechatPage, selector: string, value: string): Promise<void> {
301
+ if (value === '') return;
302
+ try {
303
+ await page.fill(selector, value);
304
+ } catch {
305
+ // optional field — the console hides it for some account types
306
+ }
307
+ }
308
+
309
+ private async tryClick(page: WechatPage, selector: string): Promise<void> {
310
+ try {
311
+ await page.click(selector);
312
+ } catch {
313
+ // confirm dialogs are not shown for every account type
314
+ }
254
315
  }
255
- return new WechatDraftApiTransport();
256
- }
257
316
 
258
- async function writePublishResult(outputPath: string, result: Result): Promise<void> {
259
- const outDir = dirname(outputPath);
260
- if (outDir && outDir !== '.') {
261
- await createNodeFileSystem().ensureDir(outDir);
317
+ private failed(error: string): Result {
318
+ return { ok: false, target: TARGET, error, metadata: { publishStatus: 'draft' } };
262
319
  }
263
- await createNodeFileSystem().writeFile(outputPath, JSON.stringify(result, null, 2));
264
320
  }
265
321
 
266
- export async function processPublishIO(
267
- inputPath: string,
268
- outputPath: string,
269
- transport: PublishTransport = getPublishTransport(),
270
- ): Promise<void> {
271
- await createNodeFileSystem().deleteFile(outputPath);
272
- let content: Content;
273
- let options: Record<string, unknown> | undefined;
274
- try {
275
- const raw = JSON.parse(await createNodeFileSystem().readFile(inputPath)) as Record<string, unknown>;
276
- content = ContentSchema.parse(raw);
277
- if (raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null) {
278
- options = raw.options as Record<string, unknown>;
279
- }
280
- } catch (error: unknown) {
281
- const message = `Invalid Content input: ${error instanceof Error ? error.message : String(error)}`;
282
- await writePublishResult(outputPath, fail(message));
283
- throw new Error(message);
284
- }
285
- const result = ResultSchema.parse(await transport.publish({ content, options }));
286
- await writePublishResult(outputPath, result);
287
- if (!result.ok) throw new Error(result.error ?? 'Publish failed');
322
+ export function getPublishTransport(): PublishTransport {
323
+ if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') return new FakeFileCliTransport();
324
+ return new WechatmpBrowserTransport();
288
325
  }
289
326
 
290
- export async function main(): Promise<number> {
291
- let values: { in?: string; out?: string };
292
- try {
293
- ({ values } = parseArgs({ options: { in: { type: 'string' }, out: { type: 'string' } } }));
294
- } catch (error: unknown) {
295
- echoError(`Invalid arguments: ${error instanceof Error ? error.message : String(error)}`);
296
- return 1;
297
- }
327
+ export async function processPublishIO(inPath: string, outPath: string, transport?: PublishTransport): Promise<void> {
328
+ const fs = createNodeFileSystem();
329
+ const raw = JSON.parse(await fs.readFile(inPath));
330
+ const content = ContentSchema.parse(raw);
331
+ const result = await (transport ?? getPublishTransport()).publish({ content, options: { target: TARGET } });
332
+ await fs.writeFile(outPath, JSON.stringify(ResultSchema.parse(result), null, 2));
333
+ }
334
+
335
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
336
+ const { values } = parseArgs({
337
+ argv,
338
+ options: {
339
+ in: { type: 'string' },
340
+ out: { type: 'string' },
341
+ },
342
+ });
298
343
  if (!values.in || !values.out) {
299
- echoError('Missing required arguments: --in and --out');
344
+ echoError('Missing required arguments: --in <content.json> --out <result.json>');
300
345
  return 1;
301
346
  }
302
347
  try {
@@ -0,0 +1,34 @@
1
+ import { resolveBrowserProfileDir } from '@gobing-ai/publish-harness';
2
+ import { loginWithProfile } from '@gobing-ai/ts-browser-automation';
3
+ import { echo } from '@gobing-ai/ts-utils';
4
+ import { checkWechatSession } from './index';
5
+
6
+ const CONSOLE_URL = 'https://mp.weixin.qq.com/';
7
+ const LOGIN_TIMEOUT_MS = 180_000;
8
+
9
+ const profileDir = resolveBrowserProfileDir('wechatmp', process.env.WECHATMP_PROFILE_DIR);
10
+ const verb = process.argv[2] ?? 'check';
11
+
12
+ try {
13
+ if (verb === 'login') {
14
+ echo(
15
+ `wechatmp login: opening the console in a headed browser — scan the QR once (timeout ${LOGIN_TIMEOUT_MS / 1000}s)`,
16
+ );
17
+ await loginWithProfile({
18
+ profileDir,
19
+ url: CONSOLE_URL,
20
+ isAuthenticated: (page) => page.url().includes('/cgi-bin/home'),
21
+ timeoutMs: LOGIN_TIMEOUT_MS,
22
+ });
23
+ echo(`wechatmp login: session saved in ${profileDir}`);
24
+ } else if (verb === 'check') {
25
+ await checkWechatSession(profileDir);
26
+ echo(`wechatmp session: OK (${profileDir})`);
27
+ } else {
28
+ echo('usage: bun src/profile-check.ts login|check');
29
+ process.exit(1);
30
+ }
31
+ } catch (error: unknown) {
32
+ echo(`wechatmp ${verb}: failed — ${error instanceof Error ? error.message : String(error)}`);
33
+ process.exit(1);
34
+ }