@lightcone-ai/daemon 0.9.72 → 0.9.73

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.
@@ -5,24 +5,52 @@
5
5
  import { formatTextWithTags } from '../text.js';
6
6
 
7
7
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
8
+ function randomInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
9
+
10
+ const PACE_SCALE = Math.max(0.2, Number(process.env.PUBLISHER_PACE_SCALE ?? '1') || 1);
11
+ const DRY_RUN = process.env.DOUYIN_PUBLISH_DRY_RUN !== '0';
12
+
13
+ async function humanPause(minMs, maxMs, label = '') {
14
+ const ms = Math.round(randomInt(minMs, maxMs) * PACE_SCALE);
15
+ if (label) console.error(`[DouyinAdapter] pause ${label}: ${ms}ms`);
16
+ await sleep(ms);
17
+ }
8
18
 
9
19
  const REQUIREMENTS = {
10
20
  image_text: {
11
21
  max_text_length: 2200,
12
22
  max_images: 35,
13
23
  image_formats: ['jpg', 'jpeg', 'png', 'webp'],
14
- required_fields: ['text'],
15
- notes: '图文模式最多 35 张图;正文最多 2200 ',
24
+ required_fields: ['text', 'images'],
25
+ notes: '图文模式最多 35 张图;正文最多 2200 字;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
16
26
  },
17
27
  short_video: {
18
28
  max_text_length: 2200,
19
29
  video_max_duration: 900,
20
30
  video_formats: ['mp4', 'mov', 'webm'],
21
31
  required_fields: ['text', 'video'],
22
- notes: '视频最长 15 分钟;建议 9:16 竖版;封面图自动从视频提取或可自定义',
32
+ notes: '视频最长 15 分钟;建议 9:16 竖版;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
23
33
  },
24
34
  };
25
35
 
36
+ const CREATOR_HOME_URL = 'https://creator.douyin.com/creator-micro/home';
37
+ const IMAGE_PUBLISH_URL = 'https://creator.douyin.com/creator-micro/content/upload-image-text';
38
+ const VIDEO_PUBLISH_URL = 'https://creator.douyin.com/creator-micro/content/upload';
39
+
40
+ const FILE_INPUT_SELECTOR = 'input[type="file"]';
41
+ const TITLE_SELECTOR = 'input[placeholder*="标题"], textarea[placeholder*="标题"]';
42
+ const CONTENT_SELECTOR = '[contenteditable="true"], textarea[placeholder*="描述"], textarea[placeholder*="作品描述"], [placeholder*="添加作品描述"]';
43
+
44
+ const ERROR_SELECTORS = [
45
+ '[class*="error"]',
46
+ '[class*="fail"]',
47
+ '[class*="tips"]',
48
+ '[class*="toast"]',
49
+ '[class*="Toast"]',
50
+ '[class*="message"]',
51
+ '[role="alert"]',
52
+ ];
53
+
26
54
  export class DouyinAdapter {
27
55
  constructor(cdp) {
28
56
  this._cdp = cdp;
@@ -33,68 +61,118 @@ export class DouyinAdapter {
33
61
  }
34
62
 
35
63
  async checkLoginStatus() {
36
- const result = await this._cdp.send('Network.getAllCookies', {});
37
- const cookies = result.cookies ?? [];
38
- return cookies.some(c => (c.name === 'sessionid' || c.name === 'sid_guard') && c.value?.length > 0);
39
- }
64
+ const profileDir = process.env.DOUYIN_PROFILE_DIR ?? '(not set)';
40
65
 
41
- async publishImageText({ title, text, tags = [], images = [] }) {
42
- const cdp = this._cdp;
66
+ await this._cdp.send('Page.navigate', { url: CREATOR_HOME_URL });
67
+ await sleep(5000);
43
68
 
44
- await cdp.send('Page.navigate', { url: 'https://creator.douyin.com/creator-micro/content/upload-image-text' });
45
- await this._waitForSelector('[class*="upload"], input[type="file"]', 12000);
69
+ const state = await this._inspectPage();
70
+ const hasSessionCookie = await this._hasSessionCookie();
71
+ const loggedIn = hasSessionCookie && !state.hasLoginHint;
72
+ console.error(`[DouyinAdapter] checkLoginStatus: loggedIn=${loggedIn} url=${state.url}`);
73
+ return { loggedIn, url: state.url, profileDir, userId: null, nickname: null };
74
+ }
46
75
 
47
- const loggedIn = await this.checkLoginStatus();
48
- if (!loggedIn) throw new Error('LOGIN_EXPIRED: 抖音登录已过期,请重新扫码连接');
76
+ async publishImageText({ title, text, tags = [], images = [] }) {
77
+ await this._openPublishComposer('image');
78
+ await this._assertReadyForPublish();
79
+ await humanPause(2500, 5500, 'composer-ready');
49
80
 
50
81
  if (images.length > 0) {
82
+ await this._waitForSelector(FILE_INPUT_SELECTOR, 15000);
83
+ await humanPause(900, 2200, 'before-upload');
51
84
  await this._uploadFiles(images);
52
- await sleep(3000);
85
+ await this._waitForUploadSettled(images.length, 120_000);
86
+ await humanPause(2500, 5500, 'after-upload');
53
87
  }
54
88
 
55
89
  const fullText = formatTextWithTags(text, tags);
56
- await this._fillField('[class*="content"] [contenteditable], [placeholder*="添加作品描述"]', fullText);
90
+ await this._fillField(CONTENT_SELECTOR, fullText);
57
91
 
58
92
  if (title) {
59
- await this._fillField('[placeholder*="标题"]', title);
93
+ await this._fillField(TITLE_SELECTOR, title);
60
94
  }
61
95
 
62
- await sleep(1000);
63
- await this._clickByText('发布');
64
- await sleep(4000);
96
+ await humanPause(4500, 9000, 'before-publish-check');
97
+ await this._assertNoBlockingErrors();
98
+ await this._assertPublishButtonReady();
99
+
100
+ if (DRY_RUN) {
101
+ console.error('[DouyinAdapter] DOUYIN_PUBLISH_DRY_RUN enabled; skipping final publish click.');
102
+ return { success: true, dry_run: true, post_url: null };
103
+ }
65
104
 
66
- const currentUrl = await this._getUrl();
67
- return { success: true, post_url: currentUrl };
105
+ await humanPause(1200, 3000, 'before-publish-click');
106
+ const clicked = await this._clickByText('发布');
107
+ if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
108
+ const result = await this._waitForPublishResult(120_000);
109
+ return { success: true, post_url: result.postUrl || result.url };
68
110
  }
69
111
 
70
112
  async publishShortVideo({ title, text, tags = [], video, cover }) {
71
- const cdp = this._cdp;
113
+ await this._openPublishComposer('video');
114
+ await this._assertReadyForPublish();
115
+ await humanPause(2500, 5500, 'composer-ready');
72
116
 
73
- await cdp.send('Page.navigate', { url: 'https://creator.douyin.com/creator-micro/content/upload' });
74
- await this._waitForSelector('input[type="file"], [class*="upload"]', 12000);
117
+ await this._waitForSelector(FILE_INPUT_SELECTOR, 15000);
118
+ await humanPause(900, 2200, 'before-upload');
119
+ await this._uploadFiles([video]);
120
+ await this._waitForUploadSettled(1, 180_000);
121
+ await humanPause(3000, 6500, 'after-video-upload');
75
122
 
76
- const loggedIn = await this.checkLoginStatus();
77
- if (!loggedIn) throw new Error('LOGIN_EXPIRED: 抖音登录已过期,请重新扫码连接');
78
-
79
- await this._uploadFiles([video], 'video');
80
- await this._waitForText('上传成功', 120000);
123
+ if (cover) {
124
+ console.error('[DouyinAdapter] cover was provided, but automatic Douyin cover upload is not implemented yet; continuing with platform default cover.');
125
+ }
81
126
 
82
127
  const fullText = formatTextWithTags(text, tags);
83
- await this._fillField('[placeholder*="添加作品描述"], [class*="content"] [contenteditable]', fullText);
128
+ await this._fillField(CONTENT_SELECTOR, fullText);
84
129
 
85
130
  if (title) {
86
- await this._fillField('[placeholder*="标题"]', title);
131
+ await this._fillField(TITLE_SELECTOR, title);
132
+ }
133
+
134
+ await humanPause(4500, 9000, 'before-publish-check');
135
+ await this._assertNoBlockingErrors();
136
+ await this._assertPublishButtonReady();
137
+
138
+ if (DRY_RUN) {
139
+ console.error('[DouyinAdapter] DOUYIN_PUBLISH_DRY_RUN enabled; skipping final publish click.');
140
+ return { success: true, dry_run: true, post_url: null };
87
141
  }
88
142
 
89
- await sleep(1000);
90
- await this._clickByText('发布');
91
- await sleep(4000);
143
+ await humanPause(1200, 3000, 'before-publish-click');
144
+ const clicked = await this._clickByText('发布');
145
+ if (!clicked) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
146
+ const result = await this._waitForPublishResult(120_000);
147
+ return { success: true, post_url: result.postUrl || result.url };
148
+ }
92
149
 
93
- const currentUrl = await this._getUrl();
94
- return { success: true, post_url: currentUrl };
150
+ async _openPublishComposer(kind) {
151
+ const url = kind === 'video' ? VIDEO_PUBLISH_URL : IMAGE_PUBLISH_URL;
152
+ await this._cdp.send('Page.navigate', { url });
153
+ await this._waitForCreatorShell(20_000);
154
+ await humanPause(1800, 4200, 'after-navigation');
95
155
  }
96
156
 
97
- // ── CDP helpers (shared pattern with xhs adapter) ───────────────────────────
157
+ async _waitForCreatorShell(timeoutMs = 20_000) {
158
+ const deadline = Date.now() + timeoutMs;
159
+ while (Date.now() < deadline) {
160
+ const state = await this._inspectPage();
161
+ if (state.hasLoginHint) {
162
+ throw new Error(`LOGIN_EXPIRED: 当前页面 ${state.url},请重新扫码连接抖音`);
163
+ }
164
+ if (state.hasUploader || state.hasPublishEditor || state.isCreatorLoggedIn) return;
165
+ await sleep(500);
166
+ }
167
+ const state = await this._inspectPage();
168
+ throw new Error(`PUBLISH_FAILED: 抖音创作平台未加载完成,当前页面 ${state.url}`);
169
+ }
170
+
171
+ async _hasSessionCookie() {
172
+ const result = await this._cdp.send('Network.getAllCookies', {});
173
+ const cookies = result.cookies ?? [];
174
+ return cookies.some(c => (c.name === 'sessionid' || c.name === 'sid_guard') && c.value?.length > 0);
175
+ }
98
176
 
99
177
  async _waitForSelector(selector, timeoutMs = 8000) {
100
178
  const deadline = Date.now() + timeoutMs;
@@ -109,34 +187,133 @@ export class DouyinAdapter {
109
187
  throw new Error(`Timeout waiting for selector: ${selector}`);
110
188
  }
111
189
 
112
- async _waitForText(text, timeoutMs = 8000) {
190
+ async _inspectPage() {
191
+ const result = await this._cdp.send('Runtime.evaluate', {
192
+ expression: `
193
+ (function() {
194
+ const text = document.body?.innerText || '';
195
+ const url = window.location.href;
196
+ const visible = (el) => {
197
+ const r = el.getBoundingClientRect();
198
+ const s = getComputedStyle(el);
199
+ return r.width >= 0 && r.height >= 0 && r.left > -1000 && s.display !== 'none' && s.visibility !== 'hidden';
200
+ };
201
+ const errorSelectors = ${JSON.stringify(ERROR_SELECTORS)};
202
+ const errors = [...document.querySelectorAll(errorSelectors.join(','))]
203
+ .filter(visible)
204
+ .map(e => e.innerText || e.textContent || '')
205
+ .map(t => t.trim())
206
+ .filter(Boolean)
207
+ .slice(0, 5);
208
+ const buttons = [...document.querySelectorAll('button, [role="button"]')].map(e => ({
209
+ text: (e.innerText || e.textContent || '').trim(),
210
+ disabled: !!e.disabled || e.getAttribute('aria-disabled') === 'true' || e.className?.toString().includes('disabled'),
211
+ }));
212
+ return {
213
+ url,
214
+ text: text.slice(0, 5000),
215
+ errors,
216
+ buttons,
217
+ hasLoginHint: /登录|扫码|验证码|手机号/.test(text) && !/发布|作品管理|创作服务平台/.test(text),
218
+ isCreatorLoggedIn: /创作服务平台|创作者中心|发布作品|作品管理|内容管理/.test(text),
219
+ hasUploader: !!document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)}),
220
+ hasPublishEditor: !!document.querySelector(${JSON.stringify(`${TITLE_SELECTOR}, ${CONTENT_SELECTOR}`)}) && /发布|作品描述|标题/.test(text),
221
+ postUrl: document.querySelector('a[href*="/video/"]')?.href || document.querySelector('a[href*="/note/"]')?.href || '',
222
+ };
223
+ })()
224
+ `,
225
+ returnByValue: true,
226
+ });
227
+ return result.result?.value ?? { url: await this._getUrl(), text: '', errors: [], buttons: [] };
228
+ }
229
+
230
+ async _assertReadyForPublish() {
231
+ const state = await this._inspectPage();
232
+ const hasSessionCookie = await this._hasSessionCookie();
233
+ if (!hasSessionCookie || state.hasLoginHint) {
234
+ throw new Error(`LOGIN_EXPIRED: 当前页面 ${state.url},请重新扫码连接抖音`);
235
+ }
236
+ if (!state.hasUploader && !state.hasPublishEditor) {
237
+ await this._assertNoBlockingErrors();
238
+ }
239
+ }
240
+
241
+ async _assertNoBlockingErrors() {
242
+ const state = await this._inspectPage();
243
+ const blocking = (state.errors ?? []).find(t =>
244
+ /失败|错误|异常|不可|不能|未通过|风控|审核|违规|实名|认证|权限|请先|过期|登录/.test(t)
245
+ );
246
+ if (blocking) throw new Error(`PUBLISH_BLOCKED: ${blocking}`);
247
+ }
248
+
249
+ async _assertPublishButtonReady() {
250
+ const state = await this._inspectPage();
251
+ const button = (state.buttons ?? []).find(b => b.text === '发布' || b.text?.includes('发布'));
252
+ if (!button) throw new Error('PUBLISH_FAILED: 找不到发布按钮,页面结构可能已变化');
253
+ if (button.disabled) {
254
+ const errors = (state.errors ?? []).join(';') || '发布按钮不可点击,可能仍在上传、必填项缺失或账号状态受限';
255
+ throw new Error(`PUBLISH_BLOCKED: ${errors}`);
256
+ }
257
+ }
258
+
259
+ async _waitForUploadSettled(expectedCount, timeoutMs) {
113
260
  const deadline = Date.now() + timeoutMs;
114
261
  while (Date.now() < deadline) {
115
- const result = await this._cdp.send('Runtime.evaluate', {
116
- expression: `document.body?.innerText?.includes(${JSON.stringify(text)})`,
117
- returnByValue: true,
118
- });
119
- if (result.result?.value) return;
120
- await sleep(500);
262
+ await sleep(1000);
263
+ await this._assertNoBlockingErrors();
264
+ const state = await this._inspectPage();
265
+ const text = state.text ?? '';
266
+ if (/上传失败|上传出错|格式不支持|文件过大/.test(text)) {
267
+ throw new Error(`UPLOAD_FAILED: ${(state.errors ?? []).join(';') || '页面提示上传失败'}`);
268
+ }
269
+ if (/上传中|处理中|转码中|正在上传|解析中|合成中/.test(text)) continue;
270
+ if (state.hasPublishEditor || /上传成功|重新上传|更换|已上传|封面|发布作品/.test(text)) return;
271
+ if (expectedCount === 0) return;
121
272
  }
122
- throw new Error(`Timeout waiting for text: ${text}`);
273
+ throw new Error('UPLOAD_TIMEOUT: 等待抖音上传完成超时');
274
+ }
275
+
276
+ async _waitForPublishResult(timeoutMs) {
277
+ const deadline = Date.now() + timeoutMs;
278
+ let lastState = null;
279
+ while (Date.now() < deadline) {
280
+ await sleep(1500);
281
+ const state = await this._inspectPage();
282
+ lastState = state;
283
+ const text = state.text ?? '';
284
+ if (state.postUrl) return { url: state.url, postUrl: state.postUrl };
285
+ if (/发布成功|提交成功|审核中|已发布/.test(text)) {
286
+ return { url: state.url, postUrl: state.postUrl || state.url };
287
+ }
288
+ const blocking = (state.errors ?? []).find(t => /失败|错误|异常|不可|不能|未通过|风控|审核|违规|实名|认证|权限/.test(t));
289
+ if (blocking) throw new Error(`PUBLISH_FAILED: ${blocking}`);
290
+ }
291
+ const hint = (lastState?.errors ?? []).join(';') || '没有检测到成功跳转或成功提示';
292
+ throw new Error(`PUBLISH_TIMEOUT: ${hint}`);
123
293
  }
124
294
 
125
295
  async _fillField(selector, value) {
126
- await this._cdp.send('Runtime.evaluate', {
296
+ await humanPause(500, 1400, 'before-focus-field');
297
+ const result = await this._cdp.send('Runtime.evaluate', {
127
298
  expression: `
128
299
  (function() {
129
300
  const el = document.querySelector(${JSON.stringify(selector)});
130
301
  if (!el) return false;
131
302
  el.focus();
132
303
  if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
133
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
134
- || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
135
- nativeInputValueSetter?.call(el, ${JSON.stringify(value)});
304
+ const nativeValueSetter = Object.getOwnPropertyDescriptor(el.tagName === 'INPUT' ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype, 'value')?.set;
305
+ if (nativeValueSetter) nativeValueSetter.call(el, '');
306
+ else el.value = '';
136
307
  el.dispatchEvent(new Event('input', { bubbles: true }));
137
- el.dispatchEvent(new Event('change', { bubbles: true }));
308
+ el.select?.();
138
309
  } else {
139
- el.innerText = ${JSON.stringify(value)};
310
+ const range = document.createRange();
311
+ range.selectNodeContents(el);
312
+ const selection = window.getSelection();
313
+ selection.removeAllRanges();
314
+ selection.addRange(range);
315
+ document.execCommand?.('delete', false, null);
316
+ el.focus();
140
317
  el.dispatchEvent(new InputEvent('input', { bubbles: true }));
141
318
  }
142
319
  return true;
@@ -144,12 +321,39 @@ export class DouyinAdapter {
144
321
  `,
145
322
  returnByValue: true,
146
323
  });
147
- await sleep(300);
324
+ if (result.result?.value !== true) throw new Error(`PUBLISH_FAILED: 找不到输入框:${selector}`);
325
+ await this._typeTextHumanLike(value);
326
+ await this._cdp.send('Runtime.evaluate', {
327
+ expression: `
328
+ (function() {
329
+ const el = document.activeElement;
330
+ if (!el) return false;
331
+ el.dispatchEvent(new Event('change', { bubbles: true }));
332
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
333
+ return true;
334
+ })()
335
+ `,
336
+ returnByValue: true,
337
+ });
338
+ await humanPause(700, 1800, 'after-fill-field');
339
+ }
340
+
341
+ async _typeTextHumanLike(value) {
342
+ const text = String(value ?? '');
343
+ let i = 0;
344
+ while (i < text.length) {
345
+ const chunkSize = randomInt(10, 26);
346
+ const chunk = text.slice(i, i + chunkSize);
347
+ await this._cdp.send('Input.insertText', { text: chunk });
348
+ i += chunk.length;
349
+ await humanPause(90, 260);
350
+ }
148
351
  }
149
352
 
150
353
  async _uploadFiles(filePaths) {
354
+ await humanPause(600, 1700, 'before-set-files');
151
355
  const result = await this._cdp.send('Runtime.evaluate', {
152
- expression: `document.querySelector('input[type="file"]')`,
356
+ expression: `document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)})`,
153
357
  returnByValue: false,
154
358
  });
155
359
  if (!result.result?.objectId) throw new Error('No file input found on page');
@@ -157,21 +361,32 @@ export class DouyinAdapter {
157
361
  objectId: result.result.objectId,
158
362
  files: filePaths,
159
363
  });
160
- await sleep(500);
364
+ await humanPause(1200, 2600, 'after-set-files');
161
365
  }
162
366
 
163
367
  async _clickByText(text) {
164
- await this._cdp.send('Runtime.evaluate', {
368
+ const result = await this._cdp.send('Runtime.evaluate', {
165
369
  expression: `
166
370
  (function() {
167
371
  const els = [...document.querySelectorAll('button, [role="button"]')];
168
- const el = els.find(e => e.innerText?.trim() === ${JSON.stringify(text)} || e.textContent?.trim() === ${JSON.stringify(text)});
169
- if (el) { el.click(); return true; }
170
- return false;
372
+ const el = els.find(e => e.innerText?.trim() === ${JSON.stringify(text)} || e.textContent?.trim() === ${JSON.stringify(text)} || e.innerText?.includes(${JSON.stringify(text)}));
373
+ if (!el) return null;
374
+ const r = el.getBoundingClientRect();
375
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
171
376
  })()
172
377
  `,
173
378
  returnByValue: true,
174
379
  });
380
+ const point = result.result?.value;
381
+ if (!point) return false;
382
+ await humanPause(500, 1400, `before-click-${text}`);
383
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
384
+ await humanPause(120, 420);
385
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 });
386
+ await humanPause(80, 220);
387
+ await this._cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 });
388
+ await humanPause(1500, 3500, `after-click-${text}`);
389
+ return true;
175
390
  }
176
391
 
177
392
  async _getUrl() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.9.72",
3
+ "version": "0.9.73",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {