@gobing-ai/knowledge-kit 0.0.34 → 0.0.35
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 +1 -1
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +17 -3
- package/plugins/generations/daily-article-gen/src/index.ts +32 -3
- package/plugins/generations/dailynews-gen/dist/index.js +2 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
- package/plugins/generations/gemini-tts/dist/index.js +4 -3
- package/plugins/generations/omni-voice-gen/plugin.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +4 -3
- package/plugins/generations/voice-gen/src/index.ts +9 -3
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-stages.ts +296 -39
- package/plugins/kk/scripts/kk-workflow-stages.ts +40 -0
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +32 -7
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +7 -4
- package/plugins/kk/workflows/kk-daily-safety.yaml +1 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +30336 -0
- package/plugins/publishings/infoq-pub/plugin.json +1 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +30322 -0
- package/plugins/publishings/juejin-pub/plugin.json +1 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +30392 -0
- package/plugins/publishings/substack-pub/plugin.json +1 -1
- package/plugins/publishings/wechatmp-pub/dist/index.js +307 -132
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +30271 -0
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -1
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +106 -145
- package/plugins/publishings/wechatmp-pub/src/flow.ts +283 -42
- package/plugins/publishings/wechatmp-pub/src/page.ts +20 -0
- package/plugins/publishings/x-pub/dist/profile-check.js +30328 -0
- package/plugins/publishings/x-pub/plugin.json +1 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +30538 -0
- package/plugins/publishings/xhs-pub/plugin.json +1 -1
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
|
@@ -3,7 +3,7 @@ import { ResultSchema } from '@gobing-ai/kk-core';
|
|
|
3
3
|
import { trySelectors } from '@gobing-ai/publish-harness';
|
|
4
4
|
import { expectOutcome, pasteHtml, readScopedSignal, uploadFile } from '@gobing-ai/publish-harness/browser';
|
|
5
5
|
import type { Page } from 'playwright';
|
|
6
|
-
import { enrichArticle
|
|
6
|
+
import { enrichArticle } from './enrich';
|
|
7
7
|
import {
|
|
8
8
|
artifactPaths,
|
|
9
9
|
CREATE_TYPES,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
followNewPage,
|
|
24
24
|
tryFindSelector,
|
|
25
25
|
type WechatPage,
|
|
26
|
+
waitForVisible,
|
|
26
27
|
} from './page';
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -65,21 +66,33 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
|
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* 封面 has NO file input (§5.2): the panel offers `修改` / `换一张` / `从正文选择`
|
|
68
|
-
* (`a.js_selectCoverFromContent`) /
|
|
69
|
-
*
|
|
69
|
+
* (`a.js_selectCoverFromContent`) / `从图片库选择` (`a.js_imagedialog`) / 微信扫码上传 / AI 配图.
|
|
70
|
+
* `从图片库选择` is the default because the daily flow uploads the episode cover there as the audio
|
|
71
|
+
* material's cover, so the library holds exactly the image the operator wants (2026-10-10);
|
|
72
|
+
* `从正文选择` remains selectable via `metadata.wechatmp.coverSource`.
|
|
70
73
|
*/
|
|
71
74
|
const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
|
|
72
75
|
const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
|
|
76
|
+
const COVER_FROM_LIBRARY = ['a.js_imagedialog', 'text=从图片库选择'];
|
|
77
|
+
/**
|
|
78
|
+
* The library picker's item list — the same `not <img>-based` widget as the body-source panel, so
|
|
79
|
+
* these stay candidate shapes. The newest upload sorts first (operator walk 2026-10-10: the just-uploaded
|
|
80
|
+
* audio cover is the default selection, `默认第一个`), so the first item is the intended one.
|
|
81
|
+
*/
|
|
82
|
+
const COVER_LIBRARY_ITEM = [
|
|
83
|
+
'div.weui-desktop-img-picker__item',
|
|
84
|
+
'div[class*="img_picker" i] li',
|
|
85
|
+
'div[class*="imgList" i] li',
|
|
86
|
+
'div[class*="cover" i] li',
|
|
87
|
+
];
|
|
88
|
+
/** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
|
|
89
|
+
const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
|
|
73
90
|
/**
|
|
74
91
|
* The panel's own image list is NOT `<img>`-based (measured `panels: 5, imgs: 0`), so the item and
|
|
75
92
|
* confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
|
|
76
93
|
* to do). A miss is recorded as a warning, never as a false cover claim.
|
|
77
94
|
*/
|
|
78
|
-
const COVER_ITEM =
|
|
79
|
-
'div[class*="cover" i] li',
|
|
80
|
-
'div[class*="imgList" i] li',
|
|
81
|
-
'div[class*="weui-desktop-img-picker" i] li',
|
|
82
|
-
];
|
|
95
|
+
const COVER_ITEM = COVER_LIBRARY_ITEM;
|
|
83
96
|
const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
|
|
84
97
|
|
|
85
98
|
/** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
|
|
@@ -111,6 +124,37 @@ const PODCAST_COVER_INPUT = [
|
|
|
111
124
|
];
|
|
112
125
|
const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'button:has-text("插入")'];
|
|
113
126
|
|
|
127
|
+
/** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
|
|
128
|
+
const AUDIO_MENU = 'li.js_insertaudio';
|
|
129
|
+
const AUDIO_DIALOG = '.audio_dialog';
|
|
130
|
+
/** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
|
|
131
|
+
const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
|
|
132
|
+
const AUDIO_ROW_RETRIES = 2;
|
|
133
|
+
const AUDIO_ROW_SETTLE_MS = 1_200;
|
|
134
|
+
/**
|
|
135
|
+
* The upload form (operator walk 2026-10-10: `上传音频` → file → `上传封面` → file → title → agree →
|
|
136
|
+
* 保存). The form lives inside the audio dialog, so the file inputs stay scoped to it.
|
|
137
|
+
*/
|
|
138
|
+
const UPLOAD_AUDIO = ['text=上传音频', 'button:has-text("上传音频")'];
|
|
139
|
+
const AUDIO_UPLOAD_INPUT = [
|
|
140
|
+
'.weui-desktop-dialog input[type="file"][accept*="audio"]',
|
|
141
|
+
'.weui-desktop-dialog input[type="file"][accept*=".mp3"]',
|
|
142
|
+
'.audio_dialog input[type="file"][accept*="audio"]',
|
|
143
|
+
'input[type="file"][accept*=".mp3"]',
|
|
144
|
+
];
|
|
145
|
+
const UPLOAD_COVER = ['text=上传封面', 'button:has-text("上传封面")'];
|
|
146
|
+
const AUDIO_COVER_INPUT = [
|
|
147
|
+
'.weui-desktop-dialog input[type="file"][accept*="image/"]',
|
|
148
|
+
'.audio_dialog input[type="file"][accept*="image/"]',
|
|
149
|
+
'input[type="file"][accept*=".png"]',
|
|
150
|
+
];
|
|
151
|
+
const AUDIO_TITLE_INPUT = [
|
|
152
|
+
'.weui-desktop-dialog input[placeholder*="标题"]',
|
|
153
|
+
'.audio_dialog input[placeholder*="标题"]',
|
|
154
|
+
'.audio_dialog input.frm_input',
|
|
155
|
+
];
|
|
156
|
+
const UPLOAD_SAVE = ['.weui-desktop-dialog button:has-text("保存")', 'button:has-text("保存")'];
|
|
157
|
+
|
|
114
158
|
/** The recorded save control. `发表` is never clicked on a draft run (ADR-022/ADR-026). */
|
|
115
159
|
const SAVE_DRAFT = ['button:has-text("保存为草稿")', 'text=保存为草稿'];
|
|
116
160
|
const PUBLISH_SEND = ['button.mass_send', '.mass_send', 'button:has-text("发表")'];
|
|
@@ -452,20 +496,35 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
|
452
496
|
}
|
|
453
497
|
|
|
454
498
|
/**
|
|
455
|
-
* `一键排版` applies the layout
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
499
|
+
* `一键排版` applies the platform's own layout to the pasted body. The operator walk of 2026-10-10 shows
|
|
500
|
+
* the control is present and expected to be applied, so a click that leads nowhere is now REPORTED
|
|
501
|
+
* rather than swallowed (the earlier version failed silently, which is why 正文 came out unformatted).
|
|
502
|
+
*
|
|
503
|
+
* The format set (`保留源格式` / `匹配目标格式` / `仅保留文本`) is what we want when it renders as a menu;
|
|
504
|
+
* `匹配目标格式` is the correct choice for markdown-derived content. A build where the trio is only a
|
|
505
|
+
* label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
|
|
460
506
|
*/
|
|
461
|
-
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<
|
|
507
|
+
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
|
|
508
|
+
let control: string;
|
|
509
|
+
try {
|
|
510
|
+
control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
return `一键排版: ${error instanceof Error ? error.message : String(error)}`;
|
|
513
|
+
}
|
|
462
514
|
try {
|
|
463
|
-
const control = await findSelector(page, LAYOUT_CONTROL, '一键排版 control', deps.stepTimeoutMs);
|
|
464
515
|
await clickSelector(page, control, deps.stepTimeoutMs);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return `一键排版: click failed — ${error instanceof Error ? error.message : String(error)}`;
|
|
518
|
+
}
|
|
519
|
+
const option = await tryFindSelector(page, LAYOUT_TARGET, 5_000);
|
|
520
|
+
if (option === undefined) {
|
|
521
|
+
return '一键排版: clicked, but no 匹配目标格式 option appeared — the body may be unformatted; check the draft';
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
await clickSelector(page, option, 5_000);
|
|
525
|
+
return null;
|
|
526
|
+
} catch (error) {
|
|
527
|
+
return `一键排版: 匹配目标格式 not applied — ${error instanceof Error ? error.message : String(error)}`;
|
|
469
528
|
}
|
|
470
529
|
}
|
|
471
530
|
|
|
@@ -490,24 +549,29 @@ async function fillOptional(
|
|
|
490
549
|
}
|
|
491
550
|
|
|
492
551
|
/**
|
|
493
|
-
* 封面
|
|
494
|
-
*
|
|
495
|
-
*
|
|
552
|
+
* 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover, which the daily
|
|
553
|
+
* flow has already uploaded as the audio material's cover; `从正文选择` picks the first body image,
|
|
554
|
+
* which is what the platform's 默认首图为封面 fallback would use anyway. Best-effort and recorded: a
|
|
555
|
+
* miss is a warning naming the step, never a false claim that the cover landed.
|
|
496
556
|
*/
|
|
497
|
-
async function
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
return 'cover: the panel offers neither 从正文选择 nor its openers — the platform default (默认首图为封面) is all this run has';
|
|
557
|
+
async function setCover(page: WechatPage, source: 'library' | 'body', deps: ModeRunDeps): Promise<string | null> {
|
|
558
|
+
const picker = source === 'library' ? COVER_FROM_LIBRARY : COVER_FROM_CONTENT;
|
|
559
|
+
const what = source === 'library' ? '从图片库选择' : '从正文选择';
|
|
501
560
|
try {
|
|
561
|
+
const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
|
|
562
|
+
if (open === undefined)
|
|
563
|
+
return 'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has';
|
|
502
564
|
await clickSelector(page, open, deps.stepTimeoutMs);
|
|
503
|
-
const pick = await tryFindSelector(page,
|
|
504
|
-
if (pick === undefined) return
|
|
565
|
+
const pick = await tryFindSelector(page, picker, Math.min(deps.stepTimeoutMs, 8_000));
|
|
566
|
+
if (pick === undefined) return `cover: ${what} was not offered by the open cover panel`;
|
|
505
567
|
await clickSelector(page, pick, deps.stepTimeoutMs);
|
|
506
|
-
const item = await tryFindSelector(page, COVER_ITEM, Math.min(deps.stepTimeoutMs,
|
|
507
|
-
if (item === undefined)
|
|
508
|
-
return 'cover: the panel listed no body image to choose (its list is not <img>-based, §5.2)';
|
|
568
|
+
const item = await tryFindSelector(page, COVER_ITEM, Math.min(deps.stepTimeoutMs, 8_000));
|
|
569
|
+
if (item === undefined) return `cover: ${what} listed no image to choose (its list is not <img>-based, §5.2)`;
|
|
509
570
|
await clickSelector(page, item, deps.stepTimeoutMs);
|
|
510
|
-
|
|
571
|
+
// The library picker stages the choice: 下一步 advances it, then the dialog confirms.
|
|
572
|
+
const next = await tryFindSelector(page, COVER_LIBRARY_NEXT, Math.min(deps.stepTimeoutMs, 4_000));
|
|
573
|
+
if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
|
|
574
|
+
const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
|
|
511
575
|
if (confirm === undefined) return 'cover: the choice panel offered no confirm control';
|
|
512
576
|
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
513
577
|
return null;
|
|
@@ -516,20 +580,39 @@ async function setCoverFromBody(page: WechatPage, deps: ModeRunDeps): Promise<st
|
|
|
516
580
|
}
|
|
517
581
|
}
|
|
518
582
|
|
|
519
|
-
/**
|
|
583
|
+
/** `metadata.wechatmp.coverSource` — `library` (default) is the uploaded episode cover. */
|
|
584
|
+
function coverSourceOf(content: Content): 'library' | 'body' {
|
|
585
|
+
const meta = content.metadata?.wechatmp;
|
|
586
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return 'library';
|
|
587
|
+
return (meta as Record<string, unknown>).coverSource === 'body' ? 'body' : 'library';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
|
|
520
591
|
async function enrichArticleDraft(page: WechatPage, content: Content): Promise<string[]> {
|
|
521
592
|
const enrichMeta = content.metadata?.wechatmp;
|
|
522
593
|
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta)) return [];
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
594
|
+
const meta = enrichMeta as Record<string, unknown>;
|
|
595
|
+
const warnings = await enrichArticle(page, meta);
|
|
596
|
+
const audioTitle = meta.audioTitle;
|
|
597
|
+
const audioPath = meta.audioPath;
|
|
598
|
+
const audioCoverPath = meta.audioCoverPath;
|
|
599
|
+
if (
|
|
600
|
+
typeof audioTitle === 'string' &&
|
|
601
|
+
audioTitle.trim() !== '' &&
|
|
602
|
+
typeof audioPath === 'string' &&
|
|
603
|
+
audioPath !== ''
|
|
604
|
+
) {
|
|
605
|
+
const audioWarning = await insertAudioMaterial(page, {
|
|
606
|
+
title: audioTitle.trim(),
|
|
607
|
+
audioPath,
|
|
608
|
+
coverPath: typeof audioCoverPath === 'string' ? audioCoverPath : '',
|
|
609
|
+
});
|
|
527
610
|
if (audioWarning !== '') warnings.push(audioWarning);
|
|
528
611
|
}
|
|
529
612
|
return warnings;
|
|
530
613
|
}
|
|
531
614
|
|
|
532
|
-
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover
|
|
615
|
+
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover, settings. */
|
|
533
616
|
async function composeArticle(
|
|
534
617
|
input: { operation: '文章'; images: string[]; digest?: string },
|
|
535
618
|
deps: ModeRunDeps,
|
|
@@ -540,14 +623,15 @@ async function composeArticle(
|
|
|
540
623
|
await attachImages(page, input.images, deps);
|
|
541
624
|
const titlePath = await typeTitle(page, deps);
|
|
542
625
|
await insertBody(page, deps);
|
|
543
|
-
await applyLayout(page, deps);
|
|
626
|
+
const layout = await applyLayout(page, deps);
|
|
627
|
+
if (layout !== null) warnings.push(layout);
|
|
544
628
|
for (const warning of [
|
|
545
629
|
await fillOptional(page, DIGEST_INPUT, input.digest ?? '', '摘要', deps),
|
|
546
630
|
await fillOptional(page, AUTHOR_INPUT, authorOf(deps.content), '作者', deps),
|
|
547
631
|
]) {
|
|
548
632
|
if (warning !== null) warnings.push(warning);
|
|
549
633
|
}
|
|
550
|
-
const cover = await
|
|
634
|
+
const cover = await setCover(page, coverSourceOf(deps.content), deps);
|
|
551
635
|
if (cover !== null) warnings.push(cover);
|
|
552
636
|
return { warnings, titlePath, enrichWarnings: await enrichArticleDraft(page, deps.content) };
|
|
553
637
|
}
|
|
@@ -647,8 +731,18 @@ async function compose(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): P
|
|
|
647
731
|
}
|
|
648
732
|
|
|
649
733
|
function authorOf(content: Content): string {
|
|
650
|
-
|
|
651
|
-
|
|
734
|
+
// The 作者 field is the byline readers see. The daily stage carries it as `metadata.wechatmp.author`
|
|
735
|
+
// (with the other MP preferences); `metadata.author` is the generic Content field and wins when a
|
|
736
|
+
// producer sets it directly. Reading only the generic one left 请输入作者 empty on every daily run
|
|
737
|
+
// (operator report 2026-10-10).
|
|
738
|
+
const wechatmp = content.metadata?.wechatmp;
|
|
739
|
+
const scoped =
|
|
740
|
+
wechatmp !== null && typeof wechatmp === 'object' && !Array.isArray(wechatmp)
|
|
741
|
+
? (wechatmp as Record<string, unknown>).author
|
|
742
|
+
: undefined;
|
|
743
|
+
const generic = content.metadata?.author;
|
|
744
|
+
if (typeof generic === 'string' && generic.trim() !== '') return generic.trim();
|
|
745
|
+
return typeof scoped === 'string' ? scoped.trim() : '';
|
|
652
746
|
}
|
|
653
747
|
|
|
654
748
|
function baseMetadata(input: ModeInput, deps: ModeRunDeps, entry: ModeEntry): Record<string, unknown> {
|
|
@@ -894,3 +988,150 @@ export function markdownToWechatHtml(markdown: string): string {
|
|
|
894
988
|
function escapeHtml(text: string): string {
|
|
895
989
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
896
990
|
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Audio at the article top (operator request 2026-09-29; upload path added 2026-10-10).
|
|
994
|
+
*
|
|
995
|
+
* Reuses a same-title 素材库 material when one exists; otherwise UPLOADS it. The operator's measured
|
|
996
|
+
* upload shape: 音频 menu → 上传音频 → pick the audio file → 上传封面 → pick the cover → type the title
|
|
997
|
+
* → tick 我已阅读并同意《公众平台音频上传服务规则》 → 保存 → the material list reopens → select the
|
|
998
|
+
* default (first) row → 插入. 插入 places it at the caret, so the caret is put at the body start first.
|
|
999
|
+
*
|
|
1000
|
+
* Fail-soft — every non-committed outcome returns a warning string, `''` means placed at the top.
|
|
1001
|
+
*/
|
|
1002
|
+
export async function insertAudioMaterial(
|
|
1003
|
+
page: WechatPage,
|
|
1004
|
+
input: { title: string; audioPath: string; coverPath: string },
|
|
1005
|
+
): Promise<string> {
|
|
1006
|
+
const { title, audioPath, coverPath } = input;
|
|
1007
|
+
const CARET = `(() => {
|
|
1008
|
+
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
1009
|
+
if (!pm || !pm.firstChild) return;
|
|
1010
|
+
const r = document.createRange();
|
|
1011
|
+
r.setStart(pm.firstChild, 0);
|
|
1012
|
+
r.collapse(true);
|
|
1013
|
+
const sel = window.getSelection();
|
|
1014
|
+
sel?.removeAllRanges();
|
|
1015
|
+
sel?.addRange(r);
|
|
1016
|
+
})()`;
|
|
1017
|
+
const DEDUPE = `(() => {
|
|
1018
|
+
const want = ${JSON.stringify(title)}.replace(/\\s+/g, '');
|
|
1019
|
+
const hit = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
|
|
1020
|
+
.find((l) => (l.textContent ?? '').replace(/\\s+/g, '').includes(want));
|
|
1021
|
+
if (!hit) return 'missing';
|
|
1022
|
+
const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
|
|
1023
|
+
if (!cb) return 'nobox';
|
|
1024
|
+
cb.click();
|
|
1025
|
+
return 'checked';
|
|
1026
|
+
})()`;
|
|
1027
|
+
/** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
|
|
1028
|
+
const AGREE = `(() => {
|
|
1029
|
+
const node = [...document.querySelectorAll('label, span, div')]
|
|
1030
|
+
.find((e) => (e.textContent ?? '').includes('我已阅读并同意') && e.children.length === 0);
|
|
1031
|
+
if (!node) return 'no-label';
|
|
1032
|
+
const scope = node.closest('label') ?? node.parentElement;
|
|
1033
|
+
const cb = scope?.querySelector('input[type=checkbox]') ?? document.querySelector('input[type=checkbox].js_agree');
|
|
1034
|
+
if (!cb) return 'no-box';
|
|
1035
|
+
if (!cb.checked) cb.click();
|
|
1036
|
+
return cb.checked ? 'checked' : 'unchecked';
|
|
1037
|
+
})()`;
|
|
1038
|
+
/** Select the material the upload just created — the operator's 默认第一个. */
|
|
1039
|
+
const SELECT_FIRST = `(() => {
|
|
1040
|
+
const row = document.querySelector('label.frm_checkbox_label.audio_item');
|
|
1041
|
+
if (!row) return 'norow';
|
|
1042
|
+
const cb = (row.closest('li') ?? row.parentElement)?.querySelector('input[type=checkbox]');
|
|
1043
|
+
if (!cb) return 'nobox';
|
|
1044
|
+
if (!cb.checked) cb.click();
|
|
1045
|
+
return 'checked';
|
|
1046
|
+
})()`;
|
|
1047
|
+
const INSERT = `(() => {
|
|
1048
|
+
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
|
|
1049
|
+
if (!btn) return false;
|
|
1050
|
+
btn.click();
|
|
1051
|
+
return true;
|
|
1052
|
+
})()`;
|
|
1053
|
+
const CANCEL = `(() => {
|
|
1054
|
+
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '取消' && b.offsetWidth > 0);
|
|
1055
|
+
btn?.click();
|
|
1056
|
+
})()`;
|
|
1057
|
+
const VERIFY = `(() => {
|
|
1058
|
+
const pm = [...document.querySelectorAll('.ProseMirror')].find((e) => !(e.getAttribute('data-placeholder') ?? '').includes('标题'));
|
|
1059
|
+
if (!pm) return 'noeditor';
|
|
1060
|
+
const node = pm.querySelector('mpvoice, [class*="audio"]');
|
|
1061
|
+
if (!node) return 'absent';
|
|
1062
|
+
const first = pm.firstElementChild;
|
|
1063
|
+
return first && (first === node || first.contains(node)) ? 'top' : 'notop';
|
|
1064
|
+
})()`;
|
|
1065
|
+
|
|
1066
|
+
/** Upload the audio + its cover, acknowledge the rules, and 保存. Returns a warning or null. */
|
|
1067
|
+
const upload = async (): Promise<string | null> => {
|
|
1068
|
+
const uploadControl = await trySelectors(page, UPLOAD_AUDIO);
|
|
1069
|
+
if (!uploadControl.found) return '音频: 上传音频 control not found in the audio dialog';
|
|
1070
|
+
await clickSelector(page, UPLOAD_AUDIO.join(', '), 8_000);
|
|
1071
|
+
const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
|
|
1072
|
+
if (!audioInput.found) return '音频: no audio file input appeared';
|
|
1073
|
+
await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
|
|
1074
|
+
timeoutMs: 60_000,
|
|
1075
|
+
});
|
|
1076
|
+
// The form requires a cover before it will save.
|
|
1077
|
+
const coverControl = await trySelectors(page, UPLOAD_COVER);
|
|
1078
|
+
if (coverControl.found) {
|
|
1079
|
+
await clickSelector(page, UPLOAD_COVER.join(', '), 8_000);
|
|
1080
|
+
const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
|
|
1081
|
+
if (coverInput.found)
|
|
1082
|
+
await uploadFile(
|
|
1083
|
+
asPlaywrightPage(page),
|
|
1084
|
+
coverInput.selector ?? AUDIO_COVER_INPUT.join(', '),
|
|
1085
|
+
coverPath,
|
|
1086
|
+
{
|
|
1087
|
+
timeoutMs: 60_000,
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
const titleInput = await trySelectors(page, AUDIO_TITLE_INPUT);
|
|
1092
|
+
if (titleInput.found) await fillSelector(page, titleInput.selector ?? AUDIO_TITLE_INPUT.join(', '), title);
|
|
1093
|
+
const agreed = String(await page.evaluate(AGREE));
|
|
1094
|
+
if (agreed !== 'checked') return `音频: 服务规则勾选失败 (${agreed})`;
|
|
1095
|
+
const save = await trySelectors(page, UPLOAD_SAVE);
|
|
1096
|
+
if (!save.found) return '音频: 上传表单的保存按钮未找到';
|
|
1097
|
+
await clickSelector(page, UPLOAD_SAVE.join(', '), 8_000);
|
|
1098
|
+
return null;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
await page.evaluate(CARET);
|
|
1103
|
+
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
|
|
1104
|
+
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1105
|
+
// The 素材库 list loads async after the dialog opens — retry while rows stream in.
|
|
1106
|
+
let dedupe = String(await page.evaluate(DEDUPE));
|
|
1107
|
+
for (let i = 0; dedupe === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
|
|
1108
|
+
await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
|
|
1109
|
+
dedupe = String(await page.evaluate(DEDUPE));
|
|
1110
|
+
}
|
|
1111
|
+
if (dedupe !== 'checked') {
|
|
1112
|
+
// No reusable material: upload one, which reopens the list with it selected-by-default.
|
|
1113
|
+
const uploadWarning = await upload();
|
|
1114
|
+
if (uploadWarning !== null) {
|
|
1115
|
+
await page.evaluate(CANCEL);
|
|
1116
|
+
return uploadWarning;
|
|
1117
|
+
}
|
|
1118
|
+
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1119
|
+
const selected = String(await page.evaluate(SELECT_FIRST));
|
|
1120
|
+
if (selected !== 'checked') {
|
|
1121
|
+
await page.evaluate(CANCEL);
|
|
1122
|
+
return `音频: 上传后未找到素材行 (${selected})`;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if ((await page.evaluate(INSERT)) !== true) {
|
|
1126
|
+
await page.evaluate(CANCEL);
|
|
1127
|
+
return '音频: 插入按钮未找到,请手动插入';
|
|
1128
|
+
}
|
|
1129
|
+
await page.waitForTimeout?.(2_000);
|
|
1130
|
+
const state = String(await page.evaluate(VERIFY));
|
|
1131
|
+
if (state === 'absent' || state === 'noeditor') return '音频: 插入未生效,请手动检查';
|
|
1132
|
+
if (state === 'notop') return '音频: 已插入但未在文首,请手动调整位置';
|
|
1133
|
+
return '';
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
return `音频: ${error instanceof Error ? error.message.slice(0, 120) : '自动插入失败'},请手动插入`;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
@@ -258,3 +258,23 @@ export function wrapPlaywrightContext(raw: unknown, entryPage: unknown): OpenedC
|
|
|
258
258
|
close: () => context.close(),
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
|
+
|
|
262
|
+
/** Polls a selector list for visibility; optionally returns `undefined` instead of throwing. */
|
|
263
|
+
export async function waitForVisible(
|
|
264
|
+
page: WechatPage,
|
|
265
|
+
selector: string,
|
|
266
|
+
timeoutMs: number,
|
|
267
|
+
opts: { required?: boolean } = {},
|
|
268
|
+
): Promise<string | undefined> {
|
|
269
|
+
const visible = await waitUntil(
|
|
270
|
+
async () => {
|
|
271
|
+
const found = await trySelectors(page, [selector]).catch(() => ({ found: false, selector: null }));
|
|
272
|
+
return found.found;
|
|
273
|
+
},
|
|
274
|
+
timeoutMs,
|
|
275
|
+
POLL_MS,
|
|
276
|
+
);
|
|
277
|
+
if (visible) return selector;
|
|
278
|
+
if (opts.required === false) return undefined;
|
|
279
|
+
throw new Error(`${selector}: not visible within ${timeoutMs}ms`);
|
|
280
|
+
}
|