@gobing-ai/knowledge-kit 0.0.27 → 0.0.28

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.
@@ -6,10 +6,10 @@ import {
6
6
  type PublishTransportPayload,
7
7
  resolveBrowserProfileDir,
8
8
  } from '@gobing-ai/publish-harness';
9
- import { BrowserProfileMissingError, withProfilePage } from '@gobing-ai/ts-browser-automation';
9
+ import { BrowserProfileMissingError, openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
10
10
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
11
11
  import { echoError } from '@gobing-ai/ts-utils';
12
- import type { Page } from 'playwright';
12
+ import type { BrowserContext, Cookie, Page } from 'playwright';
13
13
 
14
14
  const TARGET = 'wechatmp';
15
15
  const CONSOLE_URL = 'https://mp.weixin.qq.com/';
@@ -19,11 +19,19 @@ const SEL = {
19
19
  // Console home: an authenticated session lands on /cgi-bin/home; logged-out shows the QR scan page.
20
20
  loggedInFragment: '/cgi-bin/home',
21
21
  menuItem: '.new-creation__menu .new-creation__menu-item',
22
- titleInput: '#title',
22
+ // The 2024+ console editor renders title/body as two ProseMirror instances; the legacy
23
+ // textarea#title is a hidden mirror. The title PM carries a 标题 placeholder — the only
24
+ // reliable discriminator between the two editors.
25
+ titleInput: '.ProseMirror[data-placeholder*="标题"]',
23
26
  authorInput: '#author',
24
- editor: '.ProseMirror',
27
+ editor: '.ProseMirror:not([data-placeholder])',
25
28
  summaryInput: '#js_description',
26
- saveDraftBtn: '.js_save_draft, button:has-text("保存为草稿"), button:has-text("保存草稿")',
29
+ saveDraftBtn: 'button:has-text("保存为草稿")',
30
+ originalSwitch: '.setting-group__switch.js_edit_ori',
31
+ tagRow: '.allow_click_opr.js_article_tags_label',
32
+ tagInput: 'input[placeholder*="合集"]',
33
+ claimSourceCheckbox: '#js_claim_source_area .frm_checkbox_label',
34
+ coverBtn: '.js_cover_btn_area',
27
35
  publishBtn: '.js_publish, button:has-text("发表"), button:has-text("发布")',
28
36
  confirmBtn: '.weui-desktop-dialog .weui-desktop-btn_primary, button:has-text("确定")',
29
37
  toast: '.weui-desktop-toast',
@@ -145,10 +153,21 @@ export interface WechatPage {
145
153
  goto(url: string): Promise<void>;
146
154
  url(): string;
147
155
  fill(selector: string, value: string): Promise<void>;
148
- click(selector: string): Promise<void>;
156
+ click(selector: string, timeoutMs?: number): Promise<void>;
157
+ press?(key: string): Promise<void>;
149
158
  setInnerHtml(selector: string, html: string): Promise<void>;
150
159
  waitFor(selector: string, timeoutMs: number): Promise<void>;
151
160
  waitForAny(selectors: readonly string[], timeoutMs: number): Promise<string>;
161
+ /** Resolves when the page URL contains the fragment (real browsers); absent on test fakes. */
162
+ waitForUrlFragment?(fragment: string, timeoutMs: number): Promise<void>;
163
+ setInputFiles?(selector: string, files: string): Promise<void>;
164
+ waitForTimeout?(ms: number): Promise<void>;
165
+ /** Evaluate a JS expression in the page (real browsers) - used for commit verification; absent on fakes. */
166
+ evalJs?(expression: string): Promise<unknown>;
167
+ /** Click and adopt the tab the console opens for the editor; absent on test fakes (same-page click). */
168
+ clickAndAwaitNewPage?(selector: string): Promise<WechatPage>;
169
+ /** Present on real browser pages; absent on test fakes. publish() closes it in finally. */
170
+ close?(): Promise<void>;
152
171
  }
153
172
 
154
173
  export function isWechatLoggedIn(page: Page): boolean {
@@ -157,18 +176,33 @@ export function isWechatLoggedIn(page: Page): boolean {
157
176
 
158
177
  export type SessionCheck = (profileDir: string) => Promise<void>;
159
178
 
179
+ /** Cookies captured at login time — mp.weixin.qq.com grant cookies do not survive a Chromium restart
180
+ * (the store drops them despite 4-day expiries), but the server session does, so we re-inject. */
181
+ export function storageStatePath(profileDir: string): string {
182
+ return `${profileDir}/storage-state.json`;
183
+ }
184
+
160
185
  /** Headless session preflight: throws (incl. BrowserProfileMissingError) when the profile cannot drive the console. */
161
186
  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
- );
187
+ const { page, context } = await openWithState(profileDir, true);
188
+ try {
189
+ await pollLogin(page);
190
+ } finally {
191
+ await context.close();
192
+ }
193
+ }
194
+
195
+ async function pollLogin(page: Page): Promise<void> {
196
+ const deadline = Date.now() + CONSOLE_READY_TIMEOUT_MS;
197
+ while (!isWechatLoggedIn(page)) {
198
+ if (Date.now() > deadline) {
199
+ throw new Error(
200
+ `Authentication was not confirmed within ${CONSOLE_READY_TIMEOUT_MS}ms — the saved session state has ` +
201
+ 'likely expired: re-run `bun src/profile-check.ts login` to sign in again.',
202
+ );
203
+ }
204
+ await new Promise((resolve) => setTimeout(resolve, 1000));
205
+ }
172
206
  }
173
207
 
174
208
  export interface WechatmpTransportOptions {
@@ -176,12 +210,16 @@ export interface WechatmpTransportOptions {
176
210
  checkSession?: SessionCheck;
177
211
  }
178
212
 
179
- export function wrapPlaywrightPage(page: Page): WechatPage {
213
+ export function wrapPlaywrightPage(page: Page, context?: BrowserContext): WechatPage {
180
214
  return {
181
215
  goto: (url) => page.goto(url, { waitUntil: 'domcontentloaded' }).then(() => undefined),
182
216
  url: () => page.url(),
183
217
  fill: (selector, value) => page.locator(selector).first().fill(value),
184
- click: (selector) => page.locator(selector).first().click(),
218
+ click: (selector, timeoutMs) =>
219
+ page
220
+ .locator(selector)
221
+ .first()
222
+ .click(timeoutMs ? { timeout: timeoutMs } : undefined),
185
223
  setInnerHtml: (selector, html) =>
186
224
  page
187
225
  .locator(selector)
@@ -210,13 +248,59 @@ export function wrapPlaywrightPage(page: Page): WechatPage {
210
248
  }
211
249
  return '';
212
250
  },
251
+ waitForUrlFragment: (fragment, timeoutMs) =>
252
+ page
253
+ .waitForURL(new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: timeoutMs })
254
+ .then(() => undefined),
255
+ setInputFiles: (selector, files) => page.locator(selector).first().setInputFiles(files),
256
+ waitForTimeout: (ms) => page.waitForTimeout(ms),
257
+ evalJs: (expression) => page.evaluate(expression),
258
+ press: (key) => page.keyboard.press(key),
259
+ clickAndAwaitNewPage: context
260
+ ? async (selector) => {
261
+ const popup = context.waitForEvent('page', { timeout: 15000 }).catch(() => undefined);
262
+ await page.locator(selector).first().click();
263
+ const opened = await popup;
264
+ if (!opened) return wrapPlaywrightPage(page, context);
265
+ await opened.waitForLoadState('domcontentloaded').catch(() => {});
266
+ return wrapPlaywrightPage(opened, context);
267
+ }
268
+ : undefined,
269
+ close: context ? () => context.close() : undefined,
213
270
  };
214
271
  }
215
272
 
273
+ /** Launch, inject the saved login cookies, and land on the console. Caller owns closing the context. */
274
+ async function openWithState(profileDir: string, headless: boolean): Promise<{ page: Page; context: BrowserContext }> {
275
+ const context = await openPersistentBrowser({ profileDir, headless });
276
+ try {
277
+ const stateFile = storageStatePath(profileDir);
278
+ let state: { cookies?: Cookie[] } | undefined;
279
+ try {
280
+ state = JSON.parse(await createNodeFileSystem().readFile(stateFile));
281
+ } catch {
282
+ state = undefined; // absent or corrupt session state — reported below
283
+ }
284
+ const cookies = state?.cookies;
285
+ if (!cookies || cookies.length === 0) {
286
+ throw new BrowserProfileMissingError(
287
+ `no saved console session state (${stateFile}) — run \`bun src/profile-check.ts login\` once`,
288
+ );
289
+ }
290
+ await context.addCookies(cookies);
291
+ const page = context.pages()[0] ?? (await context.newPage());
292
+ await page.goto(CONSOLE_URL);
293
+ return { page, context };
294
+ } catch (error: unknown) {
295
+ await context.close();
296
+ throw error;
297
+ }
298
+ }
299
+
216
300
  async function defaultOpenPage(profileDir: string): Promise<WechatPage> {
217
- return withProfilePage({ profileDir, headless: false, url: CONSOLE_URL }, async (handle) =>
218
- wrapPlaywrightPage(handle.page),
219
- );
301
+ // Headless works end-to-end for the draft flow (probed 2026-09-29); no window is popped.
302
+ const { page, context } = await openWithState(profileDir, true);
303
+ return wrapPlaywrightPage(page, context);
220
304
  }
221
305
 
222
306
  export class WechatmpBrowserTransport implements PublishTransport {
@@ -257,29 +341,53 @@ export class WechatmpBrowserTransport implements PublishTransport {
257
341
  }
258
342
 
259
343
  const page = await this.openPage(profileDir);
344
+ let enrichWarnings: string[] = [];
260
345
  try {
261
346
  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));
347
+ // The console opens the article editor in a new tab — adopt it when it appears.
348
+ const editor = page.clickAndAwaitNewPage
349
+ ? await page.clickAndAwaitNewPage(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`)
350
+ : page;
351
+ if (editor === page) {
352
+ await page.click(`${SEL.menuItem}:has-text("图文"), ${SEL.menuItem}`);
353
+ }
354
+ await editor.waitFor(SEL.titleInput, CONSOLE_READY_TIMEOUT_MS);
355
+ await editor.fill(SEL.titleInput, title);
356
+ await this.tryFill(editor, SEL.authorInput, String(content.metadata?.author ?? ''));
357
+ await editor.waitFor(SEL.editor, CONSOLE_READY_TIMEOUT_MS);
358
+ await editor.setInnerHtml(SEL.editor, markdownToWechatHtml(content.body));
268
359
  const digest = resolveDigest(content);
269
- if (digest) await this.tryFill(page, SEL.summaryInput, digest);
360
+ if (digest) await this.tryFill(editor, SEL.summaryInput, digest);
361
+
362
+ const enrichMeta = content.metadata?.wechatmp;
363
+ if (enrichMeta && typeof enrichMeta === 'object' && !Array.isArray(enrichMeta)) {
364
+ enrichWarnings = await this.enrichDraft(editor, enrichMeta as Record<string, unknown>);
365
+ const audioTitle = (enrichMeta as Record<string, unknown>).audioTitle;
366
+ if (typeof audioTitle === 'string' && audioTitle.trim() !== '') {
367
+ const audioWarn = await this.insertAudio(editor, audioTitle.trim());
368
+ if (audioWarn !== '') enrichWarnings.push(audioWarn);
369
+ }
370
+ }
270
371
 
271
372
  if (want === 'draft') {
272
- await page.click(SEL.saveDraftBtn);
373
+ await editor.click(SEL.saveDraftBtn);
273
374
  } else {
274
- await page.click(SEL.publishBtn);
275
- await this.tryClick(page, SEL.confirmBtn);
375
+ await editor.click(SEL.publishBtn);
376
+ await this.tryClick(editor, SEL.confirmBtn);
276
377
  }
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`,
281
- );
378
+ // Real consoles confirm the draft save by navigating from the isNew editor
379
+ // (appmsg_edit_v2) to the saved-draft edit page carrying the new appmsgid.
380
+ if (editor.waitForUrlFragment) {
381
+ await editor.waitForUrlFragment('appmsg_edit&action=edit', CONSOLE_READY_TIMEOUT_MS);
382
+ } else {
383
+ const seen = await editor.waitForAny([SEL.toast], TOAST_TIMEOUT_MS);
384
+ if (seen === '') {
385
+ return this.failed(
386
+ `save was clicked but no confirmation toast appeared within ${TOAST_TIMEOUT_MS}ms — verify the console manually`,
387
+ );
388
+ }
282
389
  }
390
+ const appmsgid = /appmsgid=(\d+)/.exec(editor.url())?.[1] ?? '';
283
391
  return {
284
392
  ok: true,
285
393
  target: TARGET,
@@ -287,13 +395,201 @@ export class WechatmpBrowserTransport implements PublishTransport {
287
395
  publishStatus: want,
288
396
  note:
289
397
  want === 'draft'
290
- ? 'saved to the WeChat MP draft box (草稿箱)'
398
+ ? `saved to the WeChat MP draft box (草稿箱)${appmsgid ? ` as appmsgid ${appmsgid}` : ''}`
291
399
  : 'published via the console 发表 flow',
400
+ ...(enrichWarnings.length > 0 ? { enrichWarnings } : {}),
292
401
  },
293
402
  };
294
403
  } catch (error: unknown) {
295
404
  const why = error instanceof Error ? error.message : String(error);
296
- return this.failed(`console flow failed: ${why}`);
405
+ const enrich = enrichWarnings.length > 0 ? ` (enrich warnings: ${enrichWarnings.join(' | ')})` : '';
406
+ return this.failed(`console flow failed: ${why}${enrich}`);
407
+ } finally {
408
+ await page.close?.();
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Set the operator's draft metadata (原创/作者/合集/创作来源/封面) in the v2 editor.
414
+ * Runs on a FRESH draft only - the toggles are not state-aware (no idempotency for re-runs).
415
+ * Per-field failures are collected into warnings and surfaced in the Result; the draft still saves.
416
+ * Dialog mapping evidence (probes 2026-09-29, drafts 100000805/808): 原创 + 赞赏 share ONE dialog
417
+ * (声明类型 radios, 作者 input, 确定) - the 赞赏 switch stays hidden for this account, so 赞赏 is a manual step;
418
+ * 合集 dialog = 请选择合集 input + 确认; 创作来源 is an inline checkbox (no dialog); the 原文链接 row has
419
+ * no reachable URL input in headless - also manual; cover accepts a direct input[type=file] set.
420
+ * Commit verification (evalJs): the original dialog only closes when WeChat's validation passes - the
421
+ * author is the reward (shang) account and must be picked from its search, which does not resolve for
422
+ * this account headless; a non-closing dialog is swept and surfaced as a warning. Same for the
423
+ * creation-source checkbox (DOM re-check).
424
+ */
425
+ private async enrichDraft(editor: WechatPage, meta: Record<string, unknown>): Promise<string[]> {
426
+ const DIALOG = '.weui-desktop-dialog:visible';
427
+ const CONFIRM = `${DIALOG} button:has-text("确认"), ${DIALOG} button:has-text("确定")`;
428
+ const warnings: string[] = [];
429
+ const str = (key: string) => (typeof meta[key] === 'string' ? (meta[key] as string) : '');
430
+ const on = (key: string) => meta[key] === true;
431
+ // Sweep any leaked dialog so the next step (especially 保存为草稿) is never overlay-blocked.
432
+ const sweep = async () => {
433
+ await this.tryClick(editor, 'button:has-text("取消")', 2000);
434
+ await editor.press?.('Escape');
435
+ await editor.waitForTimeout?.(400);
436
+ };
437
+ // One retry per step: the settings panel re-renders after dialog confirms and can briefly
438
+ // detach rows (observed on the tags row right after the original-declaration confirm).
439
+ const step = async (name: string, fn: () => Promise<void>) => {
440
+ try {
441
+ await fn();
442
+ return;
443
+ } catch {
444
+ // retry once below
445
+ }
446
+ await sweep();
447
+ await editor.waitForTimeout?.(1000);
448
+ try {
449
+ await fn();
450
+ } catch (error) {
451
+ warnings.push(`${name}: ${(error instanceof Error ? error.message : String(error)).slice(0, 120)}`);
452
+ await sweep();
453
+ }
454
+ };
455
+ if (on('original')) {
456
+ await step('原创声明', async () => {
457
+ await editor.click(SEL.originalSwitch, 10000);
458
+ await editor.click(`${DIALOG} :text("文字原创")`, 8000);
459
+ const author = str('author');
460
+ if (author) await this.tryFill(editor, `${DIALOG} input[placeholder*="作者"]`, author);
461
+ await editor.click(CONFIRM, 8000);
462
+ });
463
+ const committed = await editor.evalJs?.(
464
+ `[...document.querySelectorAll('.weui-desktop-dialog')].every((d) => !d.offsetWidth)`,
465
+ );
466
+ if (committed === false) {
467
+ warnings.push('原创声明: 未生效 - 作者须为赞赏账户(搜索选择),请手动设置');
468
+ await sweep();
469
+ }
470
+ }
471
+ const collection = str('collection');
472
+ if (collection) {
473
+ await step('合集', async () => {
474
+ await editor.click(SEL.tagRow, 8000);
475
+ await editor.waitFor(SEL.tagInput, 8000);
476
+ await editor.fill(SEL.tagInput, collection);
477
+ await editor.waitForTimeout?.(1200);
478
+ await editor.click(`${DIALOG} :text("${collection}")`, 8000);
479
+ await editor.click(CONFIRM, 8000);
480
+ });
481
+ }
482
+ if (on('aiGenerated')) {
483
+ await step('创作来源', async () => {
484
+ await editor.click(SEL.claimSourceCheckbox, 8000);
485
+ // Probes show no dialog on current builds — the checkbox alone declares AI generation.
486
+ const dialog = await editor.waitForAny([DIALOG], 3000);
487
+ if (dialog) {
488
+ await editor.click(`${DIALOG} :text("内容由AI生成")`, 8000);
489
+ await editor.click(CONFIRM, 8000);
490
+ }
491
+ });
492
+ const checked = await editor.evalJs?.(`!!document.querySelector('#js_claim_source_area input:checked')`);
493
+ if (checked === false) warnings.push('创作来源: 勾选未生效,请手动设置');
494
+ }
495
+ const coverPath = str('coverPath');
496
+ const putFile = editor.setInputFiles?.bind(editor);
497
+ if (coverPath && putFile) {
498
+ await step('封面', async () => {
499
+ try {
500
+ await putFile('input[type="file"]', coverPath);
501
+ } catch {
502
+ await editor.click(SEL.coverBtn, 8000);
503
+ await editor.waitForTimeout?.(1500);
504
+ await putFile('input[type="file"]', coverPath);
505
+ }
506
+ const cropBtn = await editor.waitForAny(
507
+ [`${DIALOG} button:has-text("完成")`, `${DIALOG} button:has-text("确定")`],
508
+ 5000,
509
+ );
510
+ if (cropBtn) await editor.click(cropBtn, 8000);
511
+ });
512
+ }
513
+ await sweep();
514
+ return warnings;
515
+ }
516
+
517
+ /**
518
+ * Audio embed at the article top (operator request 2026-09-29): open the editor's audio menu
519
+ * (a hover dropdown - JS click bypasses the hover gate), reuse an existing same-title 素材库
520
+ * material, and insert at the caret (placed at the article start first). Upload of a missing
521
+ * material is deliberately manual for now: the 上传音频 form is iframe-based and unmapped.
522
+ * Fail-soft: every non-committed outcome returns a warning string; '' means placed at top.
523
+ */
524
+ private async insertAudio(editor: WechatPage, title: string): Promise<string> {
525
+ const evalJs = editor.evalJs;
526
+ if (typeof evalJs !== 'function') return '';
527
+ const CARET = `(() => {
528
+ const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
529
+ if (!pm || !pm.firstChild) return;
530
+ const r = document.createRange();
531
+ r.setStart(pm.firstChild, 0);
532
+ r.collapse(true);
533
+ const sel = window.getSelection();
534
+ sel?.removeAllRanges();
535
+ sel?.addRange(r);
536
+ })()`;
537
+ const DEDUPE = `(() => {
538
+ const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
539
+ const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
540
+ .find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
541
+ if (!hit) return 'missing';
542
+ const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
543
+ if (!cb) return 'nobox';
544
+ cb.click();
545
+ return 'checked';
546
+ })()`;
547
+ const INSERT = `(() => {
548
+ const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
549
+ if (!btn) return false;
550
+ btn.click();
551
+ return true;
552
+ })()`;
553
+ const CANCEL = `(() => {
554
+ const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
555
+ btn?.click();
556
+ })()`;
557
+ const VERIFY = `(() => {
558
+ const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
559
+ if (!pm) return 'noeditor';
560
+ const node = pm.querySelector('mpvoice, [class*="audio"]');
561
+ if (!node) return 'absent';
562
+ const first = pm.firstElementChild;
563
+ return first && (first === node || first.contains(node)) ? 'top' : 'notop';
564
+ })()`;
565
+ try {
566
+ const run = (expression: string) => evalJs.call(editor, expression);
567
+ await run(CARET);
568
+ await run("document.querySelector('li.js_insertaudio')?.click()");
569
+ await editor.waitFor('.audio_dialog', TOAST_TIMEOUT_MS);
570
+ // The 素材库 list loads async after the dialog opens - retry while rows stream in.
571
+ let dedupe = String(await run(DEDUPE));
572
+ for (let i = 0; dedupe === 'missing' && i < 2; i++) {
573
+ await editor.waitForTimeout?.(1200);
574
+ dedupe = String(await run(DEDUPE));
575
+ }
576
+ if (dedupe !== 'checked') {
577
+ await run(CANCEL);
578
+ return dedupe === 'missing'
579
+ ? '音频: 素材库无同名音频,请手动上传后插入'
580
+ : '音频: 同名素材行未找到勾选框,请手动插入';
581
+ }
582
+ if ((await run(INSERT)) !== true) {
583
+ await run(CANCEL);
584
+ return '音频: 插入按钮未找到,请手动插入';
585
+ }
586
+ await editor.waitForTimeout?.(2000);
587
+ const state = String(await run(VERIFY));
588
+ if (state === 'absent' || state === 'noeditor') return '音频: 插入未生效,请手动检查';
589
+ if (state === 'notop') return '音频: 已插入但未在文首,请手动调整位置';
590
+ return '';
591
+ } catch {
592
+ return '音频: 自动插入失败,请手动插入';
297
593
  }
298
594
  }
299
595
 
@@ -306,9 +602,9 @@ export class WechatmpBrowserTransport implements PublishTransport {
306
602
  }
307
603
  }
308
604
 
309
- private async tryClick(page: WechatPage, selector: string): Promise<void> {
605
+ private async tryClick(page: WechatPage, selector: string, timeoutMs?: number): Promise<void> {
310
606
  try {
311
- await page.click(selector);
607
+ await page.click(selector, timeoutMs);
312
608
  } catch {
313
609
  // confirm dialogs are not shown for every account type
314
610
  }
@@ -1,10 +1,11 @@
1
1
  import { resolveBrowserProfileDir } from '@gobing-ai/publish-harness';
2
- import { loginWithProfile } from '@gobing-ai/ts-browser-automation';
2
+ import { openPersistentBrowser } from '@gobing-ai/ts-browser-automation';
3
3
  import { echo } from '@gobing-ai/ts-utils';
4
- import { checkWechatSession } from './index';
4
+ import { checkWechatSession, storageStatePath } from './index';
5
5
 
6
6
  const CONSOLE_URL = 'https://mp.weixin.qq.com/';
7
7
  const LOGIN_TIMEOUT_MS = 180_000;
8
+ const LOGGED_IN_FRAGMENT = '/cgi-bin/home';
8
9
 
9
10
  const profileDir = resolveBrowserProfileDir('wechatmp', process.env.WECHATMP_PROFILE_DIR);
10
11
  const verb = process.argv[2] ?? 'check';
@@ -14,13 +15,24 @@ try {
14
15
  echo(
15
16
  `wechatmp login: opening the console in a headed browser — scan the QR once (timeout ${LOGIN_TIMEOUT_MS / 1000}s)`,
16
17
  );
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}`);
18
+ const context = await openPersistentBrowser({ profileDir, headless: false });
19
+ try {
20
+ const page = context.pages()[0] ?? (await context.newPage());
21
+ await page.goto(CONSOLE_URL);
22
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
23
+ while (!page.url().includes(LOGGED_IN_FRAGMENT)) {
24
+ if (Date.now() > deadline) throw new Error(`QR scan not completed within ${LOGIN_TIMEOUT_MS / 1000}s`);
25
+ await new Promise((resolve) => setTimeout(resolve, 1000));
26
+ }
27
+ await page.waitForTimeout(10_000); // settle: let the console finish its post-auth Set-Cookies
28
+ // The Chromium profile store drops the grant cookies on restart even though they carry
29
+ // multi-day expiries — the durable session artifact is the captured storageState file,
30
+ // which the publish flow re-injects into every launch.
31
+ await context.storageState({ path: storageStatePath(profileDir) });
32
+ echo(`wechatmp login: session saved in ${storageStatePath(profileDir)}`);
33
+ } finally {
34
+ await context.close();
35
+ }
24
36
  } else if (verb === 'check') {
25
37
  await checkWechatSession(profileDir);
26
38
  echo(`wechatmp session: OK (${profileDir})`);
@@ -30411,29 +30411,35 @@ async function findAttachedSelector(page, selectors, what, timeoutMs = STEP_TIME
30411
30411
  throw new Error(`${what}: no matching element observed`);
30412
30412
  }
30413
30413
  async function checkXhsProfileSession(opts) {
30414
- try {
30415
- await withProfilePage({
30416
- profileDir: opts.profileDir,
30417
- headless: true,
30418
- url: PUBLISH_URL,
30419
- isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
30420
- timeoutMs: opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS,
30421
- launcher: opts.launcher
30422
- }, async () => {
30423
- return;
30424
- });
30425
- return {
30426
- status: "verified",
30427
- profileDir: opts.profileDir,
30428
- detail: `authenticated ${CHANNEL} creator console observed headlessly via profile ${opts.profileDir} (read-only)`
30429
- };
30430
- } catch (error51) {
30431
- return {
30432
- status: "refused",
30433
- profileDir: opts.profileDir,
30434
- detail: `headless readiness refused: ${error51 instanceof Error ? error51.message : String(error51)}`
30435
- };
30414
+ const timeoutMs = opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
30415
+ for (const headless of [true, false]) {
30416
+ try {
30417
+ await withProfilePage({
30418
+ profileDir: opts.profileDir,
30419
+ headless,
30420
+ url: PUBLISH_URL,
30421
+ isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
30422
+ timeoutMs,
30423
+ launcher: opts.launcher
30424
+ }, async () => {
30425
+ return;
30426
+ });
30427
+ return {
30428
+ status: "verified",
30429
+ profileDir: opts.profileDir,
30430
+ detail: `authenticated ${CHANNEL} creator console observed ${headless ? "headlessly" : "via headed fallback"} via profile ${opts.profileDir} (read-only)`
30431
+ };
30432
+ } catch (error51) {
30433
+ if (headless)
30434
+ continue;
30435
+ return {
30436
+ status: "refused",
30437
+ profileDir: opts.profileDir,
30438
+ detail: `headless and headed readiness refused: ${error51 instanceof Error ? error51.message : String(error51)}`
30439
+ };
30440
+ }
30436
30441
  }
30442
+ throw new Error("unreachable: session check loop must return or throw");
30437
30443
  }
30438
30444
 
30439
30445
  class XhsBrowserTransport {
@@ -427,30 +427,38 @@ export interface SessionCheckOptions {
427
427
  * `verified`, which is what turns "no session" from a 120s headed login wait into a fast refusal.
428
428
  */
429
429
  export async function checkXhsProfileSession(opts: SessionCheckOptions): Promise<ProfileSessionCheck> {
430
- try {
431
- await withProfilePage(
432
- {
430
+ const timeoutMs = opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
431
+ // XHS bot-walls and slow-walls headless loads (observed 2026-09-29: headless auth never
432
+ // confirms while the same profile reads ready in 27s headed), so a headless refusal is
433
+ // retried once headed — still a read-only readiness check before any publish actions.
434
+ for (const headless of [true, false] as const) {
435
+ try {
436
+ await withProfilePage(
437
+ {
438
+ profileDir: opts.profileDir,
439
+ headless,
440
+ url: PUBLISH_URL,
441
+ isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
442
+ timeoutMs,
443
+ launcher: opts.launcher,
444
+ },
445
+ async () => undefined,
446
+ );
447
+ return {
448
+ status: 'verified',
433
449
  profileDir: opts.profileDir,
434
- headless: true,
435
- url: PUBLISH_URL,
436
- isAuthenticated: (page) => isXhsConsoleReady(wrapPlaywrightPage(page)),
437
- timeoutMs: opts.timeoutMs ?? PREFLIGHT_TIMEOUT_MS,
438
- launcher: opts.launcher,
439
- },
440
- async () => undefined,
441
- );
442
- return {
443
- status: 'verified',
444
- profileDir: opts.profileDir,
445
- detail: `authenticated ${CHANNEL} creator console observed headlessly via profile ${opts.profileDir} (read-only)`,
446
- };
447
- } catch (error) {
448
- return {
449
- status: 'refused',
450
- profileDir: opts.profileDir,
451
- detail: `headless readiness refused: ${error instanceof Error ? error.message : String(error)}`,
452
- };
450
+ detail: `authenticated ${CHANNEL} creator console observed ${headless ? 'headlessly' : 'via headed fallback'} via profile ${opts.profileDir} (read-only)`,
451
+ };
452
+ } catch (error) {
453
+ if (headless) continue;
454
+ return {
455
+ status: 'refused',
456
+ profileDir: opts.profileDir,
457
+ detail: `headless and headed readiness refused: ${error instanceof Error ? error.message : String(error)}`,
458
+ };
459
+ }
453
460
  }
461
+ throw new Error('unreachable: session check loop must return or throw');
454
462
  }
455
463
 
456
464
  export class XhsBrowserTransport implements PublishTransport {