@lightcone-ai/daemon 0.15.12 → 0.15.13

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Bilibili (B站) publisher adapter.
3
+ * Uses:
4
+ * - 图文专栏: https://www.bilibili.com/read/editor/article
5
+ * - 短视频投稿: https://member.bilibili.com/platform/upload/video/frame
6
+ */
7
+ import { formatTextWithTags } from '../text.js';
8
+
9
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
10
+
11
+ const ARTICLE_EDITOR_URL = 'https://www.bilibili.com/read/editor/article';
12
+ const VIDEO_UPLOAD_URL = 'https://member.bilibili.com/platform/upload/video/frame';
13
+
14
+ const REQUIREMENTS = {
15
+ image_text: {
16
+ max_text_length: 100000,
17
+ max_images: 30,
18
+ image_formats: ['jpg', 'jpeg', 'png', 'gif'],
19
+ required_fields: ['text', 'images'],
20
+ notes: 'B站专栏图文最多 30 张图;正文最多 100000 字',
21
+ },
22
+ short_video: {
23
+ max_text_length: 100000,
24
+ video_max_duration: 600,
25
+ video_formats: ['mp4', 'flv'],
26
+ required_fields: ['text', 'video'],
27
+ notes: 'B站短视频支持 mp4/flv;视频最长 600 秒',
28
+ },
29
+ };
30
+
31
+ const ARTICLE_IMAGE_FILE_SELECTOR = [
32
+ 'input[type="file"][accept*="image"]',
33
+ 'input[type="file"][accept*=".jpg"]',
34
+ 'input[type="file"][accept*=".jpeg"]',
35
+ 'input[type="file"][accept*=".png"]',
36
+ 'input[type="file"][accept*=".gif"]',
37
+ 'input[type="file"]',
38
+ ].join(', ');
39
+
40
+ const VIDEO_FILE_SELECTOR = [
41
+ 'input[type="file"][accept*="video"]',
42
+ 'input[type="file"][accept*=".mp4"]',
43
+ 'input[type="file"][accept*=".flv"]',
44
+ 'input[type="file"]',
45
+ ].join(', ');
46
+
47
+ const ARTICLE_TITLE_SELECTOR = [
48
+ 'input[placeholder*="标题"]',
49
+ 'textarea[placeholder*="标题"]',
50
+ 'input[aria-label*="标题"]',
51
+ '[contenteditable="true"][data-placeholder*="标题"]',
52
+ '[contenteditable="true"][aria-label*="标题"]',
53
+ ].join(', ');
54
+
55
+ const ARTICLE_CONTENT_SELECTOR = [
56
+ 'textarea[placeholder*="正文"]',
57
+ 'textarea[placeholder*="内容"]',
58
+ 'textarea[placeholder*="描述"]',
59
+ '[contenteditable="true"][data-placeholder*="正文"]',
60
+ '[contenteditable="true"][data-placeholder*="内容"]',
61
+ '.ql-editor[contenteditable="true"]',
62
+ '.ProseMirror[contenteditable="true"]',
63
+ '[contenteditable="true"]',
64
+ ].join(', ');
65
+
66
+ const VIDEO_TITLE_SELECTOR = [
67
+ 'input[placeholder*="标题"]',
68
+ 'textarea[placeholder*="标题"]',
69
+ '[contenteditable="true"][data-placeholder*="标题"]',
70
+ '[contenteditable="true"][aria-label*="标题"]',
71
+ ].join(', ');
72
+
73
+ const VIDEO_DESC_SELECTOR = [
74
+ 'textarea[placeholder*="简介"]',
75
+ 'textarea[placeholder*="描述"]',
76
+ 'textarea[placeholder*="文案"]',
77
+ '[contenteditable="true"][data-placeholder*="简介"]',
78
+ '[contenteditable="true"][data-placeholder*="描述"]',
79
+ '.ql-editor[contenteditable="true"]',
80
+ '.ProseMirror[contenteditable="true"]',
81
+ '[contenteditable="true"]',
82
+ ].join(', ');
83
+
84
+ const ARTICLE_PUBLISH_BUTTON_TEXTS = ['发布', '发布专栏', '立即发布', '确认发布'];
85
+ const VIDEO_PUBLISH_BUTTON_TEXTS = ['发布', '立即投稿', '投稿', '发布视频', '确认发布'];
86
+
87
+ export class BilibiliAdapter {
88
+ constructor(cdp) {
89
+ this._cdp = cdp;
90
+ }
91
+
92
+ getCapabilities() {
93
+ return {
94
+ max_image: 30,
95
+ image_formats: ['jpg', 'jpeg', 'png', 'gif'],
96
+ video_formats: ['mp4', 'flv'],
97
+ video_max_duration: 600,
98
+ };
99
+ }
100
+
101
+ getRequirements(contentType) {
102
+ return REQUIREMENTS[contentType] ?? null;
103
+ }
104
+
105
+ async checkLoginStatus() {
106
+ const profileDir = process.env.BILIBILI_PROFILE_DIR ?? '(not set)';
107
+ const result = await this._cdp.send('Network.getAllCookies', {});
108
+ const cookies = result.cookies ?? [];
109
+ const loggedIn = cookies.some(c =>
110
+ (c.name === 'bili_jct' || c.name === 'SESSDATA') && c.value?.length > 0
111
+ );
112
+ const url = await this._getUrl();
113
+ console.error(`[BilibiliAdapter] checkLoginStatus: loggedIn=${loggedIn} url=${url}`);
114
+ return { loggedIn, url, profileDir, userId: null, nickname: null };
115
+ }
116
+
117
+ async publishImageText({ title, text, tags = [], images = [] }) {
118
+ await this._cdp.send('Page.navigate', { url: ARTICLE_EDITOR_URL });
119
+ await this._waitForSelector('body', 15_000);
120
+ await sleep(4_000);
121
+
122
+ const { loggedIn } = await this.checkLoginStatus();
123
+ if (!loggedIn) throw new Error('LOGIN_EXPIRED: B站登录已过期,请重新扫码连接');
124
+
125
+ if (images.length > 0) {
126
+ await this._waitForSelector(ARTICLE_IMAGE_FILE_SELECTOR, 15_000);
127
+ await this._uploadFiles(images, 'image');
128
+ await this._waitForUploadSettled(180_000);
129
+ }
130
+
131
+ if (title) {
132
+ await this._fillField(ARTICLE_TITLE_SELECTOR, title, 'title');
133
+ }
134
+
135
+ const fullText = formatTextWithTags(text, tags);
136
+ await this._fillField(ARTICLE_CONTENT_SELECTOR, fullText, 'content');
137
+
138
+ const published = await this._clickByTextCandidates(ARTICLE_PUBLISH_BUTTON_TEXTS);
139
+ if (!published) throw new Error('PUBLISH_FAILED: 未找到 B站专栏发布按钮');
140
+ await sleep(5_000);
141
+
142
+ const currentUrl = await this._getUrl();
143
+ return { success: true, post_url: currentUrl };
144
+ }
145
+
146
+ async publishShortVideo({ title, text, tags = [], video, cover }) {
147
+ await this._cdp.send('Page.navigate', { url: VIDEO_UPLOAD_URL });
148
+ await this._waitForSelector('body', 15_000);
149
+ await sleep(4_000);
150
+
151
+ const { loggedIn } = await this.checkLoginStatus();
152
+ if (!loggedIn) throw new Error('LOGIN_EXPIRED: B站登录已过期,请重新扫码连接');
153
+
154
+ await this._waitForSelector(VIDEO_FILE_SELECTOR, 20_000);
155
+ await this._uploadFiles([video], 'video');
156
+ await this._waitForUploadSettled(240_000);
157
+
158
+ if (cover) {
159
+ console.error('[BilibiliAdapter] cover was provided, but automatic cover upload is not implemented; continuing with platform default cover.');
160
+ }
161
+
162
+ if (title) {
163
+ await this._fillField(VIDEO_TITLE_SELECTOR, title, 'title');
164
+ }
165
+
166
+ const fullText = formatTextWithTags(text, tags);
167
+ await this._fillField(VIDEO_DESC_SELECTOR, fullText, 'content');
168
+
169
+ const published = await this._clickByTextCandidates(VIDEO_PUBLISH_BUTTON_TEXTS);
170
+ if (!published) throw new Error('PUBLISH_FAILED: 未找到 B站视频发布按钮');
171
+ await sleep(6_000);
172
+
173
+ const currentUrl = await this._getUrl();
174
+ return { success: true, post_url: currentUrl };
175
+ }
176
+
177
+ async _waitForSelector(selector, timeoutMs = 8_000) {
178
+ const deadline = Date.now() + timeoutMs;
179
+ while (Date.now() < deadline) {
180
+ const result = await this._cdp.send('Runtime.evaluate', {
181
+ expression: `!!document.querySelector(${JSON.stringify(selector)})`,
182
+ returnByValue: true,
183
+ });
184
+ if (result.result?.value) return;
185
+ await sleep(400);
186
+ }
187
+ throw new Error(`Timeout waiting for selector: ${selector}`);
188
+ }
189
+
190
+ async _uploadFiles(filePaths, kind = 'image') {
191
+ const selector = kind === 'video' ? VIDEO_FILE_SELECTOR : ARTICLE_IMAGE_FILE_SELECTOR;
192
+ const result = await this._cdp.send('Runtime.evaluate', {
193
+ expression: `
194
+ (function() {
195
+ const selector = ${JSON.stringify(selector)};
196
+ const kind = ${JSON.stringify(kind)};
197
+ const inputs = [...document.querySelectorAll(selector)];
198
+ if (!inputs.length) return null;
199
+
200
+ const score = (el) => {
201
+ const accept = (el.getAttribute('accept') || '').toLowerCase();
202
+ let s = 0;
203
+ if (kind === 'video') {
204
+ if (/video|mp4|flv/.test(accept)) s += 120;
205
+ if (/image|jpg|jpeg|png|gif/.test(accept)) s -= 80;
206
+ } else {
207
+ if (/image|jpg|jpeg|png|gif/.test(accept)) s += 120;
208
+ if (/video|mp4|flv/.test(accept)) s -= 80;
209
+ }
210
+ return s;
211
+ };
212
+
213
+ return inputs.sort((a, b) => score(b) - score(a))[0] ?? inputs[0] ?? null;
214
+ })()
215
+ `,
216
+ returnByValue: false,
217
+ });
218
+
219
+ if (!result.result?.objectId) throw new Error('PUBLISH_FAILED: 页面未找到文件上传输入框');
220
+ await this._cdp.send('DOM.setFileInputFiles', {
221
+ objectId: result.result.objectId,
222
+ files: filePaths,
223
+ });
224
+ await sleep(1_000);
225
+ }
226
+
227
+ async _waitForUploadSettled(timeoutMs = 120_000) {
228
+ const deadline = Date.now() + timeoutMs;
229
+ let stableRounds = 0;
230
+
231
+ while (Date.now() < deadline) {
232
+ const result = await this._cdp.send('Runtime.evaluate', {
233
+ expression: `
234
+ (function() {
235
+ const text = document.body?.innerText || '';
236
+ return text.slice(0, 12000);
237
+ })()
238
+ `,
239
+ returnByValue: true,
240
+ });
241
+ const text = String(result.result?.value ?? '');
242
+ const normalized = text.replace(/\s+/g, ' ');
243
+
244
+ if (/上传失败|上传出错|格式不支持|文件过大|转码失败|上传异常/.test(normalized)) {
245
+ throw new Error(`UPLOAD_FAILED: ${normalized.slice(0, 180)}`);
246
+ }
247
+
248
+ const processing = /上传中|正在上传|处理中|转码中|校验中|上传进度/.test(normalized);
249
+ const completionHint = /上传完成|上传成功|重新上传|更换|替换|已上传/.test(normalized);
250
+
251
+ if (!processing && completionHint) {
252
+ stableRounds += 1;
253
+ if (stableRounds >= 2) return;
254
+ } else if (!processing) {
255
+ stableRounds += 1;
256
+ if (stableRounds >= 4) return;
257
+ } else {
258
+ stableRounds = 0;
259
+ }
260
+ await sleep(1_000);
261
+ }
262
+ throw new Error('UPLOAD_TIMEOUT: 等待 B站上传完成超时');
263
+ }
264
+
265
+ async _fillField(selector, value, kind = 'content') {
266
+ const result = await this._cdp.send('Runtime.evaluate', {
267
+ expression: `
268
+ (function() {
269
+ const selector = ${JSON.stringify(selector)};
270
+ const kind = ${JSON.stringify(kind)};
271
+ const value = ${JSON.stringify(value)};
272
+ const visible = (el) => {
273
+ if (!el) return false;
274
+ const r = el.getBoundingClientRect();
275
+ const s = getComputedStyle(el);
276
+ return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
277
+ };
278
+ const candidates = [...document.querySelectorAll(selector)].filter(visible);
279
+ if (!candidates.length) return false;
280
+
281
+ const score = (el) => {
282
+ const text = [
283
+ el.getAttribute('placeholder'),
284
+ el.getAttribute('data-placeholder'),
285
+ el.getAttribute('aria-label'),
286
+ el.getAttribute('title'),
287
+ el.className?.toString?.(),
288
+ ].filter(Boolean).join(' ');
289
+ let s = 0;
290
+ if (kind === 'title') {
291
+ if (/标题|title/i.test(text)) s += 80;
292
+ if (/简介|描述|正文|文案|content/i.test(text)) s -= 40;
293
+ } else {
294
+ if (/简介|描述|正文|文案|内容|content/i.test(text)) s += 80;
295
+ if (/标题|title/i.test(text)) s -= 30;
296
+ }
297
+ if (el.tagName === 'TEXTAREA') s += 10;
298
+ return s;
299
+ };
300
+
301
+ const el = candidates.sort((a, b) => score(b) - score(a))[0];
302
+ if (!el) return false;
303
+
304
+ el.focus();
305
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
306
+ const proto = el.tagName === 'INPUT' ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype;
307
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
308
+ if (setter) setter.call(el, value);
309
+ else el.value = value;
310
+ el.dispatchEvent(new Event('input', { bubbles: true }));
311
+ el.dispatchEvent(new Event('change', { bubbles: true }));
312
+ } else {
313
+ el.innerText = value;
314
+ el.dispatchEvent(new InputEvent('input', { bubbles: true }));
315
+ el.dispatchEvent(new Event('change', { bubbles: true }));
316
+ }
317
+ return true;
318
+ })()
319
+ `,
320
+ returnByValue: true,
321
+ });
322
+
323
+ if (!result.result?.value) {
324
+ throw new Error(`PUBLISH_FAILED: 未找到${kind === 'title' ? '标题' : '正文'}输入框`);
325
+ }
326
+ await sleep(400);
327
+ }
328
+
329
+ async _clickByTextCandidates(candidates = []) {
330
+ for (const text of candidates) {
331
+ const clicked = await this._clickByText(text);
332
+ if (clicked) return true;
333
+ }
334
+ return false;
335
+ }
336
+
337
+ async _clickByText(text) {
338
+ const result = await this._cdp.send('Runtime.evaluate', {
339
+ expression: `
340
+ (function() {
341
+ const visible = (el) => {
342
+ const r = el.getBoundingClientRect();
343
+ const s = getComputedStyle(el);
344
+ return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
345
+ };
346
+ const nodes = [...document.querySelectorAll('button, [role="button"], a, div, span')]
347
+ .filter(visible)
348
+ .filter(el => {
349
+ const raw = (el.innerText || el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim();
350
+ return raw === ${JSON.stringify(text)} || raw.includes(${JSON.stringify(text)});
351
+ });
352
+ const target = nodes[0];
353
+ if (!target) return null;
354
+ const r = target.getBoundingClientRect();
355
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
356
+ })()
357
+ `,
358
+ returnByValue: true,
359
+ });
360
+
361
+ const point = result.result?.value;
362
+ if (!point) return false;
363
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
364
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 });
365
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 });
366
+ await sleep(800);
367
+ return true;
368
+ }
369
+
370
+ async _getUrl() {
371
+ const result = await this._cdp.send('Runtime.evaluate', {
372
+ expression: 'window.location.href',
373
+ returnByValue: true,
374
+ });
375
+ return result.result?.value ?? '';
376
+ }
377
+ }
@@ -48,7 +48,12 @@ export class KuaishouAdapter {
48
48
  const cdp = this._cdp;
49
49
 
50
50
  await cdp.send('Page.navigate', { url: 'https://cp.kuaishou.com/article/publish/image' });
51
- await this._waitForSelector('input[type="file"], [class*="upload"]', 12000);
51
+ await sleep(3000);
52
+ const redirectUrl = await this._getUrl();
53
+ if (!redirectUrl.includes('cp.kuaishou.com/article/publish')) {
54
+ throw new Error(`LOGIN_EXPIRED: 快手登录已过期,请重新扫码连接 (redirected to: ${redirectUrl})`);
55
+ }
56
+ await this._waitForSelector('input[type="file"], [class*="upload"]', 20000);
52
57
 
53
58
  const { loggedIn } = await this.checkLoginStatus();
54
59
  if (!loggedIn) throw new Error('LOGIN_EXPIRED: 快手登录已过期,请重新扫码连接');
@@ -77,7 +82,13 @@ export class KuaishouAdapter {
77
82
  const cdp = this._cdp;
78
83
 
79
84
  await cdp.send('Page.navigate', { url: 'https://cp.kuaishou.com/article/publish/video' });
80
- await this._waitForSelector('input[type="file"], [class*="upload"]', 12000);
85
+ await sleep(3000);
86
+ // If session expired, the page redirects to the landing/login page — detect early
87
+ const redirectUrl = await this._getUrl();
88
+ if (!redirectUrl.includes('cp.kuaishou.com/article/publish')) {
89
+ throw new Error(`LOGIN_EXPIRED: 快手登录已过期,请重新扫码连接 (redirected to: ${redirectUrl})`);
90
+ }
91
+ await this._waitForSelector('input[type="file"], [class*="upload"]', 20000);
81
92
 
82
93
  const { loggedIn } = await this.checkLoginStatus();
83
94
  if (!loggedIn) throw new Error('LOGIN_EXPIRED: 快手登录已过期,请重新扫码连接');
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Publisher MCP Server
4
- * Exposes deterministic publishing tools for XHS, Douyin, Kuaishou.
4
+ * Exposes deterministic publishing tools for XHS, Douyin, Kuaishou, Bilibili.
5
5
  * Runs on the daemon machine; credentials (profile dirs) are injected via env.
6
6
  *
7
7
  * MCP Tools:
@@ -18,6 +18,7 @@ import { getSession, closeSession } from './chrome-pool.js';
18
18
  import { XhsAdapter } from './adapters/xhs.js';
19
19
  import { DouyinAdapter } from './adapters/douyin.js';
20
20
  import { KuaishouAdapter } from './adapters/kuaishou.js';
21
+ import { BilibiliAdapter } from './adapters/bilibili.js';
21
22
  import { callOfficialTool } from './official-tool-client.js';
22
23
  import { runPublishPrecheck } from './precheck.js';
23
24
  import { withProfileLock } from '../../src/profile-lock.js';
@@ -35,18 +36,21 @@ const PLATFORM_ENV_KEYS = {
35
36
  xhs: 'XHS_PROFILE_DIR',
36
37
  douyin: 'DOUYIN_PROFILE_DIR',
37
38
  kuaishou: 'KUAISHOU_PROFILE_DIR',
39
+ bilibili: 'BILIBILI_PROFILE_DIR',
38
40
  };
39
41
 
40
42
  const PLATFORM_LABELS = {
41
43
  xhs: '小红书',
42
44
  douyin: '抖音',
43
45
  kuaishou: '快手',
46
+ bilibili: 'B站',
44
47
  };
45
48
 
46
49
  const ADAPTER_REGISTRY = Object.freeze({
47
50
  xhs: XhsAdapter,
48
51
  douyin: DouyinAdapter,
49
52
  kuaishou: KuaishouAdapter,
53
+ bilibili: BilibiliAdapter,
50
54
  });
51
55
 
52
56
  function getProfileDir(platform) {
@@ -136,6 +140,7 @@ const DEFAULT_MEDIA_LIMITS = {
136
140
  xhs: { maxImages: 9, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov'] },
137
141
  douyin: { maxImages: 35, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov', '.webm'] },
138
142
  kuaishou: { maxImages: 12, imageExts: ['.jpg', '.jpeg', '.png'], videoExts: ['.mp4', '.mov'] },
143
+ bilibili: { maxImages: 30, imageExts: ['.jpg', '.jpeg', '.png', '.gif'], videoExts: ['.mp4', '.flv'] },
139
144
  };
140
145
 
141
146
  function isInsideDir(filePath, dir) {
@@ -299,11 +304,11 @@ const server = new McpServer({
299
304
 
300
305
  server.tool(
301
306
  'publish_content',
302
- `发布内容到指定平台。支持平台: xhs(小红书)、douyin(抖音)、kuaishou(快手)。
307
+ `发布内容到指定平台。支持平台: xhs(小红书)、douyin(抖音)、kuaishou(快手)、bilibili(B站)。
303
308
  内容类型: image_text(图文)、short_video(短视频)。
304
309
  images/video 字段填写本地绝对路径(在 agent workspace 目录下)。`,
305
310
  {
306
- platform: z.enum(['xhs', 'douyin', 'kuaishou']).describe('目标平台'),
311
+ platform: z.enum(['xhs', 'douyin', 'kuaishou', 'bilibili']).describe('目标平台'),
307
312
  content_type: z.enum(['image_text', 'short_video']).describe('内容类型'),
308
313
  title: z.string().optional().describe('标题(部分平台必须,部分可选)'),
309
314
  text: z.string().describe('正文内容'),
@@ -427,7 +432,7 @@ server.tool(
427
432
  'get_platform_requirements',
428
433
  '获取指定平台和内容类型的规格要求(字数上限、图片格式、视频规格等)。发布前先调用此工具确认内容符合规范。',
429
434
  {
430
- platform: z.enum(['xhs', 'douyin', 'kuaishou']).describe('目标平台'),
435
+ platform: z.enum(['xhs', 'douyin', 'kuaishou', 'bilibili']).describe('目标平台'),
431
436
  content_type: z.enum(['image_text', 'short_video']).describe('内容类型'),
432
437
  },
433
438
  async ({ platform, content_type }) => {
@@ -448,7 +453,7 @@ server.tool(
448
453
  'check_login_status',
449
454
  '检查指定平台的浏览器 Profile 是否仍处于登录状态。如果未登录,提示用户重新扫码连接。',
450
455
  {
451
- platform: z.enum(['xhs', 'douyin', 'kuaishou']).describe('目标平台'),
456
+ platform: z.enum(['xhs', 'douyin', 'kuaishou', 'bilibili']).describe('目标平台'),
452
457
  },
453
458
  async ({ platform }) => {
454
459
  const label = PLATFORM_LABELS[platform] ?? platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -688,6 +688,7 @@ export class AgentManager {
688
688
  '${XHS_PROFILE_DIR}': path.join(profileRoot, `xhs-${userId}`),
689
689
  '${DOUYIN_PROFILE_DIR}': path.join(profileRoot, `douyin-${userId}`),
690
690
  '${KUAISHOU_PROFILE_DIR}': path.join(profileRoot, `kuaishou-${userId}`),
691
+ '${BILIBILI_PROFILE_DIR}': path.join(profileRoot, `bilibili-${userId}`),
691
692
  };
692
693
  const mcpServers = this._resolveDirectiveMcpServers(directive, baseReplacements);
693
694
 
@@ -76,6 +76,19 @@ export const PLATFORM_CONFIGS = {
76
76
  return val !== null && val !== baseline;
77
77
  },
78
78
  },
79
+ bilibili: {
80
+ loginUrl: 'https://www.bilibili.com',
81
+ getSessionValue: (cookies) =>
82
+ cookies.find(c => c.name === 'SESSDATA')?.value
83
+ ?? cookies.find(c => c.name === 'bili_jct')?.value
84
+ ?? null,
85
+ isLoggedIn: (cookies, baseline) => {
86
+ const val = cookies.find(c => c.name === 'SESSDATA')?.value
87
+ ?? cookies.find(c => c.name === 'bili_jct')?.value
88
+ ?? null;
89
+ return val !== null && val !== baseline;
90
+ },
91
+ },
79
92
  };
80
93
 
81
94
  export function profileDir(platform, userId) {
@@ -1701,13 +1701,46 @@ server.tool('execute_approved_action',
1701
1701
  const data = await api('POST', `/actions/${action_id}/execute`, {});
1702
1702
  if (data.error) return { isError: true, content: [{ type: 'text', text: `Failed: ${data.error}` }] };
1703
1703
  if (data?.execution?.mode === 'user_daemon_job') {
1704
- return { content: [{ type: 'text', text:
1705
- `Action approved. Publish has been routed to a user-side daemon job.\n` +
1706
- `actionType=${data.actionType} platform=${data.platform}\n` +
1707
- `publish_job_id=${data.execution.publish_job_id} target_machine_id=${data.execution.target_machine_id}\n` +
1708
- `status=${data.execution.status}\n` +
1709
- `Do not call publish_content for this action_id again; wait for the daemon job result.`
1710
- }]};
1704
+ const actionId = data.execution.action_id ?? data.action_id;
1705
+ const jobId = data.execution.publish_job_id;
1706
+ // Poll until the daemon job finishes (max 5 min)
1707
+ const deadline = Date.now() + 5 * 60 * 1000;
1708
+ let jobStatus = 'queued';
1709
+ let jobResult = null;
1710
+ while (Date.now() < deadline) {
1711
+ await new Promise(r => setTimeout(r, 4000));
1712
+ try {
1713
+ const poll = await api('GET', `/actions/${actionId}/publish-job`, null);
1714
+ jobStatus = poll.status ?? 'unknown';
1715
+ if (jobStatus === 'succeeded' || jobStatus === 'failed') {
1716
+ jobResult = poll;
1717
+ break;
1718
+ }
1719
+ } catch { /* server may not have result yet, keep polling */ }
1720
+ }
1721
+ if (!jobResult) {
1722
+ return { content: [{ type: 'text', text:
1723
+ `Publish job timed out after 5 minutes.\n` +
1724
+ `publish_job_id=${jobId} platform=${data.platform}\n` +
1725
+ `The job may still be running. Report this as a timeout to the user.`
1726
+ }]};
1727
+ }
1728
+ if (jobStatus === 'succeeded') {
1729
+ const postUrl = jobResult.result_json?.publish_result?.post_url ?? null;
1730
+ return { content: [{ type: 'text', text:
1731
+ `Publish succeeded ✓\n` +
1732
+ `platform=${data.platform} publish_job_id=${jobId}\n` +
1733
+ (postUrl ? `post_url=${postUrl}` : `post_url not available yet (may be processing)`)
1734
+ }]};
1735
+ } else {
1736
+ const errMsg = jobResult.dispatch_error ?? jobResult.result_json?.error ?? 'unknown error';
1737
+ return { isError: true, content: [{ type: 'text', text:
1738
+ `Publish FAILED ✗\n` +
1739
+ `platform=${data.platform} publish_job_id=${jobId}\n` +
1740
+ `error=${errMsg}\n` +
1741
+ `Report this failure to the user and do NOT say the publish succeeded.`
1742
+ }]};
1743
+ }
1711
1744
  }
1712
1745
  return { content: [{ type: 'text', text:
1713
1746
  `Action approved. Now call the appropriate platform tool with approval_action_id="${action_id}" to actually perform the operation.\n` +
package/src/mcp-config.js CHANGED
@@ -56,6 +56,8 @@ function resolveSkillArg(arg, config) {
56
56
  return profileDir('douyin', config.userId ?? 'default');
57
57
  if (arg === '{kuaishou_profile_dir}')
58
58
  return profileDir('kuaishou', config.userId ?? 'default');
59
+ if (arg === '{bilibili_profile_dir}')
60
+ return profileDir('bilibili', config.userId ?? 'default');
59
61
  return arg;
60
62
  }
61
63
 
@@ -4,6 +4,7 @@ import { getSession, closeSession } from '../mcp-servers/publisher/chrome-pool.j
4
4
  import { XhsAdapter } from '../mcp-servers/publisher/adapters/xhs.js';
5
5
  import { DouyinAdapter } from '../mcp-servers/publisher/adapters/douyin.js';
6
6
  import { KuaishouAdapter } from '../mcp-servers/publisher/adapters/kuaishou.js';
7
+ import { BilibiliAdapter } from '../mcp-servers/publisher/adapters/bilibili.js';
7
8
  import { callOfficialTool } from '../mcp-servers/publisher/official-tool-client.js';
8
9
  import { runPublishPrecheck } from '../mcp-servers/publisher/precheck.js';
9
10
  import { withProfileLock } from './profile-lock.js';
@@ -13,18 +14,21 @@ const PLATFORM_ENV_KEYS = {
13
14
  xhs: 'XHS_PROFILE_DIR',
14
15
  douyin: 'DOUYIN_PROFILE_DIR',
15
16
  kuaishou: 'KUAISHOU_PROFILE_DIR',
17
+ bilibili: 'BILIBILI_PROFILE_DIR',
16
18
  };
17
19
 
18
20
  const ADAPTER_REGISTRY = Object.freeze({
19
21
  xhs: XhsAdapter,
20
22
  douyin: DouyinAdapter,
21
23
  kuaishou: KuaishouAdapter,
24
+ bilibili: BilibiliAdapter,
22
25
  });
23
26
 
24
27
  const DEFAULT_MEDIA_LIMITS = {
25
28
  xhs: { maxImages: 9, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov'] },
26
29
  douyin: { maxImages: 35, imageExts: ['.jpg', '.jpeg', '.png', '.webp'], videoExts: ['.mp4', '.mov', '.webm'] },
27
30
  kuaishou: { maxImages: 12, imageExts: ['.jpg', '.jpeg', '.png'], videoExts: ['.mp4', '.mov'] },
31
+ bilibili: { maxImages: 30, imageExts: ['.jpg', '.jpeg', '.png', '.gif'], videoExts: ['.mp4', '.flv'] },
28
32
  };
29
33
  const PARTIAL_FILE_RE = /\.partial-\d+-[a-z0-9]+$/;
30
34