@gobing-ai/knowledge-kit 0.0.37 → 0.1.1

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 (157) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.css +654 -16
  3. package/dist/board/collect/index.js +191 -64
  4. package/dist/board/daily-voice/index.css +679 -12
  5. package/dist/board/daily-voice/index.js +185 -66
  6. package/dist/board/itc/index.css +679 -13
  7. package/dist/board/itc/index.js +188 -66
  8. package/dist/index.js +2864 -139
  9. package/package.json +3 -2
  10. package/plugins/generations/content-gen/dist/index.js +158 -12
  11. package/plugins/generations/core-facts-gen/dist/index.js +158 -12
  12. package/plugins/generations/daily-article-gen/dist/index.js +158 -12
  13. package/plugins/generations/dailynews-gen/dist/index.js +158 -12
  14. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  15. package/plugins/generations/gemini-tts/README.md +4 -3
  16. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  17. package/plugins/generations/image-gen/dist/index.js +158 -12
  18. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  19. package/plugins/generations/omni-voice-gen/README.md +15 -4
  20. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  21. package/plugins/generations/voice-gen/dist/index.js +158 -12
  22. package/plugins/generations/voxcpm-tts/README.md +21 -1
  23. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  24. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  25. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  26. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  27. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/web-search/dist/index.js +158 -12
  30. package/plugins/kk/commands/workflow-run.md +56 -18
  31. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  32. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  33. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  34. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  35. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  36. package/plugins/kk/plugin.json +1 -1
  37. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  38. package/plugins/kk/publish-presets/technical-article.json +14 -0
  39. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  40. package/plugins/kk/scripts/daily-stages.ts +42 -2
  41. package/plugins/kk/scripts/itc-stages.ts +25 -118
  42. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  43. package/plugins/kk/scripts/seo-decisions.ts +491 -0
  44. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  45. package/plugins/kk/scripts/seo-facts.ts +610 -0
  46. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  47. package/plugins/kk/scripts/seo-report.ts +460 -0
  48. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  49. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  50. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  51. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  52. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  53. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  54. package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
  55. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  56. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  57. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
  58. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  59. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  60. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  61. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  62. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  63. package/plugins/publishings/bilibili-pub/package.json +20 -0
  64. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  65. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  66. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  67. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  68. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  69. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  70. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  71. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  72. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  73. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  74. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  75. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  76. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  77. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  78. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  79. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  80. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  81. package/plugins/publishings/csdn-pub/package.json +19 -0
  82. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  83. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  84. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  85. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  86. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  87. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  88. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  89. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  90. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  91. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  92. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  93. package/plugins/publishings/jike-pub/package.json +19 -0
  94. package/plugins/publishings/jike-pub/plugin.json +12 -0
  95. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  96. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  97. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  99. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  100. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  101. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  102. package/plugins/publishings/medium-pub/plugin.json +22 -1
  103. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  104. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  105. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  106. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  107. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  108. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  109. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  110. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  111. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  112. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  113. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  114. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  115. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  116. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  117. package/plugins/publishings/sspai-pub/package.json +19 -0
  118. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  119. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  120. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  121. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  122. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  123. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  125. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  126. package/plugins/publishings/substack-pub/plugin.json +21 -0
  127. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  128. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  129. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  130. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  131. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  132. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  133. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  134. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  136. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  137. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  138. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  139. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  140. package/plugins/publishings/x-pub/dist/index.js +160 -12
  141. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  142. package/plugins/publishings/x-pub/plugin.json +40 -0
  143. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  144. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  145. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  146. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  147. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  155. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  156. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  157. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
@@ -0,0 +1,1296 @@
1
+ /**
2
+ * bilibili-pub transport and publishing flow (task 0297).
3
+ *
4
+ * Implements two distinct flows matching the Bilibili platform architecture:
5
+ * 1. Short Video (`shortVideo`): Video upload and metadata filling at member.bilibili.com.
6
+ * Grounded in `.spur/run/yijianfa-source/platforms/bilibili.js` (lines 290-360, 365-556):
7
+ * - Native file input upload (bilibili.js:397, 401) and file injection (bilibili.js:417)
8
+ * - Media processing wait for form appearance (bilibili.js:431)
9
+ * - Title fill and readback (max 80 chars, bilibili.js:431-447)
10
+ * - Description paste and readback (bilibili.js:453-463)
11
+ * - Partition selection — **Provisional**: the adapter exposes no partition UI control; it only
12
+ * writes the API field `form.append("category", "daily")` (bilibili.js:571)
13
+ * - Tag cleanup and fill (max 10 tags, Enter key, bilibili.js:485-507)
14
+ * - Original ("自制") radio selection (bilibili.js:520-521)
15
+ * - Cover upload and crop confirmation (bilibili.js:290-360)
16
+ * - Submission: draft save via observed draft control vs public submission via span.submit-add
17
+ * resulting in a pending-review receipt (bilibili.js:548, 552).
18
+ * 2. Dynamic Post (`dynamic`): Rich-text and multi-image posts at t.bilibili.com.
19
+ * Grounded in `bilibili.js` (lines 158-287) and `bilibili-main.js` (lines 33-90):
20
+ * - Editor element in composer scope (bilibili.js:171, 209, 270-271)
21
+ * - Image cleanup (bilibili.js:174-184)
22
+ * - Native setInputFiles for images (bilibili.js:231, 238, 257; the vendor injects the file
23
+ * through a MAIN-world message bridge, bilibili-main.js:54-81 — deliberately NOT reproduced)
24
+ * - Launch button submission (bilibili.js:273, 285)
25
+ * - Public-only policy: drafts are refused pre-browser.
26
+ */
27
+
28
+ import { type Result, ResultSchema } from '@gobing-ai/kk-core';
29
+ import type { BrowserDriver, PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
30
+ import {
31
+ checkChannelSession,
32
+ expectOutcome,
33
+ kkBrowserLauncher,
34
+ markdownToHtml,
35
+ resolveChannelProfileDir,
36
+ } from '@gobing-ai/publish-harness/browser';
37
+ import {
38
+ type BrowserLauncher,
39
+ BrowserProfileMissingError,
40
+ LoginTimeoutError,
41
+ withProfilePage,
42
+ } from '@gobing-ai/ts-browser-automation';
43
+ import type { Locator, Page } from 'playwright';
44
+ import {
45
+ BILI_CONFIG,
46
+ BILI_DYNAMIC_HOME_URL,
47
+ BILI_DYNAMIC_SELECTORS,
48
+ BILI_MEMBER_HOME_URL,
49
+ BILI_RECEIPT_URL_RES,
50
+ BILI_TARGET,
51
+ BILI_UPLOAD_TIMEOUT_MS,
52
+ BILI_VIDEO_SELECTORS,
53
+ type BilibiliOperation,
54
+ isBilibiliAuthenticated,
55
+ } from './selectors';
56
+
57
+ const STEP_TIMEOUT_MS = 30_000;
58
+
59
+ /**
60
+ * KK v1 local-video size bound: 2 GiB. Product bound, not an explicit platform limit.
61
+ */
62
+ const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
63
+
64
+ /** Operator recovery command named on session failures. */
65
+ export const BILI_LOGIN_INSTRUCTION = 'bun plugins/publishings/bilibili-pub/src/profile-check.ts login';
66
+
67
+ /** `ok: false` refusal Result with a bounded, single-line error. */
68
+ export function fail(target: string, error: string): Result {
69
+ return ResultSchema.parse({ ok: false, target, error: boundedDetail(error) });
70
+ }
71
+
72
+ /** A browser-stage failure: `ok: false` with `stage` and metadata. */
73
+ export function stageFail(
74
+ target: string,
75
+ error: string,
76
+ stage: string,
77
+ metadata: Record<string, unknown>,
78
+ evidence?: string,
79
+ ): Result {
80
+ return ResultSchema.parse({
81
+ ok: false,
82
+ target,
83
+ error: boundedDetail(error),
84
+ metadata: { ...metadata, stage, ...(evidence === undefined ? {} : { evidence: boundedDetail(evidence) }) },
85
+ });
86
+ }
87
+
88
+ /** One short line, bounded to 300 characters without leaking credentials or DOM dumps. */
89
+ export function boundedDetail(detail: string): string {
90
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 300);
91
+ }
92
+
93
+ /** Bounded rendering of a value for refusal messages. */
94
+ export function bounded(value: unknown): string {
95
+ if (Array.isArray(value)) return `${value.length} entr${value.length === 1 ? 'y' : 'ies'}`;
96
+ return String(value).replace(/\s+/g, ' ').slice(0, 100);
97
+ }
98
+
99
+ export function errorMessage(error: unknown): string {
100
+ return error instanceof Error ? error.message : String(error);
101
+ }
102
+
103
+ function needsLogin(error: unknown): boolean {
104
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
105
+ }
106
+
107
+ function normalizeText(value: string): string {
108
+ return value.replace(/\s+/g, '');
109
+ }
110
+
111
+ /**
112
+ * Plugin-local HTML sanitizer: strips script/style/iframe/embed/object containers,
113
+ * `on*` event attributes, and `javascript:`/`data:` URLs.
114
+ */
115
+ export function sanitizeBilibiliHtml(html: string): string {
116
+ return html
117
+ .replace(/<\s*(script|style|iframe|embed|object)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
118
+ .replace(/<\s*(script|style|iframe|embed|object)\b[^>]*>/gi, '')
119
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
120
+ .replace(/(\s(?:href|src)\s*=\s*)(["'])\s*(?:javascript|data):[^"']*\2/gi, '$1$2$2')
121
+ .replace(/(\s(?:href|src)\s*=\s*)(?:javascript|data):[^\s>]*/gi, '$1""');
122
+ }
123
+
124
+ /**
125
+ * Pre-browser local video path check via Bun file API (no node:fs).
126
+ */
127
+ async function resolveVideoPath(
128
+ raw: unknown,
129
+ ): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
130
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
131
+ return { error: `metadata.videoPath must be a non-empty local video file path, got '${bounded(raw)}'` };
132
+ }
133
+ const path = raw.trim();
134
+ if (!/\.(mp4|mov)$/i.test(path)) {
135
+ return {
136
+ error: `metadata.videoPath must be a local MP4/MOV file, got unsupported extension in '${bounded(path)}'`,
137
+ };
138
+ }
139
+ const file = Bun.file(path);
140
+ const readable = await file.exists().catch(() => false);
141
+ if (!readable || file.size === 0) {
142
+ return { error: 'metadata.videoPath does not resolve to a readable nonempty local video file' };
143
+ }
144
+ if (file.size > MAX_VIDEO_BYTES) {
145
+ return {
146
+ error: `metadata.videoPath exceeds the KK v1 local video cap of 2 GiB (${file.size} bytes; a local product bound, not a platform limit)`,
147
+ };
148
+ }
149
+ return { path };
150
+ }
151
+
152
+ /**
153
+ * Pre-browser cover image path check via Bun file API.
154
+ */
155
+ async function resolveCoverPath(
156
+ raw: unknown,
157
+ ): Promise<{ path?: string; error?: undefined } | { path?: undefined; error: string }> {
158
+ if (raw === undefined) return {};
159
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
160
+ return { error: `metadata.coverPath must be a non-empty local file path when present, got '${bounded(raw)}'` };
161
+ }
162
+ const path = raw.trim();
163
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) {
164
+ return {
165
+ error: `metadata.coverPath must be a local PNG/JPEG/WebP file, got unsupported extension in '${bounded(path)}'`,
166
+ };
167
+ }
168
+ const file = Bun.file(path);
169
+ const readable = await file.exists().catch(() => false);
170
+ if (!readable || file.size === 0) {
171
+ return { error: 'metadata.coverPath does not resolve to a readable nonempty local image file' };
172
+ }
173
+ return { path };
174
+ }
175
+
176
+ /**
177
+ * Pre-browser tags check: max 10 entries (bilibili.js:488, 507).
178
+ */
179
+ function resolveTags(
180
+ raw: unknown,
181
+ ): { tags: readonly string[]; error?: undefined } | { tags?: undefined; error: string } {
182
+ if (raw === undefined) return { tags: [] };
183
+ if (!Array.isArray(raw)) {
184
+ return { error: `metadata.tags must be an array of strings when present, got '${bounded(raw)}'` };
185
+ }
186
+ if (raw.length > 10) {
187
+ return {
188
+ error: `bilibili shortVideo tags exceed the 10-tag limit (${raw.length} entries; tags are rejected, bilibili.js:488, 507)`,
189
+ };
190
+ }
191
+ const cleaned: string[] = [];
192
+ for (const entry of raw) {
193
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
194
+ return { error: `metadata.tags entries must be non-empty strings, got '${bounded(entry)}'` };
195
+ }
196
+ cleaned.push(entry.trim());
197
+ }
198
+ return { tags: cleaned };
199
+ }
200
+
201
+ /**
202
+ * Pre-browser dynamic images check: max 9 entries.
203
+ */
204
+ async function resolveDynamicImages(
205
+ raw: unknown,
206
+ ): Promise<{ paths: readonly string[]; error?: undefined } | { paths?: undefined; error: string }> {
207
+ if (raw === undefined) return { paths: [] };
208
+ if (!Array.isArray(raw)) {
209
+ return { error: `metadata.images must be an array of file paths when present, got '${bounded(raw)}'` };
210
+ }
211
+ if (raw.length > 9) {
212
+ return {
213
+ error: `bilibili dynamic images exceed the limit of 9 images (${raw.length} provided)`,
214
+ };
215
+ }
216
+ const cleaned: string[] = [];
217
+ for (const entry of raw) {
218
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
219
+ return { error: `metadata.images entries must be non-empty strings, got '${bounded(entry)}'` };
220
+ }
221
+ const path = entry.trim();
222
+ if (!/\.(png|jpe?g|webp|gif)$/i.test(path)) {
223
+ return {
224
+ error: `metadata.images must be local image files (.png/.jpg/.jpeg/.webp/.gif), got '${bounded(path)}'`,
225
+ };
226
+ }
227
+ const file = Bun.file(path);
228
+ const readable = await file.exists().catch(() => false);
229
+ if (!readable || file.size === 0) {
230
+ return {
231
+ error: `metadata.images file does not resolve to a readable nonempty file: '${bounded(path)}'`,
232
+ };
233
+ }
234
+ cleaned.push(path);
235
+ }
236
+ return { paths: cleaned };
237
+ }
238
+
239
+ /**
240
+ * Resolves the first attached candidate selector and returns its locator.
241
+ */
242
+ async function resolveLocator(
243
+ page: Page,
244
+ selectors: readonly string[],
245
+ label: string,
246
+ timeoutMs: number = STEP_TIMEOUT_MS,
247
+ ): Promise<{ locator: Locator; selector: string }> {
248
+ const outcome = await expectOutcome(
249
+ async () => {
250
+ for (const selector of selectors) {
251
+ if ((await page.locator(selector).first().count()) > 0) return selector;
252
+ }
253
+ return null;
254
+ },
255
+ { timeoutMs, label: `${label} attach` },
256
+ );
257
+ if (!outcome.ok) throw new Error(`${label}: ${selectors.join(' | ')} not attached (${outcome.error})`);
258
+ const selector = outcome.evidence;
259
+ return { locator: page.locator(selector).first(), selector };
260
+ }
261
+
262
+ /**
263
+ * Pastes rich text into a contenteditable editor using page.evaluate (NO locator.evaluate, 0296 lesson).
264
+ */
265
+ async function pasteRichText(page: Page, selector: string, html: string, plainText: string): Promise<void> {
266
+ const outcome = String(
267
+ await page.evaluate(`(() => {
268
+ const el = document.querySelector(${JSON.stringify(selector)});
269
+ if (el === null) return 'missing';
270
+ const data = new DataTransfer();
271
+ data.setData('text/html', ${JSON.stringify(html)});
272
+ data.setData('text/plain', ${JSON.stringify(plainText)});
273
+ el.focus();
274
+ el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
275
+ el.dispatchEvent(new Event('input', { bubbles: true }));
276
+ el.dispatchEvent(new Event('change', { bubbles: true }));
277
+ return 'pasted';
278
+ })()`),
279
+ );
280
+ if (outcome !== 'pasted') throw new Error(`pasteRichText: ${selector} not attached for paste`);
281
+ }
282
+
283
+ /**
284
+ * Bilibili browser transport handling shortVideo and dynamic operations.
285
+ */
286
+ export class BilibiliBrowserTransport implements PublishTransport {
287
+ public readonly kind = 'browser' as const;
288
+ readonly mode = 'browser-script' as const;
289
+ readonly driver: BrowserDriver;
290
+ readonly operation: BilibiliOperation;
291
+ readonly stepTimeoutMs: number;
292
+ readonly uploadTimeoutMs: number;
293
+ readonly launcher: BrowserLauncher;
294
+
295
+ constructor(
296
+ operation: BilibiliOperation = 'shortVideo',
297
+ driver: BrowserDriver = 'script',
298
+ options?: {
299
+ stepTimeoutMs?: number;
300
+ uploadTimeoutMs?: number;
301
+ launcher?: BrowserLauncher;
302
+ },
303
+ ) {
304
+ this.operation = operation;
305
+ this.driver = driver;
306
+ this.stepTimeoutMs = options?.stepTimeoutMs ?? STEP_TIMEOUT_MS;
307
+ this.uploadTimeoutMs = options?.uploadTimeoutMs ?? BILI_UPLOAD_TIMEOUT_MS;
308
+ this.launcher = options?.launcher ?? kkBrowserLauncher;
309
+ }
310
+
311
+ async publish(payload: PublishTransportPayload): Promise<Result> {
312
+ if (this.driver !== 'script') {
313
+ return fail(
314
+ BILI_TARGET,
315
+ `unsupported driver '${bounded(this.driver)}' for ${BILI_TARGET}: expected one of script (the bilibili-pub browser flow has no agent path)`,
316
+ );
317
+ }
318
+
319
+ if (this.operation === 'shortVideo') {
320
+ return this.publishShortVideo(payload);
321
+ }
322
+
323
+ if (this.operation === 'dynamic') {
324
+ return this.publishDynamic(payload);
325
+ }
326
+
327
+ return fail(
328
+ BILI_TARGET,
329
+ `unsupported operation '${bounded(this.operation)}' for ${BILI_TARGET}: expected one of shortVideo, dynamic`,
330
+ );
331
+ }
332
+
333
+ private async publishShortVideo(payload: PublishTransportPayload): Promise<Result> {
334
+ // Refuse unsupported fields for shortVideo
335
+ if (payload.content.metadata?.images !== undefined || payload.options?.images !== undefined) {
336
+ return fail(
337
+ BILI_TARGET,
338
+ 'unsupported field(s) images for bilibili shortVideo: this operation accepts video, partition, tags, cover only; images are not supported for shortVideo',
339
+ );
340
+ }
341
+ const rawContent = payload.content as unknown as Record<string, unknown>;
342
+ if (
343
+ payload.content.metadata?.scheduling !== undefined ||
344
+ payload.content.metadata?.publishTime !== undefined ||
345
+ rawContent.publishTime !== undefined ||
346
+ payload.options?.scheduling !== undefined ||
347
+ payload.options?.publishTime !== undefined
348
+ ) {
349
+ return fail(
350
+ BILI_TARGET,
351
+ 'unsupported field(s) scheduling/publishTime for bilibili shortVideo: this operation does not support scheduled publishing (bilibili.js:376-378)',
352
+ );
353
+ }
354
+
355
+ const rawStatus = payload.content.metadata?.publishStatus ?? payload.options?.publishStatus;
356
+ const publishStatus = rawStatus ?? 'draft';
357
+ if (rawStatus !== undefined && rawStatus !== 'draft' && rawStatus !== 'public') {
358
+ return fail(
359
+ BILI_TARGET,
360
+ `unknown publishStatus '${bounded(rawStatus)}' for ${BILI_TARGET}: expected 'draft', 'public' or absent (absent defaults to draft)`,
361
+ );
362
+ }
363
+
364
+ if (payload.content.format !== 'video') {
365
+ return fail(
366
+ BILI_TARGET,
367
+ `bilibili shortVideo requires Content.format 'video', got '${bounded(payload.content.format)}'`,
368
+ );
369
+ }
370
+
371
+ const title = payload.content.title?.trim() ?? '';
372
+ if (title.length === 0) {
373
+ return fail(
374
+ BILI_TARGET,
375
+ 'bilibili shortVideo requires a non-empty Content.title (the title input, bilibili.js:431)',
376
+ );
377
+ }
378
+ if (title.length > 80) {
379
+ return fail(
380
+ BILI_TARGET,
381
+ `bilibili shortVideo title exceeds the 80-character platform limit (${title.length} characters; titles longer than 80 are rejected, never truncated, bilibili.js:431)`,
382
+ );
383
+ }
384
+
385
+ const partition = payload.content.metadata?.partition ?? payload.options?.partition;
386
+ if (typeof partition !== 'string' || partition.trim().length === 0) {
387
+ return fail(
388
+ BILI_TARGET,
389
+ 'bilibili shortVideo requires a non-empty string for metadata.partition specifying the category/partition',
390
+ );
391
+ }
392
+
393
+ const video = await resolveVideoPath(payload.content.metadata?.videoPath ?? payload.options?.videoPath);
394
+ if (video.error !== undefined) return fail(BILI_TARGET, video.error);
395
+
396
+ const cover = await resolveCoverPath(payload.content.metadata?.coverPath ?? payload.options?.coverPath);
397
+ if (cover.error !== undefined) return fail(BILI_TARGET, cover.error);
398
+
399
+ const tagsResult = resolveTags(payload.content.metadata?.tags ?? payload.options?.tags);
400
+ if (tagsResult.error !== undefined) return fail(BILI_TARGET, tagsResult.error);
401
+
402
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
403
+ const profileDir = resolveChannelProfileDir(BILI_TARGET, override);
404
+
405
+ const session = await checkChannelSession(BILI_CONFIG, {
406
+ profileDir,
407
+ launcher: this.launcher,
408
+ headedFallback: false,
409
+ timeoutMs: this.stepTimeoutMs,
410
+ });
411
+ if (session.status !== 'verified') {
412
+ return fail(
413
+ BILI_TARGET,
414
+ `${BILI_TARGET} session preflight refused — run: ${BILI_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
415
+ );
416
+ }
417
+
418
+ try {
419
+ return await withProfilePage(
420
+ {
421
+ profileDir,
422
+ url: BILI_MEMBER_HOME_URL,
423
+ isAuthenticated: isBilibiliAuthenticated,
424
+ headless: payload.options?.headless === true,
425
+ launcher: this.launcher,
426
+ timeoutMs: this.stepTimeoutMs,
427
+ },
428
+ async ({ page }) =>
429
+ this.runShortVideoFlow(
430
+ page,
431
+ title,
432
+ payload.content.body.trim(),
433
+ partition.trim(),
434
+ tagsResult.tags,
435
+ video.path as string,
436
+ cover.path,
437
+ publishStatus === 'public',
438
+ profileDir,
439
+ ),
440
+ );
441
+ } catch (error) {
442
+ const detail = errorMessage(error);
443
+ return fail(
444
+ BILI_TARGET,
445
+ needsLogin(error)
446
+ ? `${BILI_TARGET} session failed — run: ${BILI_LOGIN_INSTRUCTION} — detail: ${detail}`
447
+ : detail,
448
+ );
449
+ }
450
+ }
451
+
452
+ private async publishDynamic(payload: PublishTransportPayload): Promise<Result> {
453
+ // publishStatus MUST be 'public'
454
+ const rawStatus = payload.content.metadata?.publishStatus ?? payload.options?.publishStatus;
455
+ if (rawStatus !== 'public') {
456
+ return fail(
457
+ BILI_TARGET,
458
+ 'bilibili dynamic posts require explicit metadata.publishStatus: public (drafts are not supported by the platform)',
459
+ );
460
+ }
461
+
462
+ // Refuse fields unsupported by dynamic
463
+ const forbidden: string[] = [];
464
+ if (payload.content.metadata?.videoPath !== undefined || payload.options?.videoPath !== undefined) {
465
+ forbidden.push('videoPath');
466
+ }
467
+ if (payload.content.metadata?.partition !== undefined || payload.options?.partition !== undefined) {
468
+ forbidden.push('partition');
469
+ }
470
+ if (payload.content.metadata?.coverPath !== undefined || payload.options?.coverPath !== undefined) {
471
+ forbidden.push('coverPath');
472
+ }
473
+ if (payload.content.metadata?.audioPath !== undefined || payload.options?.audioPath !== undefined) {
474
+ forbidden.push('audioPath');
475
+ }
476
+ if (
477
+ payload.content.metadata?.category !== undefined ||
478
+ payload.content.metadata?.categories !== undefined ||
479
+ payload.options?.category !== undefined ||
480
+ payload.options?.categories !== undefined
481
+ ) {
482
+ forbidden.push('category');
483
+ }
484
+ if (forbidden.length > 0) {
485
+ return fail(
486
+ BILI_TARGET,
487
+ `unsupported field(s) ${forbidden.join(', ')} for bilibili dynamic: dynamic posts do not accept video/partition/cover/category/audio fields`,
488
+ );
489
+ }
490
+
491
+ const rawContent = payload.content as unknown as Record<string, unknown>;
492
+ if (
493
+ payload.content.metadata?.scheduling !== undefined ||
494
+ payload.content.metadata?.publishTime !== undefined ||
495
+ rawContent.publishTime !== undefined ||
496
+ payload.options?.scheduling !== undefined ||
497
+ payload.options?.publishTime !== undefined
498
+ ) {
499
+ return fail(
500
+ BILI_TARGET,
501
+ 'unsupported field(s) scheduling/publishTime for bilibili dynamic: this operation does not support scheduled publishing (bilibili.js:158-160)',
502
+ );
503
+ }
504
+
505
+ if (payload.content.format !== 'article' && payload.content.format !== 'text') {
506
+ return fail(
507
+ BILI_TARGET,
508
+ `bilibili dynamic requires Content.format 'article' or 'text', got '${bounded(payload.content.format)}'`,
509
+ );
510
+ }
511
+
512
+ const body = payload.content.body.trim();
513
+ const imagesResult = await resolveDynamicImages(payload.content.metadata?.images ?? payload.options?.images);
514
+ if (imagesResult.error !== undefined) return fail(BILI_TARGET, imagesResult.error);
515
+
516
+ if (body.length === 0 && imagesResult.paths.length === 0) {
517
+ return fail(
518
+ BILI_TARGET,
519
+ 'bilibili dynamic requires non-empty Content.body or at least one image in metadata.images',
520
+ );
521
+ }
522
+
523
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
524
+ const profileDir = resolveChannelProfileDir(BILI_TARGET, override);
525
+
526
+ const dynamicConfig = {
527
+ ...BILI_CONFIG,
528
+ homeUrl: BILI_DYNAMIC_HOME_URL,
529
+ };
530
+
531
+ const session = await checkChannelSession(dynamicConfig, {
532
+ profileDir,
533
+ launcher: this.launcher,
534
+ headedFallback: false,
535
+ timeoutMs: this.stepTimeoutMs,
536
+ });
537
+ if (session.status !== 'verified') {
538
+ return fail(
539
+ BILI_TARGET,
540
+ `${BILI_TARGET} session preflight refused — run: ${BILI_LOGIN_INSTRUCTION} — detail: ${session.detail}`,
541
+ );
542
+ }
543
+
544
+ try {
545
+ return await withProfilePage(
546
+ {
547
+ profileDir,
548
+ url: BILI_DYNAMIC_HOME_URL,
549
+ isAuthenticated: isBilibiliAuthenticated,
550
+ headless: payload.options?.headless === true,
551
+ launcher: this.launcher,
552
+ timeoutMs: this.stepTimeoutMs,
553
+ },
554
+ async ({ page }) => this.runDynamicFlow(page, body, imagesResult.paths, profileDir),
555
+ );
556
+ } catch (error) {
557
+ const detail = errorMessage(error);
558
+ return fail(
559
+ BILI_TARGET,
560
+ needsLogin(error)
561
+ ? `${BILI_TARGET} session failed — run: ${BILI_LOGIN_INSTRUCTION} — detail: ${detail}`
562
+ : detail,
563
+ );
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Executes the shortVideo flow at member.bilibili.com.
569
+ */
570
+ private async runShortVideoFlow(
571
+ page: Page,
572
+ title: string,
573
+ body: string,
574
+ partition: string,
575
+ tags: readonly string[],
576
+ videoPath: string,
577
+ coverPath: string | undefined,
578
+ publishPublic: boolean,
579
+ profileDir: string,
580
+ ): Promise<Result> {
581
+ const publishStatus = publishPublic ? ('public' as const) : ('draft' as const);
582
+ const metadata: Record<string, unknown> = {
583
+ operation: 'shortVideo',
584
+ driver: this.driver,
585
+ publishStatus,
586
+ profileDir,
587
+ partition,
588
+ };
589
+
590
+ // 1. Upload video (bilibili.js:397, 401)
591
+ try {
592
+ const videoInput = await resolveLocator(
593
+ page,
594
+ BILI_VIDEO_SELECTORS.videoInput,
595
+ 'bilibili video file input',
596
+ this.stepTimeoutMs,
597
+ );
598
+ await videoInput.locator.setInputFiles(videoPath);
599
+ } catch (error) {
600
+ return stageFail(
601
+ BILI_TARGET,
602
+ `bilibili video upload failed — ${errorMessage(error)}`,
603
+ 'video-upload',
604
+ metadata,
605
+ );
606
+ }
607
+
608
+ // 2. Wait for editing form appearance (bilibili.js:431)
609
+ let titleLocator: Locator;
610
+ try {
611
+ const titleResolved = await resolveLocator(
612
+ page,
613
+ BILI_VIDEO_SELECTORS.titleInput,
614
+ 'bilibili video title input',
615
+ this.uploadTimeoutMs,
616
+ );
617
+ titleLocator = titleResolved.locator;
618
+ } catch (error) {
619
+ return stageFail(
620
+ BILI_TARGET,
621
+ `bilibili media processing timed out — editing form did not appear within ${this.uploadTimeoutMs}ms — ${errorMessage(error)}`,
622
+ 'media-processing',
623
+ metadata,
624
+ );
625
+ }
626
+
627
+ // 3. Fill title (bilibili.js:431-447)
628
+ try {
629
+ await titleLocator.fill(title);
630
+ const readback = await expectOutcome(
631
+ async () => {
632
+ const val = await titleLocator.inputValue().catch(() => '');
633
+ return val === title ? 'title read back' : null;
634
+ },
635
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili title readback' },
636
+ );
637
+ if (!readback.ok) {
638
+ return stageFail(
639
+ BILI_TARGET,
640
+ `bilibili title readback failed — title input did not retain target title — ${readback.error}`,
641
+ 'title-fill',
642
+ metadata,
643
+ );
644
+ }
645
+ } catch (error) {
646
+ return stageFail(
647
+ BILI_TARGET,
648
+ `bilibili title fill failed — ${errorMessage(error)}`,
649
+ 'title-fill',
650
+ metadata,
651
+ );
652
+ }
653
+
654
+ // 4. Fill description if present (bilibili.js:453-463)
655
+ if (body.length > 0) {
656
+ try {
657
+ const descResolved = await resolveLocator(
658
+ page,
659
+ BILI_VIDEO_SELECTORS.descriptionEditor,
660
+ 'bilibili description editor',
661
+ this.stepTimeoutMs,
662
+ );
663
+ const html = sanitizeBilibiliHtml(markdownToHtml(body));
664
+ await pasteRichText(page, descResolved.selector, html, body);
665
+ const readback = await expectOutcome(
666
+ async () => {
667
+ const text = await descResolved.locator.innerText().catch(() => '');
668
+ return normalizeText(text).includes(normalizeText(body)) ? 'description read back' : null;
669
+ },
670
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili description readback' },
671
+ );
672
+ if (!readback.ok) {
673
+ return stageFail(
674
+ BILI_TARGET,
675
+ `bilibili description readback failed — editor did not retain content — ${readback.error}`,
676
+ 'description-fill',
677
+ metadata,
678
+ );
679
+ }
680
+ } catch (error) {
681
+ return stageFail(
682
+ BILI_TARGET,
683
+ `bilibili description fill failed — ${errorMessage(error)}`,
684
+ 'description-fill',
685
+ metadata,
686
+ );
687
+ }
688
+ }
689
+
690
+ // 5. Select partition — Provisional: the adapter has no partition control (bilibili.js:571
691
+ // only writes the API field `category`), so a missing chooser is a typed refusal.
692
+ try {
693
+ const partitionResult = (await page.evaluate(`(() => {
694
+ const targetPartition = ${JSON.stringify(partition)};
695
+ const items = Array.from(
696
+ document.querySelectorAll(
697
+ 'div.select-item, div.category-item, div.drop-cascader-item, div.video-type-item, .select-controller, .partition-item',
698
+ ),
699
+ );
700
+ const match = items.find((el) => el.textContent && el.textContent.trim().includes(targetPartition));
701
+ if (match) {
702
+ match.click();
703
+ return { found: true, text: match.textContent.trim() };
704
+ }
705
+ const spans = Array.from(document.querySelectorAll('.partition-wrp span, .select-box span'));
706
+ const spanMatch = spans.find((el) => el.textContent && el.textContent.trim() === targetPartition);
707
+ if (spanMatch) {
708
+ spanMatch.click();
709
+ return { found: true, text: spanMatch.textContent.trim() };
710
+ }
711
+ return { found: false };
712
+ })()`)) as { found: boolean; text?: string };
713
+
714
+ const readback = await expectOutcome(
715
+ async () => {
716
+ const selectedText = String(
717
+ await page.evaluate(`(() => {
718
+ const selected = document.querySelector(
719
+ 'div.select-item.selected, div.category-item.active, .drop-cascader-item.active, .select-controller .selected-item',
720
+ );
721
+ return selected && selected.textContent ? selected.textContent.trim() : '';
722
+ })()`),
723
+ );
724
+ return selectedText.includes(partition) || partitionResult.found ? 'partition selected' : null;
725
+ },
726
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili partition selection' },
727
+ );
728
+ if (!readback.ok && !partitionResult.found) {
729
+ return stageFail(
730
+ BILI_TARGET,
731
+ `bilibili partition selection failed — could not find or select partition '${partition}' — ${readback.error}`,
732
+ 'partition-select',
733
+ metadata,
734
+ );
735
+ }
736
+ } catch (error) {
737
+ return stageFail(
738
+ BILI_TARGET,
739
+ `bilibili partition select failed — ${errorMessage(error)}`,
740
+ 'partition-select',
741
+ metadata,
742
+ );
743
+ }
744
+
745
+ // 6. Clean existing tags and fill new tags (fill grounded in bilibili.js:485, 488, 501-507)
746
+ if (tags.length > 0) {
747
+ try {
748
+ // Clean existing tags — the adapter never clears the field, so this removal step is
749
+ // plugin-side (label-item-v2-container, bilibili.js:503).
750
+ await page.evaluate(`(() => {
751
+ const existTags = document.querySelectorAll('div.tag-pre-wrp > div.label-item-v2-container');
752
+ for (const tagEl of Array.from(existTags)) {
753
+ tagEl.click();
754
+ }
755
+ })()`);
756
+
757
+ const tagInputResolved = await resolveLocator(
758
+ page,
759
+ BILI_VIDEO_SELECTORS.tagInput,
760
+ 'bilibili tag input',
761
+ this.stepTimeoutMs,
762
+ );
763
+
764
+ for (const tag of tags.slice(0, 10)) {
765
+ await tagInputResolved.locator.fill(tag);
766
+ await tagInputResolved.locator.press('Enter');
767
+ }
768
+
769
+ const readback = await expectOutcome(
770
+ async () => {
771
+ const labels = String(
772
+ await page.evaluate(`(() => {
773
+ const els = document.querySelectorAll('div.tag-pre-wrp > div.label-item-v2-container');
774
+ return Array.from(els)
775
+ .map((el) => (el.textContent ? el.textContent.trim() : ''))
776
+ .join(' ');
777
+ })()`),
778
+ );
779
+ return tags.every((t) => labels.includes(t)) ? 'tags read back' : null;
780
+ },
781
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili tags readback' },
782
+ );
783
+ if (!readback.ok) {
784
+ return stageFail(
785
+ BILI_TARGET,
786
+ `bilibili tags readback failed — page did not read back all tags — ${readback.error}`,
787
+ 'tags-fill',
788
+ metadata,
789
+ );
790
+ }
791
+ } catch (error) {
792
+ return stageFail(
793
+ BILI_TARGET,
794
+ `bilibili tags fill failed — ${errorMessage(error)}`,
795
+ 'tags-fill',
796
+ metadata,
797
+ );
798
+ }
799
+ }
800
+
801
+ // 7. Click "自制" original radio (bilibili.js:520-521)
802
+ try {
803
+ await page.evaluate(`(() => {
804
+ const radios = document.querySelectorAll('span.check-radio-v2-name');
805
+ for (const radio of Array.from(radios)) {
806
+ if (radio.textContent && radio.textContent.trim() === '自制') {
807
+ radio.click();
808
+ }
809
+ }
810
+ })()`);
811
+ } catch {
812
+ // non-blocking
813
+ }
814
+
815
+ // 8. Attach cover if requested (bilibili.js:290-360)
816
+ if (coverPath !== undefined) {
817
+ try {
818
+ await resolveLocator(
819
+ page,
820
+ [BILI_VIDEO_SELECTORS.coverMain],
821
+ 'bilibili cover main area',
822
+ this.stepTimeoutMs,
823
+ );
824
+
825
+ const beforePreview = String(
826
+ await page.evaluate(`(() => {
827
+ const main = document.querySelector('div.cover-main');
828
+ const slot = main ? main.querySelector('.cover-slot') : null;
829
+ const image = slot ? slot.querySelector('.cover-img') : null;
830
+ const imgEl = image ? image.querySelector('img') : null;
831
+ return (image && image.style ? image.style.backgroundImage : '') || (imgEl ? imgEl.currentSrc : '') || '';
832
+ })()`),
833
+ );
834
+
835
+ const entryClicked = Boolean(
836
+ await page.evaluate(`(() => {
837
+ const main = document.querySelector('div.cover-main');
838
+ if (!main) return false;
839
+ const spans = Array.from(main.querySelectorAll('span'));
840
+ const entry = spans.find(
841
+ (el) =>
842
+ el.textContent && (
843
+ el.textContent.includes('更换封面') ||
844
+ el.textContent.includes('封面设置') ||
845
+ el.textContent.includes('添加封面')
846
+ ),
847
+ );
848
+ if (entry) {
849
+ entry.click();
850
+ return true;
851
+ }
852
+ return false;
853
+ })()`),
854
+ );
855
+ if (!entryClicked) {
856
+ return stageFail(
857
+ BILI_TARGET,
858
+ 'bilibili cover entry not found (更换封面/封面设置/添加封面, bilibili.js:302-308)',
859
+ 'cover-attach',
860
+ metadata,
861
+ );
862
+ }
863
+
864
+ const coverInputResolved = await resolveLocator(
865
+ page,
866
+ BILI_VIDEO_SELECTORS.coverFileInput,
867
+ 'bilibili cover file input',
868
+ this.stepTimeoutMs,
869
+ );
870
+ await coverInputResolved.locator.setInputFiles(coverPath);
871
+
872
+ const doneBtnOutcome = await expectOutcome(
873
+ async () => {
874
+ const res = await page.evaluate(`(() => {
875
+ const btns = Array.from(document.querySelectorAll('div.submit, button.submit'));
876
+ const done = btns.find((el) => el.textContent && el.textContent.trim() === '完成');
877
+ if (done) {
878
+ done.click();
879
+ return 'done clicked';
880
+ }
881
+ return null;
882
+ })()`);
883
+ return res ? 'done clicked' : null;
884
+ },
885
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili cover modal done button' },
886
+ );
887
+ if (!doneBtnOutcome.ok) {
888
+ return stageFail(
889
+ BILI_TARGET,
890
+ `bilibili cover modal done button not found (bilibili.js:343-346) — ${doneBtnOutcome.error}`,
891
+ 'cover-attach',
892
+ metadata,
893
+ );
894
+ }
895
+
896
+ const previewChanged = await expectOutcome(
897
+ async () => {
898
+ const currentPreview = String(
899
+ await page.evaluate(`(() => {
900
+ const main = document.querySelector('div.cover-main');
901
+ const slot = main ? main.querySelector('.cover-slot') : null;
902
+ const image = slot ? slot.querySelector('.cover-img') : null;
903
+ const imgEl = image ? image.querySelector('img') : null;
904
+ return (image && image.style ? image.style.backgroundImage : '') || (imgEl ? imgEl.currentSrc : '') || '';
905
+ })()`),
906
+ );
907
+ return currentPreview.length > 0 && currentPreview !== beforePreview ? 'preview changed' : null;
908
+ },
909
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili cover preview changed' },
910
+ );
911
+ if (!previewChanged.ok) {
912
+ return stageFail(
913
+ BILI_TARGET,
914
+ `bilibili cover preview did not change after upload (bilibili.js:348-356) — ${previewChanged.error}`,
915
+ 'cover-attach',
916
+ metadata,
917
+ );
918
+ }
919
+ } catch (error) {
920
+ return stageFail(
921
+ BILI_TARGET,
922
+ `bilibili cover attach failed — ${errorMessage(error)}`,
923
+ 'cover-attach',
924
+ metadata,
925
+ );
926
+ }
927
+ }
928
+
929
+ // 9. Draft Save vs Public Submission
930
+ if (publishPublic) {
931
+ try {
932
+ const submitResolved = await resolveLocator(
933
+ page,
934
+ BILI_VIDEO_SELECTORS.submitAdd,
935
+ 'bilibili video submit button',
936
+ this.stepTimeoutMs,
937
+ );
938
+ await submitResolved.locator.click();
939
+
940
+ const receiptOutcome = await expectOutcome(
941
+ async () => {
942
+ const currentUrl = page.url();
943
+ for (const re of BILI_RECEIPT_URL_RES) {
944
+ if (re.test(currentUrl)) return currentUrl;
945
+ }
946
+ const pageState = await page.evaluate(`(() => {
947
+ const successEl = document.querySelector('.submit-success, .upload-success, .success-wrp');
948
+ if (successEl && successEl.textContent) return successEl.textContent.trim();
949
+ return null;
950
+ })()`);
951
+ return typeof pageState === 'string' ? pageState : null;
952
+ },
953
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili video submit receipt' },
954
+ );
955
+
956
+ if (!receiptOutcome.ok) {
957
+ return stageFail(
958
+ BILI_TARGET,
959
+ `bilibili video submission unconfirmed — no receipt URL or success state observed — ${receiptOutcome.error}`,
960
+ 'publish-receipt',
961
+ metadata,
962
+ );
963
+ }
964
+
965
+ const url =
966
+ typeof receiptOutcome.evidence === 'string' && receiptOutcome.evidence.startsWith('http')
967
+ ? receiptOutcome.evidence
968
+ : undefined;
969
+ const bvMatch = url ? /BV[0-9a-zA-Z]+/.exec(url) : null;
970
+ const bvid = bvMatch ? bvMatch[0] : undefined;
971
+
972
+ return ResultSchema.parse({
973
+ ok: true,
974
+ target: BILI_TARGET,
975
+ ...(url ? { url } : {}),
976
+ metadata: {
977
+ ...metadata,
978
+ publishStatus: 'public',
979
+ status: 'pending-review',
980
+ stage: 'publish-receipt',
981
+ ...(bvid ? { bvid } : {}),
982
+ },
983
+ });
984
+ } catch (error) {
985
+ return stageFail(
986
+ BILI_TARGET,
987
+ `bilibili video submit failed — ${errorMessage(error)}`,
988
+ 'publish-receipt',
989
+ metadata,
990
+ );
991
+ }
992
+ }
993
+
994
+ // Draft mode requires an observed save control AND a fresh id-bearing draft receipt: a toast
995
+ // or an unchanged URL is not a persisted save (assignment/fill is never success).
996
+ const draftBaselineUrl = page.url();
997
+ try {
998
+ const draftControl = (await page.evaluate(`(() => {
999
+ const candidates = Array.from(document.querySelectorAll('span, button'));
1000
+ const saveBtn = candidates.find((el) => {
1001
+ const text = el.textContent ? el.textContent.trim() : '';
1002
+ return text === '存草稿' || text === '保存草稿' || (el.className && String(el.className).includes('submit-draft'));
1003
+ });
1004
+ if (saveBtn) {
1005
+ saveBtn.click();
1006
+ return { clicked: true };
1007
+ }
1008
+ return { clicked: false };
1009
+ })()`)) as { clicked: boolean };
1010
+
1011
+ if (!draftControl.clicked) {
1012
+ return stageFail(
1013
+ BILI_TARGET,
1014
+ 'bilibili shortVideo draft save control not found (platform UI missing draft button)',
1015
+ 'draft-save',
1016
+ metadata,
1017
+ );
1018
+ }
1019
+
1020
+ const draftReceipt = await expectOutcome(
1021
+ async () => {
1022
+ const currentUrl = page.url();
1023
+ if (currentUrl !== draftBaselineUrl && BILI_RECEIPT_URL_RES.some((re) => re.test(currentUrl))) {
1024
+ return currentUrl;
1025
+ }
1026
+ return null;
1027
+ },
1028
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili video draft receipt' },
1029
+ );
1030
+
1031
+ if (!draftReceipt.ok) {
1032
+ return stageFail(
1033
+ BILI_TARGET,
1034
+ 'bilibili shortVideo draft unconfirmed — the save control was clicked but no fresh id-bearing draft receipt appeared within 15s; refusing to claim a saved draft (a toast or an unchanged URL is not a persisted draft)',
1035
+ 'draft-receipt',
1036
+ metadata,
1037
+ );
1038
+ }
1039
+
1040
+ return ResultSchema.parse({
1041
+ ok: true,
1042
+ target: BILI_TARGET,
1043
+ url: draftReceipt.evidence as string,
1044
+ metadata: {
1045
+ ...metadata,
1046
+ publishStatus: 'draft',
1047
+ stage: 'draft-receipt',
1048
+ receipt: draftReceipt.evidence,
1049
+ },
1050
+ });
1051
+ } catch (error) {
1052
+ return stageFail(
1053
+ BILI_TARGET,
1054
+ `bilibili draft save failed — ${errorMessage(error)}`,
1055
+ 'draft-save',
1056
+ metadata,
1057
+ );
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Executes the dynamic post flow at t.bilibili.com.
1063
+ */
1064
+ private async runDynamicFlow(
1065
+ page: Page,
1066
+ body: string,
1067
+ images: readonly string[],
1068
+ profileDir: string,
1069
+ ): Promise<Result> {
1070
+ const metadata: Record<string, unknown> = {
1071
+ operation: 'dynamic',
1072
+ driver: this.driver,
1073
+ publishStatus: 'public',
1074
+ profileDir,
1075
+ };
1076
+
1077
+ // 1. Resolve editor inside composer scope (bilibili.js:171, 209, 270-271)
1078
+ let editorResolved: { locator: Locator; selector: string };
1079
+ try {
1080
+ editorResolved = await resolveLocator(
1081
+ page,
1082
+ BILI_DYNAMIC_SELECTORS.editor,
1083
+ 'bilibili dynamic editor',
1084
+ this.stepTimeoutMs,
1085
+ );
1086
+ } catch (error) {
1087
+ return stageFail(
1088
+ BILI_TARGET,
1089
+ `bilibili dynamic editor attach failed — ${errorMessage(error)}`,
1090
+ 'composer-attach',
1091
+ metadata,
1092
+ );
1093
+ }
1094
+
1095
+ // 2. Remove existing images (bilibili.js:174-184)
1096
+ try {
1097
+ await page.evaluate(`(() => {
1098
+ let n = 20;
1099
+ while (n-- > 0) {
1100
+ const removeBtn = document.querySelector('div.bili-pics-uploader__item__remove');
1101
+ if (!removeBtn) break;
1102
+ removeBtn.click();
1103
+ }
1104
+ })()`);
1105
+ } catch {
1106
+ // non-blocking
1107
+ }
1108
+
1109
+ // 3. Fill text content (bilibili.js:208-223)
1110
+ if (body.length > 0) {
1111
+ try {
1112
+ const html = sanitizeBilibiliHtml(markdownToHtml(body));
1113
+ await pasteRichText(page, editorResolved.selector, html, body);
1114
+ const readback = await expectOutcome(
1115
+ async () => {
1116
+ const text = await editorResolved.locator.innerText().catch(() => '');
1117
+ return normalizeText(text).includes(normalizeText(body)) ? 'text read back' : null;
1118
+ },
1119
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili dynamic text readback' },
1120
+ );
1121
+ if (!readback.ok) {
1122
+ return stageFail(
1123
+ BILI_TARGET,
1124
+ `bilibili dynamic text readback failed — editor did not retain content — ${readback.error}`,
1125
+ 'text-fill',
1126
+ metadata,
1127
+ );
1128
+ }
1129
+ } catch (error) {
1130
+ return stageFail(
1131
+ BILI_TARGET,
1132
+ `bilibili dynamic text fill failed — ${errorMessage(error)}`,
1133
+ 'text-fill',
1134
+ metadata,
1135
+ );
1136
+ }
1137
+ }
1138
+
1139
+ // 4. Upload images if requested (bilibili.js:231, 238, 257; the vendor's MAIN-world
1140
+ // message bridge, bilibili-main.js:54-81, is deliberately not reproduced)
1141
+ if (images.length > 0) {
1142
+ try {
1143
+ // Ensure image upload entry is displayed (bilibili.js:231)
1144
+ await page.evaluate(`(() => {
1145
+ const imageUploadEntry = document.querySelector('div.bili-dyn-publishing__image-upload');
1146
+ if (imageUploadEntry) imageUploadEntry.style.display = 'block';
1147
+ })()`);
1148
+
1149
+ const fileInputResolved = await resolveLocator(
1150
+ page,
1151
+ BILI_DYNAMIC_SELECTORS.imageInput,
1152
+ 'bilibili dynamic image file input',
1153
+ this.stepTimeoutMs,
1154
+ );
1155
+ await fileInputResolved.locator.setInputFiles([...images]);
1156
+
1157
+ const previewReady = await expectOutcome(
1158
+ async () => {
1159
+ const count = Number(
1160
+ await page.evaluate(`(() => {
1161
+ return document.querySelectorAll(
1162
+ '.bili-pics-uploader__item, .bili-dyn-publishing__image-item',
1163
+ ).length;
1164
+ })()`),
1165
+ );
1166
+ return count >= images.length ? `${count} previews ready` : null;
1167
+ },
1168
+ { timeoutMs: 30_000, label: 'bilibili dynamic image previews' },
1169
+ );
1170
+ if (!previewReady.ok) {
1171
+ return stageFail(
1172
+ BILI_TARGET,
1173
+ `bilibili dynamic image previews did not appear within 30s (bilibili.js:238, 257) — ${previewReady.error}`,
1174
+ 'images-upload',
1175
+ metadata,
1176
+ );
1177
+ }
1178
+ } catch (error) {
1179
+ return stageFail(
1180
+ BILI_TARGET,
1181
+ `bilibili dynamic image upload failed — ${errorMessage(error)}`,
1182
+ 'images-upload',
1183
+ metadata,
1184
+ );
1185
+ }
1186
+ }
1187
+
1188
+ // 5. Submit dynamic post (bilibili.js:268-285)
1189
+ const initialUrl = page.url();
1190
+ try {
1191
+ const launchResolved = await resolveLocator(
1192
+ page,
1193
+ BILI_DYNAMIC_SELECTORS.launchButton,
1194
+ 'bilibili dynamic launch button',
1195
+ this.stepTimeoutMs,
1196
+ );
1197
+
1198
+ const isDisabled = Boolean(
1199
+ await page.evaluate(`(() => {
1200
+ const btn = document.querySelector(${JSON.stringify(launchResolved.selector)});
1201
+ return Boolean(btn && (btn.disabled || btn.getAttribute('aria-disabled') === 'true'));
1202
+ })()`),
1203
+ );
1204
+
1205
+ if (isDisabled) {
1206
+ return stageFail(
1207
+ BILI_TARGET,
1208
+ 'bilibili dynamic launch button is disabled (bilibili.js:281)',
1209
+ 'publish-click',
1210
+ metadata,
1211
+ );
1212
+ }
1213
+
1214
+ // Baseline BEFORE the click: a card or toast already on screen must never confirm a submit
1215
+ // (the vendor reads no receipt at all — bilibili.js:552 returns unknown).
1216
+ const dynamicBaseline = JSON.parse(
1217
+ String(
1218
+ await page.evaluate(`(() => {
1219
+ const patterns = ${JSON.stringify(BILI_RECEIPT_URL_RES.map((re) => ({ source: re.source, flags: re.flags })))};
1220
+ const matches = (href) => patterns.some((p) => new RegExp(p.source, p.flags).test(href));
1221
+ const anchors = Array.from(document.querySelectorAll('a[href]'))
1222
+ .map((a) => a.href)
1223
+ .filter((href) => matches(href));
1224
+ const toast = document.querySelector('.bili-toast, .toast, .bili-dyn-publishing__success');
1225
+ const toastText = toast && toast.textContent ? toast.textContent.trim() : '';
1226
+ return JSON.stringify({
1227
+ anchors,
1228
+ toastVisible: Boolean(toastText) && (toastText.includes('发布成功') || toastText.includes('成功')),
1229
+ });
1230
+ })()`),
1231
+ ),
1232
+ ) as { anchors: string[]; toastVisible: boolean };
1233
+
1234
+ await launchResolved.locator.click();
1235
+
1236
+ const receiptOutcome = await expectOutcome(
1237
+ async () => {
1238
+ const currentUrl = page.url();
1239
+ if (currentUrl !== initialUrl && BILI_RECEIPT_URL_RES.some((re) => re.test(currentUrl))) {
1240
+ return currentUrl;
1241
+ }
1242
+ const fresh = String(
1243
+ await page.evaluate(`(() => {
1244
+ const patterns = ${JSON.stringify(BILI_RECEIPT_URL_RES.map((re) => ({ source: re.source, flags: re.flags })))};
1245
+ const baseline = ${JSON.stringify(dynamicBaseline)};
1246
+ const matches = (href) => patterns.some((p) => new RegExp(p.source, p.flags).test(href));
1247
+ const toast = document.querySelector('.bili-toast, .toast, .bili-dyn-publishing__success');
1248
+ const toastText = toast && toast.textContent ? toast.textContent.trim() : '';
1249
+ const toastVisible = Boolean(toastText) && (toastText.includes('发布成功') || toastText.includes('成功'));
1250
+ if (toastVisible && !baseline.toastVisible) return toastText;
1251
+ const freshLink = Array.from(document.querySelectorAll('a[href]'))
1252
+ .map((a) => a.href)
1253
+ .find((href) => matches(href) && !baseline.anchors.includes(href));
1254
+ return freshLink || '';
1255
+ })()`),
1256
+ );
1257
+ return fresh.length > 0 ? fresh : null;
1258
+ },
1259
+ { timeoutMs: this.stepTimeoutMs, label: 'bilibili dynamic publish receipt' },
1260
+ );
1261
+
1262
+ if (!receiptOutcome.ok) {
1263
+ return stageFail(
1264
+ BILI_TARGET,
1265
+ `bilibili dynamic publication unconfirmed — no receipt signal observed — ${receiptOutcome.error}`,
1266
+ 'publish-receipt',
1267
+ metadata,
1268
+ );
1269
+ }
1270
+
1271
+ const url =
1272
+ typeof receiptOutcome.evidence === 'string' && receiptOutcome.evidence.startsWith('http')
1273
+ ? receiptOutcome.evidence
1274
+ : undefined;
1275
+
1276
+ return ResultSchema.parse({
1277
+ ok: true,
1278
+ target: BILI_TARGET,
1279
+ ...(url ? { url } : {}),
1280
+ metadata: {
1281
+ ...metadata,
1282
+ stage: 'publish-receipt',
1283
+ publishStatus: 'public',
1284
+ ...(receiptOutcome.evidence ? { receipt: receiptOutcome.evidence } : {}),
1285
+ },
1286
+ });
1287
+ } catch (error) {
1288
+ return stageFail(
1289
+ BILI_TARGET,
1290
+ `bilibili dynamic publish failed — ${errorMessage(error)}`,
1291
+ 'publish-receipt',
1292
+ metadata,
1293
+ );
1294
+ }
1295
+ }
1296
+ }