@lightcone-ai/daemon 0.9.67 → 0.9.69
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/xhs.js +73 -19
- package/package.json +1 -1
- package/src/chat-bridge.js +56 -0
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
7
|
+
function randomInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
8
|
+
|
|
9
|
+
const PACE_SCALE = Math.max(0.2, Number(process.env.PUBLISHER_PACE_SCALE ?? '1') || 1);
|
|
10
|
+
async function humanPause(minMs, maxMs, label = '') {
|
|
11
|
+
const ms = Math.round(randomInt(minMs, maxMs) * PACE_SCALE);
|
|
12
|
+
if (label) console.error(`[XhsAdapter] pause ${label}: ${ms}ms`);
|
|
13
|
+
await sleep(ms);
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
const REQUIREMENTS = {
|
|
9
17
|
image_text: {
|
|
@@ -70,29 +78,34 @@ export class XhsAdapter {
|
|
|
70
78
|
async publishImageText({ title, text, tags = [], images = [] }) {
|
|
71
79
|
await this._openPublishComposer('image');
|
|
72
80
|
await this._assertReadyForPublish();
|
|
81
|
+
await humanPause(2500, 5500, 'composer-ready');
|
|
73
82
|
|
|
74
83
|
await this._waitForAny([IMAGE_FILE_INPUT_SELECTOR], 15000);
|
|
84
|
+
await humanPause(900, 2200, 'before-upload');
|
|
75
85
|
|
|
76
86
|
// Upload images first (if any)
|
|
77
87
|
if (images.length > 0) {
|
|
78
88
|
await this._uploadFiles(images, 'image');
|
|
79
89
|
await this._waitForUploadSettled(images.length, 90_000);
|
|
90
|
+
await humanPause(2500, 5500, 'after-upload');
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
// Fill title
|
|
83
94
|
if (title) {
|
|
84
95
|
await this._fillField(TITLE_SELECTOR, title);
|
|
96
|
+
await humanPause(1200, 2800, 'after-title');
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
// Fill content text + tags
|
|
88
100
|
const fullText = tags.length > 0 ? `${text}\n${tags.map(t => `#${t}`).join(' ')}` : text;
|
|
89
101
|
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
90
102
|
|
|
91
|
-
await
|
|
103
|
+
await humanPause(4500, 9000, 'before-publish-check');
|
|
92
104
|
await this._assertNoBlockingErrors();
|
|
93
105
|
await this._assertPublishButtonReady();
|
|
94
106
|
|
|
95
107
|
// Click publish button
|
|
108
|
+
await humanPause(1200, 3000, 'before-publish-click');
|
|
96
109
|
const clicked = await this._clickByText('发布');
|
|
97
110
|
if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
|
|
98
111
|
const result = await this._waitForPublishResult(90_000);
|
|
@@ -102,29 +115,36 @@ export class XhsAdapter {
|
|
|
102
115
|
async publishShortVideo({ title, text, tags = [], video, cover }) {
|
|
103
116
|
await this._openPublishComposer('video');
|
|
104
117
|
await this._assertReadyForPublish();
|
|
118
|
+
await humanPause(2500, 5500, 'composer-ready');
|
|
105
119
|
|
|
106
120
|
await this._waitForAny([VIDEO_FILE_INPUT_SELECTOR], 15000);
|
|
121
|
+
await humanPause(900, 2200, 'before-upload');
|
|
107
122
|
|
|
108
123
|
// Upload video
|
|
109
124
|
await this._uploadFiles([video], 'video');
|
|
110
125
|
await this._waitForUploadSettled(1, 180_000);
|
|
126
|
+
await humanPause(3000, 6500, 'after-video-upload');
|
|
111
127
|
|
|
112
128
|
if (cover) {
|
|
129
|
+
await humanPause(1200, 2800, 'before-cover-upload');
|
|
113
130
|
await this._uploadFiles([cover], 'cover');
|
|
114
131
|
await this._waitForUploadSettled(1, 60_000);
|
|
132
|
+
await humanPause(1800, 4200, 'after-cover-upload');
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
if (title) {
|
|
118
136
|
await this._fillField(TITLE_SELECTOR, title);
|
|
137
|
+
await humanPause(1200, 2800, 'after-title');
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
const fullText = tags.length > 0 ? `${text}\n${tags.map(t => `#${t}`).join(' ')}` : text;
|
|
122
141
|
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
123
142
|
|
|
124
|
-
await
|
|
143
|
+
await humanPause(4500, 9000, 'before-publish-check');
|
|
125
144
|
await this._assertNoBlockingErrors();
|
|
126
145
|
await this._assertPublishButtonReady();
|
|
127
146
|
|
|
147
|
+
await humanPause(1200, 3000, 'before-publish-click');
|
|
128
148
|
const clicked = await this._clickByText('发布');
|
|
129
149
|
if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
|
|
130
150
|
const result = await this._waitForPublishResult(120_000);
|
|
@@ -141,9 +161,11 @@ export class XhsAdapter {
|
|
|
141
161
|
await this._cdp.send('Page.navigate', { url });
|
|
142
162
|
await this._waitForCreatorShell(20_000);
|
|
143
163
|
await this._assertReadyForPublish();
|
|
164
|
+
await humanPause(1800, 4200, 'after-navigation');
|
|
144
165
|
|
|
145
166
|
if (await this._hasSelector(inputSelector)) return;
|
|
146
167
|
|
|
168
|
+
await humanPause(1000, 2600, 'before-tab-switch');
|
|
147
169
|
const clicked = await this._clickVisibleByText(targetTabText, '.creator-tab, button, [role="button"], span, div', { throwOnMissing: false });
|
|
148
170
|
if (clicked) {
|
|
149
171
|
await this._waitForAny([inputSelector], 15_000);
|
|
@@ -152,6 +174,7 @@ export class XhsAdapter {
|
|
|
152
174
|
|
|
153
175
|
await this._cdp.send('Page.navigate', { url: kind === 'image' ? `${PUBLISH_URL}?from=tab_switch&target=image` : VIDEO_PUBLISH_URL });
|
|
154
176
|
await this._waitForAny([inputSelector], 15_000);
|
|
177
|
+
await humanPause(1800, 4200, 'after-fallback-navigation');
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
async _waitForCreatorShell(timeoutMs = 20_000) {
|
|
@@ -328,6 +351,7 @@ export class XhsAdapter {
|
|
|
328
351
|
}
|
|
329
352
|
|
|
330
353
|
async _fillField(selector, value) {
|
|
354
|
+
await humanPause(500, 1400, 'before-focus-field');
|
|
331
355
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
332
356
|
expression: `
|
|
333
357
|
(function() {
|
|
@@ -337,25 +361,19 @@ export class XhsAdapter {
|
|
|
337
361
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
338
362
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
|
339
363
|
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
|
340
|
-
if (nativeInputValueSetter) nativeInputValueSetter.call(el,
|
|
341
|
-
else el.value =
|
|
364
|
+
if (nativeInputValueSetter) nativeInputValueSetter.call(el, '');
|
|
365
|
+
else el.value = '';
|
|
342
366
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
343
|
-
el.
|
|
367
|
+
el.select?.();
|
|
344
368
|
} else {
|
|
345
|
-
const value = ${JSON.stringify(value)};
|
|
346
369
|
const range = document.createRange();
|
|
347
370
|
range.selectNodeContents(el);
|
|
348
371
|
const selection = window.getSelection();
|
|
349
372
|
selection.removeAllRanges();
|
|
350
373
|
selection.addRange(range);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const html = value.split('\\n').map(line => '<p>' + line.replace(/[&<>]/g, ch => ({ '&': '&', '<': '<', '>': '>' }[ch])) + '</p>').join('');
|
|
354
|
-
el.innerHTML = html || '<p><br></p>';
|
|
355
|
-
}
|
|
356
|
-
selection.removeAllRanges();
|
|
374
|
+
document.execCommand?.('delete', false, null);
|
|
375
|
+
el.focus();
|
|
357
376
|
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
358
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
359
377
|
}
|
|
360
378
|
return true;
|
|
361
379
|
})()
|
|
@@ -363,10 +381,36 @@ export class XhsAdapter {
|
|
|
363
381
|
returnByValue: true,
|
|
364
382
|
});
|
|
365
383
|
if (result.result?.value !== true) throw new Error(`PUBLISH_FAILED: 找不到输入框:${selector}`);
|
|
366
|
-
await
|
|
384
|
+
await this._typeTextHumanLike(value);
|
|
385
|
+
await this._cdp.send('Runtime.evaluate', {
|
|
386
|
+
expression: `
|
|
387
|
+
(function() {
|
|
388
|
+
const el = document.activeElement;
|
|
389
|
+
if (!el) return false;
|
|
390
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
391
|
+
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
392
|
+
return true;
|
|
393
|
+
})()
|
|
394
|
+
`,
|
|
395
|
+
returnByValue: true,
|
|
396
|
+
});
|
|
397
|
+
await humanPause(700, 1800, 'after-fill-field');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async _typeTextHumanLike(value) {
|
|
401
|
+
const text = String(value ?? '');
|
|
402
|
+
let i = 0;
|
|
403
|
+
while (i < text.length) {
|
|
404
|
+
const chunkSize = randomInt(10, 26);
|
|
405
|
+
const chunk = text.slice(i, i + chunkSize);
|
|
406
|
+
await this._cdp.send('Input.insertText', { text: chunk });
|
|
407
|
+
i += chunk.length;
|
|
408
|
+
await humanPause(90, 260);
|
|
409
|
+
}
|
|
367
410
|
}
|
|
368
411
|
|
|
369
412
|
async _uploadFiles(filePaths, type = 'image') {
|
|
413
|
+
await humanPause(600, 1700, 'before-set-files');
|
|
370
414
|
const selector = type === 'video' ? VIDEO_FILE_INPUT_SELECTOR : IMAGE_FILE_INPUT_SELECTOR;
|
|
371
415
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
372
416
|
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
|
@@ -378,7 +422,7 @@ export class XhsAdapter {
|
|
|
378
422
|
objectId: result.result.objectId,
|
|
379
423
|
files: filePaths,
|
|
380
424
|
});
|
|
381
|
-
await
|
|
425
|
+
await humanPause(1200, 2600, 'after-set-files');
|
|
382
426
|
}
|
|
383
427
|
|
|
384
428
|
async _clickVisibleByText(text, selector, { throwOnMissing = true } = {}) {
|
|
@@ -405,7 +449,7 @@ export class XhsAdapter {
|
|
|
405
449
|
});
|
|
406
450
|
const clicked = result.result?.value === true;
|
|
407
451
|
if (!clicked && throwOnMissing) throw new Error(`PUBLISH_FAILED: 找不到小红书发布入口:${text}`);
|
|
408
|
-
if (clicked) await
|
|
452
|
+
if (clicked) await humanPause(1500, 3500, `after-click-${text}`);
|
|
409
453
|
return clicked;
|
|
410
454
|
}
|
|
411
455
|
|
|
@@ -415,13 +459,23 @@ export class XhsAdapter {
|
|
|
415
459
|
(function() {
|
|
416
460
|
const els = [...document.querySelectorAll('button, [role="button"]')];
|
|
417
461
|
const el = els.find(e => e.innerText?.trim() === ${JSON.stringify(text)} || e.textContent?.trim() === ${JSON.stringify(text)});
|
|
418
|
-
if (el)
|
|
419
|
-
|
|
462
|
+
if (!el) return null;
|
|
463
|
+
const r = el.getBoundingClientRect();
|
|
464
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
420
465
|
})()
|
|
421
466
|
`,
|
|
422
467
|
returnByValue: true,
|
|
423
468
|
});
|
|
424
|
-
|
|
469
|
+
const point = result.result?.value;
|
|
470
|
+
if (!point) return false;
|
|
471
|
+
await humanPause(500, 1400, `before-click-${text}`);
|
|
472
|
+
await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
|
|
473
|
+
await humanPause(120, 420);
|
|
474
|
+
await this._cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 });
|
|
475
|
+
await humanPause(80, 220);
|
|
476
|
+
await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 });
|
|
477
|
+
await humanPause(1500, 3500, `after-click-${text}`);
|
|
478
|
+
return true;
|
|
425
479
|
}
|
|
426
480
|
|
|
427
481
|
async _getUrl() {
|
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { extname } from 'path';
|
|
5
7
|
|
|
6
8
|
const cliArgs = process.argv.slice(2);
|
|
7
9
|
function getArg(name) {
|
|
@@ -17,6 +19,38 @@ const TEAM_ID = process.env.TEAM_ID || getArg('--team-id') || ''; // injec
|
|
|
17
19
|
// Current active teamId for memory isolation (defaults to spawn-time TEAM_ID)
|
|
18
20
|
let currentTeamId = TEAM_ID;
|
|
19
21
|
|
|
22
|
+
const WORKSPACE_BINARY_MIME = {
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.jpeg': 'image/jpeg',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.pdf': 'application/pdf',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function dataUrlSummary(content) {
|
|
32
|
+
if (typeof content !== 'string' || !content.startsWith('data:')) return null;
|
|
33
|
+
const commaIdx = content.indexOf(',');
|
|
34
|
+
if (commaIdx === -1) return null;
|
|
35
|
+
const header = content.slice(5, commaIdx);
|
|
36
|
+
const mime = header.split(';')[0] || 'application/octet-stream';
|
|
37
|
+
const isBase64 = header.split(';').includes('base64');
|
|
38
|
+
let bytes = null;
|
|
39
|
+
if (isBase64) {
|
|
40
|
+
const encoded = content.slice(commaIdx + 1).replace(/\s/g, '');
|
|
41
|
+
const padding = encoded.endsWith('==') ? 2 : encoded.endsWith('=') ? 1 : 0;
|
|
42
|
+
bytes = Math.floor(encoded.length * 3 / 4) - padding;
|
|
43
|
+
}
|
|
44
|
+
return { mime, bytes };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatBytes(bytes) {
|
|
48
|
+
if (!Number.isFinite(bytes)) return 'unknown size';
|
|
49
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
50
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
51
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
52
|
+
}
|
|
53
|
+
|
|
20
54
|
async function api(method, path, body) {
|
|
21
55
|
const url = `${SERVER_URL}/internal/agent/${AGENT_ID}${path}`;
|
|
22
56
|
const res = await fetch(url, {
|
|
@@ -277,6 +311,15 @@ server.tool('read_workspace', 'Read a file from the shared team workspace (e.g.
|
|
|
277
311
|
if (!currentTeamId) return { content: [{ type: 'text', text: 'No team context.' }] };
|
|
278
312
|
try {
|
|
279
313
|
const data = await api('GET', `/team-memory?path=${encodeURIComponent(path)}&teamId=${encodeURIComponent(currentTeamId)}`);
|
|
314
|
+
const summary = dataUrlSummary(data.content);
|
|
315
|
+
if (summary) {
|
|
316
|
+
return {
|
|
317
|
+
content: [{
|
|
318
|
+
type: 'text',
|
|
319
|
+
text: `${path} is a binary workspace file (${summary.mime}, ${formatBytes(summary.bytes)}). Do not read it as text. Use it by path in the Files tab, or use upload_image/read_file_base64 if you need a public URL or local base64.`,
|
|
320
|
+
}],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
280
323
|
return { content: [{ type: 'text', text: data.content }] };
|
|
281
324
|
} catch (err) {
|
|
282
325
|
if (err.message.includes('404')) return { content: [{ type: 'text', text: `(empty — ${path} has no content yet)` }] };
|
|
@@ -294,6 +337,19 @@ server.tool('write_workspace', 'Write a file to the shared team workspace. Use t
|
|
|
294
337
|
return { content: [{ type: 'text', text: `Saved to team workspace: ${path}` }] };
|
|
295
338
|
});
|
|
296
339
|
|
|
340
|
+
server.tool('write_workspace_file', 'Write a local file directly to the shared team workspace. Prefer this over write_workspace for images/PDFs/binary files so large base64 content never enters the model context.', {
|
|
341
|
+
file_path: z.string().describe('Absolute path to the local file, e.g. "/home/ubuntu/lightcone/public/cover.png"'),
|
|
342
|
+
path: z.string().describe('Destination path relative to team workspace root, e.g. "artifacts/cover.png"'),
|
|
343
|
+
}, async ({ file_path, path }) => {
|
|
344
|
+
if (!currentTeamId) return { content: [{ type: 'text', text: 'No team context.' }] };
|
|
345
|
+
const ext = extname(path || file_path).toLowerCase();
|
|
346
|
+
const mime = WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream';
|
|
347
|
+
const buf = readFileSync(file_path);
|
|
348
|
+
const content = `data:${mime};base64,${buf.toString('base64')}`;
|
|
349
|
+
await api('PUT', `/team-memory?path=${encodeURIComponent(path)}&teamId=${encodeURIComponent(currentTeamId)}`, { content });
|
|
350
|
+
return { content: [{ type: 'text', text: `Saved local file to team workspace: ${path} (${mime}, ${formatBytes(buf.length)})` }] };
|
|
351
|
+
});
|
|
352
|
+
|
|
297
353
|
// ── get_credential ───────────────────────────────────────────────────────────
|
|
298
354
|
server.tool('get_credential',
|
|
299
355
|
'Retrieve decrypted credential fields for a platform granted to this agent (e.g. XHS_COOKIE for "xhs"). Use when you need to inject credentials into a browser session or external call.',
|