@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.
- package/mcp-servers/publisher/adapters/bilibili.js +377 -0
- package/mcp-servers/publisher/adapters/kuaishou.js +13 -2
- package/mcp-servers/publisher/index.js +10 -5
- package/package.json +1 -1
- package/src/agent-manager.js +1 -0
- package/src/browser-login.js +13 -0
- package/src/chat-bridge.js +40 -7
- package/src/mcp-config.js +2 -0
- package/src/publish-job-runner.js +4 -0
|
@@ -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
|
|
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
|
|
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
package/src/agent-manager.js
CHANGED
|
@@ -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
|
|
package/src/browser-login.js
CHANGED
|
@@ -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) {
|
package/src/chat-bridge.js
CHANGED
|
@@ -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
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
|