/ clis / doubao / utils.js
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  }