@bsbofmusic/cdper-doubao 1.1.4 → 1.1.6
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/bin/cdper-doubao.cjs +1 -1
- package/{src → dist}/cli.js +15 -1
- package/dist/doubao.js +829 -0
- package/manifest.json +4 -4
- package/package.json +12 -12
- package/src/doubao.js +0 -793
- /package/{src → dist}/public-api.d.ts +0 -0
- /package/{src → dist}/public-api.js +0 -0
package/dist/doubao.js
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* cdper-doubao — 豆包 CDP 查询逻辑
|
|
5
|
+
* Platform-specific DOM logic only. All shared helpers live in @bsbofmusic/cdper-core/plugin_helpers.
|
|
6
|
+
*/
|
|
7
|
+
const { log } = require('@bsbofmusic/cdper-core/logger');
|
|
8
|
+
const { connectWithWarmReuse, disconnect } = require('@bsbofmusic/cdper-core/cdp');
|
|
9
|
+
const { resolveCdpWs } = require('@bsbofmusic/cdper-core/cdp-ws');
|
|
10
|
+
const { waitForDoubaoReply, captureDoubaoReplySnapshot, detectChallenge } = require('@bsbofmusic/cdper-core/smart_wait');
|
|
11
|
+
const { updateSession } = require('@bsbofmusic/cdper-core/session');
|
|
12
|
+
const { createPluginContext, buildPluginSuccess } = require('@bsbofmusic/cdper-core/plugin_contract');
|
|
13
|
+
const { runState } = require('@bsbofmusic/cdper-core/state_machine');
|
|
14
|
+
const { getRuntimeStatus } = require('@bsbofmusic/cdper-core/runtime_status');
|
|
15
|
+
const { WRAPPER_STATUSES, buildWrapperResult } = require('@bsbofmusic/cdper-core/wrapper_result');
|
|
16
|
+
const { randomInt, humanPause, withLocalTimeout, throwIfAborted, ensureMinimumPageDwell, humanWarmup, computeSendTimeoutMs, insertTextViaCdp, buildWaitPlan, createProgressRecorder, resolveConversationPolicy, buildConversationAnchor, prefersShortAnswer, handlePluginQueryError, verifySubmission, } = require('@bsbofmusic/cdper-core/plugin_helpers');
|
|
17
|
+
const PLATFORM = 'doubao';
|
|
18
|
+
const STATE_TIMEOUTS = { open: 150000, ensure_session: 30000, ensure_mode: 45000, send: 45000, extract: 10000 };
|
|
19
|
+
const WAIT_PROFILES = {
|
|
20
|
+
short: { minWait: 8000, maxWait: 90000, maxExtends: 2, pollInterval: 2500 },
|
|
21
|
+
normal: { minWait: 12000, maxWait: 300000, maxExtends: 6, pollInterval: 2500 },
|
|
22
|
+
long: { minWait: 20000, maxWait: 20 * 60 * 1000, maxExtends: 8, pollInterval: 2500 },
|
|
23
|
+
very_long: { minWait: 30000, maxWait: 45 * 60 * 1000, maxExtends: 10, pollInterval: 2500 },
|
|
24
|
+
};
|
|
25
|
+
function emptyDoubaoSnapshot() {
|
|
26
|
+
return { text: '', normalized: '', length: 0, candidateCount: 0 };
|
|
27
|
+
}
|
|
28
|
+
function buildDoubaoModeState(expertModeConfirmed) {
|
|
29
|
+
return {
|
|
30
|
+
modePath: expertModeConfirmed ? 'composer_expert' : 'composer_quick',
|
|
31
|
+
modeConfirmed: Boolean(expertModeConfirmed),
|
|
32
|
+
modeConfidence: expertModeConfirmed ? 'high' : 'low',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ─── Conversation anchor ──────────────────────────────────────────────────────
|
|
36
|
+
function isDoubaoUrl(url) {
|
|
37
|
+
return /doubao\.com/.test(String(url || ''));
|
|
38
|
+
}
|
|
39
|
+
function getDoubaoConversationId(url) {
|
|
40
|
+
const match = String(url || '').match(/https:\/\/(?:www\.)?doubao\.com\/chat\/([^/?#]+)/);
|
|
41
|
+
if (!match || match[1] === 'url-action')
|
|
42
|
+
return null;
|
|
43
|
+
return match[1];
|
|
44
|
+
}
|
|
45
|
+
function hasStrongAnchor(session) {
|
|
46
|
+
const conversation = session?.conversation || {};
|
|
47
|
+
if (conversation.platform !== PLATFORM || !conversation.anchorUrl)
|
|
48
|
+
return false;
|
|
49
|
+
return conversation.anchorConfidence === 'strong' || !!getDoubaoConversationId(conversation.anchorUrl);
|
|
50
|
+
}
|
|
51
|
+
function getStoredConversationId(session) {
|
|
52
|
+
const conversation = session?.conversation || {};
|
|
53
|
+
return conversation.conversationId || getDoubaoConversationId(conversation.anchorUrl);
|
|
54
|
+
}
|
|
55
|
+
function assertFollowupAnchorActive(page, session) {
|
|
56
|
+
const expectedId = getStoredConversationId(session);
|
|
57
|
+
const actualId = getDoubaoConversationId(page.url());
|
|
58
|
+
if (!expectedId || actualId !== expectedId) {
|
|
59
|
+
throw new Error('Doubao followup anchor was not active after navigation; refusing to send into an unanchored chat');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ─── Page readiness ───────────────────────────────────────────────────────────
|
|
63
|
+
async function findDoubaoInput(page) {
|
|
64
|
+
const handles = await page.$$('textarea, [contenteditable="true"], [role="textbox"]');
|
|
65
|
+
for (const handle of handles) {
|
|
66
|
+
try {
|
|
67
|
+
const box = await handle.boundingBox();
|
|
68
|
+
if (!box || box.width < 80 || box.height < 20)
|
|
69
|
+
continue;
|
|
70
|
+
const meta = await page.evaluate((el) => ({
|
|
71
|
+
tag: el.tagName, placeholder: el.getAttribute('placeholder') || '',
|
|
72
|
+
contenteditable: el.getAttribute('contenteditable') || '', role: el.getAttribute('role') || '',
|
|
73
|
+
disabled: !!el.disabled, ariaHidden: el.getAttribute('aria-hidden') || '',
|
|
74
|
+
}), handle);
|
|
75
|
+
if (meta.disabled || meta.ariaHidden === 'true')
|
|
76
|
+
continue;
|
|
77
|
+
return { handle, meta };
|
|
78
|
+
}
|
|
79
|
+
catch (_) { }
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
async function hasDoubaoInput(page, timeoutMs = 12000) {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
const found = await findDoubaoInput(page);
|
|
87
|
+
if (found)
|
|
88
|
+
return found;
|
|
89
|
+
await humanPause(250, 500);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
async function waitForDoubaoPredicate(page, predicate, options = {}) {
|
|
94
|
+
const { timeoutMs = 12000, intervalMs = 250, stableCount = 1, sessionId, challengeCheck = true } = options;
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
let hits = 0;
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
if (challengeCheck)
|
|
99
|
+
await assertNoChallenge(page, sessionId);
|
|
100
|
+
const matched = await predicate();
|
|
101
|
+
if (matched) {
|
|
102
|
+
hits += 1;
|
|
103
|
+
if (hits >= stableCount)
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
hits = 0;
|
|
108
|
+
await humanPause(intervalMs, intervalMs + 60);
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
async function getDoubaoUiState(page) {
|
|
113
|
+
return page.evaluate(() => {
|
|
114
|
+
const bodyText = document.body?.innerText || '';
|
|
115
|
+
const viewportHeight = window.innerHeight || 0;
|
|
116
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
117
|
+
const visibleButton = (button) => {
|
|
118
|
+
const text = (button.innerText || '').trim();
|
|
119
|
+
const rect = button.getBoundingClientRect();
|
|
120
|
+
return { text, visible: rect.width >= 28 && rect.height >= 20 && rect.y <= viewportHeight && rect.bottom >= 0 };
|
|
121
|
+
};
|
|
122
|
+
const quickButton = buttons.find((button) => {
|
|
123
|
+
const meta = visibleButton(button);
|
|
124
|
+
return ['快速', 'Quick'].includes(meta.text) && meta.visible;
|
|
125
|
+
});
|
|
126
|
+
const expertButton = buttons.find((button) => /^(?:专家|Expert)($|\n)/.test(visibleButton(button).text) && visibleButton(button).visible);
|
|
127
|
+
const superModeButton = buttons.find((button) => /^(?:超能模式|Super|Deep)($|\n)/i.test(visibleButton(button).text) && visibleButton(button).visible);
|
|
128
|
+
const menu = document.querySelector('[role="menu"]');
|
|
129
|
+
const menuItems = menu
|
|
130
|
+
? Array.from(menu.querySelectorAll('[role="menuitem"]')).map((node) => ({
|
|
131
|
+
text: (node.innerText || '').trim(),
|
|
132
|
+
visible: (() => {
|
|
133
|
+
const rect = node.getBoundingClientRect();
|
|
134
|
+
return rect.width >= 28 && rect.height >= 20;
|
|
135
|
+
})(),
|
|
136
|
+
}))
|
|
137
|
+
: [];
|
|
138
|
+
const hasInput = Array.from(document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')).some((element) => {
|
|
139
|
+
const rect = element.getBoundingClientRect();
|
|
140
|
+
if (rect.width < 80 || rect.height < 20)
|
|
141
|
+
return false;
|
|
142
|
+
const ariaHidden = element.getAttribute('aria-hidden') || '';
|
|
143
|
+
return ariaHidden !== 'true' && !element.disabled;
|
|
144
|
+
});
|
|
145
|
+
const loading = !!document.querySelector('[class*="loading"], [class*="spinner"], [class*="skeleton"]');
|
|
146
|
+
const unavailable = bodyText.includes('该页面暂时不可用');
|
|
147
|
+
return { url: location.href, title: document.title || '', hasInput, hasQuickButton: !!quickButton, hasExpertButton: !!expertButton, hasSuperModeButton: !!superModeButton, menuVisible: !!menu, menuItems, loading, unavailable };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async function waitForDoubaoReady(page, options = {}) {
|
|
151
|
+
const { timeoutMs = 24000, sessionId } = options;
|
|
152
|
+
return waitForDoubaoPredicate(page, async () => {
|
|
153
|
+
const state = await getDoubaoUiState(page);
|
|
154
|
+
if (state.unavailable) {
|
|
155
|
+
await recoverDoubaoUnavailablePage(page);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return state.hasInput;
|
|
159
|
+
}, { timeoutMs, intervalMs: 300, stableCount: 2, sessionId });
|
|
160
|
+
}
|
|
161
|
+
async function waitForDoubaoModeSettled(page, options = {}) {
|
|
162
|
+
const { timeoutMs = 7000, sessionId } = options;
|
|
163
|
+
return waitForDoubaoPredicate(page, async () => {
|
|
164
|
+
const state = await getDoubaoUiState(page);
|
|
165
|
+
return state.hasExpertButton && state.hasInput && !state.menuVisible;
|
|
166
|
+
}, { timeoutMs, intervalMs: 220, stableCount: 2, sessionId });
|
|
167
|
+
}
|
|
168
|
+
async function assertNoChallenge(page, sessionId) {
|
|
169
|
+
const detected = await detectChallenge(page);
|
|
170
|
+
if (detected.detected) {
|
|
171
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: sessionId, step: 'gate', action: 'challenge_detect', status: 'blocked', message: '命中风控/验证页: ' + detected.hint });
|
|
172
|
+
throw new Error('命中风控/验证页,需要人工接管: ' + detected.hint);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
|
176
|
+
async function adoptExistingDoubaoPage(browser, fallbackPage) {
|
|
177
|
+
try {
|
|
178
|
+
const pages = await browser.pages();
|
|
179
|
+
for (const candidate of pages) {
|
|
180
|
+
try {
|
|
181
|
+
const url = candidate.url();
|
|
182
|
+
if (isDoubaoUrl(url) && candidate !== fallbackPage) {
|
|
183
|
+
await candidate.bringToFront();
|
|
184
|
+
try {
|
|
185
|
+
await fallbackPage.close();
|
|
186
|
+
}
|
|
187
|
+
catch (_) { }
|
|
188
|
+
candidate.__cdperManagedPage = false;
|
|
189
|
+
return candidate;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (_) { }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (_) { }
|
|
196
|
+
return fallbackPage;
|
|
197
|
+
}
|
|
198
|
+
async function clickHandleAtCenter(page, handle, label) {
|
|
199
|
+
try {
|
|
200
|
+
const box = await handle.boundingBox();
|
|
201
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
202
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { delay: 80 });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
log('WARN', '[doubao]', label + '坐标点击失败: ' + error.message);
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
async function clickHandleWithoutCoordinates(handle, label) {
|
|
212
|
+
try {
|
|
213
|
+
await withLocalTimeout(handle.evaluate((element) => element.click()), label + ' DOM click');
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
log('WARN', '[doubao]', label + 'DOM click 失败,尝试键盘激活: ' + error.message);
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await withLocalTimeout((async () => {
|
|
221
|
+
await handle.focus();
|
|
222
|
+
await humanPause(80, 140);
|
|
223
|
+
await handle.press('Enter');
|
|
224
|
+
})(), label + ' keyboard activate');
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
log('WARN', '[doubao]', label + '键盘激活失败,尝试句柄点击: ' + error.message);
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
await withLocalTimeout(handle.click({ delay: 80 }), label + ' handle click');
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
log('WARN', '[doubao]', label + '句柄点击失败: ' + error.message);
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
async function clickButtonByExactText(page, text, label) {
|
|
240
|
+
try {
|
|
241
|
+
const clicked = await page.evaluate((targetText) => {
|
|
242
|
+
const button = Array.from(document.querySelectorAll('button')).find((node) => {
|
|
243
|
+
const nodeText = (node.innerText || '').trim();
|
|
244
|
+
const rect = node.getBoundingClientRect();
|
|
245
|
+
return nodeText === targetText && rect.width > 0 && rect.height > 0;
|
|
246
|
+
});
|
|
247
|
+
if (!button)
|
|
248
|
+
return false;
|
|
249
|
+
button.click();
|
|
250
|
+
return true;
|
|
251
|
+
}, text);
|
|
252
|
+
if (clicked)
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
log('WARN', '[doubao]', label + '页面级点击失败: ' + error.message);
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
async function clickMenuItemByPrefix(page, prefix, label) {
|
|
261
|
+
try {
|
|
262
|
+
const items = await page.$$('[role="menuitem"]');
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
const meta = await item.evaluate((node) => {
|
|
265
|
+
const text = (node.innerText || '').trim();
|
|
266
|
+
const rect = node.getBoundingClientRect();
|
|
267
|
+
return { text, visible: rect.width > 0 && rect.height > 0 };
|
|
268
|
+
}).catch(() => null);
|
|
269
|
+
if (!meta || !meta.visible || !meta.text.startsWith(prefix))
|
|
270
|
+
continue;
|
|
271
|
+
if (await clickHandleAtCenter(page, item, label + '坐标'))
|
|
272
|
+
return true;
|
|
273
|
+
if (await clickHandleWithoutCoordinates(item, label))
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
log('WARN', '[doubao]', label + '坐标点击链路失败: ' + error.message);
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const clicked = await page.evaluate((targetPrefix) => {
|
|
282
|
+
const menu = document.querySelector('[role="menu"]');
|
|
283
|
+
if (!menu)
|
|
284
|
+
return false;
|
|
285
|
+
const item = Array.from(menu.querySelectorAll('[role="menuitem"]')).find((node) => {
|
|
286
|
+
const text = (node.innerText || '').trim();
|
|
287
|
+
const rect = node.getBoundingClientRect();
|
|
288
|
+
return text.startsWith(targetPrefix) && rect.width > 0 && rect.height > 0;
|
|
289
|
+
});
|
|
290
|
+
if (!item)
|
|
291
|
+
return false;
|
|
292
|
+
item.click();
|
|
293
|
+
return true;
|
|
294
|
+
}, prefix);
|
|
295
|
+
if (clicked)
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
log('WARN', '[doubao]', label + '页面级点击失败: ' + error.message);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
async function recoverDoubaoUnavailablePage(page) {
|
|
304
|
+
try {
|
|
305
|
+
const refreshState = await page.evaluate(() => {
|
|
306
|
+
const bodyText = document.body?.innerText || '';
|
|
307
|
+
if (!bodyText.includes('该页面暂时不可用'))
|
|
308
|
+
return { found: false, reload: false };
|
|
309
|
+
const hasRefreshButton = Array.from(document.querySelectorAll('button')).some((button) => {
|
|
310
|
+
const text = (button.innerText || '').trim();
|
|
311
|
+
const rect = button.getBoundingClientRect();
|
|
312
|
+
return text.includes('刷新页面') && rect.width > 0 && rect.height > 0;
|
|
313
|
+
});
|
|
314
|
+
return { found: true, reload: !hasRefreshButton };
|
|
315
|
+
});
|
|
316
|
+
if (!refreshState.found)
|
|
317
|
+
return false;
|
|
318
|
+
if (refreshState.reload) {
|
|
319
|
+
await page.reload({ timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const buttons = await page.$$('button');
|
|
323
|
+
let clicked = false;
|
|
324
|
+
for (const button of buttons) {
|
|
325
|
+
const text = await button.evaluate((element) => (element.innerText || '').trim()).catch(() => '');
|
|
326
|
+
if (!text.includes('刷新页面'))
|
|
327
|
+
continue;
|
|
328
|
+
clicked = await clickHandleWithoutCoordinates(button, '刷新页面按钮');
|
|
329
|
+
if (clicked)
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
if (!clicked)
|
|
333
|
+
await page.reload({ timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
334
|
+
}
|
|
335
|
+
await humanPause(2500, 5000);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
catch (_) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function openDoubaoHome(page, sessionId) {
|
|
343
|
+
let lastError = null;
|
|
344
|
+
if (isDoubaoUrl(page.url())) {
|
|
345
|
+
await humanWarmup(page);
|
|
346
|
+
await recoverDoubaoUnavailablePage(page);
|
|
347
|
+
if (await waitForDoubaoReady(page, { timeoutMs: 5000, sessionId }))
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
for (const url of ['https://www.doubao.com/', 'https://www.doubao.com/chat/']) {
|
|
351
|
+
try {
|
|
352
|
+
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
353
|
+
await humanWarmup(page);
|
|
354
|
+
await recoverDoubaoUnavailablePage(page);
|
|
355
|
+
if (await waitForDoubaoReady(page, { timeoutMs: 24000, sessionId }))
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
lastError = error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (lastError)
|
|
363
|
+
throw lastError;
|
|
364
|
+
throw new Error('Doubao 页面未准备好:输入框未出现');
|
|
365
|
+
}
|
|
366
|
+
async function hasDoubaoConversationContent(page) {
|
|
367
|
+
const snapshot = await captureDoubaoReplySnapshot(page).catch(() => null);
|
|
368
|
+
return Number(snapshot?.length || 0) > 0;
|
|
369
|
+
}
|
|
370
|
+
async function ensureFreshDoubaoHome(page, sessionId) {
|
|
371
|
+
await openDoubaoHome(page, sessionId);
|
|
372
|
+
const hasExistingContent = await hasDoubaoConversationContent(page);
|
|
373
|
+
const isConversationUrl = !!getDoubaoConversationId(page.url());
|
|
374
|
+
if (hasExistingContent || isConversationUrl) {
|
|
375
|
+
await clickNewChat(page, { strict: true, sessionId });
|
|
376
|
+
await humanPause(900, 1800);
|
|
377
|
+
}
|
|
378
|
+
await assertNoChallenge(page, sessionId);
|
|
379
|
+
if (!(await waitForDoubaoReady(page, { timeoutMs: 12000, sessionId }))) {
|
|
380
|
+
throw new Error('Doubao fresh conversation input was not ready');
|
|
381
|
+
}
|
|
382
|
+
if (await hasDoubaoConversationContent(page)) {
|
|
383
|
+
throw new Error('Doubao fresh conversation could not be confirmed clean');
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
// ─── Input helpers ────────────────────────────────────────────────────────────
|
|
388
|
+
async function typeIntoDoubaoInput(page, inputHandle, text) {
|
|
389
|
+
await inputHandle.focus();
|
|
390
|
+
await humanPause(160, 420);
|
|
391
|
+
const value = String(text || '');
|
|
392
|
+
// Doubao composer is React-controlled; use CDP insertText for both short and
|
|
393
|
+
// multiline prompts to avoid stale keyboard/IME edge cases.
|
|
394
|
+
await insertTextViaCdp(page, value);
|
|
395
|
+
}
|
|
396
|
+
async function clickDoubaoSend(page) {
|
|
397
|
+
const candidates = await page.$$('button,[role="button"]');
|
|
398
|
+
for (const handle of candidates) {
|
|
399
|
+
const meta = await handle.evaluate((element) => {
|
|
400
|
+
const text = (element.innerText || '').trim();
|
|
401
|
+
const aria = element.getAttribute('aria-label') || '';
|
|
402
|
+
const disabled = element.disabled || element.getAttribute('aria-disabled') === 'true';
|
|
403
|
+
const rect = element.getBoundingClientRect();
|
|
404
|
+
return { text, aria, disabled, visible: rect.width > 0 && rect.height > 0 };
|
|
405
|
+
}).catch(() => null);
|
|
406
|
+
if (!meta || meta.disabled || !meta.visible)
|
|
407
|
+
continue;
|
|
408
|
+
const looksLikeSend = [meta.text, meta.aria].some((value) => /发送|提交|send/i.test(value));
|
|
409
|
+
if (looksLikeSend)
|
|
410
|
+
return clickHandleWithoutCoordinates(handle, '发送按钮');
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
async function clearDoubaoInput(page, inputHandle) {
|
|
415
|
+
await inputHandle.focus();
|
|
416
|
+
await humanPause(100, 220);
|
|
417
|
+
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
|
418
|
+
try {
|
|
419
|
+
await page.keyboard.down(modifier);
|
|
420
|
+
await page.keyboard.press('A');
|
|
421
|
+
await page.keyboard.up(modifier);
|
|
422
|
+
}
|
|
423
|
+
catch (_) { }
|
|
424
|
+
await humanPause(60, 160);
|
|
425
|
+
try {
|
|
426
|
+
await page.keyboard.press('Backspace');
|
|
427
|
+
}
|
|
428
|
+
catch (_) { }
|
|
429
|
+
await humanPause(80, 180);
|
|
430
|
+
}
|
|
431
|
+
async function clickNewChat(page, options = {}) {
|
|
432
|
+
const { strict = false, sessionId } = options;
|
|
433
|
+
try {
|
|
434
|
+
const handles = await page.$$('button,[role="button"],div,span,a');
|
|
435
|
+
let target = null;
|
|
436
|
+
for (const handle of handles) {
|
|
437
|
+
const meta = await handle.evaluate((el) => {
|
|
438
|
+
const text = (el.innerText || '').trim();
|
|
439
|
+
const rect = el.getBoundingClientRect();
|
|
440
|
+
return { text, x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
|
441
|
+
}).catch(() => null);
|
|
442
|
+
if (!meta || !['新对话', '新聊天', 'New chat'].includes(meta.text))
|
|
443
|
+
continue;
|
|
444
|
+
if (meta.w <= 0 || meta.h <= 0)
|
|
445
|
+
continue;
|
|
446
|
+
target = handle;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (target) {
|
|
450
|
+
await clickHandleWithoutCoordinates(target, '新对话按钮');
|
|
451
|
+
log('DEBUG', '[doubao]', '已点击新对话');
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (strict) {
|
|
455
|
+
log('WARN', '[doubao]', '未找到新对话按钮,严格 fresh 模式下重新打开首页');
|
|
456
|
+
await page.goto('https://www.doubao.com/', { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
457
|
+
await humanPause(900, 1800);
|
|
458
|
+
return waitForDoubaoReady(page, { timeoutMs: 12000, sessionId });
|
|
459
|
+
}
|
|
460
|
+
const input = await hasDoubaoInput(page, 4000);
|
|
461
|
+
if (input) {
|
|
462
|
+
log('WARN', '[doubao]', '未找到新对话按钮,但输入框可用,保留当前页面继续');
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
log('WARN', '[doubao]', '未找到新对话按钮,重新打开首页');
|
|
466
|
+
await openDoubaoHome(page, sessionId);
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
log('WARN', '[doubao]', '新建对话失败: ' + e.message);
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function switchToExpert(page, sessionId) {
|
|
475
|
+
const isExpertButton = async () => {
|
|
476
|
+
const state = await getDoubaoUiState(page);
|
|
477
|
+
return state.hasExpertButton;
|
|
478
|
+
};
|
|
479
|
+
if (await isExpertButton()) {
|
|
480
|
+
log('INFO', '[doubao]', '专家按钮已存在,模式已就绪');
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
const findQuickButtons = async () => {
|
|
484
|
+
const btns = await page.$$('button');
|
|
485
|
+
let trigger = null;
|
|
486
|
+
let fallback = null;
|
|
487
|
+
for (const btn of btns) {
|
|
488
|
+
const meta = await btn.evaluate((el) => ({
|
|
489
|
+
text: (el.innerText || '').trim(), ariaHasPopup: el.getAttribute('aria-haspopup') || '',
|
|
490
|
+
dataSlot: el.getAttribute('data-slot') || '', dataState: el.getAttribute('data-state') || '',
|
|
491
|
+
})).catch(() => null);
|
|
492
|
+
if (!meta || meta.text !== '快速')
|
|
493
|
+
continue;
|
|
494
|
+
if (meta.ariaHasPopup === 'menu' || meta.dataSlot === 'dropdown-menu-trigger') {
|
|
495
|
+
trigger = btn;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
if (!fallback)
|
|
499
|
+
fallback = btn;
|
|
500
|
+
}
|
|
501
|
+
return { trigger, fallback };
|
|
502
|
+
};
|
|
503
|
+
const waitForExpertMenu = () => waitForDoubaoPredicate(page, async () => {
|
|
504
|
+
const state = await getDoubaoUiState(page);
|
|
505
|
+
return state.menuVisible && state.menuItems.some((item) => /^专家($|\n)/.test(item.text));
|
|
506
|
+
}, { timeoutMs: 5000, intervalMs: 180, stableCount: 1, challengeCheck: false });
|
|
507
|
+
const openQuickMenu = async (attempt) => {
|
|
508
|
+
const { trigger, fallback } = await findQuickButtons();
|
|
509
|
+
const candidates = [
|
|
510
|
+
{ handle: trigger, label: '快速菜单触发器(第' + attempt + '次)' },
|
|
511
|
+
{ handle: fallback, label: '快速按钮候选(第' + attempt + '次)' },
|
|
512
|
+
].filter((item) => item.handle);
|
|
513
|
+
const activate = async (handle, key) => {
|
|
514
|
+
try {
|
|
515
|
+
await handle.focus();
|
|
516
|
+
await humanPause(80, 140);
|
|
517
|
+
await handle.press(key);
|
|
518
|
+
return waitForExpertMenu().catch(() => false);
|
|
519
|
+
}
|
|
520
|
+
catch (_) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
for (const candidate of candidates) {
|
|
525
|
+
if (await clickHandleAtCenter(page, candidate.handle, candidate.label + '坐标')) {
|
|
526
|
+
if (await waitForExpertMenu().catch(() => false))
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
const clicked = await clickHandleWithoutCoordinates(candidate.handle, candidate.label);
|
|
530
|
+
if (clicked && await waitForExpertMenu().catch(() => false))
|
|
531
|
+
return true;
|
|
532
|
+
if (await activate(candidate.handle, 'ArrowDown'))
|
|
533
|
+
return true;
|
|
534
|
+
if (await activate(candidate.handle, 'Enter'))
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
return (await clickButtonByExactText(page, '快速', '快速按钮(第' + attempt + '次)')) && await waitForExpertMenu().catch(() => false);
|
|
538
|
+
};
|
|
539
|
+
const clickExpertInMenu = async () => {
|
|
540
|
+
if (await clickMenuItemByPrefix(page, '专家', '专家菜单项'))
|
|
541
|
+
return true;
|
|
542
|
+
const menu = await page.$('[role="menu"]');
|
|
543
|
+
if (!menu)
|
|
544
|
+
return false;
|
|
545
|
+
const items = await menu.$$('[role="menuitem"]');
|
|
546
|
+
for (const item of items) {
|
|
547
|
+
const text = await item.evaluate((el) => (el.innerText || '').trim()).catch(() => '');
|
|
548
|
+
if (!/^专家($|\n)/.test(text))
|
|
549
|
+
continue;
|
|
550
|
+
if (await clickHandleWithoutCoordinates(item, '专家菜单项'))
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
};
|
|
555
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
556
|
+
const { trigger, fallback } = await findQuickButtons();
|
|
557
|
+
if (!trigger && !fallback) {
|
|
558
|
+
log('WARN', '[doubao]', '未找到快速按钮');
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
if (!(await openQuickMenu(attempt))) {
|
|
562
|
+
log('WARN', '[doubao]', '点击快速后菜单未出现,第' + attempt + '次重试');
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (!(await clickExpertInMenu())) {
|
|
566
|
+
const state = await getDoubaoUiState(page).catch(() => ({ menuItems: [] }));
|
|
567
|
+
log('WARN', '[doubao]', '未在菜单中找到专家项: ' + JSON.stringify((state.menuItems || []).map((item) => item.text)));
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const settled = await waitForDoubaoModeSettled(page, { timeoutMs: 7000, sessionId }).catch(() => false);
|
|
571
|
+
if (settled) {
|
|
572
|
+
log('INFO', '[doubao]', '专家模式切换成功');
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
const inputStable = await waitForDoubaoReady(page, { timeoutMs: 5000, sessionId }).catch(() => false);
|
|
576
|
+
if (inputStable && await isExpertButton()) {
|
|
577
|
+
log('INFO', '[doubao]', '专家模式切换成功,输入区稳定');
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
log('WARN', '[doubao]', '专家模式切换未能确认成功');
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
// ─── Main query ───────────────────────────────────────────────────────────────
|
|
585
|
+
async function queryDoubao(queryText, opts = {}) {
|
|
586
|
+
const { mode = 'expert', sessionId, _retried = false } = opts;
|
|
587
|
+
const abortSignal = opts.abortSignal || null;
|
|
588
|
+
const fallbackMeta = opts._fallbackMeta || null;
|
|
589
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
590
|
+
const hasExplicitSession = Boolean(sessionId);
|
|
591
|
+
const queryPreview = String(queryText || '').slice(0, 50);
|
|
592
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: sessionId || '', step: 'query', action: 'start', status: 'ok', message: '查询: "' + queryPreview + '..." [mode=' + mode + ']' });
|
|
593
|
+
const { session, flow } = createPluginContext('doubao', PLATFORM, sessionId);
|
|
594
|
+
const conversationPlan = resolveConversationPolicy(opts, session, hasExplicitSession, hasStrongAnchor);
|
|
595
|
+
if (conversationPlan.effective === 'followup' && !hasStrongAnchor(session)) {
|
|
596
|
+
throw new Error('Doubao followup requires an existing conversation anchor for this sessionId');
|
|
597
|
+
}
|
|
598
|
+
const waitPlan = buildWaitPlan(opts, WAIT_PROFILES);
|
|
599
|
+
const progressRecorder = createProgressRecorder('doubao', opts);
|
|
600
|
+
let activeSession = session;
|
|
601
|
+
let initialReplySnapshot = null;
|
|
602
|
+
let inputReadyAfterOpen = false;
|
|
603
|
+
let promptSubmitted = false;
|
|
604
|
+
let expertModeConfirmed = false;
|
|
605
|
+
let page, browser;
|
|
606
|
+
try {
|
|
607
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
608
|
+
await runState(flow, 'open', async () => {
|
|
609
|
+
throwIfAborted(abortSignal, 'Doubao open');
|
|
610
|
+
const wantsFreshPage = conversationPlan.effective === 'fresh';
|
|
611
|
+
const connected = await connectWithWarmReuse({
|
|
612
|
+
wsUrl: resolveCdpWs(),
|
|
613
|
+
preferHost: 'doubao.com',
|
|
614
|
+
prewarmUrl: 'https://www.doubao.com/',
|
|
615
|
+
titleHint: '豆包',
|
|
616
|
+
mode: 'advanced',
|
|
617
|
+
profile: 'Default',
|
|
618
|
+
forceNewPage: wantsFreshPage,
|
|
619
|
+
waitMs: randomInt(800, 1600),
|
|
620
|
+
driver: opts.driver,
|
|
621
|
+
});
|
|
622
|
+
page = connected.page;
|
|
623
|
+
browser = connected.browser;
|
|
624
|
+
// Fresh queries should start from a fresh tab instead of adopting a stale
|
|
625
|
+
// conversation that happens to share the same host.
|
|
626
|
+
if (!connected.reusedExistingPage && !wantsFreshPage)
|
|
627
|
+
page = await adoptExistingDoubaoPage(browser, page);
|
|
628
|
+
if (conversationPlan.effective === 'followup') {
|
|
629
|
+
inputReadyAfterOpen = await openDoubaoHome(page, session.sessionId);
|
|
630
|
+
const anchorUrl = session.conversation?.anchorUrl;
|
|
631
|
+
if (anchorUrl && page.url() !== anchorUrl) {
|
|
632
|
+
await page.goto(anchorUrl, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
633
|
+
await humanPause(900, 1800);
|
|
634
|
+
await assertNoChallenge(page, session.sessionId);
|
|
635
|
+
assertFollowupAnchorActive(page, session);
|
|
636
|
+
inputReadyAfterOpen = await waitForDoubaoReady(page, { timeoutMs: 24000, sessionId: session.sessionId });
|
|
637
|
+
if (!inputReadyAfterOpen)
|
|
638
|
+
throw new Error('Doubao followup anchor opened but input was not ready');
|
|
639
|
+
}
|
|
640
|
+
assertFollowupAnchorActive(page, session);
|
|
641
|
+
}
|
|
642
|
+
else if (conversationPlan.effective === 'fresh') {
|
|
643
|
+
// Fresh queries: open a clean doubao home and confirm no prior content.
|
|
644
|
+
// We intentionally avoid the url-action entrypoint because doubao
|
|
645
|
+
// cannot reliably open/fetch external URLs through it — the page says
|
|
646
|
+
// "无法打开外链". Instead we open the composer, switch to expert mode
|
|
647
|
+
// (in ensure_mode), then type the full prompt including any URLs, which
|
|
648
|
+
// lets doubao's built-in link preview handle them natively.
|
|
649
|
+
inputReadyAfterOpen = await ensureFreshDoubaoHome(page, session.sessionId);
|
|
650
|
+
initialReplySnapshot = emptyDoubaoSnapshot();
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
inputReadyAfterOpen = await openDoubaoHome(page, session.sessionId);
|
|
654
|
+
}
|
|
655
|
+
page.__cdperManagedPage = false;
|
|
656
|
+
await ensureMinimumPageDwell(Date.now(), 'doubao');
|
|
657
|
+
}, { timeoutMs: STATE_TIMEOUTS.open, retries: 1, retryable: (error) => /页面未准备好|输入框未出现|STATE_TIMEOUT/.test(String(error.message || '') + ' ' + String(error.code || '')) });
|
|
658
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'open', status: 'ok' });
|
|
659
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
660
|
+
await runState(flow, 'ensure_session', async () => {
|
|
661
|
+
if (inputReadyAfterOpen && await hasDoubaoInput(page, 1800))
|
|
662
|
+
return;
|
|
663
|
+
const existingInput = await hasDoubaoInput(page, 12000);
|
|
664
|
+
if (!existingInput) {
|
|
665
|
+
if (conversationPlan.effective === 'followup') {
|
|
666
|
+
throw new Error('Doubao followup anchor opened but input was not ready; refusing to start a fresh chat');
|
|
667
|
+
}
|
|
668
|
+
await clickNewChat(page, { sessionId: session.sessionId });
|
|
669
|
+
}
|
|
670
|
+
if (!(await waitForDoubaoReady(page, { timeoutMs: 12000, sessionId: session.sessionId }))) {
|
|
671
|
+
throw new Error('Doubao 输入框未就绪');
|
|
672
|
+
}
|
|
673
|
+
}, { timeoutMs: STATE_TIMEOUTS.ensure_session });
|
|
674
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'ensure_session', status: 'ok' });
|
|
675
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
676
|
+
expertModeConfirmed = await runState(flow, 'ensure_mode', async () => {
|
|
677
|
+
// Fresh/followup queries always use the composer path; verify and switch
|
|
678
|
+
// to expert mode via the 快速→专家 menu before sending.
|
|
679
|
+
const confirmed = await switchToExpert(page, session.sessionId);
|
|
680
|
+
await assertNoChallenge(page, session.sessionId);
|
|
681
|
+
if (confirmed)
|
|
682
|
+
await waitForDoubaoModeSettled(page, { timeoutMs: 6000, sessionId: session.sessionId }).catch(() => false);
|
|
683
|
+
return confirmed;
|
|
684
|
+
}, { timeoutMs: STATE_TIMEOUTS.ensure_mode });
|
|
685
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'ensure_mode', status: 'ok', ...buildDoubaoModeState(expertModeConfirmed), expertModeConfirmed });
|
|
686
|
+
// Block sending when expert mode could not be confirmed; composer path
|
|
687
|
+
// should not silently degrade into the quick model.
|
|
688
|
+
if (!expertModeConfirmed) {
|
|
689
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'mode', action: 'expert_confirm', status: 'blocked', message: '未确认进入专家模式,停止发送以避免错误模式输出' });
|
|
690
|
+
throw new Error('Doubao 专家模式未确认,需要人工接管');
|
|
691
|
+
}
|
|
692
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
693
|
+
await runState(flow, 'send', async () => {
|
|
694
|
+
const input = await hasDoubaoInput(page, 18000);
|
|
695
|
+
if (!input)
|
|
696
|
+
throw new Error('Doubao 输入框未就绪');
|
|
697
|
+
await assertNoChallenge(page, session.sessionId);
|
|
698
|
+
initialReplySnapshot = await captureDoubaoReplySnapshot(page).catch(() => null);
|
|
699
|
+
await clearDoubaoInput(page, input.handle);
|
|
700
|
+
await typeIntoDoubaoInput(page, input.handle, queryText);
|
|
701
|
+
await assertNoChallenge(page, session.sessionId);
|
|
702
|
+
await humanPause(900, 1800);
|
|
703
|
+
const clicked = await clickDoubaoSend(page);
|
|
704
|
+
if (!clicked)
|
|
705
|
+
await page.keyboard.press('Enter');
|
|
706
|
+
await assertNoChallenge(page, session.sessionId);
|
|
707
|
+
await humanPause(350, 900);
|
|
708
|
+
promptSubmitted = true;
|
|
709
|
+
// P1-3: Verify submission was accepted
|
|
710
|
+
const submitVerify = await verifySubmission(page, {
|
|
711
|
+
timeoutMs: 15000,
|
|
712
|
+
pollIntervalMs: 500,
|
|
713
|
+
baselineSnapshot: initialReplySnapshot,
|
|
714
|
+
captureUserTurn: async (page, baseline) => {
|
|
715
|
+
const baselineCount = Number(baseline?.candidateCount || 0);
|
|
716
|
+
return page.evaluate((bc) => {
|
|
717
|
+
// Broad selector set matching smart_wait's selectBestDoubaoReplyText
|
|
718
|
+
const replyContainers = document.querySelectorAll('[class*="flow-markdown-body"], [class*="container-P2rR72"],' +
|
|
719
|
+
'[class*="mdbox-theme"], [class*="response"], [class*="answer"], .prose');
|
|
720
|
+
return replyContainers.length > bc;
|
|
721
|
+
}, baselineCount);
|
|
722
|
+
},
|
|
723
|
+
captureInputCleared: async (page) => {
|
|
724
|
+
return page.evaluate(() => {
|
|
725
|
+
const textarea = document.querySelector('textarea') || document.querySelector('[contenteditable="true"]');
|
|
726
|
+
if (!textarea)
|
|
727
|
+
return false;
|
|
728
|
+
const val = textarea.value || textarea.innerText || '';
|
|
729
|
+
return val.trim().length <= 5;
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
captureGenerating: async (page) => {
|
|
733
|
+
return page.evaluate(() => {
|
|
734
|
+
const isVisible = (el) => {
|
|
735
|
+
if (!el)
|
|
736
|
+
return false;
|
|
737
|
+
const rect = el.getBoundingClientRect();
|
|
738
|
+
const style = window.getComputedStyle(el);
|
|
739
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && el.getAttribute('aria-hidden') !== 'true';
|
|
740
|
+
};
|
|
741
|
+
const stopBtn = Array.from(document.querySelectorAll('button,[role="button"]')).find((button) => {
|
|
742
|
+
if (!isVisible(button) || button.disabled || button.getAttribute('aria-disabled') === 'true')
|
|
743
|
+
return false;
|
|
744
|
+
const text = button.innerText || '';
|
|
745
|
+
const aria = button.getAttribute('aria-label') || '';
|
|
746
|
+
return /停止生成|停止回答|Stop generating|stop-button/i.test(`${text} ${aria}`) || !!button.querySelector('svg[icon="stop"]');
|
|
747
|
+
});
|
|
748
|
+
const loading = Array.from(document.querySelectorAll('[class*="loading"], [class*="skeleton"], [class*="spinner"]')).find((element) => isVisible(element) && /loading|spinner|生成中|思考中|回答中/i.test(String(element.className || '') + ' ' + (element.innerText || '')));
|
|
749
|
+
return !!stopBtn || !!loading;
|
|
750
|
+
});
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
if (!submitVerify.accepted) {
|
|
754
|
+
flow.enter('send', { submitVerified: false, submitElapsed: submitVerify.elapsed });
|
|
755
|
+
const error = new Error('Doubao 消息未成功提交 (send_not_accepted)');
|
|
756
|
+
error.code = 'SEND_NOT_ACCEPTED';
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
flow.enter('send', { submitVerified: true, submitSignal: submitVerify.signal, submitElapsed: submitVerify.elapsed });
|
|
760
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'submit', status: 'ok', message: '问题已发送' });
|
|
761
|
+
}, { timeoutMs: computeSendTimeoutMs(queryText, STATE_TIMEOUTS.send) });
|
|
762
|
+
const result = await runState(flow, 'wait', () => waitForDoubaoReply(page, {
|
|
763
|
+
query: queryText, abortSignal, minWait: waitPlan.minWait, maxWait: waitPlan.maxWait, pollInterval: waitPlan.pollInterval,
|
|
764
|
+
stableThreshold: 4, enableCircuitBreaker: true, enableExtend: true, maxExtends: waitPlan.maxExtends,
|
|
765
|
+
acceptShortContent: prefersShortAnswer(queryText), minShortContentLength: 1,
|
|
766
|
+
onProgress: progressRecorder.onProgress, challengeCheckIntervalMs: 6000, initialReplySnapshot,
|
|
767
|
+
}), { timeoutMs: waitPlan.stateTimeoutMs });
|
|
768
|
+
if (!result?.content)
|
|
769
|
+
throw new Error('未获取到有效回复');
|
|
770
|
+
const wrapperStatus = result.wrapperStatus || result.exitReason;
|
|
771
|
+
if ([WRAPPER_STATUSES.CAPTCHA_REQUIRED, WRAPPER_STATUSES.CIRCUIT_BREAKER, 'challenge', 'circuit_breaker'].includes(wrapperStatus)) {
|
|
772
|
+
throw new Error('查询被阻断,需要人工接管: ' + wrapperStatus + (result.detectedChallenge ? ' (' + result.detectedChallenge + ')' : ''));
|
|
773
|
+
}
|
|
774
|
+
const completenessScore = result.completeness?.score || 0;
|
|
775
|
+
const contentMarkedIncomplete = result.completeness?.isComplete === false;
|
|
776
|
+
if (wrapperStatus === WRAPPER_STATUSES.PARTIAL_TIMEOUT && result.content) {
|
|
777
|
+
return buildWrapperResult({
|
|
778
|
+
status: WRAPPER_STATUSES.PARTIAL_TIMEOUT,
|
|
779
|
+
content: result.content,
|
|
780
|
+
exitReason: result.exitReason,
|
|
781
|
+
diagnostics: { pillars: result.pillars, completeness: result.completeness },
|
|
782
|
+
platform: 'doubao',
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
activeSession = await runState(flow, 'extract', async () => {
|
|
786
|
+
const conversation = buildConversationAnchor(PLATFORM, page.url(), getDoubaoConversationId, conversationPlan.effective);
|
|
787
|
+
return updateSession(session.sessionId, { turns: session.turns + 1, conversation });
|
|
788
|
+
}, { timeoutMs: STATE_TIMEOUTS.extract });
|
|
789
|
+
const modeState = buildDoubaoModeState(expertModeConfirmed);
|
|
790
|
+
const contentIsComplete = result.completeness?.isComplete === true;
|
|
791
|
+
const finalStatus = ['complete', 'complete_short'].includes(result.exitReason) && contentIsComplete ? 'ok' : 'degraded';
|
|
792
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'complete', status: finalStatus, message: '回复完成:' + result.content.length + ' 字,完整度 ' + (result.completeness?.score || 0) + '分,exitReason=' + (result.exitReason || 'unknown') });
|
|
793
|
+
return buildPluginSuccess({
|
|
794
|
+
pluginName: 'doubao',
|
|
795
|
+
session: activeSession,
|
|
796
|
+
flow,
|
|
797
|
+
runtimeStatus: await getRuntimeStatus('doubao'),
|
|
798
|
+
doneMeta: { status: finalStatus, turn: activeSession.turns, ...modeState, expertModeConfirmed, conversationPolicy: conversationPlan.effective },
|
|
799
|
+
payload: {
|
|
800
|
+
content: result.content,
|
|
801
|
+
score: completenessScore,
|
|
802
|
+
completeness: result.completeness || null,
|
|
803
|
+
status: finalStatus,
|
|
804
|
+
fallback: fallbackMeta,
|
|
805
|
+
exitReason: result.exitReason || 'unknown',
|
|
806
|
+
...modeState,
|
|
807
|
+
expertModeConfirmed,
|
|
808
|
+
detectedChallenge: result.detectedChallenge || null,
|
|
809
|
+
conversationPolicy: conversationPlan.requested,
|
|
810
|
+
effectiveConversationPolicy: conversationPlan.effective,
|
|
811
|
+
conversation: activeSession.conversation || null,
|
|
812
|
+
waitPlan: { expectedDuration: waitPlan.expectedDuration, minWait: waitPlan.minWait, maxWait: waitPlan.maxWait, maxExtends: waitPlan.maxExtends, pollInterval: waitPlan.pollInterval, elapsed: result.elapsed, extendCount: result.extendCount },
|
|
813
|
+
progress: progressRecorder.events,
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
return handlePluginQueryError(error, {
|
|
819
|
+
flow, pluginName: 'doubao', retried: _retried,
|
|
820
|
+
retryFn: () => queryDoubao(queryText, { ...opts, _retried: true }),
|
|
821
|
+
failedMeta: { error: error.message, conversationPolicy: conversationPlan.effective, promptSubmitted },
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
if (page || browser)
|
|
826
|
+
await disconnect(page, browser);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
module.exports = { queryDoubao };
|