@lightcone-ai/daemon 0.9.71 → 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.
|
@@ -2,26 +2,55 @@
|
|
|
2
2
|
* Douyin (抖音) publisher adapter.
|
|
3
3
|
* Uses 抖音创作服务平台: https://creator.douyin.com
|
|
4
4
|
*/
|
|
5
|
+
import { formatTextWithTags } from '../text.js';
|
|
5
6
|
|
|
6
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
|
+
}
|
|
7
18
|
|
|
8
19
|
const REQUIREMENTS = {
|
|
9
20
|
image_text: {
|
|
10
21
|
max_text_length: 2200,
|
|
11
22
|
max_images: 35,
|
|
12
23
|
image_formats: ['jpg', 'jpeg', 'png', 'webp'],
|
|
13
|
-
required_fields: ['text'],
|
|
14
|
-
notes: '图文模式最多 35 张图;正文最多 2200
|
|
24
|
+
required_fields: ['text', 'images'],
|
|
25
|
+
notes: '图文模式最多 35 张图;正文最多 2200 字;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
|
|
15
26
|
},
|
|
16
27
|
short_video: {
|
|
17
28
|
max_text_length: 2200,
|
|
18
29
|
video_max_duration: 900,
|
|
19
30
|
video_formats: ['mp4', 'mov', 'webm'],
|
|
20
31
|
required_fields: ['text', 'video'],
|
|
21
|
-
notes: '视频最长 15 分钟;建议 9:16
|
|
32
|
+
notes: '视频最长 15 分钟;建议 9:16 竖版;默认停在最终发布前,需设置 DOUYIN_PUBLISH_DRY_RUN=0 才会点击发布',
|
|
22
33
|
},
|
|
23
34
|
};
|
|
24
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
|
+
|
|
25
54
|
export class DouyinAdapter {
|
|
26
55
|
constructor(cdp) {
|
|
27
56
|
this._cdp = cdp;
|
|
@@ -32,68 +61,118 @@ export class DouyinAdapter {
|
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
async checkLoginStatus() {
|
|
35
|
-
const
|
|
36
|
-
const cookies = result.cookies ?? [];
|
|
37
|
-
return cookies.some(c => (c.name === 'sessionid' || c.name === 'sid_guard') && c.value?.length > 0);
|
|
38
|
-
}
|
|
64
|
+
const profileDir = process.env.DOUYIN_PROFILE_DIR ?? '(not set)';
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
await this._cdp.send('Page.navigate', { url: CREATOR_HOME_URL });
|
|
67
|
+
await sleep(5000);
|
|
42
68
|
|
|
43
|
-
|
|
44
|
-
await this.
|
|
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
|
+
}
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
async publishImageText({ title, text, tags = [], images = [] }) {
|
|
77
|
+
await this._openPublishComposer('image');
|
|
78
|
+
await this._assertReadyForPublish();
|
|
79
|
+
await humanPause(2500, 5500, 'composer-ready');
|
|
48
80
|
|
|
49
81
|
if (images.length > 0) {
|
|
82
|
+
await this._waitForSelector(FILE_INPUT_SELECTOR, 15000);
|
|
83
|
+
await humanPause(900, 2200, 'before-upload');
|
|
50
84
|
await this._uploadFiles(images);
|
|
51
|
-
await
|
|
85
|
+
await this._waitForUploadSettled(images.length, 120_000);
|
|
86
|
+
await humanPause(2500, 5500, 'after-upload');
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
const fullText =
|
|
55
|
-
await this._fillField(
|
|
89
|
+
const fullText = formatTextWithTags(text, tags);
|
|
90
|
+
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
56
91
|
|
|
57
92
|
if (title) {
|
|
58
|
-
await this._fillField(
|
|
93
|
+
await this._fillField(TITLE_SELECTOR, title);
|
|
59
94
|
}
|
|
60
95
|
|
|
61
|
-
await
|
|
62
|
-
await this.
|
|
63
|
-
await
|
|
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
|
+
}
|
|
64
104
|
|
|
65
|
-
|
|
66
|
-
|
|
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 };
|
|
67
110
|
}
|
|
68
111
|
|
|
69
112
|
async publishShortVideo({ title, text, tags = [], video, cover }) {
|
|
70
|
-
|
|
113
|
+
await this._openPublishComposer('video');
|
|
114
|
+
await this._assertReadyForPublish();
|
|
115
|
+
await humanPause(2500, 5500, 'composer-ready');
|
|
71
116
|
|
|
72
|
-
await
|
|
73
|
-
await
|
|
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');
|
|
74
122
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await this._uploadFiles([video], 'video');
|
|
79
|
-
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
|
+
}
|
|
80
126
|
|
|
81
|
-
const fullText =
|
|
82
|
-
await this._fillField(
|
|
127
|
+
const fullText = formatTextWithTags(text, tags);
|
|
128
|
+
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
83
129
|
|
|
84
130
|
if (title) {
|
|
85
|
-
await this._fillField(
|
|
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 };
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
await
|
|
89
|
-
await this._clickByText('发布');
|
|
90
|
-
|
|
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
|
+
}
|
|
91
149
|
|
|
92
|
-
|
|
93
|
-
|
|
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');
|
|
94
155
|
}
|
|
95
156
|
|
|
96
|
-
|
|
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
|
+
}
|
|
97
176
|
|
|
98
177
|
async _waitForSelector(selector, timeoutMs = 8000) {
|
|
99
178
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -108,34 +187,133 @@ export class DouyinAdapter {
|
|
|
108
187
|
throw new Error(`Timeout waiting for selector: ${selector}`);
|
|
109
188
|
}
|
|
110
189
|
|
|
111
|
-
async
|
|
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) {
|
|
112
260
|
const deadline = Date.now() + timeoutMs;
|
|
113
261
|
while (Date.now() < deadline) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
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;
|
|
120
272
|
}
|
|
121
|
-
throw new Error(
|
|
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}`);
|
|
122
293
|
}
|
|
123
294
|
|
|
124
295
|
async _fillField(selector, value) {
|
|
125
|
-
await
|
|
296
|
+
await humanPause(500, 1400, 'before-focus-field');
|
|
297
|
+
const result = await this._cdp.send('Runtime.evaluate', {
|
|
126
298
|
expression: `
|
|
127
299
|
(function() {
|
|
128
300
|
const el = document.querySelector(${JSON.stringify(selector)});
|
|
129
301
|
if (!el) return false;
|
|
130
302
|
el.focus();
|
|
131
303
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
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 = '';
|
|
135
307
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
136
|
-
el.
|
|
308
|
+
el.select?.();
|
|
137
309
|
} else {
|
|
138
|
-
|
|
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();
|
|
139
317
|
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
140
318
|
}
|
|
141
319
|
return true;
|
|
@@ -143,12 +321,39 @@ export class DouyinAdapter {
|
|
|
143
321
|
`,
|
|
144
322
|
returnByValue: true,
|
|
145
323
|
});
|
|
146
|
-
|
|
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
|
+
}
|
|
147
351
|
}
|
|
148
352
|
|
|
149
353
|
async _uploadFiles(filePaths) {
|
|
354
|
+
await humanPause(600, 1700, 'before-set-files');
|
|
150
355
|
const result = await this._cdp.send('Runtime.evaluate', {
|
|
151
|
-
expression: `document.querySelector(
|
|
356
|
+
expression: `document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)})`,
|
|
152
357
|
returnByValue: false,
|
|
153
358
|
});
|
|
154
359
|
if (!result.result?.objectId) throw new Error('No file input found on page');
|
|
@@ -156,21 +361,32 @@ export class DouyinAdapter {
|
|
|
156
361
|
objectId: result.result.objectId,
|
|
157
362
|
files: filePaths,
|
|
158
363
|
});
|
|
159
|
-
await
|
|
364
|
+
await humanPause(1200, 2600, 'after-set-files');
|
|
160
365
|
}
|
|
161
366
|
|
|
162
367
|
async _clickByText(text) {
|
|
163
|
-
await this._cdp.send('Runtime.evaluate', {
|
|
368
|
+
const result = await this._cdp.send('Runtime.evaluate', {
|
|
164
369
|
expression: `
|
|
165
370
|
(function() {
|
|
166
371
|
const els = [...document.querySelectorAll('button, [role="button"]')];
|
|
167
|
-
const el = els.find(e => e.innerText?.trim() === ${JSON.stringify(text)} || e.textContent?.trim() === ${JSON.stringify(text)});
|
|
168
|
-
if (el)
|
|
169
|
-
|
|
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 };
|
|
170
376
|
})()
|
|
171
377
|
`,
|
|
172
378
|
returnByValue: true,
|
|
173
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;
|
|
174
390
|
}
|
|
175
391
|
|
|
176
392
|
async _getUrl() {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Kuaishou (快手) publisher adapter.
|
|
3
3
|
* Uses 快手创作者平台: https://cp.kuaishou.com
|
|
4
4
|
*/
|
|
5
|
+
import { formatTextWithTags } from '../text.js';
|
|
5
6
|
|
|
6
7
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
7
8
|
|
|
@@ -55,7 +56,7 @@ export class KuaishouAdapter {
|
|
|
55
56
|
await this._fillField('[placeholder*="标题"]', title);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
const fullText =
|
|
59
|
+
const fullText = formatTextWithTags(text, tags);
|
|
59
60
|
await this._fillField('[placeholder*="描述"], [contenteditable]', fullText);
|
|
60
61
|
|
|
61
62
|
await sleep(1000);
|
|
@@ -82,7 +83,7 @@ export class KuaishouAdapter {
|
|
|
82
83
|
await this._fillField('[placeholder*="标题"]', title);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
const fullText =
|
|
86
|
+
const fullText = formatTextWithTags(text, tags);
|
|
86
87
|
await this._fillField('[placeholder*="描述"], [contenteditable]', fullText);
|
|
87
88
|
|
|
88
89
|
await sleep(1000);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* XHS (小红书) publisher adapter.
|
|
3
3
|
* Uses deterministic CDP operations — no AI vision required.
|
|
4
4
|
*/
|
|
5
|
+
import { formatTextWithTags } from '../text.js';
|
|
5
6
|
|
|
6
7
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
7
8
|
function randomInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
@@ -97,8 +98,8 @@ export class XhsAdapter {
|
|
|
97
98
|
await humanPause(1200, 2800, 'after-title');
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// Fill content text
|
|
101
|
-
const fullText =
|
|
101
|
+
// Fill content text and only append tags that are not already present.
|
|
102
|
+
const fullText = formatTextWithTags(text, tags);
|
|
102
103
|
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
103
104
|
|
|
104
105
|
await humanPause(4500, 9000, 'before-publish-check');
|
|
@@ -142,7 +143,7 @@ export class XhsAdapter {
|
|
|
142
143
|
await humanPause(1200, 2800, 'after-title');
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
const fullText =
|
|
146
|
+
const fullText = formatTextWithTags(text, tags);
|
|
146
147
|
await this._fillField(CONTENT_SELECTOR, fullText);
|
|
147
148
|
|
|
148
149
|
await humanPause(4500, 9000, 'before-publish-check');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function escapeRegExp(value) {
|
|
2
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function normalizeTags(tags = []) {
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const normalized = [];
|
|
8
|
+
for (const raw of tags) {
|
|
9
|
+
const tag = String(raw ?? '').trim().replace(/^[##]+/, '').trim();
|
|
10
|
+
if (!tag || seen.has(tag)) continue;
|
|
11
|
+
seen.add(tag);
|
|
12
|
+
normalized.push(tag);
|
|
13
|
+
}
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatTextWithTags(text, tags = []) {
|
|
18
|
+
const base = String(text ?? '').trimEnd();
|
|
19
|
+
const missingTags = normalizeTags(tags).filter(tag => {
|
|
20
|
+
const pattern = new RegExp(`(^|[\\s,,。!?!?::;;、])(?:#|#)${escapeRegExp(tag)}(?=$|[\\s,,。!?!?::;;、])`);
|
|
21
|
+
return !pattern.test(base);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (missingTags.length === 0) return base;
|
|
25
|
+
const suffix = missingTags.map(tag => `#${tag}`).join(' ');
|
|
26
|
+
return base ? `${base}\n${suffix}` : suffix;
|
|
27
|
+
}
|