@gobing-ai/knowledge-kit 0.1.12 → 0.1.14

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 (25) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +16 -4
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/publish-presets/daily-ai-voice-migration.json +3 -3
  6. package/plugins/kk/publish-presets/daily-ai-voice.json +4 -4
  7. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +2 -2
  8. package/plugins/kk/scripts/daily-scheduled-run.ts +138 -44
  9. package/plugins/kk/scripts/daily-stages.ts +52 -9
  10. package/plugins/kk/skills/content-safety/references/platforms/podcast-salty.md +41 -0
  11. package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
  12. package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
  13. package/plugins/kk/workflows/kk-collect.yaml +3 -3
  14. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +12 -12
  15. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +2 -2
  16. package/plugins/publishings/surfdash-pub/dist/index.js +52 -23
  17. package/plugins/publishings/surfdash-pub/src/index.ts +69 -24
  18. package/plugins/publishings/wechatmp-pub/dist/index.js +171 -30
  19. package/plugins/publishings/wechatmp-pub/src/flow.ts +287 -49
  20. package/plugins/publishings/weibo-pub/dist/index.js +114 -8
  21. package/plugins/publishings/weibo-pub/src/index.ts +140 -16
  22. package/plugins/publishings/xhs-pub/dist/index.js +164 -12
  23. package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
  24. package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
  25. package/plugins/publishings/xhs-pub/src/page.ts +26 -0
@@ -57,8 +57,15 @@ const ARTICLE_LAYOUT = ['button:has-text("一键排版")', 'div.ailayout-btn_wrp
57
57
  const PODCAST_OPEN = ['text=上传音频', 'button:has-text("上传音频")'];
58
58
  const PODCAST_MODAL = ['div.creator-modal-sty', '.d-modal.d-modal-centered', '.d-modal'];
59
59
  const PODCAST_AUDIO_INPUT = ['input[type="file"][accept*=".mp3"]', 'input[type="file"][accept*=".wav"]'];
60
- const PODCAST_COVER_INPUT = ['input[type="file"][accept*=".jpg"]', 'input[type="file"][accept*=".png"]'];
61
60
  const PODCAST_MODAL_ADVANCE = ['去发布'];
61
+ /* Measured post-attach flow (2026-11-06 headless probe, 10-min mp3 + 1:1 png): a raw
62
+ * input.files assignment registers the AUDIO (`上传成功` appears) but the COVER card never
63
+ * advances and `去发布` stays `disabled` — only clicking the cover card and handing the file to
64
+ * the native file chooser the site opens takes both. `去发布` is then just the ADVANCE control: the
65
+ * next page is the full podcast editor carrying the closed-shadow bar (`暂存离开`@left, `发布`@right),
66
+ * so a draft run may click it too — it does not publish on its own. */
67
+ const PODCAST_COVER_ZONE = '.d-modal div:has-text("设置封面")';
68
+ const PODCAST_UPLOAD_ACK = '上传成功';
62
69
 
63
70
  /**
64
71
  * The `暂存离开` labels, newest first: XHS renamed the control, and the older labels stay as
@@ -401,32 +408,85 @@ async function isPodcastModalOpen(page: XhsPage): Promise<boolean> {
401
408
  .catch(() => false);
402
409
  }
403
410
 
411
+ async function clickPodcastModalDomFallback(page: XhsPage): Promise<string> {
412
+ const res = await page
413
+ .evaluate(
414
+ `(() => {
415
+ const modals = Array.from(document.querySelectorAll('.d-modal, div.creator-modal-sty'));
416
+ const buttons = Array.from(document.querySelectorAll('.d-modal button, div.creator-modal-sty button, .d-modal-mask button'));
417
+ const confirmLabels = ['确定', '完成', '确认', '保存'];
418
+ const cropBtn = buttons.find((b) => {
419
+ const t = (b.textContent || '').trim();
420
+ return confirmLabels.includes(t) && !b.classList.contains('cancel-btn') && !b.dataset.kkClicked;
421
+ }) || (modals.length > 1 ? Array.from(modals[modals.length - 1].querySelectorAll('button')).find((b) => !b.classList.contains('cancel-btn') && b.classList.contains('bg-red') && !(b.textContent || '').includes('去发布') && !b.dataset.kkClicked) : null);
422
+ if (cropBtn && typeof cropBtn.click === 'function') {
423
+ cropBtn.dataset.kkClicked = '1';
424
+ cropBtn.click();
425
+ return JSON.stringify({ modals: modals.length, clickedCrop: true, clickedAdvance: false });
426
+ }
427
+ const advBtns = buttons.filter((b) => (b.textContent || '').includes('去发布') && !b.hasAttribute('disabled') && b.getAttribute('aria-disabled') !== 'true');
428
+ const adv = advBtns[advBtns.length - 1];
429
+ let clickedAdvance = false;
430
+ if (adv && !adv.dataset.kkClicked && typeof adv.click === 'function') {
431
+ adv.dataset.kkClicked = '1';
432
+ adv.click();
433
+ clickedAdvance = true;
434
+ }
435
+ return JSON.stringify({
436
+ modals: modals.length,
437
+ clickedCrop: false,
438
+ clickedAdvance,
439
+ buttons: buttons.map((b) => ({ text: (b.textContent || '').trim(), cls: b.className, disabled: b.hasAttribute('disabled') })),
440
+ });
441
+ })()`,
442
+ )
443
+ .catch(() => '');
444
+ return typeof res === 'string' ? res : '';
445
+ }
446
+
404
447
  /**
405
- * Leave the 发播客 modal. The recorded advance control is `去发布`, which is publish-adjacent, so a
406
- draft run never clicks it: it waits for the platform to move on by itself and fails loud naming the
407
- control it refused to click. Only an explicit `publishStatus: 'public'` may click it.
408
- *
409
- * 0236 R3 (deferred, operator decision 2026-10-09): whether the modal offers a save path OTHER than
410
- * `去发布` once both files are attached was never measured (`.spur/run/discovery/` holds only the
411
- * modal open), and an attended walk under ADR-025 is what would measure it. So no draft-save selector
412
- * is invented here: the run stops before the publish-adjacent control and says so, which is the
413
- * failure the 0236 result carries (a `ok:false` entry that never fails the visit).
448
+ * Leave the 发播客 modal. 0236 R3 is now measured (2026-11-06 headless probe): the modal's only
449
+ * controls are `取消` and the light-DOM `去发布`, and `去发布` does not publish — it opens the full
450
+ * podcast editor whose action bar is the same closed-shadow `暂存离开`/`发布` pair every other mode
451
+ * uses, so BOTH publish statuses take the same advance click and the draft decision moves to the
452
+ * editor's action bar (MODE_ACTION_PATHS['发播客'] already routes `shadow`). Draft saving
453
+ * waits for the modal to close by itself first — only that timed-dead path names what it saw.
414
454
  */
415
455
  async function leavePodcastModal(deps: ModeRunDeps): Promise<void> {
416
456
  const closed = () => isPodcastModalOpen(deps.page).then((open) => !open);
417
- if (await waitUntil(closed, deps.stepTimeoutMs)) return;
418
- if (deps.publishStatus === 'public') {
457
+ if (!(await isPodcastModalOpen(deps.page))) return;
458
+ let usedFallback = false;
459
+ let lastDiag = '';
460
+ try {
419
461
  const advance = await clickLightControl(
420
462
  deps.page,
421
463
  PODCAST_MODAL_ADVANCE,
422
- deps.stepTimeoutMs,
464
+ Math.min(deps.stepTimeoutMs, 2_500),
423
465
  'podcast modal advance',
424
466
  );
425
- if (!advance.ok) throw new Error(advance.error);
426
- if (await waitUntil(closed, deps.stepTimeoutMs)) return;
467
+ if (!advance.ok) {
468
+ lastDiag = await clickPodcastModalDomFallback(deps.page);
469
+ if (!lastDiag.startsWith('{')) throw new Error(advance.error);
470
+ usedFallback = true;
471
+ }
472
+ } catch {
473
+ lastDiag = await clickPodcastModalDomFallback(deps.page);
474
+ usedFallback = true;
475
+ }
476
+ if (
477
+ await waitUntil(async () => {
478
+ if (await closed()) return true;
479
+ if (usedFallback) {
480
+ lastDiag = await clickPodcastModalDomFallback(deps.page);
481
+ }
482
+ return false;
483
+ }, deps.stepTimeoutMs)
484
+ ) {
485
+ return;
427
486
  }
487
+ const diagSuffix = lastDiag ? ` [modal=${lastDiag}]` : '';
428
488
  throw new Error(
429
- `podcast modal: the 发播客 modal was still open ${deps.stepTimeoutMs}ms after the audio and cover were attached; its post-attach save path has never been measured (task 0236 R3 — the attended walk is still pending) and the only recorded advance control is '${PODCAST_MODAL_ADVANCE[0]}', which a draft run does not click (it is publish-adjacent)`,
489
+ `podcast modal: the 发播客 modal stayed open ${deps.stepTimeoutMs}ms after the advance click; the measured 2026-11-06 flow moves to the podcast editor page with the closed-shadow '${DRAFT_LABELS[0]}' bar, so a fast re-measure with a fresh probe is the next step, not a retry${diagSuffix}`,
430
490
  );
431
491
  }
432
492
 
@@ -445,16 +505,127 @@ async function composePodcast(
445
505
  deps.stepTimeoutMs,
446
506
  );
447
507
  await uploadFile(asPlaywrightPage(deps.page), audioInput, input.audio, { timeoutMs: deps.stepTimeoutMs });
448
- const coverInput = await findAttachedSelector(
449
- deps.page,
450
- PODCAST_COVER_INPUT,
451
- 'podcast cover input',
452
- deps.stepTimeoutMs,
453
- );
454
- await uploadFile(asPlaywrightPage(deps.page), coverInput, input.cover, { timeoutMs: deps.stepTimeoutMs });
508
+ // The audio card's acknowledgement is the registration signal: no ack in the window means the
509
+ // assignment did not take, and the cover chooser would then find `去发布` disabled and stall.
510
+ const ack = await waitUntil(async () => {
511
+ try {
512
+ return (await deps.page.locator(`text=${PODCAST_UPLOAD_ACK}`).count()) > 0;
513
+ } catch {
514
+ return false;
515
+ }
516
+ }, deps.stepTimeoutMs);
517
+ if (!ack) {
518
+ throw new Error(
519
+ `podcast modal: the audio card never showed '${PODCAST_UPLOAD_ACK}' within ${deps.stepTimeoutMs}ms of the file assignment; mimic-file upload of ${input.audio.split('/').pop()} did not register`,
520
+ );
521
+ }
522
+ await uploadPodcastCover(deps, input.cover);
523
+ await podcastAdvanceReady(deps);
455
524
  await leavePodcastModal(deps);
456
525
  }
457
526
 
527
+ async function uploadPodcastCover(deps: ModeRunDeps, cover: string): Promise<void> {
528
+ const zone = deps.page.locator(PODCAST_COVER_ZONE).first();
529
+ const box = await zone.boundingBox().catch(() => null);
530
+ if (!box) {
531
+ throw new Error(`podcast modal: the cover card ('${PODCAST_COVER_ZONE}') has no bounding box to click`);
532
+ }
533
+ if (!deps.page.mouse) {
534
+ throw new Error('podcast modal: the page seam does not expose a raw mouse for the cover-card coordinate click');
535
+ }
536
+ const chooser = deps.page.waitForEvent?.('filechooser', { timeout: 8_000 }).catch(() => null) as Promise<
537
+ unknown | null
538
+ >;
539
+ await deps.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
540
+ const pending = Symbol('pending');
541
+ const quick = await Promise.race([
542
+ chooser,
543
+ new Promise<typeof pending>((resolve) => setTimeout(() => resolve(pending), 400)),
544
+ ]);
545
+ let diag = '';
546
+ if (quick === pending) {
547
+ const fallback = await deps.page
548
+ .evaluate(
549
+ `(() => {
550
+ const modal = document.querySelector('.d-modal') || document.body;
551
+ const inputs = Array.from(modal.querySelectorAll('input[type="file"]'));
552
+ const coverInput = inputs.find((i) => !String(i.getAttribute('accept') || '').includes('.mp3'));
553
+ const all = Array.from(modal.querySelectorAll('*')).filter((el) => (el.textContent || '').includes('设置封面'));
554
+ const leaf = all[all.length - 1];
555
+ let card = leaf;
556
+ while (card && card.parentElement && card.parentElement !== modal) {
557
+ const pt = card.parentElement.textContent || '';
558
+ if (pt.includes('上传成功') || pt.includes('点击上传') || pt.includes('去发布')) break;
559
+ card = card.parentElement;
560
+ }
561
+ let clickedInput = false;
562
+ if (coverInput && typeof coverInput.click === 'function') {
563
+ coverInput.click();
564
+ clickedInput = true;
565
+ } else {
566
+ if (card && typeof card.click === 'function') card.click();
567
+ if (leaf && typeof leaf.click === 'function') leaf.click();
568
+ }
569
+ const rect = card ? card.getBoundingClientRect() : null;
570
+ return JSON.stringify({
571
+ clickedInput,
572
+ x: rect ? Math.round(rect.left + rect.width / 2) : null,
573
+ y: rect ? Math.round(rect.top + rect.height * 0.35) : null,
574
+ inputs: inputs.map((i) => ({ accept: i.getAttribute('accept'), cls: i.className })),
575
+ cardTag: card ? card.tagName + '.' + String(card.className) : null,
576
+ cardRect: rect ? [Math.round(rect.left), Math.round(rect.top), Math.round(rect.width), Math.round(rect.height)] : null,
577
+ });
578
+ })()`,
579
+ )
580
+ .catch(() => '');
581
+ if (typeof fallback === 'string' && fallback.startsWith('{')) {
582
+ diag = ` [modal=${fallback}]`;
583
+ try {
584
+ const parsed = JSON.parse(fallback) as {
585
+ clickedInput?: boolean;
586
+ x?: number | null;
587
+ y?: number | null;
588
+ };
589
+ if (!parsed.clickedInput && typeof parsed.x === 'number' && typeof parsed.y === 'number') {
590
+ await deps.page.mouse.click(parsed.x, parsed.y);
591
+ }
592
+ } catch {
593
+ // ignore parse errors on diagnostic payload
594
+ }
595
+ }
596
+ }
597
+ const opened = quick === pending ? await chooser : quick;
598
+ if (!opened) {
599
+ throw new Error(
600
+ `podcast modal: clicking the cover card did not open a native file chooser within 8000ms${diag}`,
601
+ );
602
+ }
603
+ await (opened as { setFiles(path: string): Promise<void> }).setFiles(cover);
604
+ }
605
+
606
+ /** Wait until the advance control is enabled — `去发布` stays `disabled` until BOTH cards took. */
607
+ async function podcastAdvanceReady(deps: ModeRunDeps): Promise<void> {
608
+ if (deps.publishStatus !== 'draft') {
609
+ return;
610
+ }
611
+ const enabled = await waitUntil(async () => {
612
+ try {
613
+ if ((await deps.page.locator('button:has-text("去发布")').count()) === 0) return false;
614
+ const disabled = await deps.page
615
+ .locator('button:has-text("去发布")[disabled], button:has-text("去发布")[aria-disabled="true"]')
616
+ .count();
617
+ return disabled === 0;
618
+ } catch {
619
+ return false;
620
+ }
621
+ }, deps.stepTimeoutMs);
622
+ if (!enabled) {
623
+ throw new Error(
624
+ `podcast modal: '${PODCAST_MODAL_ADVANCE[0]}' stayed disabled — the cards were not both accepted; check the audio ack '${PODCAST_UPLOAD_ACK}' and retry with the cover chooser path`,
625
+ );
626
+ }
627
+ }
628
+
458
629
  /** Compose the requested mode's post. Exhaustive over {@link ModeInput}. */
459
630
  async function compose(input: ModeInput, deps: ModeRunDeps): Promise<void> {
460
631
  switch (input.operation) {
@@ -58,6 +58,8 @@ export interface XhsLocator {
58
58
  */
59
59
  type?(text: string, options?: { timeout?: number; delay?: number }): Promise<void>;
60
60
  textContent?(): Promise<string>;
61
+ /** The card's click box — the native-chooser path clicks at its centre. */
62
+ boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null>;
61
63
  }
62
64
 
63
65
  /** Structural subset of the Playwright page the script driver uses. */
@@ -74,6 +76,10 @@ export interface XhsPage {
74
76
  countOnScreen(selector: string): Promise<number>;
75
77
  /** Click the first on-screen match — the console renders the tabs 3×. */
76
78
  clickInViewport?(selector: string): Promise<boolean>;
79
+ /** The 发播客 cover card resolves its file through the site's native file chooser. */
80
+ waitForEvent?(event: string, options?: { timeout?: number }): Promise<unknown | null>;
81
+ /** Raw mouse press (the tab-bar clone debris needs coordinate clicks). */
82
+ mouse?: { click(x: number, y: number): Promise<void> };
77
83
  /** Resolve a labelled control inside a closed shadow root. */
78
84
  findShadowControl?(labels: readonly string[]): Promise<ShadowControl | null>;
79
85
  /** Click at viewport coordinates. */
@@ -154,6 +160,7 @@ export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage
154
160
  viewportSize?(): { width: number; height: number } | null;
155
161
  evaluate(expression: string): Promise<unknown>;
156
162
  mouse: { click(x: number, y: number): Promise<void> };
163
+ waitForEvent?: (event: string, options?: Record<string, unknown>) => Promise<unknown>;
157
164
  };
158
165
 
159
166
  /**
@@ -202,12 +209,22 @@ export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage
202
209
  isVisible(o?: Record<string, unknown>): Promise<boolean>;
203
210
  fill(t: string, o?: Record<string, unknown>): Promise<void>;
204
211
  click(o?: Record<string, unknown>): Promise<void>;
212
+ boundingBox?(): Promise<{
213
+ x: number;
214
+ y: number;
215
+ width: number;
216
+ height: number;
217
+ } | null>;
205
218
  };
206
219
  return {
207
220
  count: () => loc.count(),
208
221
  isVisible: (options) => loc.isVisible(options),
209
222
  fill: (text, options) => loc.fill(text, options),
210
223
  click: (options) => loc.click(options),
224
+ // A `:has-text(...)` selector matches every ancestor `div` in pre-order before
225
+ // the leaf card itself; the last on-screen match is the innermost element.
226
+ boundingBox: async () =>
227
+ (await onScreen(selector)).at(-1)?.box ?? (await loc.boundingBox?.()) ?? null,
211
228
  type: (text, options) =>
212
229
  (
213
230
  loc as unknown as {
@@ -232,6 +249,15 @@ export function wrapPlaywrightPage(raw: unknown, shadow?: ShadowProbe): XhsPage
232
249
  },
233
250
  findShadowControl: async (labels) => (shadow ? shadow.findControl(labels) : null),
234
251
  clickAt: (x, y) => pw.mouse.click(x, y),
252
+ waitForEvent: (event, options) => {
253
+ if (typeof pw.waitForEvent !== 'function') {
254
+ return Promise.reject(new Error(`the page seam does not expose a '${event}' event source`));
255
+ }
256
+ return pw.waitForEvent(event, options) as Promise<unknown>;
257
+ },
258
+ mouse: {
259
+ click: (x, y) => pw.mouse.click(x, y),
260
+ },
235
261
  };
236
262
  }
237
263