@lightcone-ai/daemon 0.9.77 → 0.9.79
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.
|
@@ -34,12 +34,42 @@ const REQUIREMENTS = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const CREATOR_HOME_URL = 'https://creator.douyin.com/creator-micro/home';
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
37
|
+
const UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload';
|
|
38
|
+
const IMAGE_PUBLISH_URL = `${UPLOAD_URL}?default-tab=3&enter_from=publish`;
|
|
39
|
+
const VIDEO_PUBLISH_URL = `${UPLOAD_URL}?default-tab=1&enter_from=publish`;
|
|
40
|
+
|
|
41
|
+
const IMAGE_FILE_INPUT_SELECTOR = [
|
|
42
|
+
'input[type="file"][accept*="image"]',
|
|
43
|
+
'input[type="file"][accept*=".jpg"]',
|
|
44
|
+
'input[type="file"][accept*=".jpeg"]',
|
|
45
|
+
'input[type="file"][accept*=".png"]',
|
|
46
|
+
'input[type="file"][accept*=".webp"]',
|
|
47
|
+
'input[type="file"][accept="image"]',
|
|
48
|
+
'input[type="file"]',
|
|
49
|
+
].join(', ');
|
|
50
|
+
const VIDEO_FILE_INPUT_SELECTOR = [
|
|
51
|
+
'input[type="file"][accept*="video"]',
|
|
52
|
+
'input[type="file"][accept*=".mp4"]',
|
|
53
|
+
'input[type="file"][accept*=".mov"]',
|
|
54
|
+
'input[type="file"][accept*=".webm"]',
|
|
55
|
+
'input[type="file"]',
|
|
56
|
+
].join(', ');
|
|
57
|
+
const FILE_INPUT_SELECTOR = `${IMAGE_FILE_INPUT_SELECTOR}, ${VIDEO_FILE_INPUT_SELECTOR}`;
|
|
58
|
+
const TITLE_SELECTOR = [
|
|
59
|
+
'input[placeholder*="标题"]',
|
|
60
|
+
'textarea[placeholder*="标题"]',
|
|
61
|
+
'[contenteditable="true"][data-placeholder*="标题"]',
|
|
62
|
+
'[contenteditable="true"][aria-label*="标题"]',
|
|
63
|
+
].join(', ');
|
|
64
|
+
const CONTENT_SELECTOR = [
|
|
65
|
+
'textarea[placeholder*="描述"]',
|
|
66
|
+
'textarea[placeholder*="作品描述"]',
|
|
67
|
+
'[placeholder*="添加作品描述"]',
|
|
68
|
+
'[contenteditable="true"][data-placeholder*="描述"]',
|
|
69
|
+
'[contenteditable="true"][aria-label*="描述"]',
|
|
70
|
+
'.ProseMirror[contenteditable="true"]',
|
|
71
|
+
'[contenteditable="true"]',
|
|
72
|
+
].join(', ');
|
|
43
73
|
|
|
44
74
|
const ERROR_SELECTORS = [
|
|
45
75
|
'[class*="error"]',
|
|
@@ -79,18 +109,18 @@ export class DouyinAdapter {
|
|
|
79
109
|
await humanPause(2500, 5500, 'composer-ready');
|
|
80
110
|
|
|
81
111
|
if (images.length > 0) {
|
|
82
|
-
await this.
|
|
112
|
+
await this._ensureUploaderReady('image');
|
|
83
113
|
await humanPause(900, 2200, 'before-upload');
|
|
84
|
-
await this._uploadFiles(images);
|
|
114
|
+
await this._uploadFiles(images, 'image');
|
|
85
115
|
await this._waitForUploadSettled(images.length, 120_000);
|
|
86
116
|
await humanPause(2500, 5500, 'after-upload');
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
const fullText = formatTextWithTags(text, tags);
|
|
90
|
-
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
120
|
+
await this._fillField(CONTENT_SELECTOR, fullText, 'content');
|
|
91
121
|
|
|
92
122
|
if (title) {
|
|
93
|
-
await this._fillField(TITLE_SELECTOR, title);
|
|
123
|
+
await this._fillField(TITLE_SELECTOR, title, 'title');
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
await humanPause(4500, 9000, 'before-publish-check');
|
|
@@ -114,9 +144,9 @@ export class DouyinAdapter {
|
|
|
114
144
|
await this._assertReadyForPublish();
|
|
115
145
|
await humanPause(2500, 5500, 'composer-ready');
|
|
116
146
|
|
|
117
|
-
await this.
|
|
147
|
+
await this._ensureUploaderReady('video');
|
|
118
148
|
await humanPause(900, 2200, 'before-upload');
|
|
119
|
-
await this._uploadFiles([video]);
|
|
149
|
+
await this._uploadFiles([video], 'video');
|
|
120
150
|
await this._waitForUploadSettled(1, 180_000);
|
|
121
151
|
await humanPause(3000, 6500, 'after-video-upload');
|
|
122
152
|
|
|
@@ -125,10 +155,10 @@ export class DouyinAdapter {
|
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
const fullText = formatTextWithTags(text, tags);
|
|
128
|
-
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
158
|
+
await this._fillField(CONTENT_SELECTOR, fullText, 'content');
|
|
129
159
|
|
|
130
160
|
if (title) {
|
|
131
|
-
await this._fillField(TITLE_SELECTOR, title);
|
|
161
|
+
await this._fillField(TITLE_SELECTOR, title, 'title');
|
|
132
162
|
}
|
|
133
163
|
|
|
134
164
|
await humanPause(4500, 9000, 'before-publish-check');
|
|
@@ -152,6 +182,7 @@ export class DouyinAdapter {
|
|
|
152
182
|
await this._cdp.send('Page.navigate', { url });
|
|
153
183
|
await this._waitForCreatorShell(20_000);
|
|
154
184
|
await humanPause(1800, 4200, 'after-navigation');
|
|
185
|
+
await this._ensureComposer(kind);
|
|
155
186
|
}
|
|
156
187
|
|
|
157
188
|
async _waitForCreatorShell(timeoutMs = 20_000) {
|
|
@@ -187,6 +218,67 @@ export class DouyinAdapter {
|
|
|
187
218
|
throw new Error(`Timeout waiting for selector: ${selector}`);
|
|
188
219
|
}
|
|
189
220
|
|
|
221
|
+
async _ensureComposer(kind) {
|
|
222
|
+
if (await this._waitForUploaderOrEditor(8000)) return;
|
|
223
|
+
|
|
224
|
+
const labels = kind === 'video'
|
|
225
|
+
? ['发布视频', '上传视频', '视频', '发布作品', '上传']
|
|
226
|
+
: ['发布图文', '上传图文', '图文', '发布图片', '上传图片', '图片', '发布作品', '上传'];
|
|
227
|
+
|
|
228
|
+
for (const label of labels) {
|
|
229
|
+
const clicked = await this._clickByText(label);
|
|
230
|
+
if (!clicked) continue;
|
|
231
|
+
if (await this._waitForUploaderOrEditor(12_000)) return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await this._cdp.send('Page.navigate', { url: kind === 'video' ? VIDEO_PUBLISH_URL : IMAGE_PUBLISH_URL });
|
|
235
|
+
if (await this._waitForUploaderOrEditor(15_000)) return;
|
|
236
|
+
|
|
237
|
+
const state = await this._inspectPage();
|
|
238
|
+
throw new Error(`PUBLISH_FAILED: 找不到抖音${kind === 'image' ? '图文' : '视频'}上传入口。${this._formatPageHint(state)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _ensureUploaderReady(kind) {
|
|
242
|
+
const selector = kind === 'video' ? VIDEO_FILE_INPUT_SELECTOR : IMAGE_FILE_INPUT_SELECTOR;
|
|
243
|
+
if (await this._waitForSelectorQuiet(selector, 8000)) return;
|
|
244
|
+
await this._ensureComposer(kind);
|
|
245
|
+
if (await this._waitForSelectorQuiet(selector, 12_000)) return;
|
|
246
|
+
|
|
247
|
+
const labels = kind === 'video'
|
|
248
|
+
? ['上传视频', '点击上传', '选择视频', '上传']
|
|
249
|
+
: ['上传图片', '添加图片', '点击上传', '选择图片', '上传'];
|
|
250
|
+
for (const label of labels) {
|
|
251
|
+
const clicked = await this._clickByText(label);
|
|
252
|
+
if (!clicked) continue;
|
|
253
|
+
if (await this._waitForSelectorQuiet(selector, 8000)) return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const state = await this._inspectPage();
|
|
257
|
+
throw new Error(`PUBLISH_FAILED: 找不到抖音文件上传输入框。${this._formatPageHint(state)}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async _waitForUploaderOrEditor(timeoutMs) {
|
|
261
|
+
const deadline = Date.now() + timeoutMs;
|
|
262
|
+
while (Date.now() < deadline) {
|
|
263
|
+
const state = await this._inspectPage();
|
|
264
|
+
if (state.hasLoginHint) {
|
|
265
|
+
throw new Error(`LOGIN_EXPIRED: 当前页面 ${state.url},请重新扫码连接抖音`);
|
|
266
|
+
}
|
|
267
|
+
if (state.hasUploader || state.hasPublishEditor) return true;
|
|
268
|
+
await sleep(500);
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async _waitForSelectorQuiet(selector, timeoutMs = 8000) {
|
|
274
|
+
try {
|
|
275
|
+
await this._waitForSelector(selector, timeoutMs);
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
190
282
|
async _inspectPage() {
|
|
191
283
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
192
284
|
expression: `
|
|
@@ -205,15 +297,31 @@ export class DouyinAdapter {
|
|
|
205
297
|
.map(t => t.trim())
|
|
206
298
|
.filter(Boolean)
|
|
207
299
|
.slice(0, 5);
|
|
208
|
-
const buttons = [...document.querySelectorAll('button, [role="button"]')].map(e => ({
|
|
300
|
+
const buttons = [...document.querySelectorAll('button, [role="button"], .semi-button, [class*="button"], [class*="Button"]')].map(e => ({
|
|
209
301
|
text: (e.innerText || e.textContent || '').trim(),
|
|
210
302
|
disabled: !!e.disabled || e.getAttribute('aria-disabled') === 'true' || e.className?.toString().includes('disabled'),
|
|
211
|
-
}));
|
|
303
|
+
})).filter(b => b.text).slice(0, 30);
|
|
304
|
+
const links = [...document.querySelectorAll('a')].map(e => ({
|
|
305
|
+
text: (e.innerText || e.textContent || '').trim(),
|
|
306
|
+
href: e.href || '',
|
|
307
|
+
})).filter(a => a.text).slice(0, 30);
|
|
308
|
+
const uploadish = [...document.querySelectorAll('input, button, [role="button"], a, div, span')]
|
|
309
|
+
.map(e => ({
|
|
310
|
+
tag: e.tagName,
|
|
311
|
+
text: (e.innerText || e.textContent || e.getAttribute('aria-label') || e.getAttribute('title') || '').trim(),
|
|
312
|
+
type: e.getAttribute('type') || '',
|
|
313
|
+
accept: e.getAttribute('accept') || '',
|
|
314
|
+
cls: e.className?.toString?.() || '',
|
|
315
|
+
}))
|
|
316
|
+
.filter(e => /上传|图文|图片|视频|作品|file|upload/i.test([e.text, e.type, e.accept, e.cls].join(' ')))
|
|
317
|
+
.slice(0, 40);
|
|
212
318
|
return {
|
|
213
319
|
url,
|
|
214
320
|
text: text.slice(0, 5000),
|
|
215
321
|
errors,
|
|
216
322
|
buttons,
|
|
323
|
+
links,
|
|
324
|
+
uploadish,
|
|
217
325
|
hasLoginHint: /登录|扫码|验证码|手机号/.test(text) && !/发布|作品管理|创作服务平台/.test(text),
|
|
218
326
|
isCreatorLoggedIn: /创作服务平台|创作者中心|发布作品|作品管理|内容管理/.test(text),
|
|
219
327
|
hasUploader: !!document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)}),
|
|
@@ -224,7 +332,19 @@ export class DouyinAdapter {
|
|
|
224
332
|
`,
|
|
225
333
|
returnByValue: true,
|
|
226
334
|
});
|
|
227
|
-
return result.result?.value ?? { url: await this._getUrl(), text: '', errors: [], buttons: [] };
|
|
335
|
+
return result.result?.value ?? { url: await this._getUrl(), text: '', errors: [], buttons: [], links: [], uploadish: [] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_formatPageHint(state) {
|
|
339
|
+
const buttons = (state.buttons ?? []).map(b => b.text).filter(Boolean).slice(0, 12).join(' / ');
|
|
340
|
+
const links = (state.links ?? []).map(a => a.text).filter(Boolean).slice(0, 12).join(' / ');
|
|
341
|
+
const uploadish = (state.uploadish ?? [])
|
|
342
|
+
.map(e => [e.tag, e.text, e.type, e.accept].filter(Boolean).join(':'))
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.slice(0, 12)
|
|
345
|
+
.join(' / ');
|
|
346
|
+
const text = (state.text ?? '').replace(/\s+/g, ' ').slice(0, 240);
|
|
347
|
+
return `当前 url=${state.url}; buttons=${buttons || 'none'}; links=${links || 'none'}; uploadCandidates=${uploadish || 'none'}; text=${text || 'empty'}`;
|
|
228
348
|
}
|
|
229
349
|
|
|
230
350
|
async _assertReadyForPublish() {
|
|
@@ -292,12 +412,58 @@ export class DouyinAdapter {
|
|
|
292
412
|
throw new Error(`PUBLISH_TIMEOUT: ${hint}`);
|
|
293
413
|
}
|
|
294
414
|
|
|
295
|
-
async _fillField(selector, value) {
|
|
415
|
+
async _fillField(selector, value, kind = 'content') {
|
|
296
416
|
await humanPause(500, 1400, 'before-focus-field');
|
|
297
417
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
298
418
|
expression: `
|
|
299
419
|
(function() {
|
|
300
|
-
const
|
|
420
|
+
const selector = ${JSON.stringify(selector)};
|
|
421
|
+
const kind = ${JSON.stringify(kind)};
|
|
422
|
+
const visible = (el) => {
|
|
423
|
+
if (!el) return false;
|
|
424
|
+
const r = el.getBoundingClientRect();
|
|
425
|
+
const s = getComputedStyle(el);
|
|
426
|
+
return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
|
|
427
|
+
};
|
|
428
|
+
const metaText = (el) => [
|
|
429
|
+
el.getAttribute('placeholder'),
|
|
430
|
+
el.getAttribute('data-placeholder'),
|
|
431
|
+
el.getAttribute('aria-label'),
|
|
432
|
+
el.getAttribute('title'),
|
|
433
|
+
el.className?.toString?.(),
|
|
434
|
+
el.closest('[class*="editor"], [class*="form"], [class*="field"], [class*="mention"], [class*="caption"]')?.innerText,
|
|
435
|
+
].filter(Boolean).join(' ');
|
|
436
|
+
const candidates = [
|
|
437
|
+
...document.querySelectorAll('input, textarea, [contenteditable="true"]')
|
|
438
|
+
].filter(visible).filter(el => !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
439
|
+
|
|
440
|
+
function score(el) {
|
|
441
|
+
const text = metaText(el);
|
|
442
|
+
let s = 0;
|
|
443
|
+
if (kind === 'title') {
|
|
444
|
+
if (/标题|title|caption/i.test(text)) s += 120;
|
|
445
|
+
if (/添加作品标题|图文标题|作品标题/.test(text)) s += 80;
|
|
446
|
+
if (/描述|正文|description/i.test(text)) s -= 120;
|
|
447
|
+
if (el.tagName === 'INPUT') s += 20;
|
|
448
|
+
} else {
|
|
449
|
+
if (/描述|作品描述|添加作品描述|正文|description/i.test(text)) s += 120;
|
|
450
|
+
if (/标题|title|caption/i.test(text)) s -= 100;
|
|
451
|
+
if (el.tagName === 'TEXTAREA') s += 30;
|
|
452
|
+
if (el.getAttribute('contenteditable') === 'true') s += 15;
|
|
453
|
+
}
|
|
454
|
+
const r = el.getBoundingClientRect();
|
|
455
|
+
if (r.width < 40 || r.height < 12) s -= 40;
|
|
456
|
+
return s;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let el = null;
|
|
460
|
+
const direct = [...document.querySelectorAll(selector)].filter(visible);
|
|
461
|
+
if (direct.length) {
|
|
462
|
+
el = direct.sort((a, b) => score(b) - score(a))[0];
|
|
463
|
+
}
|
|
464
|
+
if (!el || score(el) <= 0) {
|
|
465
|
+
el = candidates.sort((a, b) => score(b) - score(a))[0];
|
|
466
|
+
}
|
|
301
467
|
if (!el) return false;
|
|
302
468
|
el.focus();
|
|
303
469
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
@@ -321,7 +487,7 @@ export class DouyinAdapter {
|
|
|
321
487
|
`,
|
|
322
488
|
returnByValue: true,
|
|
323
489
|
});
|
|
324
|
-
if (result.result?.value !== true) throw new Error(`PUBLISH_FAILED:
|
|
490
|
+
if (result.result?.value !== true) throw new Error(`PUBLISH_FAILED: 找不到${kind === 'title' ? '标题' : '描述'}输入框:${selector}`);
|
|
325
491
|
await this._typeTextHumanLike(value);
|
|
326
492
|
await this._cdp.send('Runtime.evaluate', {
|
|
327
493
|
expression: `
|
|
@@ -350,10 +516,32 @@ export class DouyinAdapter {
|
|
|
350
516
|
}
|
|
351
517
|
}
|
|
352
518
|
|
|
353
|
-
async _uploadFiles(filePaths) {
|
|
519
|
+
async _uploadFiles(filePaths, kind = 'image') {
|
|
354
520
|
await humanPause(600, 1700, 'before-set-files');
|
|
355
521
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
356
|
-
expression: `
|
|
522
|
+
expression: `
|
|
523
|
+
(function() {
|
|
524
|
+
const kind = ${JSON.stringify(kind)};
|
|
525
|
+
const inputs = [...document.querySelectorAll('input[type="file"]')];
|
|
526
|
+
if (!inputs.length) return null;
|
|
527
|
+
const score = (el) => {
|
|
528
|
+
const accept = (el.getAttribute('accept') || '').toLowerCase();
|
|
529
|
+
let s = 0;
|
|
530
|
+
if (kind === 'image') {
|
|
531
|
+
if (/image|jpg|jpeg|png|webp/.test(accept)) s += 100;
|
|
532
|
+
if (/video|mp4|mov|webm/.test(accept)) s -= 100;
|
|
533
|
+
} else {
|
|
534
|
+
if (/video|mp4|mov|webm/.test(accept)) s += 100;
|
|
535
|
+
if (/image|jpg|jpeg|png|webp/.test(accept)) s -= 100;
|
|
536
|
+
}
|
|
537
|
+
const containerText = (el.closest('div')?.innerText || '').slice(0, 200);
|
|
538
|
+
if (kind === 'image' && /图文|图片/.test(containerText)) s += 20;
|
|
539
|
+
if (kind === 'video' && /视频/.test(containerText)) s += 20;
|
|
540
|
+
return s;
|
|
541
|
+
};
|
|
542
|
+
return inputs.sort((a, b) => score(b) - score(a))[0] ?? null;
|
|
543
|
+
})()
|
|
544
|
+
`,
|
|
357
545
|
returnByValue: false,
|
|
358
546
|
});
|
|
359
547
|
if (!result.result?.objectId) throw new Error('No file input found on page');
|
|
@@ -368,8 +556,23 @@ export class DouyinAdapter {
|
|
|
368
556
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
369
557
|
expression: `
|
|
370
558
|
(function() {
|
|
371
|
-
const
|
|
372
|
-
|
|
559
|
+
const visible = (el) => {
|
|
560
|
+
const r = el.getBoundingClientRect();
|
|
561
|
+
const s = getComputedStyle(el);
|
|
562
|
+
return r.width > 0 && r.height > 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
|
|
563
|
+
};
|
|
564
|
+
const els = [...document.querySelectorAll('button, [role="button"], a, div, span')]
|
|
565
|
+
.filter(visible)
|
|
566
|
+
.filter(e => {
|
|
567
|
+
const txt = (e.innerText || e.textContent || e.getAttribute('aria-label') || e.getAttribute('title') || '').trim();
|
|
568
|
+
if (!txt) return false;
|
|
569
|
+
return txt === ${JSON.stringify(text)} || txt.includes(${JSON.stringify(text)});
|
|
570
|
+
});
|
|
571
|
+
const el = els.sort((a, b) => {
|
|
572
|
+
const ar = a.getBoundingClientRect();
|
|
573
|
+
const br = b.getBoundingClientRect();
|
|
574
|
+
return (ar.width * ar.height) - (br.width * br.height);
|
|
575
|
+
})[0];
|
|
373
576
|
if (!el) return null;
|
|
374
577
|
const r = el.getBoundingClientRect();
|
|
375
578
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
-
import { existsSync, statSync, realpathSync } from 'fs';
|
|
15
|
+
import { existsSync, statSync, realpathSync, mkdirSync, writeFileSync } from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import { getSession, closeSession } from './chrome-pool.js';
|
|
18
18
|
import { XhsAdapter } from './adapters/xhs.js';
|
|
@@ -23,7 +23,9 @@ import { withProfileLock } from '../../src/profile-lock.js';
|
|
|
23
23
|
const SERVER_URL = process.env.SERVER_URL ?? '';
|
|
24
24
|
const MACHINE_API_KEY = process.env.MACHINE_API_KEY ?? '';
|
|
25
25
|
const AGENT_ID = process.env.AGENT_ID ?? '';
|
|
26
|
+
const TEAM_ID = process.env.TEAM_ID ?? '';
|
|
26
27
|
const WORKSPACE_DIR = process.env.WORKSPACE_DIR ?? process.cwd();
|
|
28
|
+
const TEAM_WORKSPACE_DIR = path.dirname(WORKSPACE_DIR);
|
|
27
29
|
|
|
28
30
|
// ── Platform registry ──────────────────────────────────────────────────────────
|
|
29
31
|
|
|
@@ -115,6 +117,74 @@ const MEDIA_LIMITS = {
|
|
|
115
117
|
kuaishou: { maxImages: 12, imageExts: ['.jpg', '.jpeg', '.png'], videoExts: ['.mp4', '.mov'] },
|
|
116
118
|
};
|
|
117
119
|
|
|
120
|
+
function isInsideDir(filePath, dir) {
|
|
121
|
+
const rel = path.relative(dir, filePath);
|
|
122
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function decodeWorkspaceContent(content) {
|
|
126
|
+
if (typeof content !== 'string') throw new Error('workspace file content is not a string');
|
|
127
|
+
if (!content.startsWith('data:')) return Buffer.from(content, 'utf8');
|
|
128
|
+
|
|
129
|
+
const commaIdx = content.indexOf(',');
|
|
130
|
+
if (commaIdx === -1) throw new Error('invalid data URL in workspace file');
|
|
131
|
+
const header = content.slice(5, commaIdx);
|
|
132
|
+
const body = content.slice(commaIdx + 1);
|
|
133
|
+
if (header.split(';').includes('base64')) return Buffer.from(body, 'base64');
|
|
134
|
+
return Buffer.from(decodeURIComponent(body), 'utf8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function workspacePathFromMediaPath(filePath, approvalData) {
|
|
138
|
+
if (!filePath) return null;
|
|
139
|
+
|
|
140
|
+
const normalized = filePath.replaceAll('\\', '/');
|
|
141
|
+
const virtualMatch = normalized.match(/^\/agent-workspace\/([^/]+)\/workspace\/(.+)$/);
|
|
142
|
+
if (virtualMatch) return { teamId: virtualMatch[1], relPath: virtualMatch[2] };
|
|
143
|
+
|
|
144
|
+
const workspaceSegmentMatch = normalized.match(/\/workspace\/((?:artifacts|notes|tmp)\/.+)$/);
|
|
145
|
+
if (workspaceSegmentMatch) {
|
|
146
|
+
return { teamId: approvalData?.teamId ?? TEAM_ID, relPath: workspaceSegmentMatch[1] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!path.isAbsolute(filePath) && /^(artifacts|notes|tmp)\//.test(normalized)) {
|
|
150
|
+
return { teamId: approvalData?.teamId ?? TEAM_ID, relPath: normalized };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function materializeWorkspaceMedia(filePath, approvalData) {
|
|
157
|
+
if (!filePath || existsSync(filePath)) return filePath;
|
|
158
|
+
|
|
159
|
+
const workspacePath = workspacePathFromMediaPath(filePath, approvalData);
|
|
160
|
+
if (!workspacePath?.teamId || !workspacePath.relPath) return filePath;
|
|
161
|
+
|
|
162
|
+
const localPath = path.resolve(TEAM_WORKSPACE_DIR, workspacePath.relPath);
|
|
163
|
+
const allowedRoots = ['artifacts', 'notes', 'tmp'].map(dir => path.join(TEAM_WORKSPACE_DIR, dir));
|
|
164
|
+
if (!allowedRoots.some(root => isInsideDir(localPath, root))) {
|
|
165
|
+
throw new Error(`workspace media path is outside allowed team workspace directories: ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (existsSync(localPath)) return localPath;
|
|
169
|
+
|
|
170
|
+
const data = await api(
|
|
171
|
+
'GET',
|
|
172
|
+
`/team-memory?path=${encodeURIComponent(workspacePath.relPath)}&teamId=${encodeURIComponent(workspacePath.teamId)}`
|
|
173
|
+
);
|
|
174
|
+
mkdirSync(path.dirname(localPath), { recursive: true });
|
|
175
|
+
writeFileSync(localPath, decodeWorkspaceContent(data.content));
|
|
176
|
+
console.error(`[publisher] Materialized team workspace media ${workspacePath.relPath} -> ${localPath}`);
|
|
177
|
+
return localPath;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function materializeMedia({ images = [], video, cover }, approvalData) {
|
|
181
|
+
return {
|
|
182
|
+
images: await Promise.all(images.map(filePath => materializeWorkspaceMedia(filePath, approvalData))),
|
|
183
|
+
video: video ? await materializeWorkspaceMedia(video, approvalData) : video,
|
|
184
|
+
cover: cover ? await materializeWorkspaceMedia(cover, approvalData) : cover,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
118
188
|
function validateLocalFile(filePath, { kind, required = false, allowedExts = [] }) {
|
|
119
189
|
if (!filePath) {
|
|
120
190
|
if (required) throw new Error(`${kind} file path is required`);
|
|
@@ -128,10 +198,15 @@ function validateLocalFile(filePath, { kind, required = false, allowedExts = []
|
|
|
128
198
|
}
|
|
129
199
|
|
|
130
200
|
const realFile = realpathSync(filePath);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
201
|
+
const allowedRoots = [
|
|
202
|
+
realpathSync(WORKSPACE_DIR),
|
|
203
|
+
...['artifacts', 'notes', 'tmp']
|
|
204
|
+
.map(dir => path.join(TEAM_WORKSPACE_DIR, dir))
|
|
205
|
+
.filter(existsSync)
|
|
206
|
+
.map(dir => realpathSync(dir)),
|
|
207
|
+
];
|
|
208
|
+
if (!allowedRoots.some(root => isInsideDir(realFile, root))) {
|
|
209
|
+
throw new Error(`${kind} file must be inside the agent workspace or team shared artifacts/notes/tmp directory: ${filePath}`);
|
|
135
210
|
}
|
|
136
211
|
|
|
137
212
|
const stat = statSync(realFile);
|
|
@@ -191,8 +266,9 @@ images/video 字段填写本地绝对路径(在 agent workspace 目录下)
|
|
|
191
266
|
async ({ platform, content_type, title, text, tags, images, video, cover, approval_action_id }) => {
|
|
192
267
|
const label = PLATFORM_LABELS[platform] ?? platform;
|
|
193
268
|
try {
|
|
194
|
-
await validateApproval(approval_action_id, platform);
|
|
195
|
-
const
|
|
269
|
+
const approvalData = await validateApproval(approval_action_id, platform);
|
|
270
|
+
const localMedia = await materializeMedia({ images: images ?? [], video, cover }, approvalData);
|
|
271
|
+
const media = validateMedia({ platform, contentType: content_type, ...localMedia });
|
|
196
272
|
const result = await withPublisherProfile(platform, async () => {
|
|
197
273
|
const adapter = await getAdapter(platform);
|
|
198
274
|
if (content_type === 'image_text') {
|
package/package.json
CHANGED
package/src/agent-manager.js
CHANGED
package/src/drivers/codex.js
CHANGED
package/src/drivers/kimi.js
CHANGED
package/src/mcp-config.js
CHANGED
|
@@ -18,7 +18,7 @@ function resolveSkillArg(arg, config) {
|
|
|
18
18
|
return arg;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceDir }) {
|
|
21
|
+
function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, teamId, workspaceDir }) {
|
|
22
22
|
if (serverKey === 'workspace-migrate') {
|
|
23
23
|
return { SERVER_URL: serverUrl, MACHINE_API_KEY: authToken, AGENT_ID: agentId };
|
|
24
24
|
}
|
|
@@ -27,6 +27,7 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceD
|
|
|
27
27
|
SERVER_URL: serverUrl,
|
|
28
28
|
MACHINE_API_KEY: authToken,
|
|
29
29
|
AGENT_ID: agentId,
|
|
30
|
+
TEAM_ID: teamId ?? '',
|
|
30
31
|
WORKSPACE_DIR: workspaceDir,
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -38,6 +39,7 @@ export function buildSkillMcpServers({
|
|
|
38
39
|
credentialGrants,
|
|
39
40
|
config,
|
|
40
41
|
agentId,
|
|
42
|
+
teamId,
|
|
41
43
|
workspaceDir,
|
|
42
44
|
serverUrl,
|
|
43
45
|
authToken,
|
|
@@ -61,7 +63,7 @@ export function buildSkillMcpServers({
|
|
|
61
63
|
args: resolvedArgs,
|
|
62
64
|
env: {
|
|
63
65
|
...resolvedEnv,
|
|
64
|
-
...baseEnvForServer(mc.server, { serverUrl, authToken, agentId, workspaceDir }),
|
|
66
|
+
...baseEnvForServer(mc.server, { serverUrl, authToken, agentId, teamId, workspaceDir }),
|
|
65
67
|
},
|
|
66
68
|
};
|
|
67
69
|
}
|