@gobing-ai/knowledge-kit 0.0.35 → 0.0.37
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/README.md +1 -1
- package/dist/board/collect/index.css +42 -0
- package/dist/board/collect/index.js +787 -0
- package/dist/board/daily-voice/index.css +13 -0
- package/dist/board/daily-voice/index.js +902 -0
- package/dist/board/itc/index.css +14 -0
- package/dist/board/itc/index.js +1026 -0
- package/dist/index.js +12996 -10476
- package/package.json +10 -9
- package/plugins/generations/content-gen/dist/index.js +1015 -10814
- package/plugins/generations/content-gen/package.json +1 -1
- package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
- package/plugins/generations/core-facts-gen/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +1004 -10803
- package/plugins/generations/daily-article-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +1111 -10888
- package/plugins/generations/dailynews-gen/package.json +1 -1
- package/plugins/generations/dailynews-gen/src/script-plan.ts +32 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
- package/plugins/generations/episode-plan-gen/package.json +1 -1
- package/plugins/generations/gemini-tts/dist/index.js +1104 -10903
- package/plugins/generations/gemini-tts/package.json +1 -1
- package/plugins/generations/image-gen/dist/index.js +1239 -11035
- package/plugins/generations/image-gen/package.json +1 -1
- package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
- package/plugins/generations/news-report-gen/package.json +1 -1
- package/plugins/generations/voice-gen/dist/index.js +1062 -10861
- package/plugins/generations/voice-gen/package.json +1 -1
- package/plugins/generations/voxcpm-tts/README.md +72 -0
- package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
- package/plugins/generations/voxcpm-tts/package.json +16 -0
- package/plugins/generations/voxcpm-tts/plugin.json +6 -0
- package/plugins/generations/voxcpm-tts/profiles.json +10 -0
- package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
- package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
- package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
- package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
- package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
- package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
- package/plugins/ingestions/aihot-ingest/package.json +1 -1
- package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
- package/plugins/ingestions/horizon-ingest/package.json +1 -1
- package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
- package/plugins/ingestions/karakeep-local/package.json +1 -1
- package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
- package/plugins/ingestions/last30days-ingest/package.json +1 -1
- package/plugins/ingestions/web-search/dist/index.js +1250 -11033
- package/plugins/ingestions/web-search/package.json +1 -1
- package/plugins/kk/README.md +29 -2
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +9 -1
- package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
- package/plugins/kk/scripts/daily-stages.ts +428 -46
- package/plugins/kk/scripts/daily-voice-style.zh.md +1 -1
- package/plugins/kk/scripts/kk-workflow-stages.ts +76 -146
- package/plugins/kk/skills/publish/SKILL.md +8 -4
- package/plugins/kk/workflows/feature-verification.yaml +93 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
- package/plugins/kk/workflows/kk-collect.yaml +43 -11
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +4 -2
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +87 -16
- package/plugins/kk/workflows/kk-daily-safety.yaml +15 -2
- package/plugins/kk/workflows/kk-itc.yaml +8 -1
- package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
- package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
- package/plugins/publishings/emdash-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
- package/plugins/publishings/infoq-pub/dist/profile-check.js +1677 -11199
- package/plugins/publishings/infoq-pub/package.json +1 -1
- package/plugins/publishings/infoq-pub/plugin.json +5 -1
- package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
- package/plugins/publishings/juejin-pub/dist/profile-check.js +1677 -11199
- package/plugins/publishings/juejin-pub/package.json +1 -1
- package/plugins/publishings/juejin-pub/plugin.json +5 -1
- package/plugins/publishings/medium-pub/dist/index.js +978 -10805
- package/plugins/publishings/medium-pub/package.json +1 -1
- package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
- package/plugins/publishings/podcast-pub/package.json +1 -1
- package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
- package/plugins/publishings/qiita-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
- package/plugins/publishings/substack-pub/dist/profile-check.js +1698 -11200
- package/plugins/publishings/substack-pub/package.json +1 -1
- package/plugins/publishings/substack-pub/plugin.json +4 -1
- package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
- package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
- package/plugins/publishings/surfdash-pub/package.json +1 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
- package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
- package/plugins/publishings/threads-pub/dist/index.js +12394 -0
- package/plugins/publishings/threads-pub/package.json +17 -0
- package/plugins/publishings/threads-pub/plugin.json +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +340 -0
- package/plugins/publishings/threads-pub/tsconfig.json +4 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +2508 -11695
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +1656 -11188
- package/plugins/publishings/wechatmp-pub/package.json +1 -1
- package/plugins/publishings/wechatmp-pub/plugin.json +5 -1
- package/plugins/publishings/wechatmp-pub/src/enrich.ts +244 -71
- package/plugins/publishings/wechatmp-pub/src/flow.ts +412 -96
- package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
- package/plugins/publishings/wechatmp-pub/src/page.ts +16 -0
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
- package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
- package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
- package/plugins/publishings/weibo-pub/package.json +19 -0
- package/plugins/publishings/weibo-pub/plugin.json +12 -0
- package/plugins/publishings/weibo-pub/src/index.ts +868 -0
- package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
- package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
- package/plugins/publishings/x-pub/dist/index.js +1948 -11440
- package/plugins/publishings/x-pub/dist/profile-check.js +1677 -11199
- package/plugins/publishings/x-pub/package.json +1 -1
- package/plugins/publishings/x-pub/plugin.json +5 -1
- package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
- package/plugins/publishings/xhs-pub/dist/profile-check.js +1697 -11212
- package/plugins/publishings/xhs-pub/package.json +1 -1
- package/plugins/publishings/xhs-pub/plugin.json +5 -1
- package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
- package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
- package/plugins/publishings/zenn-pub/package.json +1 -1
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
findAttachedSelector,
|
|
22
22
|
findSelector,
|
|
23
23
|
followNewPage,
|
|
24
|
+
modalState,
|
|
24
25
|
tryFindSelector,
|
|
25
26
|
type WechatPage,
|
|
26
27
|
waitForVisible,
|
|
@@ -74,17 +75,6 @@ const LAYOUT_TARGET = ['div[role="menuitem"]:has-text("匹配目标格式")', 'l
|
|
|
74
75
|
const COVER_PANEL_OPEN = ['a.js_selectCoverFromContent', '.js_cover_btn_area', 'text=换一张', 'text=修改'];
|
|
75
76
|
const COVER_FROM_CONTENT = ['a.js_selectCoverFromContent', 'text=从正文选择'];
|
|
76
77
|
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
78
|
/** The library picker is two-stage: pick the image, then 下一步, then the dialog's 确认. */
|
|
89
79
|
const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
|
|
90
80
|
/**
|
|
@@ -92,9 +82,77 @@ const COVER_LIBRARY_NEXT = ['button:has-text("下一步")'];
|
|
|
92
82
|
* confirm selectors below are candidate shapes and **not** re-measured (§6 still lists the widget as
|
|
93
83
|
* to do). A miss is recorded as a warning, never as a false cover claim.
|
|
94
84
|
*/
|
|
95
|
-
const COVER_ITEM = COVER_LIBRARY_ITEM;
|
|
96
85
|
const COVER_CONFIRM = ['button:has-text("确定")', 'button:has-text("确认")', 'button:has-text("完成")'];
|
|
97
86
|
|
|
87
|
+
/** The controls that close whatever modal the cover step left open, most specific first. */
|
|
88
|
+
const DIALOG_DISMISS = [
|
|
89
|
+
'.weui-desktop-dialog:visible button:has-text("取消")',
|
|
90
|
+
'.weui-desktop-dialog__close',
|
|
91
|
+
'button:has-text("取消")',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Close an open console dialog, best-effort. Measured 2026-10-12: `setCover` returned its warning
|
|
96
|
+
* while the cover panel stayed open, and an open panel's footer (`div.weui-desktop-dialog__ft`)
|
|
97
|
+
* covers the editor — 赞赏 / 原文链接 / 创作来源 / 音频 each click-timed out behind it and the final
|
|
98
|
+
* `保存为草稿` was blocked for 30 s, so the run saved no draft at all. The same sweep shape the
|
|
99
|
+
* settings rail already uses (`./enrich`): the dialog's own 取消, else Escape.
|
|
100
|
+
*/
|
|
101
|
+
async function dismissOpenDialog(page: WechatPage): Promise<void> {
|
|
102
|
+
for (const selector of DIALOG_DISMISS) {
|
|
103
|
+
const control = await tryFindSelector(page, [selector], 2_000);
|
|
104
|
+
if (control === undefined) continue;
|
|
105
|
+
try {
|
|
106
|
+
await clickSelector(page, control, 2_000);
|
|
107
|
+
} catch {
|
|
108
|
+
// Gone between the probe and the click — nothing left to dismiss.
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
await page.press?.('Escape');
|
|
113
|
+
await page.waitForTimeout?.(400);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The close controls a console modal exposes, most specific first (the audio picker shows no 取消).
|
|
117
|
+
* `.weui-desktop-dialog__close-btn` is the 0272-captured audio-dialog variant the bare `__close`
|
|
118
|
+
* probe missed. */
|
|
119
|
+
const DIALOG_CLOSE = [
|
|
120
|
+
'.weui-desktop-dialog__wrp .weui-desktop-dialog__close',
|
|
121
|
+
'.weui-desktop-dialog__close',
|
|
122
|
+
'.weui-desktop-dialog__close-btn',
|
|
123
|
+
'.weui-desktop-dialog__wrp button:has-text("取消")',
|
|
124
|
+
'button:has-text("取消")',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Close every open console modal before a submit click. Measured 2026-10-12: the 插入音频 dialog
|
|
129
|
+
* (`weui-desktop-dialog__wrp align_edge audio_dialog js_audio_music_dialog`) stayed open after its
|
|
130
|
+
* own 上传音频 click timed out, its footer covered `保存为草稿` for 30 s, and the run saved no draft
|
|
131
|
+
* at all. A failed enrichment is a warning by contract, never a gate — so nothing may outlive it.
|
|
132
|
+
*
|
|
133
|
+
* Returns `'residual'` when a dialog is still open — or its state is UNREADABLE — after all passes:
|
|
134
|
+
* an unknown overlay must never read as cleaned up (task 0260 R3/AC5).
|
|
135
|
+
*/
|
|
136
|
+
async function closeOpenDialogs(page: WechatPage): Promise<'closed' | 'residual'> {
|
|
137
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
138
|
+
const state = await modalState(page);
|
|
139
|
+
if (state === 'closed') return 'closed';
|
|
140
|
+
for (const selector of DIALOG_CLOSE) {
|
|
141
|
+
const control = await tryFindSelector(page, [selector], 1_500);
|
|
142
|
+
if (control === undefined) continue;
|
|
143
|
+
try {
|
|
144
|
+
await clickSelector(page, control, 2_000);
|
|
145
|
+
} catch {
|
|
146
|
+
// Gone between the probe and the click.
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
await page.press?.('Escape');
|
|
151
|
+
await page.waitForTimeout?.(400);
|
|
152
|
+
}
|
|
153
|
+
return (await modalState(page)) === 'closed' ? 'closed' : 'residual';
|
|
154
|
+
}
|
|
155
|
+
|
|
98
156
|
/** 视频's media modal (§5.2): tabs 素材库/视频号动态/视频链接 + 本地上传 + 确定/取消. */
|
|
99
157
|
const VIDEO_LOCAL_UPLOAD = ['text=本地上传', 'button:has-text("本地上传")'];
|
|
100
158
|
const VIDEO_FILE_INPUT = [
|
|
@@ -126,6 +184,9 @@ const PODCAST_INSERT = ['div.weui-desktop-dialog button:has-text("插入")', 'bu
|
|
|
126
184
|
|
|
127
185
|
/** 音频 material dialog: the toolbar menu opens it, then the 素材库 list or the upload form. */
|
|
128
186
|
const AUDIO_MENU = 'li.js_insertaudio';
|
|
187
|
+
/** The toolbar dropdown that reveals the 音频 menu item (measured 2026-10-13): the item lives in a
|
|
188
|
+
* `display:none` menu under `#audio_music_plugin_btn` until that trigger is clicked. */
|
|
189
|
+
const AUDIO_TOOLBAR_TRIGGER = '#audio_music_plugin_btn';
|
|
129
190
|
const AUDIO_DIALOG = '.audio_dialog';
|
|
130
191
|
/** The 素材库 rows stream in after the dialog opens, so the dedupe probe retries while they load. */
|
|
131
192
|
const AUDIO_DIALOG_TIMEOUT_MS = 15_000;
|
|
@@ -505,12 +566,10 @@ async function insertBody(page: WechatPage, deps: ModeRunDeps): Promise<void> {
|
|
|
505
566
|
* label (the 2026-10-05 dogfood reading) still gets the bare control click, and says so.
|
|
506
567
|
*/
|
|
507
568
|
async function applyLayout(page: WechatPage, deps: ModeRunDeps): Promise<string | null> {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
return `一键排版: ${error instanceof Error ? error.message : String(error)}`;
|
|
513
|
-
}
|
|
569
|
+
// Measured 2026-10-13: the current editor build exposes no 一键排版 control at all. An absent
|
|
570
|
+
// control is the platform's layout — silently skip; only a found-but-failing control warns.
|
|
571
|
+
const control = await tryFindSelector(page, LAYOUT_CONTROL, Math.min(deps.stepTimeoutMs, 5_000));
|
|
572
|
+
if (control === undefined) return null;
|
|
514
573
|
try {
|
|
515
574
|
await clickSelector(page, control, deps.stepTimeoutMs);
|
|
516
575
|
} catch (error) {
|
|
@@ -548,35 +607,135 @@ async function fillOptional(
|
|
|
548
607
|
}
|
|
549
608
|
}
|
|
550
609
|
|
|
610
|
+
/** Local path basename — the only piece of a requested file a remote asset could ever expose. */
|
|
611
|
+
function baseName(path: string): string {
|
|
612
|
+
return path.split(/[\\/]/).pop() ?? path;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The identities `setCover` can accept for a candidate: the requested article image and/or the
|
|
616
|
+
* upload binding actually observed going into the audio form. Either is optional; at least one
|
|
617
|
+
* must produce a UNIQUE exposed match before anything is clicked (task 0260 R1). */
|
|
618
|
+
interface CoverIdentity {
|
|
619
|
+
/** `coverPath(input)` — the article's first body image, requested as the cover. */
|
|
620
|
+
requested?: string;
|
|
621
|
+
/** The cover file the audio upload just bound into the form/library (when audio ran). */
|
|
622
|
+
binding?: string;
|
|
623
|
+
}
|
|
624
|
+
|
|
551
625
|
/**
|
|
552
|
-
* 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover,
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
626
|
+
* 封面 (§5.2): there is no cover file input. `从图片库选择` picks the episode cover, `从正文选择`
|
|
627
|
+
* the first body image. A candidate is identified ONLY by an exposed exact filename (`title` /
|
|
628
|
+
* `data-name` / its own filename text) uniquely equal to one of the requested identities; a miss or
|
|
629
|
+
* an ambiguity warns WITHOUT clicking first/newest. After the confirm, the committed preview under
|
|
630
|
+
* `#js_cover_area` is read back: the empty state (`add_cover` icon still visible) or an unreadable
|
|
631
|
+
* preview is a named warning, never a success claim (task 0260 R1).
|
|
556
632
|
*/
|
|
557
|
-
async function setCover(
|
|
633
|
+
async function setCover(
|
|
634
|
+
page: WechatPage,
|
|
635
|
+
source: 'library' | 'body',
|
|
636
|
+
deps: Pick<ModeRunDeps, 'stepTimeoutMs'>,
|
|
637
|
+
identity: CoverIdentity,
|
|
638
|
+
): Promise<string | null> {
|
|
558
639
|
const picker = source === 'library' ? COVER_FROM_LIBRARY : COVER_FROM_CONTENT;
|
|
559
640
|
const what = source === 'library' ? '从图片库选择' : '从正文选择';
|
|
641
|
+
const wanted = [
|
|
642
|
+
...(identity.requested ? [baseName(identity.requested)] : []),
|
|
643
|
+
...(identity.binding ? [baseName(identity.binding)] : []),
|
|
644
|
+
]
|
|
645
|
+
.map((name) => name.trim())
|
|
646
|
+
.filter((name) => name.length > 0);
|
|
647
|
+
// A cover miss is a warning, never a gate — so the panel must not outlive the step that opened
|
|
648
|
+
// it (see {@link dismissOpenDialog} for what its footer blocked on 2026-10-12).
|
|
649
|
+
const warn = async (message: string): Promise<string> => {
|
|
650
|
+
await dismissOpenDialog(page);
|
|
651
|
+
return message;
|
|
652
|
+
};
|
|
560
653
|
try {
|
|
561
654
|
const open = await tryFindSelector(page, COVER_PANEL_OPEN, Math.min(deps.stepTimeoutMs, 5_000));
|
|
562
655
|
if (open === undefined)
|
|
563
|
-
return
|
|
656
|
+
return await warn(
|
|
657
|
+
'cover: the panel offers none of 从正文选择 / 从图片库选择 — the platform default (默认首图为封面) is all this run has',
|
|
658
|
+
);
|
|
564
659
|
await clickSelector(page, open, deps.stepTimeoutMs);
|
|
565
660
|
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
|
|
661
|
+
if (pick === undefined) return await warn(`cover: ${what} was not offered by the open cover panel`);
|
|
567
662
|
await clickSelector(page, pick, deps.stepTimeoutMs);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
663
|
+
if (wanted.length === 0)
|
|
664
|
+
return await warn(`cover: no requested cover file identity to match against — ${what} not driven`);
|
|
665
|
+
// Identify before clicking: the item list is not <img>-based (§5.2) and never sorted the way
|
|
666
|
+
// a first-item assumption needs, so the only honest pick is an exposed unique exact name.
|
|
667
|
+
const IDENTIFY = `(() => {
|
|
668
|
+
const wanted = ${JSON.stringify(wanted)};
|
|
669
|
+
const rows = [...document.querySelectorAll('.weui-desktop-img-picker__item, div[class*="img_picker" i] li, div[class*="imgList" i] li, div[class*="cover" i] li')]
|
|
670
|
+
.filter((row) => row.offsetWidth > 0);
|
|
671
|
+
const nameOf = (row) => {
|
|
672
|
+
const el = row.querySelector('[title], [data-name], [data-file_name]') ?? row;
|
|
673
|
+
const attrs = [el.getAttribute?.('title'), el.getAttribute?.('data-name'), el.getAttribute?.('data-file_name')];
|
|
674
|
+
const text = [...row.querySelectorAll('[class*="name" i], [class*="title" i]')].map((n) => n.textContent ?? '');
|
|
675
|
+
return [...attrs, ...text].map((v) => (v ?? '').trim()).find((v) => v.length > 0) ?? '';
|
|
676
|
+
};
|
|
677
|
+
const hits = rows.filter((row) => wanted.some((w) => nameOf(row) === w));
|
|
678
|
+
if (hits.length === 0) return 'missing';
|
|
679
|
+
if (hits.length > 1) return 'ambiguous';
|
|
680
|
+
const row = hits[0];
|
|
681
|
+
row.querySelector('input[type=checkbox], [role=checkbox]')?.click();
|
|
682
|
+
row.click();
|
|
683
|
+
const checked = row.querySelector('input[type=checkbox], [role=checkbox]');
|
|
684
|
+
const src = row.querySelector('img')?.src ?? (row.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '');
|
|
685
|
+
return JSON.stringify({ picked: true, src });
|
|
686
|
+
})()`;
|
|
687
|
+
const rawIdentify = String(await page.evaluate(IDENTIFY));
|
|
688
|
+
if (rawIdentify === 'missing')
|
|
689
|
+
return await warn(`cover: ${what} 未标出与请求文件同名的素材,未选择(不按首图猜测)`);
|
|
690
|
+
if (rawIdentify === 'ambiguous') return await warn(`cover: ${what} 匹配到多个同名素材,身份不唯一,未选择`);
|
|
691
|
+
let selected: { picked?: boolean; src?: string } = {};
|
|
692
|
+
try {
|
|
693
|
+
selected = JSON.parse(rawIdentify) as { picked?: boolean; src?: string };
|
|
694
|
+
} catch {
|
|
695
|
+
return await warn('cover: 素材身份探测返回未知结果,未选择');
|
|
696
|
+
}
|
|
571
697
|
// The library picker stages the choice: 下一步 advances it, then the dialog confirms.
|
|
572
698
|
const next = await tryFindSelector(page, COVER_LIBRARY_NEXT, Math.min(deps.stepTimeoutMs, 4_000));
|
|
573
699
|
if (next !== undefined) await clickSelector(page, next, deps.stepTimeoutMs);
|
|
574
700
|
const confirm = await tryFindSelector(page, COVER_CONFIRM, Math.min(deps.stepTimeoutMs, 8_000));
|
|
575
|
-
if (confirm === undefined) return 'cover: the choice panel offered no confirm control';
|
|
701
|
+
if (confirm === undefined) return await warn('cover: the choice panel offered no confirm control');
|
|
576
702
|
await clickSelector(page, confirm, deps.stepTimeoutMs);
|
|
703
|
+
// Committed readback (observed capture: the preview lives under #js_cover_area; the empty
|
|
704
|
+
// state keeps the `add_cover` icon visible). Poll briefly — the preview renders late.
|
|
705
|
+
const READBACK = `(() => {
|
|
706
|
+
const area = document.querySelector('#js_cover_area');
|
|
707
|
+
if (!area) return 'noarea';
|
|
708
|
+
if (area.querySelector('.icon20_common.add_cover') && area.querySelector('.icon20_common.add_cover').offsetWidth > 0) return 'empty';
|
|
709
|
+
const preview = area.querySelector('.js_cover_preview_new');
|
|
710
|
+
if (!preview || preview.offsetWidth === 0) return 'nopreview';
|
|
711
|
+
const src = preview.querySelector('img')?.src ??
|
|
712
|
+
(preview.querySelector('[style*="background-image"]')?.style.backgroundImage.match(/url("?([^")]+)"?)/)?.[1] ?? '') ||
|
|
713
|
+
(preview.querySelector('input[type=hidden]')?.value ?? '');
|
|
714
|
+
return src ? src : 'unknown';
|
|
715
|
+
})()`;
|
|
716
|
+
let committed = '';
|
|
717
|
+
for (let read = 0; read < 5 && committed === ''; read += 1) {
|
|
718
|
+
const raw = String(await page.evaluate(READBACK).catch(() => 'error'));
|
|
719
|
+
if (raw !== '' && raw !== 'nopreview' && raw !== 'noarea') {
|
|
720
|
+
committed = raw;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
await page.waitForTimeout?.(600);
|
|
724
|
+
}
|
|
725
|
+
if (committed === 'empty') return await warn('cover: 确认后面板仍显示未设置封面,提交未生效');
|
|
726
|
+
if (committed === '' || committed === 'noarea' || committed === 'error')
|
|
727
|
+
return await warn('cover: 已确认但无法读取已提交封面,请手动核对');
|
|
728
|
+
// Identity comparison: both sides are remote asset references from the same widget. Only an
|
|
729
|
+
// exact match proves the requested asset committed; an unreadable/edited preview warns
|
|
730
|
+
// rather than inventing equivalence (local paths are never compared to asset URLs).
|
|
731
|
+
const selectedSrc = (selected.src ?? '').trim();
|
|
732
|
+
if (selectedSrc === '' || (committed !== selectedSrc && !committed.includes(selectedSrc)))
|
|
733
|
+
return await warn(
|
|
734
|
+
`cover: 已提交封面的素材身份与所选不一致(selected=${selectedSrc || '未读到'} committed=${committed.slice(0, 80)})`,
|
|
735
|
+
);
|
|
577
736
|
return null;
|
|
578
737
|
} catch (error) {
|
|
579
|
-
return `cover: ${error instanceof Error ? error.message : String(error)}
|
|
738
|
+
return await warn(`cover: ${error instanceof Error ? error.message : String(error)}`);
|
|
580
739
|
}
|
|
581
740
|
}
|
|
582
741
|
|
|
@@ -587,29 +746,65 @@ function coverSourceOf(content: Content): 'library' | 'body' {
|
|
|
587
746
|
return (meta as Record<string, unknown>).coverSource === 'body' ? 'body' : 'library';
|
|
588
747
|
}
|
|
589
748
|
|
|
749
|
+
/** The audio-embed block of `metadata.wechatmp`, or null when this run embeds no audio. Shared by
|
|
750
|
+
* the cover step (the library only holds the episode cover once audio uploaded — task 0272). */
|
|
751
|
+
function audioBlockOf(content: Content): { title: string; audioPath: string; coverPath: string } | null {
|
|
752
|
+
const meta = content.metadata?.wechatmp;
|
|
753
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
|
754
|
+
const m = meta as Record<string, unknown>;
|
|
755
|
+
if (
|
|
756
|
+
typeof m.audioTitle !== 'string' ||
|
|
757
|
+
m.audioTitle.trim() === '' ||
|
|
758
|
+
typeof m.audioPath !== 'string' ||
|
|
759
|
+
m.audioPath === ''
|
|
760
|
+
)
|
|
761
|
+
return null;
|
|
762
|
+
return {
|
|
763
|
+
title: m.audioTitle.trim(),
|
|
764
|
+
audioPath: m.audioPath,
|
|
765
|
+
coverPath: typeof m.audioCoverPath === 'string' ? m.audioCoverPath : '',
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
590
769
|
/** 文章's `metadata.wechatmp` enrichment rail, plus its opt-in audio embed (2026-09-29, ADR-026). */
|
|
591
|
-
async function enrichArticleDraft(
|
|
770
|
+
async function enrichArticleDraft(
|
|
771
|
+
page: WechatPage,
|
|
772
|
+
content: Content,
|
|
773
|
+
): Promise<{ enrichWarnings: string[]; coverWarnings: string[] }> {
|
|
592
774
|
const enrichMeta = content.metadata?.wechatmp;
|
|
593
|
-
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
|
|
775
|
+
if (enrichMeta === undefined || typeof enrichMeta !== 'object' || Array.isArray(enrichMeta))
|
|
776
|
+
return { enrichWarnings: [], coverWarnings: [] };
|
|
594
777
|
const meta = enrichMeta as Record<string, unknown>;
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (
|
|
778
|
+
const enrichWarnings = await enrichArticle(page, meta);
|
|
779
|
+
const audio = audioBlockOf(content);
|
|
780
|
+
let audioFailed = false;
|
|
781
|
+
if (audio !== null) {
|
|
782
|
+
const audioWarning = await insertAudioMaterial(page, audio);
|
|
783
|
+
if (audioWarning !== '') {
|
|
784
|
+
enrichWarnings.push(audioWarning);
|
|
785
|
+
audioFailed = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Deferred library cover (task 0272): the audio upload just put the episode cover in the 图片库 —
|
|
789
|
+
// but ONLY a committed upload guarantees that (task 0260 R1: a failed/uncertain audio upload
|
|
790
|
+
// leaves the library without the requested cover, so the dependent picker must not run).
|
|
791
|
+
const coverWarnings: string[] = [];
|
|
792
|
+
if (coverSourceOf(content) === 'library' && audio !== null) {
|
|
793
|
+
if (audioFailed) {
|
|
794
|
+
coverWarnings.push('cover: 音频上传未成功,图片库不保证已有请求封面,已跳过图片库封面');
|
|
795
|
+
} else {
|
|
796
|
+
const cover = await setCover(
|
|
797
|
+
page,
|
|
798
|
+
'library',
|
|
799
|
+
{ stepTimeoutMs: 8_000 },
|
|
800
|
+
// The upload binding is the image the audio form just bound (the episode cover
|
|
801
|
+
// file); `coverPath(input)` is the article's own first body image, not this.
|
|
802
|
+
{ binding: audio.coverPath === '' ? undefined : audio.coverPath },
|
|
803
|
+
);
|
|
804
|
+
if (cover !== null) coverWarnings.push(cover);
|
|
805
|
+
}
|
|
611
806
|
}
|
|
612
|
-
return
|
|
807
|
+
return { enrichWarnings, coverWarnings };
|
|
613
808
|
}
|
|
614
809
|
|
|
615
810
|
/** Compose 文章 (createType 0): body images, title, body + 一键排版, 摘要, 作者, cover, settings. */
|
|
@@ -631,9 +826,20 @@ async function composeArticle(
|
|
|
631
826
|
]) {
|
|
632
827
|
if (warning !== null) warnings.push(warning);
|
|
633
828
|
}
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
829
|
+
const coverSource = coverSourceOf(deps.content);
|
|
830
|
+
const audioRuns = audioBlockOf(deps.content) !== null;
|
|
831
|
+
if (!(coverSource === 'library' && audioRuns)) {
|
|
832
|
+
const cover = await setCover(page, coverSource, deps, {
|
|
833
|
+
// Requested identity = the article's first body image (the 默认首图为封面 semantics of
|
|
834
|
+
// modes.coverPath). No audio ran, so the library binding is unknown — only an exposed
|
|
835
|
+
// exact filename can match.
|
|
836
|
+
requested: input.images[0],
|
|
837
|
+
});
|
|
838
|
+
if (cover !== null) warnings.push(cover);
|
|
839
|
+
}
|
|
840
|
+
const { enrichWarnings, coverWarnings } = await enrichArticleDraft(page, deps.content);
|
|
841
|
+
warnings.push(...coverWarnings);
|
|
842
|
+
return { warnings, titlePath, enrichWarnings };
|
|
637
843
|
}
|
|
638
844
|
|
|
639
845
|
/** Compose 贴图 (createType 8): ≥1 image is the message; title and body are 选填. */
|
|
@@ -779,10 +985,20 @@ async function saveDraft(
|
|
|
779
985
|
let control: string | null = null;
|
|
780
986
|
let signal: string | null = null;
|
|
781
987
|
let attempts = 0;
|
|
988
|
+
let residuals = 0;
|
|
989
|
+
const residualDialogs: string[] = [];
|
|
782
990
|
let stampAfter: string | null = deps.draftBaseline.stamp;
|
|
783
991
|
while (signal === null && attempts < deps.submitAttempts) {
|
|
784
992
|
attempts += 1;
|
|
785
993
|
await sleep(deps.submitSettleMs);
|
|
994
|
+
if ((await closeOpenDialogs(entry.editor)) !== 'closed') {
|
|
995
|
+
residuals += 1;
|
|
996
|
+
residualDialogs.push(await openDialogInventory(entry.editor).catch(() => 'unreadable'));
|
|
997
|
+
// Refuse to click through an unresolved overlay (0260 R3/AC5): the click would land on
|
|
998
|
+
// the dialog footer again, not 保存为草稿. Retry the cleanup on the next attempt.
|
|
999
|
+
if (attempts < deps.submitAttempts) continue;
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
786
1002
|
control = await findSelector(entry.editor, SAVE_DRAFT, '保存为草稿 control', deps.stepTimeoutMs);
|
|
787
1003
|
await clickSelector(entry.editor, control, deps.stepTimeoutMs);
|
|
788
1004
|
// The editor lives in its own tab, so re-read the console entry page — and ENTER the 草稿箱
|
|
@@ -805,6 +1021,10 @@ async function saveDraft(
|
|
|
805
1021
|
}
|
|
806
1022
|
|
|
807
1023
|
if (signal === null || control === null) {
|
|
1024
|
+
if (control === null)
|
|
1025
|
+
return fail(
|
|
1026
|
+
`draft confirmation: a residual modal refused cleanup after ${attempts} attempt(s), ${residuals} refused cleanup(s) (${residualDialogs.join(' | ') || 'no inventory'}) — '保存为草稿' was not clicked through an unresolved overlay (inventory: ${await openDialogInventory(entry.editor).catch(() => 'unreadable')})`,
|
|
1027
|
+
);
|
|
808
1028
|
return fail(
|
|
809
1029
|
`draft confirmation: '${SAVE_DRAFT[0]}' was clicked ${attempts}× but no fresh 草稿箱 row for '${title}' (its ${DRAFT_STAMP} stamp) was observed within ${deps.draftTimeoutMs}ms (before=${deps.draftBaseline.stamp ?? 'none'}, after=${stampAfter ?? 'none'}, page=${deps.entry.url()}, frames=${await describeFrames(deps.entry, 5)})`,
|
|
810
1030
|
);
|
|
@@ -821,6 +1041,13 @@ async function saveDraft(
|
|
|
821
1041
|
draftStampBefore: deps.draftBaseline.stamp,
|
|
822
1042
|
draftStampAfter: signal,
|
|
823
1043
|
titlePath: composed.titlePath,
|
|
1044
|
+
...(residuals > 0
|
|
1045
|
+
? {
|
|
1046
|
+
warnings: [
|
|
1047
|
+
`保存前清理了 ${residuals} 个残留弹层(${residualDialogs.join(' | ')}),草稿仍保存成功`,
|
|
1048
|
+
],
|
|
1049
|
+
}
|
|
1050
|
+
: {}),
|
|
824
1051
|
...(composed.warnings.length > 0 ? { warnings: composed.warnings } : {}),
|
|
825
1052
|
...(composed.enrichWarnings.length > 0 ? { enrichWarnings: composed.enrichWarnings } : {}),
|
|
826
1053
|
},
|
|
@@ -886,6 +1113,23 @@ async function publishContent(
|
|
|
886
1113
|
});
|
|
887
1114
|
}
|
|
888
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Text inventory of whatever modal is still open on `page` — the diagnostic a blocked submit click
|
|
1118
|
+
* needs (2026-10-12: `保存为草稿` was covered by a dialog footer for 30 s and the failure named only
|
|
1119
|
+
* the Playwright timeout). Covers the inner dialog and the overlay wrapper, both of which can be the
|
|
1120
|
+
* element that intercepts pointer events.
|
|
1121
|
+
*/
|
|
1122
|
+
async function openDialogInventory(page: WechatPage): Promise<string> {
|
|
1123
|
+
const raw = await page
|
|
1124
|
+
.evaluate(
|
|
1125
|
+
`[...document.querySelectorAll('.weui-desktop-dialog, .weui-desktop-dialog__wrp')]` +
|
|
1126
|
+
`.filter((d) => d.offsetWidth).map((d) => (d.className || '') + ' :: ' + ` +
|
|
1127
|
+
`(d.innerText || '').replace(/\\s+/g, ' ').slice(0, 160)).join(' || ')`,
|
|
1128
|
+
)
|
|
1129
|
+
.catch(() => '');
|
|
1130
|
+
return typeof raw === 'string' ? raw.slice(0, 600) : '';
|
|
1131
|
+
}
|
|
1132
|
+
|
|
889
1133
|
/**
|
|
890
1134
|
* Run one validated operation end to end: enter its mode, compose the post, then take it to a draft
|
|
891
1135
|
* or — only on an explicit live request — publish it.
|
|
@@ -893,9 +1137,24 @@ async function publishContent(
|
|
|
893
1137
|
export async function runMode(input: ModeInput, deps: ModeRunDeps): Promise<Result> {
|
|
894
1138
|
const entry = await enterMode(deps, input.operation);
|
|
895
1139
|
const composed = await compose(input, deps, entry);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1140
|
+
// 2026-10-12: a throw from the submit step drops every diagnostic the compose phase collected —
|
|
1141
|
+
// a leaked settings dialog blocked 保存为草稿 on the daily run and the failure Result carried only
|
|
1142
|
+
// the Playwright timeout, so the stuck step had to be rediscovered by hand. Ride them on the error.
|
|
1143
|
+
const diagnostics = [...composed.warnings, ...composed.enrichWarnings];
|
|
1144
|
+
try {
|
|
1145
|
+
return deps.publishStatus === 'public'
|
|
1146
|
+
? await publishContent(input, deps, entry, composed)
|
|
1147
|
+
: await saveDraft(input, deps, entry, composed);
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1150
|
+
const blocked = await openDialogInventory(entry.editor);
|
|
1151
|
+
const carried = [
|
|
1152
|
+
...(diagnostics.length > 0 ? [`compose diagnostics: ${diagnostics.join(' | ')}`] : []),
|
|
1153
|
+
...(blocked !== '' ? [`open dialogs at submit: ${blocked}`] : []),
|
|
1154
|
+
];
|
|
1155
|
+
if (carried.length === 0) throw error;
|
|
1156
|
+
throw new Error(`${message} — ${carried.join(' — ')}`);
|
|
1157
|
+
}
|
|
899
1158
|
}
|
|
900
1159
|
|
|
901
1160
|
/**
|
|
@@ -992,12 +1251,14 @@ function escapeHtml(text: string): string {
|
|
|
992
1251
|
/**
|
|
993
1252
|
* Audio at the article top (operator request 2026-09-29; upload path added 2026-10-10).
|
|
994
1253
|
*
|
|
995
|
-
* Reuses a same-title 素材库 material when one exists
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
1254
|
+
* Reuses a same-title 素材库 material when one exists (UNIQUE exposed-title match, identity
|
|
1255
|
+
* captured); otherwise UPLOADS it. The operator's measured upload shape: 音频 menu → 上传音频 → pick
|
|
1256
|
+
* the audio file → 上传封面 → pick the cover → type the title → tick 我已阅读并同意《公众平台音频上传服务规则》
|
|
1257
|
+
* → 保存 → the material list reopens → select the row whose exposed title matches uniquely → 插入.
|
|
1258
|
+
* 插入 places it at the caret, so the caret is put at the body start first.
|
|
999
1259
|
*
|
|
1000
|
-
* Fail-soft — every non-committed outcome returns a warning string
|
|
1260
|
+
* Fail-soft — every non-committed outcome returns a warning string; `''` means the inserted node is
|
|
1261
|
+
* the body's first content AND carries the selected material's identity (task 0260 R2).
|
|
1001
1262
|
*/
|
|
1002
1263
|
export async function insertAudioMaterial(
|
|
1003
1264
|
page: WechatPage,
|
|
@@ -1014,15 +1275,28 @@ export async function insertAudioMaterial(
|
|
|
1014
1275
|
sel?.removeAllRanges();
|
|
1015
1276
|
sel?.addRange(r);
|
|
1016
1277
|
})()`;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1278
|
+
/** Trim + collapse whitespace/NBSP — MP titles carry ` ` entities (0272 capture). */
|
|
1279
|
+
const norm = (value: unknown): string =>
|
|
1280
|
+
typeof value === 'string' ? value.replace(/[\s\u00a0]+/g, ' ').trim() : '';
|
|
1281
|
+
/** Unique exposed-title match; ticks only an unticked row and captures its stable identity
|
|
1282
|
+
* (data-mediaid/data-id, else the thumb's media URL) for the body readback. Never first-item. */
|
|
1283
|
+
const MATCH = `(() => {
|
|
1284
|
+
const norm = (v) => (v ?? '').replace(/[\\s\\u00a0]+/g, ' ').trim();
|
|
1285
|
+
const want = ${JSON.stringify(norm(title))};
|
|
1286
|
+
const rows = [...document.querySelectorAll('label.frm_checkbox_label.audio_item')]
|
|
1287
|
+
.filter((l) => l.offsetWidth > 0);
|
|
1288
|
+
const hits = rows.filter((l) => norm((l.querySelector('.audio_title') ?? l).textContent) === want);
|
|
1289
|
+
if (hits.length === 0) return 'missing';
|
|
1290
|
+
if (hits.length > 1) return 'ambiguous';
|
|
1291
|
+
const hit = hits[0];
|
|
1022
1292
|
const cb = (hit.closest('li') ?? hit.parentElement)?.querySelector('input[type=checkbox]');
|
|
1023
1293
|
if (!cb) return 'nobox';
|
|
1024
|
-
cb.click();
|
|
1025
|
-
|
|
1294
|
+
if (!cb.checked) cb.click();
|
|
1295
|
+
const wrp = hit.closest('.audio_item_wrp') ?? hit.parentElement;
|
|
1296
|
+
const thumb = wrp?.querySelector('.audio_item_thumb');
|
|
1297
|
+
const identity = wrp?.getAttribute('data-mediaid') ?? wrp?.getAttribute('data-id') ??
|
|
1298
|
+
(thumb ? (thumb.style.backgroundImage.match(/url\\("?([^")]+)"?\\)/)?.[1] ?? '') : '');
|
|
1299
|
+
return JSON.stringify({ state: 'checked', identity });
|
|
1026
1300
|
})()`;
|
|
1027
1301
|
/** Tick the service-rules acknowledgement, found by its own label text rather than a class. */
|
|
1028
1302
|
const AGREE = `(() => {
|
|
@@ -1035,15 +1309,6 @@ export async function insertAudioMaterial(
|
|
|
1035
1309
|
if (!cb.checked) cb.click();
|
|
1036
1310
|
return cb.checked ? 'checked' : 'unchecked';
|
|
1037
1311
|
})()`;
|
|
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
1312
|
const INSERT = `(() => {
|
|
1048
1313
|
const btn = [...document.querySelectorAll('button')].find((b) => (b.textContent ?? '').trim() === '插入' && b.offsetWidth > 0);
|
|
1049
1314
|
if (!btn) return false;
|
|
@@ -1056,18 +1321,23 @@ export async function insertAudioMaterial(
|
|
|
1056
1321
|
})()`;
|
|
1057
1322
|
const VERIFY = `(() => {
|
|
1058
1323
|
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';
|
|
1324
|
+
if (!pm) return JSON.stringify({ state: 'noeditor' });
|
|
1062
1325
|
const first = pm.firstElementChild;
|
|
1063
|
-
|
|
1326
|
+
if (!first) return JSON.stringify({ state: 'absent' });
|
|
1327
|
+
const node = first.matches('mpvoice, [data-mediaid]') ? first : first.querySelector('mpvoice, [data-mediaid]');
|
|
1328
|
+
if (!node) return JSON.stringify({ state: 'absent' });
|
|
1329
|
+
const identity = node.getAttribute('data-mediaid') ?? node.getAttribute('data-appid') ??
|
|
1330
|
+
node.getAttribute('data-src') ?? node.getAttribute('src') ?? '';
|
|
1331
|
+
return JSON.stringify({ state: 'top', identity });
|
|
1064
1332
|
})()`;
|
|
1065
1333
|
|
|
1066
1334
|
/** Upload the audio + its cover, acknowledge the rules, and 保存. Returns a warning or null. */
|
|
1067
1335
|
const upload = async (): Promise<string | null> => {
|
|
1068
1336
|
const uploadControl = await trySelectors(page, UPLOAD_AUDIO);
|
|
1069
1337
|
if (!uploadControl.found) return '音频: 上传音频 control not found in the audio dialog';
|
|
1070
|
-
|
|
1338
|
+
// Click the winner, never a joined union: `text=…, css…` mixes selector engines and
|
|
1339
|
+
// Playwright parses the whole string as CSS — zero matches (DOM fixture 0260).
|
|
1340
|
+
await clickSelector(page, uploadControl.selector ?? UPLOAD_AUDIO[0] ?? 'text=上传音频', 8_000);
|
|
1071
1341
|
const audioInput = await trySelectors(page, AUDIO_UPLOAD_INPUT);
|
|
1072
1342
|
if (!audioInput.found) return '音频: no audio file input appeared';
|
|
1073
1343
|
await uploadFile(asPlaywrightPage(page), audioInput.selector ?? AUDIO_UPLOAD_INPUT.join(', '), audioPath, {
|
|
@@ -1076,7 +1346,7 @@ export async function insertAudioMaterial(
|
|
|
1076
1346
|
// The form requires a cover before it will save.
|
|
1077
1347
|
const coverControl = await trySelectors(page, UPLOAD_COVER);
|
|
1078
1348
|
if (coverControl.found) {
|
|
1079
|
-
await clickSelector(page,
|
|
1349
|
+
await clickSelector(page, coverControl.selector ?? UPLOAD_COVER[0] ?? 'text=上传封面', 8_000);
|
|
1080
1350
|
const coverInput = await trySelectors(page, AUDIO_COVER_INPUT);
|
|
1081
1351
|
if (coverInput.found)
|
|
1082
1352
|
await uploadFile(
|
|
@@ -1098,38 +1368,84 @@ export async function insertAudioMaterial(
|
|
|
1098
1368
|
return null;
|
|
1099
1369
|
};
|
|
1100
1370
|
|
|
1371
|
+
/** One MATCH read; a throwing probe stays 'error' (unknown, never treated as missing). */
|
|
1372
|
+
const matchOnce = async (): Promise<string> => {
|
|
1373
|
+
try {
|
|
1374
|
+
return String(await page.evaluate(MATCH));
|
|
1375
|
+
} catch {
|
|
1376
|
+
return 'error';
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
/** Poll the picker while rows stream in; 'missing' keeps polling, JSON answers settle it. */
|
|
1380
|
+
const awaitMatch = async (): Promise<string> => {
|
|
1381
|
+
let match = await matchOnce();
|
|
1382
|
+
for (let i = 0; match === 'missing' && i < AUDIO_ROW_RETRIES; i += 1) {
|
|
1383
|
+
await page.waitForTimeout?.(AUDIO_ROW_SETTLE_MS);
|
|
1384
|
+
match = await matchOnce();
|
|
1385
|
+
}
|
|
1386
|
+
return match;
|
|
1387
|
+
};
|
|
1101
1388
|
try {
|
|
1102
1389
|
await page.evaluate(CARET);
|
|
1390
|
+
// Open the toolbar dropdown first (measured 2026-10-13): the menu item is hidden until its
|
|
1391
|
+
// trigger is clicked, so the old direct `js_insertaudio` click timed out.
|
|
1392
|
+
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_TOOLBAR_TRIGGER)})?.click()`);
|
|
1393
|
+
await page.waitForTimeout?.(300);
|
|
1103
1394
|
await page.evaluate(`document.querySelector(${JSON.stringify(AUDIO_MENU)})?.click()`);
|
|
1104
1395
|
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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.
|
|
1396
|
+
let match = await awaitMatch();
|
|
1397
|
+
if (match === 'missing') {
|
|
1398
|
+
// No reusable material: upload one, then POLL until the new row actually exists — never
|
|
1399
|
+
// assume the upload auto-selected a default row (task 0260 AC2).
|
|
1113
1400
|
const uploadWarning = await upload();
|
|
1114
1401
|
if (uploadWarning !== null) {
|
|
1115
1402
|
await page.evaluate(CANCEL);
|
|
1116
1403
|
return uploadWarning;
|
|
1117
1404
|
}
|
|
1118
1405
|
await waitForVisible(page, AUDIO_DIALOG, AUDIO_DIALOG_TIMEOUT_MS);
|
|
1119
|
-
|
|
1120
|
-
if (
|
|
1406
|
+
match = await awaitMatch();
|
|
1407
|
+
if (match === 'missing') {
|
|
1408
|
+
await page.evaluate(CANCEL);
|
|
1409
|
+
return `音频: 保存后素材列表未出现「${title}」,未插入音频`;
|
|
1410
|
+
}
|
|
1411
|
+
if (match === 'error') {
|
|
1412
|
+
await page.evaluate(CANCEL);
|
|
1413
|
+
return '音频: 保存后素材匹配探测失败,未插入音频';
|
|
1414
|
+
}
|
|
1415
|
+
} else if (match === 'error') {
|
|
1416
|
+
await page.evaluate(CANCEL);
|
|
1417
|
+
return '音频: 素材匹配探测失败,未插入音频';
|
|
1418
|
+
}
|
|
1419
|
+
// Parse the match; anything but a unique ticked row stays a warning (never first-item).
|
|
1420
|
+
let identity = '';
|
|
1421
|
+
try {
|
|
1422
|
+
const parsed = JSON.parse(match) as { state?: string; identity?: string };
|
|
1423
|
+
if (parsed.state !== 'checked') {
|
|
1121
1424
|
await page.evaluate(CANCEL);
|
|
1122
|
-
return `音频:
|
|
1425
|
+
return `音频: 素材「${title}」${parsed.state === 'ambiguous' ? '列表匹配到多个同名条目,身份不唯一' : parsed.state === 'nobox' ? '行没有勾选框' : '未选中'},未插入音频`;
|
|
1123
1426
|
}
|
|
1427
|
+
identity = parsed.identity ?? '';
|
|
1428
|
+
} catch {
|
|
1429
|
+
await page.evaluate(CANCEL);
|
|
1430
|
+
return '音频: 素材匹配探测返回未知结果,未插入音频';
|
|
1124
1431
|
}
|
|
1125
1432
|
if ((await page.evaluate(INSERT)) !== true) {
|
|
1126
1433
|
await page.evaluate(CANCEL);
|
|
1127
1434
|
return '音频: 插入按钮未找到,请手动插入';
|
|
1128
1435
|
}
|
|
1129
1436
|
await page.waitForTimeout?.(2_000);
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1437
|
+
let verdict: { state?: string; identity?: string };
|
|
1438
|
+
try {
|
|
1439
|
+
verdict = JSON.parse(String(await page.evaluate(VERIFY))) as { state?: string; identity?: string };
|
|
1440
|
+
} catch {
|
|
1441
|
+
return '音频: 已插入但无法读取正文状态,请手动检查';
|
|
1442
|
+
}
|
|
1443
|
+
if (verdict.state === 'absent' || verdict.state === 'noeditor') return '音频: 插入未生效,请手动检查';
|
|
1444
|
+
if (verdict.state !== 'top') return '音频: 已插入但未在文首,请手动调整位置';
|
|
1445
|
+
// Identity readback (0260 R2): success requires the selected identity to reappear on the
|
|
1446
|
+
// inserted node; an unreadable identity is an unverifiable warning, never a silent pass.
|
|
1447
|
+
if (identity === '' || (verdict.identity ?? '') === '' || verdict.identity !== identity)
|
|
1448
|
+
return `音频: 已插入文首但无法核对素材身份(selected=${identity || '未读到'} node=${(verdict.identity ?? '').slice(0, 60) || '未读到'}),请手动核对`;
|
|
1133
1449
|
return '';
|
|
1134
1450
|
} catch (error) {
|
|
1135
1451
|
return `音频: ${error instanceof Error ? error.message.slice(0, 120) : '自动插入失败'},请手动插入`;
|