utils.js
1 import { CommandExecutionError } from '@jackwener/opencli/errors'; 2 3 export const DOUBAO_DOMAIN = 'www.doubao.com'; 4 export const DOUBAO_CHAT_URL = 'https://www.doubao.com/chat'; 5 export const DOUBAO_NEW_CHAT_URL = 'https://www.doubao.com/chat/new-thread/create-by-msg'; 6 const DOUBAO_COMPOSER_SELECTORS = [ 7 'textarea[data-testid="chat_input_input"]', 8 '[data-testid="chat_input"] textarea', 9 '.chat-input textarea', 10 '.chat-input [contenteditable="true"]', 11 '.chat-editor textarea', 12 '.chat-editor [contenteditable="true"]', 13 'textarea[placeholder*="发消息"]', 14 'textarea[placeholder*="Message"]', 15 '[contenteditable="true"][placeholder*="发消息"]', 16 '[contenteditable="true"][placeholder*="Message"]', 17 '[contenteditable="true"][aria-label*="发消息"]', 18 '[contenteditable="true"][aria-label*="Message"]', 19 'textarea', 20 '[contenteditable="true"]', 21 ]; 22 function buildDoubaoComposerLocatorScript() { 23 return ` 24 const isVisible = (el) => { 25 if (!(el instanceof HTMLElement)) return false; 26 const style = window.getComputedStyle(el); 27 if (style.display === 'none' || style.visibility === 'hidden') return false; 28 const rect = el.getBoundingClientRect(); 29 return rect.width > 0 && rect.height > 0; 30 }; 31 32 const composerSelectors = ${JSON.stringify(DOUBAO_COMPOSER_SELECTORS)}; 33 const findComposer = () => { 34 for (const selector of composerSelectors) { 35 const node = Array.from(document.querySelectorAll(selector)).find(isVisible); 36 if (node) return node; 37 } 38 return null; 39 }; 40 `; 41 } 42 function getTranscriptLinesScript() { 43 return ` 44 (() => { 45 const clean = (value) => (value || '') 46 .replace(/\\u00a0/g, ' ') 47 .replace(/\\n{3,}/g, '\\n\\n') 48 .trim(); 49 50 const root = document.body.cloneNode(true); 51 const removableSelectors = [ 52 '[data-testid="flow_chat_sidebar"]', 53 '[data-testid="chat_input"]', 54 '[data-testid="flow_chat_guidance_page"]', 55 ]; 56 57 for (const selector of removableSelectors) { 58 root.querySelectorAll(selector).forEach((node) => node.remove()); 59 } 60 61 root.querySelectorAll('script, style, noscript').forEach((node) => node.remove()); 62 63 const stopLines = new Set([ 64 '豆包', 65 '新对话', 66 '内容由豆包 AI 生成', 67 'AI 创作', 68 '云盘', 69 '更多', 70 '历史对话', 71 '手机版对话', 72 '快速', 73 '超能模式', 74 'Beta', 75 'PPT 生成', 76 '图像生成', 77 '帮我写作', 78 ]); 79 80 const noisyPatterns = [ 81 /^window\\._SSR_DATA/, 82 /^window\\._ROUTER_DATA/, 83 /^\{"namedChunks"/, 84 /^在此处拖放文件/, 85 /^文件数量:/, 86 /^文件类型:/, 87 ]; 88 89 const transcriptText = clean(root.innerText || root.textContent || '') 90 .replace(/新对话/g, '\\n') 91 .replace(/内容由豆包 AI 生成/g, '\\n') 92 .replace(/在此处拖放文件/g, '\\n') 93 .replace(/文件数量:[^\\n]*/g, '') 94 .replace(/文件类型:[^\\n]*/g, ''); 95 96 return clean(transcriptText) 97 .split('\\n') 98 .map((line) => clean(line)) 99 .filter((line) => line 100 && line.length <= 400 101 && !stopLines.has(line) 102 && !noisyPatterns.some((pattern) => pattern.test(line))); 103 })() 104 `; 105 } 106 function getStateScript() { 107 return ` 108 (() => { 109 const routerData = window._ROUTER_DATA?.loaderData?.chat_layout; 110 const placeholderNode = document.querySelector( 111 'textarea[data-testid="chat_input_input"], textarea[placeholder], [contenteditable="true"][placeholder], [aria-label*="发消息"], [aria-label*="Message"]' 112 ); 113 return { 114 url: window.location.href, 115 title: document.title || '', 116 isLogin: typeof routerData?.userSetting?.data?.is_login === 'boolean' 117 ? routerData.userSetting.data.is_login 118 : null, 119 accountDescription: routerData?.accountInfo?.data?.description || '', 120 placeholder: placeholderNode?.getAttribute('placeholder') 121 || placeholderNode?.getAttribute('aria-label') 122 || '', 123 }; 124 })() 125 `; 126 } 127 function getTurnsScript() { 128 return ` 129 (() => { 130 const clean = (value) => (value || '') 131 .replace(/\\u00a0/g, ' ') 132 .replace(/\\n{3,}/g, '\\n\\n') 133 .trim(); 134 135 const isVisible = (el) => { 136 if (!(el instanceof HTMLElement)) return false; 137 const style = window.getComputedStyle(el); 138 if (style.display === 'none' || style.visibility === 'hidden') return false; 139 const rect = el.getBoundingClientRect(); 140 return rect.width > 0 && rect.height > 0; 141 }; 142 143 const getRole = (root) => { 144 if ( 145 root.matches('[data-testid="send_message"], [class*="send-message"]') 146 || root.querySelector('[data-testid="send_message"], [class*="send-message"]') 147 ) { 148 return 'User'; 149 } 150 if ( 151 root.matches('[data-testid="receive_message"], [data-testid*="receive_message"], [class*="receive-message"]') 152 || root.querySelector('[data-testid="receive_message"], [data-testid*="receive_message"], [class*="receive-message"]') 153 ) { 154 return 'Assistant'; 155 } 156 return ''; 157 }; 158 159 const messageTextSelectors = [ 160 '[data-testid="message_text_content"]', 161 '[data-testid="message_content"]', 162 '[data-testid*="message_text"]', 163 '[data-testid*="message_content"]', 164 '[class*="message-text"]', 165 '[class*="message-content"]', 166 ]; 167 const messageImageSelector = messageTextSelectors.map((s) => s + ' img').join(', '); 168 169 const extractTextChunks = (root) => { 170 const chunks = []; 171 const seen = new Set(); 172 for (const selector of messageTextSelectors) { 173 const nodes = Array.from(root.querySelectorAll(selector)) 174 .filter((el) => isVisible(el)) 175 .map((el) => clean(el.innerText || el.textContent || '')) 176 .filter(Boolean); 177 178 for (const nodeText of nodes) { 179 if (seen.has(nodeText)) continue; 180 seen.add(nodeText); 181 chunks.push(nodeText); 182 } 183 184 if (chunks.length > 0) break; 185 } 186 return chunks; 187 }; 188 189 const extractImageLines = (root) => Array.from(root.querySelectorAll(messageImageSelector)) 190 .filter((el) => el instanceof HTMLImageElement && isVisible(el)) 191 .map((el) => { 192 const width = el.naturalWidth || el.width || 0; 193 const height = el.naturalHeight || el.height || 0; 194 if (width > 0 && height > 0 && width <= 48 && height <= 48) return ''; 195 const url = clean(el.currentSrc || el.src || ''); 196 return /^https?:\\/\\//i.test(url) ? 'Image: ' + url : ''; 197 }) 198 .filter((line, index, items) => Boolean(line) && items.indexOf(line) === index); 199 200 const extractText = (root) => { 201 const chunks = extractTextChunks(root); 202 const text = chunks.length > 0 ? clean(chunks.join('\\n')) : clean(root.innerText || root.textContent || ''); 203 const imageLines = extractImageLines(root); 204 if (imageLines.length === 0) return text; 205 return text ? text + '\\n' + imageLines.join('\\n') : imageLines.join('\\n'); 206 }; 207 208 const messageList = document.querySelector('[data-testid="message-list"]'); 209 if (!messageList) return []; 210 211 const unionRoots = Array.from(messageList.querySelectorAll('[data-testid="union_message"]')) 212 .filter((el) => isVisible(el)); 213 const blockRoots = Array.from(messageList.querySelectorAll('[data-testid="message-block-container"]')) 214 .filter((el) => isVisible(el) && !el.closest('[data-testid="union_message"]')); 215 const roots = (unionRoots.length > 0 ? unionRoots : blockRoots) 216 .filter((el, index, items) => !items.some((other, otherIndex) => otherIndex !== index && other.contains(el))); 217 218 const turns = roots 219 .map((el) => { 220 const role = getRole(el); 221 const text = extractText(el); 222 return { el, role, text }; 223 }) 224 .filter((item) => (item.role === 'User' || item.role === 'Assistant') && item.text); 225 226 turns.sort((a, b) => { 227 if (a.el === b.el) return 0; 228 const pos = a.el.compareDocumentPosition(b.el); 229 return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; 230 }); 231 232 const deduped = []; 233 const seen = new Set(); 234 for (const turn of turns) { 235 const key = turn.role + '::' + turn.text; 236 if (seen.has(key)) continue; 237 seen.add(key); 238 deduped.push({ Role: turn.role, Text: turn.text }); 239 } 240 241 if (deduped.length > 0) return deduped; 242 return []; 243 })() 244 `; 245 } 246 function prepareDoubaoComposerScript() { 247 return ` 248 (() => { 249 ${buildDoubaoComposerLocatorScript()} 250 const composer = findComposer(); 251 252 if ( 253 !(composer instanceof HTMLTextAreaElement) 254 && !(composer instanceof HTMLInputElement) 255 && !(composer instanceof HTMLElement) 256 ) { 257 return { ok: false, reason: 'Could not find Doubao input element' }; 258 } 259 260 try { 261 composer.focus(); 262 263 if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { 264 const length = composer.value.length; 265 composer.setSelectionRange(0, length); 266 } else { 267 const selection = window.getSelection(); 268 const range = document.createRange(); 269 range.selectNodeContents(composer); 270 selection?.removeAllRanges(); 271 selection?.addRange(range); 272 } 273 } catch (error) { 274 return { 275 ok: false, 276 reason: error instanceof Error ? error.message : String(error), 277 }; 278 } 279 280 return { ok: true }; 281 })() 282 `; 283 } 284 function composerStateScript() { 285 return ` 286 (() => { 287 ${buildDoubaoComposerLocatorScript()} 288 const composer = findComposer(); 289 290 if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { 291 return { hasText: !!composer.value.trim(), text: composer.value }; 292 } 293 294 if (composer instanceof HTMLElement) { 295 const text = (composer.innerText || '').trim() || (composer.textContent || '').trim(); 296 return { 297 hasText: !!text, 298 text, 299 }; 300 } 301 302 return { hasText: false, text: '' }; 303 })() 304 `; 305 } 306 function syncComposerAfterNativeTypeScript() { 307 return ` 308 (() => { 309 ${buildDoubaoComposerLocatorScript()} 310 const composer = findComposer(); 311 312 if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { 313 const value = composer.value; 314 composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value, inputType: 'insertText' })); 315 composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' })); 316 composer.dispatchEvent(new Event('change', { bubbles: true })); 317 return { hasText: !!value.trim(), text: value }; 318 } 319 320 if (composer instanceof HTMLElement) { 321 const text = (composer.innerText || '').trim() || (composer.textContent || '').trim(); 322 composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: text, inputType: 'insertText' })); 323 composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' })); 324 composer.dispatchEvent(new Event('change', { bubbles: true })); 325 return { hasText: !!text, text }; 326 } 327 328 return { hasText: false, text: '' }; 329 })() 330 `; 331 } 332 function fillComposerScript(text) { 333 return ` 334 ((inputText) => { 335 ${buildDoubaoComposerLocatorScript()} 336 const composer = findComposer(); 337 338 if (!composer) throw new Error('Could not find Doubao input element'); 339 340 composer.focus(); 341 342 if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { 343 const proto = composer instanceof HTMLTextAreaElement 344 ? window.HTMLTextAreaElement.prototype 345 : window.HTMLInputElement.prototype; 346 const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; 347 setter?.call(composer, inputText); 348 composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: inputText, inputType: 'insertText' })); 349 composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' })); 350 composer.dispatchEvent(new Event('change', { bubbles: true })); 351 return { hasText: !!composer.value.trim(), mode: 'text-input', text: composer.value }; 352 } 353 354 if (composer instanceof HTMLElement) { 355 composer.textContent = ''; 356 const selection = window.getSelection(); 357 const range = document.createRange(); 358 range.selectNodeContents(composer); 359 range.collapse(false); 360 selection?.removeAllRanges(); 361 selection?.addRange(range); 362 document.execCommand('insertText', false, inputText); 363 composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: inputText, inputType: 'insertText' })); 364 composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' })); 365 composer.dispatchEvent(new Event('change', { bubbles: true })); 366 return { 367 hasText: !!((composer.innerText || '').trim() || (composer.textContent || '').trim()), 368 mode: 'contenteditable', 369 text: (composer.innerText || '').trim() || (composer.textContent || '').trim(), 370 }; 371 } 372 373 throw new Error('Unsupported Doubao input element'); 374 })(${JSON.stringify(text)}) 375 `; 376 } 377 function detectDoubaoVerificationScript() { 378 return ` 379 (() => { 380 const isVisible = (el) => { 381 if (!(el instanceof HTMLElement)) return false; 382 const style = window.getComputedStyle(el); 383 if (style.display === 'none' || style.visibility === 'hidden') return false; 384 const rect = el.getBoundingClientRect(); 385 return rect.width > 0 && rect.height > 0; 386 }; 387 388 const challengeSelectors = [ 389 'iframe[src*="captcha"]', 390 'iframe[src*="verify"]', 391 'input[placeholder*="验证码"]', 392 'input[aria-label*="验证码"]', 393 ]; 394 const selectorMatch = challengeSelectors.find((selector) => { 395 return Array.from(document.querySelectorAll(selector)).some((node) => isVisible(node)); 396 }); 397 if (selectorMatch) { 398 return { detected: true, reason: selectorMatch }; 399 } 400 401 const phrasePattern = /人机验证|完成安全验证|异常访问|滑动验证|拖动滑块/i; 402 const candidateRoots = Array.from( 403 document.querySelectorAll('[role="dialog"], [aria-modal="true"], .semi-modal, .modal') 404 ); 405 const match = candidateRoots.find((node) => { 406 if (!(node instanceof HTMLElement)) return false; 407 if (!isVisible(node)) return false; 408 const text = (node.innerText || node.textContent || '').trim(); 409 if (!text || text.length > 400) return false; 410 return phrasePattern.test(text); 411 }); 412 413 return { 414 detected: !!match, 415 reason: match ? ((match.innerText || match.textContent || '').trim().slice(0, 80) || 'challenge-ui') : '', 416 }; 417 })() 418 `; 419 } 420 function clickSendButtonScript() { 421 return ` 422 (() => { 423 ${buildDoubaoComposerLocatorScript()} 424 const composer = findComposer(); 425 if (!(composer instanceof HTMLElement)) return false; 426 427 const composerRect = composer.getBoundingClientRect(); 428 const rootCandidates = [ 429 composer.closest('form'), 430 composer.closest('[role="form"]'), 431 composer.closest('[data-testid="chat_input"]'), 432 composer.closest('.chat-input'), 433 composer.parentElement, 434 composer.parentElement?.parentElement, 435 ].filter(Boolean); 436 437 const seen = new Set(); 438 const buttons = []; 439 for (const root of rootCandidates) { 440 root.querySelectorAll('button, [role="button"]').forEach((node) => { 441 if (!(node instanceof HTMLElement)) return; 442 if (seen.has(node)) return; 443 seen.add(node); 444 buttons.push(node); 445 }); 446 } 447 448 const submitPattern = /send|发送|提交|发消息/i; 449 const excludedPattern = /新对话|new chat|快速|视频生成|深入研究|图像生成|帮我写作|音乐生成|更多|上传|upload|麦克风|microphone|模式|mode|工具|tools|设置|settings|云盘|history|历史/i; 450 let bestButton = null; 451 let bestScore = -Infinity; 452 453 for (const button of buttons) { 454 if (!isVisible(button)) continue; 455 const disabled = button.getAttribute('disabled') !== null 456 || button.getAttribute('aria-disabled') === 'true'; 457 if (disabled) continue; 458 459 const text = (button.innerText || button.textContent || '').trim(); 460 const aria = (button.getAttribute('aria-label') || '').trim(); 461 const title = (button.getAttribute('title') || '').trim(); 462 const className = String(button.className || ''); 463 const haystack = [text, aria, title].join(' ').trim(); 464 if (excludedPattern.test(haystack)) continue; 465 466 const rect = button.getBoundingClientRect(); 467 const dx = rect.left - composerRect.right; 468 const dy = Math.abs((rect.top + rect.height / 2) - (composerRect.top + composerRect.height / 2)); 469 const distancePenalty = Math.abs(dx) + dy; 470 const isSubmitLike = submitPattern.test(haystack) 471 || button.getAttribute('type') === 'submit' 472 || className.includes('bg-dbx-text-highlight') 473 || className.includes('bg-dbx-fill-highlight') 474 || className.includes('text-dbx-text-static-white-primary'); 475 if (!isSubmitLike) continue; 476 if (dx < -80 || dx > 280) continue; 477 if (dy > 140) continue; 478 479 let score = -distancePenalty; 480 if (submitPattern.test(haystack)) score += 5000; 481 if (button.getAttribute('type') === 'submit') score += 1200; 482 if (button.closest('.chat-input-button')) score += 1200; 483 if (className.includes('bg-dbx-text-highlight')) score += 600; 484 if (className.includes('bg-dbx-fill-highlight')) score += 600; 485 if (className.includes('text-dbx-text-static-white-primary')) score += 400; 486 if (dx >= -40 && dx <= 240) score += 120; 487 if (rect.left >= composerRect.left - 40) score += 40; 488 489 if (score > bestScore) { 490 bestScore = score; 491 bestButton = button; 492 } 493 } 494 495 if (bestButton && bestScore >= 200) { 496 bestButton.click(); 497 return true; 498 } 499 500 return false; 501 })() 502 `; 503 } 504 function clickNewChatScript() { 505 return ` 506 (() => { 507 const isVisible = (el) => { 508 if (!(el instanceof HTMLElement)) return false; 509 const style = window.getComputedStyle(el); 510 if (style.display === 'none' || style.visibility === 'hidden') return false; 511 const rect = el.getBoundingClientRect(); 512 return rect.width > 0 && rect.height > 0; 513 }; 514 515 const labels = ['新对话', 'New Chat', '创建新对话']; 516 const buttons = Array.from(document.querySelectorAll('button, a, [role="button"]')); 517 518 for (const button of buttons) { 519 if (!isVisible(button)) continue; 520 const text = (button.innerText || button.textContent || '').trim(); 521 const aria = (button.getAttribute('aria-label') || '').trim(); 522 const title = (button.getAttribute('title') || '').trim(); 523 const haystacks = [text, aria, title]; 524 if (haystacks.some((value) => labels.some((label) => value.includes(label)))) { 525 button.click(); 526 return text || aria || title || 'new-chat'; 527 } 528 } 529 530 return ''; 531 })() 532 `; 533 } 534 function normalizeDoubaoTabs(rawTabs) { 535 return rawTabs 536 .map((tab, index) => { 537 const record = (tab || {}); 538 return { 539 index: typeof record.index === 'number' ? record.index : index, 540 url: typeof record.url === 'string' ? record.url : '', 541 title: typeof record.title === 'string' ? record.title : '', 542 active: record.active === true, 543 }; 544 }) 545 .filter((tab) => tab.url.includes('doubao.com/chat')); 546 } 547 async function selectPreferredDoubaoTab(page) { 548 const rawTabs = await page.tabs().catch(() => []); 549 if (!Array.isArray(rawTabs) || rawTabs.length === 0) 550 return false; 551 const tabs = normalizeDoubaoTabs(rawTabs); 552 if (tabs.length === 0) 553 return false; 554 const preferred = [...tabs].sort((left, right) => { 555 const score = (tab) => { 556 let value = tab.index; 557 if (/https:\/\/www\.doubao\.com\/chat\/[A-Za-z0-9_-]+/.test(tab.url)) 558 value += 1000; 559 else if (tab.url.startsWith(DOUBAO_CHAT_URL)) 560 value += 100; 561 if (tab.active) 562 value += 25; 563 return value; 564 }; 565 return score(right) - score(left); 566 })[0]; 567 if (!preferred) 568 return false; 569 await page.selectTab(preferred.index); 570 await page.wait(0.8); 571 return true; 572 } 573 export async function ensureDoubaoChatPage(page) { 574 let currentUrl = await page.evaluate('window.location.href').catch(() => ''); 575 if (typeof currentUrl === 'string' && currentUrl.includes('doubao.com/chat')) { 576 await page.wait(1); 577 return; 578 } 579 const reusedTab = await selectPreferredDoubaoTab(page); 580 if (reusedTab) { 581 currentUrl = await page.evaluate('window.location.href').catch(() => ''); 582 if (typeof currentUrl === 'string' && currentUrl.includes('doubao.com/chat')) { 583 await page.wait(1); 584 return; 585 } 586 } 587 await page.goto(DOUBAO_CHAT_URL, { waitUntil: 'load', settleMs: 2500 }); 588 await page.wait(1.5); 589 } 590 export async function getDoubaoPageState(page) { 591 await ensureDoubaoChatPage(page); 592 return await page.evaluate(getStateScript()); 593 } 594 export async function getDoubaoTurns(page) { 595 await ensureDoubaoChatPage(page); 596 const turns = await page.evaluate(getTurnsScript()); 597 if (turns.length > 0) 598 return turns; 599 const lines = await page.evaluate(getTranscriptLinesScript()); 600 return lines.map((line) => ({ Role: 'System', Text: line })); 601 } 602 export async function getDoubaoVisibleTurns(page) { 603 await ensureDoubaoChatPage(page); 604 return await page.evaluate(getTurnsScript()); 605 } 606 export async function getDoubaoTranscriptLines(page) { 607 await ensureDoubaoChatPage(page); 608 return await page.evaluate(getTranscriptLinesScript()); 609 } 610 export async function sendDoubaoMessage(page, text) { 611 await ensureDoubaoChatPage(page); 612 const normalizeComposerText = (value) => value.replace(/\r\n/g, '\n').trim(); 613 const expectedText = normalizeComposerText(text); 614 const prepared = await page.evaluate(prepareDoubaoComposerScript()); 615 if (!prepared?.ok) { 616 throw new CommandExecutionError(prepared?.reason || 'Could not find Doubao input element'); 617 } 618 let hasText = false; 619 if (page.nativeType) { 620 try { 621 await page.nativeType(text); 622 await page.wait(0.2); 623 await page.evaluate(syncComposerAfterNativeTypeScript()); 624 const nativeState = await page.evaluate(composerStateScript()); 625 hasText = !!nativeState?.hasText && normalizeComposerText(nativeState?.text || '') === expectedText; 626 } 627 catch { } 628 } 629 if (!hasText) { 630 const fallbackState = await page.evaluate(fillComposerScript(text)); 631 hasText = !!fallbackState?.hasText && normalizeComposerText(fallbackState?.text || '') === expectedText; 632 } 633 if (!hasText) { 634 throw new CommandExecutionError('Failed to insert text into Doubao composer'); 635 } 636 let submittedBy = 'enter'; 637 const clicked = await page.evaluate(clickSendButtonScript()); 638 if (clicked) { 639 submittedBy = 'button'; 640 } 641 else if (page.nativeKeyPress) { 642 try { 643 await page.nativeKeyPress('Enter'); 644 } 645 catch { 646 await page.pressKey('Enter'); 647 } 648 } 649 else { 650 await page.pressKey('Enter'); 651 } 652 await page.wait(0.8); 653 const verification = await page.evaluate(detectDoubaoVerificationScript()); 654 if (verification?.detected) { 655 throw new CommandExecutionError('Doubao blocked the request with a verification challenge', verification.reason 656 ? `Detected challenge signal: ${verification.reason}` 657 : 'Please complete the challenge in the browser and try again.'); 658 } 659 return submittedBy; 660 } 661 export async function waitForDoubaoResponse(page, beforeLines, beforeTurns, promptText, timeoutSeconds) { 662 const beforeTurnSet = new Set(beforeTurns 663 .filter((turn) => turn.Role === 'Assistant') 664 .map((turn) => `${turn.Role}::${turn.Text}`)); 665 const sanitizeCandidate = (value) => value 666 .replace(promptText, '') 667 .replace(/内容由豆包 AI 生成/g, '') 668 .replace(/在此处拖放文件/g, '') 669 .replace(/文件数量:.*$/g, '') 670 .replace(/\{"namedChunks".*$/g, '') 671 .replace(/window\\._SSR_DATA.*$/g, '') 672 .trim(); 673 const getCandidate = async () => { 674 const verification = await page.evaluate(detectDoubaoVerificationScript()); 675 if (verification?.detected) { 676 throw new CommandExecutionError('Doubao blocked the request with a verification challenge', verification.reason 677 ? `Detected challenge signal: ${verification.reason}` 678 : 'Please complete the challenge in the browser and try again.'); 679 } 680 const turns = await getDoubaoVisibleTurns(page); 681 const assistantCandidate = [...turns] 682 .reverse() 683 .find((turn) => turn.Role === 'Assistant' && !beforeTurnSet.has(`${turn.Role}::${turn.Text}`)); 684 const visibleCandidate = assistantCandidate ? sanitizeCandidate(assistantCandidate.Text) : ''; 685 if (visibleCandidate) 686 return visibleCandidate; 687 const lines = await getDoubaoTranscriptLines(page); 688 const additions = collectDoubaoTranscriptAdditions(beforeLines, lines, promptText, sanitizeCandidate) 689 .split('\n') 690 .filter(Boolean); 691 const shortCandidate = additions.find((line) => line.length <= 120); 692 return shortCandidate || additions[additions.length - 1] || ''; 693 }; 694 const pollIntervalSeconds = 2; 695 const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds)); 696 let lastCandidate = ''; 697 let stableCount = 0; 698 for (let index = 0; index < maxPolls; index += 1) { 699 await page.wait(index === 0 ? 1.5 : pollIntervalSeconds); 700 const candidate = await getCandidate(); 701 if (!candidate) 702 continue; 703 if (candidate === lastCandidate) { 704 stableCount += 1; 705 } 706 else { 707 lastCandidate = candidate; 708 stableCount = 1; 709 } 710 if (stableCount >= 2 || index === maxPolls - 1) { 711 return candidate; 712 } 713 } 714 return lastCandidate; 715 } 716 export function isLikelyDoubaoUiNoise(value) { 717 const text = value.replace(/\s+/g, ''); 718 if (!text) 719 return false; 720 const exactNoise = new Set([ 721 '快速视频生成深入研究图像生成帮我写作音乐生成更多', 722 ]); 723 return exactNoise.has(text); 724 } 725 function isAlwaysTranscriptUiNoise(value) { 726 const text = value.replace(/\s+/g, ''); 727 if (!text) 728 return false; 729 const exactNoise = new Set([ 730 'AI创作云盘更多历史对话', 731 ]); 732 return exactNoise.has(text); 733 } 734 function isLikelyTranscriptUiNoise(rawValue, sanitizedValue, promptText) { 735 const normalizeWhitespace = (value) => value.replace(/\s+/g, ' ').trim(); 736 const normalizedRaw = normalizeWhitespace(rawValue); 737 const normalizedPrompt = normalizeWhitespace(promptText); 738 if (!normalizedPrompt || !normalizedRaw.startsWith(normalizedPrompt)) 739 return false; 740 const remainder = normalizedRaw.slice(normalizedPrompt.length).trim(); 741 if (!remainder) 742 return true; 743 return isLikelyDoubaoUiNoise(remainder) || isLikelyDoubaoUiNoise(sanitizedValue); 744 } 745 export function collectDoubaoTranscriptAdditions(beforeLines, currentLines, promptText, sanitize = (value) => value.trim()) { 746 const normalizedBefore = new Set(beforeLines.map((line) => sanitize(line)).filter(Boolean)); 747 return currentLines 748 .filter((line) => !beforeLines.includes(line)) 749 .map((line) => ({ raw: line, sanitized: sanitize(line) })) 750 .filter(({ raw, sanitized }) => sanitized 751 && sanitized !== promptText 752 && !normalizedBefore.has(sanitized) 753 && !isAlwaysTranscriptUiNoise(sanitized) 754 && !isLikelyTranscriptUiNoise(raw, sanitized, promptText)) 755 .map(({ sanitized }) => sanitized) 756 .join('\n'); 757 } 758 function getConversationListScript() { 759 return ` 760 (() => { 761 const sidebar = document.querySelector('[data-testid="flow_chat_sidebar"]'); 762 if (!sidebar) return []; 763 764 const items = Array.from( 765 sidebar.querySelectorAll('a[data-testid="chat_list_thread_item"]') 766 ); 767 768 return items 769 .map(a => { 770 const href = a.getAttribute('href') || ''; 771 const match = href.match(/\\/chat\\/(\\d{10,})/); 772 if (!match) return null; 773 const id = match[1]; 774 const textContent = (a.textContent || a.innerText || '').trim(); 775 const title = textContent 776 .replace(/\\s+/g, ' ') 777 .substring(0, 200); 778 return { id, title, href }; 779 }) 780 .filter(Boolean); 781 })() 782 `; 783 } 784 export async function getDoubaoConversationList(page) { 785 await ensureDoubaoChatPage(page); 786 const raw = await page.evaluate(getConversationListScript()); 787 if (!Array.isArray(raw)) 788 return []; 789 return raw.map((item) => ({ 790 Id: item.id, 791 Title: item.title, 792 Url: `${DOUBAO_CHAT_URL}/${item.id}`, 793 })); 794 } 795 export function parseDoubaoConversationId(input) { 796 const match = input.match(/(\d{10,})/); 797 return match ? match[1] : input; 798 } 799 function getConversationDetailScript() { 800 return ` 801 (() => { 802 const clean = (v) => (v || '').replace(/\\u00a0/g, ' ').replace(/\\n{3,}/g, '\\n\\n').trim(); 803 804 const messageList = document.querySelector('[data-testid="message-list"]'); 805 if (!messageList) return { messages: [], meeting: null }; 806 807 const meetingCard = messageList.querySelector('[data-testid="meeting-minutes-card"]'); 808 let meeting = null; 809 if (meetingCard) { 810 const raw = clean(meetingCard.textContent || ''); 811 const match = raw.match(/^(.+?)(?:会议时间:|\\s*$)(.*)/); 812 meeting = { 813 title: match ? match[1].trim() : raw, 814 time: match && match[2] ? match[2].trim() : '', 815 }; 816 } 817 818 const unions = Array.from(messageList.querySelectorAll('[data-testid="union_message"]')); 819 const messages = unions.map(u => { 820 const isSend = !!u.querySelector('[data-testid="send_message"]'); 821 const isReceive = !!u.querySelector('[data-testid="receive_message"]'); 822 const textEl = u.querySelector('[data-testid="message_text_content"]'); 823 const text = textEl ? clean(textEl.innerText || textEl.textContent || '') : ''; 824 return { 825 role: isSend ? 'User' : isReceive ? 'Assistant' : 'System', 826 text, 827 hasMeetingCard: !!u.querySelector('[data-testid="meeting-minutes-card"]'), 828 }; 829 }).filter(m => m.text); 830 831 return { messages, meeting }; 832 })() 833 `; 834 } 835 export async function navigateToConversation(page, conversationId) { 836 const url = `${DOUBAO_CHAT_URL}/${conversationId}`; 837 const currentUrl = await page.evaluate('window.location.href').catch(() => ''); 838 if (typeof currentUrl === 'string' && currentUrl.includes(`/chat/${conversationId}`)) { 839 await page.wait(1); 840 return; 841 } 842 await page.goto(url, { waitUntil: 'load', settleMs: 3000 }); 843 await page.wait(2); 844 } 845 export async function getConversationDetail(page, conversationId) { 846 await navigateToConversation(page, conversationId); 847 const raw = await page.evaluate(getConversationDetailScript()); 848 const messages = (raw.messages || []).map((m) => ({ 849 Role: m.role, 850 Text: m.text, 851 HasMeetingCard: m.hasMeetingCard, 852 })); 853 return { messages, meeting: raw.meeting }; 854 } 855 // --------------------------------------------------------------------------- 856 // Meeting minutes panel helpers 857 // --------------------------------------------------------------------------- 858 function clickMeetingCardScript() { 859 return ` 860 (() => { 861 const card = document.querySelector('[data-testid="meeting-minutes-card"]'); 862 if (!card) return false; 863 card.click(); 864 return true; 865 })() 866 `; 867 } 868 function readMeetingSummaryScript() { 869 return ` 870 (() => { 871 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 872 if (!panel) return { error: 'no panel' }; 873 874 const summary = panel.querySelector('[data-testid="meeting-summary-todos"]'); 875 const summaryText = summary 876 ? (summary.innerText || summary.textContent || '').trim() 877 : ''; 878 879 return { summary: summaryText }; 880 })() 881 `; 882 } 883 function clickTextNotesTabScript() { 884 return ` 885 (() => { 886 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 887 if (!panel) return false; 888 const tabs = panel.querySelectorAll('[role="tab"], .semi-tabs-tab'); 889 for (const tab of tabs) { 890 if ((tab.textContent || '').trim().includes('文字')) { 891 tab.click(); 892 return true; 893 } 894 } 895 return false; 896 })() 897 `; 898 } 899 function readTextNotesScript() { 900 return ` 901 (() => { 902 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 903 if (!panel) return ''; 904 const textNotes = panel.querySelector('[data-testid="meeting-text-notes"]'); 905 if (!textNotes) return ''; 906 return (textNotes.innerText || textNotes.textContent || '').trim(); 907 })() 908 `; 909 } 910 function normalizeTranscriptLines(text) { 911 return text 912 .split('\n') 913 .map(line => line.trim()) 914 .filter(Boolean); 915 } 916 function containsLineSequence(haystack, needle) { 917 if (needle.length === 0) 918 return true; 919 if (needle.length > haystack.length) 920 return false; 921 for (let start = 0; start <= haystack.length - needle.length; start += 1) { 922 let matched = true; 923 for (let offset = 0; offset < needle.length; offset += 1) { 924 if (haystack[start + offset] !== needle[offset]) { 925 matched = false; 926 break; 927 } 928 } 929 if (matched) 930 return true; 931 } 932 return false; 933 } 934 export function mergeTranscriptSnapshots(existing, incoming) { 935 const currentLines = normalizeTranscriptLines(existing); 936 const nextLines = normalizeTranscriptLines(incoming); 937 if (nextLines.length === 0) 938 return currentLines.join('\n'); 939 if (currentLines.length === 0) 940 return nextLines.join('\n'); 941 if (containsLineSequence(currentLines, nextLines)) 942 return currentLines.join('\n'); 943 const maxOverlap = Math.min(currentLines.length, nextLines.length); 944 for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { 945 let matched = true; 946 for (let index = 0; index < overlap; index += 1) { 947 if (currentLines[currentLines.length - overlap + index] !== nextLines[index]) { 948 matched = false; 949 break; 950 } 951 } 952 if (matched) { 953 return [...currentLines, ...nextLines.slice(overlap)].join('\n'); 954 } 955 } 956 return [...currentLines, ...nextLines].join('\n'); 957 } 958 function clickChapterTabScript() { 959 return ` 960 (() => { 961 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 962 if (!panel) return false; 963 const tabs = panel.querySelectorAll('[role="tab"], .semi-tabs-tab'); 964 for (const tab of tabs) { 965 if ((tab.textContent || '').trim().includes('章节')) { 966 tab.click(); 967 return true; 968 } 969 } 970 return false; 971 })() 972 `; 973 } 974 function readChapterScript() { 975 return ` 976 (() => { 977 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 978 if (!panel) return ''; 979 const chapter = panel.querySelector('[data-testid="meeting-ai-chapter"]'); 980 if (!chapter) return ''; 981 return (chapter.innerText || chapter.textContent || '').trim(); 982 })() 983 `; 984 } 985 function triggerTranscriptDownloadScript() { 986 return ` 987 (() => { 988 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 989 if (!panel) return { error: 'no panel' }; 990 991 const downloadIcon = panel.querySelector('[class*="DownloadMeetingAudio"] span[role="img"]'); 992 if (!downloadIcon) return { error: 'no download icon' }; 993 994 downloadIcon.click(); 995 return { clicked: 'icon' }; 996 })() 997 `; 998 } 999 function clickTranscriptDownloadBtnScript() { 1000 return ` 1001 (() => { 1002 const btn = document.querySelector('[data-testid="minutes-download-text-btn"]'); 1003 if (!btn) return { error: 'no download text btn' }; 1004 btn.click(); 1005 return { clicked: 'transcript' }; 1006 })() 1007 `; 1008 } 1009 export async function openMeetingPanel(page, conversationId) { 1010 await navigateToConversation(page, conversationId); 1011 const clicked = await page.evaluate(clickMeetingCardScript()); 1012 if (!clicked) 1013 return false; 1014 await page.wait(2); 1015 return true; 1016 } 1017 export async function getMeetingSummary(page) { 1018 const result = await page.evaluate(readMeetingSummaryScript()); 1019 return result.summary || ''; 1020 } 1021 export async function getMeetingChapters(page) { 1022 await page.evaluate(clickChapterTabScript()); 1023 await page.wait(1.5); 1024 return await page.evaluate(readChapterScript()); 1025 } 1026 function scrollTextNotesPanelScript() { 1027 return ` 1028 (() => { 1029 const panel = document.querySelector('[data-testid="canvas_panel_container"]'); 1030 if (!panel) return 0; 1031 const textNotes = panel.querySelector('[data-testid="meeting-text-notes"]'); 1032 if (!textNotes) return 0; 1033 1034 const scrollable = textNotes.closest('[class*="overflow"]') 1035 || textNotes.parentElement 1036 || textNotes; 1037 const maxScroll = scrollable.scrollHeight - scrollable.clientHeight; 1038 if (maxScroll > 0) { 1039 scrollable.scrollTop = scrollable.scrollHeight; 1040 } 1041 return maxScroll; 1042 })() 1043 `; 1044 } 1045 export async function getMeetingTranscript(page) { 1046 await page.evaluate(clickTextNotesTabScript()); 1047 await page.wait(2); 1048 let merged = ''; 1049 let stableRounds = 0; 1050 for (let i = 0; i < 10; i++) { 1051 await page.evaluate(scrollTextNotesPanelScript()); 1052 await page.wait(1); 1053 const snapshot = await page.evaluate(readTextNotesScript()); 1054 const nextMerged = mergeTranscriptSnapshots(merged, snapshot); 1055 if (nextMerged === merged && snapshot.length > 0) { 1056 stableRounds += 1; 1057 if (stableRounds >= 2) 1058 break; 1059 } 1060 else { 1061 stableRounds = 0; 1062 merged = nextMerged; 1063 } 1064 } 1065 return merged; 1066 } 1067 export async function triggerTranscriptDownload(page) { 1068 const iconResult = await page.evaluate(triggerTranscriptDownloadScript()); 1069 if (iconResult.error) 1070 return false; 1071 await page.wait(1); 1072 const btnResult = await page.evaluate(clickTranscriptDownloadBtnScript()); 1073 return !btnResult.error; 1074 } 1075 export const __test__ = { 1076 clickSendButtonScript, 1077 composerStateScript, 1078 detectDoubaoVerificationScript, 1079 }; 1080 export async function startNewDoubaoChat(page) { 1081 await ensureDoubaoChatPage(page); 1082 const clickedLabel = await page.evaluate(clickNewChatScript()); 1083 if (clickedLabel) { 1084 await page.wait(1.5); 1085 return clickedLabel; 1086 } 1087 await page.goto(DOUBAO_NEW_CHAT_URL, { waitUntil: 'load', settleMs: 2000 }); 1088 await page.wait(1.5); 1089 return 'navigate'; 1090 }