diff --git a/README.zh-CN.md b/README.zh-CN.md index be296df5..f901fc4d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,7 +126,7 @@ npm install -g @jackwener/opencli@latest | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 | | **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 | | **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 | -| **doubao** | `status` `new` `send` `read` `ask` | 浏览器 | +| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 | | **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 | | **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 | | **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 | diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 8804b39a..ed0b5680 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -28,7 +28,7 @@ Run `opencli list` for the live registry. | **[linux-do](/adapters/browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser | | **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser | | **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser | -| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` | 🔐 Browser | +| **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser | | **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser | | **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser | | **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 🔐 Browser | diff --git a/src/clis/doubao/detail.test.ts b/src/clis/doubao/detail.test.ts new file mode 100644 index 00000000..b80172d5 --- /dev/null +++ b/src/clis/doubao/detail.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetConversationDetail } = vi.hoisted(() => ({ + mockGetConversationDetail: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getConversationDetail: mockGetConversationDetail, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './detail.js'; + +describe('doubao detail', () => { + const detail = getRegistry().get('doubao/detail'); + + beforeEach(() => { + mockGetConversationDetail.mockReset(); + }); + + it('returns meeting metadata even when the conversation has no chat messages', async () => { + mockGetConversationDetail.mockResolvedValue({ + messages: [], + meeting: { + title: 'Weekly Sync', + time: '2026-03-28 10:00', + }, + }); + + const result = await detail!.func!({} as any, { id: '1234567890' }); + + expect(result).toEqual([ + { Role: 'Meeting', Text: 'Weekly Sync (2026-03-28 10:00)' }, + ]); + }); + + it('still returns an error row for a truly empty conversation', async () => { + mockGetConversationDetail.mockResolvedValue({ + messages: [], + meeting: null, + }); + + const result = await detail!.func!({} as any, { id: '1234567890' }); + + expect(result).toEqual([ + { Role: 'System', Text: 'No messages found. Verify the conversation ID.' }, + ]); + }); +}); diff --git a/src/clis/doubao/detail.ts b/src/clis/doubao/detail.ts new file mode 100644 index 00000000..5e4dc683 --- /dev/null +++ b/src/clis/doubao/detail.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { DOUBAO_DOMAIN, getConversationDetail, parseDoubaoConversationId } from './utils.js'; + +export const detailCommand = cli({ + site: 'doubao', + name: 'detail', + description: 'Read a specific Doubao conversation by ID', + domain: DOUBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Conversation ID (numeric or full URL)' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: Record) => { + const conversationId = parseDoubaoConversationId(kwargs.id as string); + + const { messages, meeting } = await getConversationDetail(page, conversationId); + + if (messages.length === 0 && !meeting) { + return [{ Role: 'System', Text: 'No messages found. Verify the conversation ID.' }]; + } + + const result: Array<{ Role: string; Text: string }> = []; + + if (meeting) { + result.push({ + Role: 'Meeting', + Text: `${meeting.title}${meeting.time ? ` (${meeting.time})` : ''}`, + }); + } + + for (const m of messages) { + result.push({ Role: m.Role, Text: m.Text }); + } + + return result; + }, +}); diff --git a/src/clis/doubao/history.test.ts b/src/clis/doubao/history.test.ts new file mode 100644 index 00000000..e83e41e2 --- /dev/null +++ b/src/clis/doubao/history.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetDoubaoConversationList } = vi.hoisted(() => ({ + mockGetDoubaoConversationList: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getDoubaoConversationList: mockGetDoubaoConversationList, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './history.js'; + +describe('doubao history', () => { + const history = getRegistry().get('doubao/history'); + + beforeEach(() => { + mockGetDoubaoConversationList.mockReset(); + }); + + it('includes the conversation id in the tabular output', async () => { + mockGetDoubaoConversationList.mockResolvedValue([ + { + Id: '1234567890123', + Title: 'Weekly Sync', + Url: 'https://www.doubao.com/chat/1234567890123', + }, + ]); + + const result = await history!.func!({} as any, {}); + + expect(result).toEqual([ + { + Index: 1, + Id: '1234567890123', + Title: 'Weekly Sync', + Url: 'https://www.doubao.com/chat/1234567890123', + }, + ]); + }); +}); diff --git a/src/clis/doubao/history.ts b/src/clis/doubao/history.ts new file mode 100644 index 00000000..a266ec14 --- /dev/null +++ b/src/clis/doubao/history.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { DOUBAO_DOMAIN, getDoubaoConversationList } from './utils.js'; + +export const historyCommand = cli({ + site: 'doubao', + name: 'history', + description: 'List conversation history from Doubao sidebar', + domain: DOUBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'limit', required: false, help: 'Max number of conversations to show', default: '50' }, + ], + columns: ['Index', 'Id', 'Title', 'Url'], + func: async (page: IPage, kwargs: Record) => { + const limit = parseInt(kwargs.limit as string, 10) || 50; + const conversations = await getDoubaoConversationList(page); + + if (conversations.length === 0) { + return [{ Index: 0, Id: '', Title: 'No conversation history found. Make sure you are logged in.', Url: '' }]; + } + + return conversations.slice(0, limit).map((conv, i) => ({ + Index: i + 1, + Id: conv.Id, + Title: conv.Title, + Url: conv.Url, + })); + }, +}); diff --git a/src/clis/doubao/meeting-summary.ts b/src/clis/doubao/meeting-summary.ts new file mode 100644 index 00000000..faa84a6b --- /dev/null +++ b/src/clis/doubao/meeting-summary.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + DOUBAO_DOMAIN, + openMeetingPanel, + getMeetingSummary, + getMeetingChapters, + parseDoubaoConversationId, +} from './utils.js'; + +export const meetingSummaryCommand = cli({ + site: 'doubao', + name: 'meeting-summary', + description: 'Get meeting summary and chapters from a Doubao conversation', + domain: DOUBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Conversation ID (numeric or full URL)' }, + { name: 'chapters', required: false, help: 'Also include AI chapters', default: 'false' }, + ], + columns: ['Section', 'Content'], + func: async (page: IPage, kwargs: Record) => { + const conversationId = parseDoubaoConversationId(kwargs.id as string); + const includeChapters = kwargs.chapters === 'true' || kwargs.chapters === true; + + const opened = await openMeetingPanel(page, conversationId); + if (!opened) { + return [{ Section: 'Error', Content: 'No meeting card found in this conversation.' }]; + } + + const summary = await getMeetingSummary(page); + const result: Array<{ Section: string; Content: string }> = []; + + if (summary) { + result.push({ Section: 'Summary', Content: summary }); + } + + if (includeChapters) { + const chapters = await getMeetingChapters(page); + if (chapters) { + result.push({ Section: 'Chapters', Content: chapters }); + } + } + + if (result.length === 0) { + return [{ Section: 'Info', Content: 'Meeting panel opened but no content found yet. Try again.' }]; + } + + return result; + }, +}); diff --git a/src/clis/doubao/meeting-transcript.ts b/src/clis/doubao/meeting-transcript.ts new file mode 100644 index 00000000..86f74e90 --- /dev/null +++ b/src/clis/doubao/meeting-transcript.ts @@ -0,0 +1,48 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + DOUBAO_DOMAIN, + openMeetingPanel, + getMeetingTranscript, + parseDoubaoConversationId, + triggerTranscriptDownload, +} from './utils.js'; + +export const meetingTranscriptCommand = cli({ + site: 'doubao', + name: 'meeting-transcript', + description: 'Get or download the meeting transcript from a Doubao conversation', + domain: DOUBAO_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { name: 'id', required: true, positional: true, help: 'Conversation ID (numeric or full URL)' }, + { name: 'download', required: false, help: 'Trigger browser file download instead of reading text', default: 'false' }, + ], + columns: ['Section', 'Content'], + func: async (page: IPage, kwargs: Record) => { + const conversationId = parseDoubaoConversationId(kwargs.id as string); + const shouldDownload = kwargs.download === 'true' || kwargs.download === true; + + const opened = await openMeetingPanel(page, conversationId); + if (!opened) { + return [{ Section: 'Error', Content: 'No meeting card found in this conversation.' }]; + } + + if (shouldDownload) { + const ok = await triggerTranscriptDownload(page); + if (!ok) { + return [{ Section: 'Error', Content: 'Failed to trigger transcript download.' }]; + } + return [{ Section: 'Download', Content: 'Transcript download triggered in browser. Check your Downloads folder.' }]; + } + + const transcript = await getMeetingTranscript(page); + if (!transcript) { + return [{ Section: 'Info', Content: 'No transcript content found. The meeting may not have a text record.' }]; + } + + return [{ Section: 'Transcript', Content: transcript }]; + }, +}); diff --git a/src/clis/doubao/utils.test.ts b/src/clis/doubao/utils.test.ts new file mode 100644 index 00000000..6287287e --- /dev/null +++ b/src/clis/doubao/utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { mergeTranscriptSnapshots, parseDoubaoConversationId } from './utils.js'; + +describe('parseDoubaoConversationId', () => { + it('extracts the numeric id from a full conversation URL', () => { + expect(parseDoubaoConversationId('https://www.doubao.com/chat/1234567890123')).toBe('1234567890123'); + }); + + it('keeps a raw id unchanged', () => { + expect(parseDoubaoConversationId('1234567890123')).toBe('1234567890123'); + }); +}); + +describe('mergeTranscriptSnapshots', () => { + it('extends the transcript when the next snapshot overlaps with the tail', () => { + const merged = mergeTranscriptSnapshots( + 'Alice 00:00\nHello team\nBob 00:05\nHi', + 'Bob 00:05\nHi\nAlice 00:10\nNext topic', + ); + + expect(merged).toBe( + 'Alice 00:00\nHello team\nBob 00:05\nHi\nAlice 00:10\nNext topic', + ); + }); + + it('does not duplicate a snapshot that is already contained in the transcript', () => { + const merged = mergeTranscriptSnapshots( + 'Alice 00:00\nHello team\nBob 00:05\nHi', + 'Bob 00:05\nHi', + ); + + expect(merged).toBe('Alice 00:00\nHello team\nBob 00:05\nHi'); + }); + + it('keeps both windows when a virtualized panel returns adjacent chunks without full history', () => { + const merged = mergeTranscriptSnapshots( + 'Alice 00:00\nHello team\nBob 00:05\nHi', + 'Alice 00:10\nNext topic\nBob 00:15\nAction items', + ); + + expect(merged).toBe( + 'Alice 00:00\nHello team\nBob 00:05\nHi\nAlice 00:10\nNext topic\nBob 00:15\nAction items', + ); + }); +}); diff --git a/src/clis/doubao/utils.ts b/src/clis/doubao/utils.ts index 56e0b4c9..d5e2b6a4 100644 --- a/src/clis/doubao/utils.ts +++ b/src/clis/doubao/utils.ts @@ -4,6 +4,12 @@ export const DOUBAO_DOMAIN = 'www.doubao.com'; export const DOUBAO_CHAT_URL = 'https://www.doubao.com/chat'; export const DOUBAO_NEW_CHAT_URL = 'https://www.doubao.com/chat/new-thread/create-by-msg'; +export interface DoubaoConversation { + Id: string; + Title: string; + Url: string; +} + export interface DoubaoTurn { Role: 'User' | 'Assistant' | 'System'; Text: string; @@ -605,6 +611,371 @@ export async function waitForDoubaoResponse( return lastCandidate; } +function getConversationListScript(): string { + return ` + (() => { + const sidebar = document.querySelector('[data-testid="flow_chat_sidebar"]'); + if (!sidebar) return []; + + const items = Array.from( + sidebar.querySelectorAll('a[data-testid="chat_list_thread_item"]') + ); + + return items + .map(a => { + const href = a.getAttribute('href') || ''; + const match = href.match(/\\/chat\\/(\\d{10,})/); + if (!match) return null; + const id = match[1]; + const textContent = (a.textContent || a.innerText || '').trim(); + const title = textContent + .replace(/\\s+/g, ' ') + .substring(0, 200); + return { id, title, href }; + }) + .filter(Boolean); + })() + `; +} + +export async function getDoubaoConversationList(page: IPage): Promise { + await ensureDoubaoChatPage(page); + const raw = await page.evaluate(getConversationListScript()) as + Array<{ id: string; title: string; href: string }>; + + if (!Array.isArray(raw)) return []; + + return raw.map((item) => ({ + Id: item.id, + Title: item.title, + Url: `${DOUBAO_CHAT_URL}/${item.id}`, + })); +} + +// --------------------------------------------------------------------------- +// Conversation detail helpers +// --------------------------------------------------------------------------- + +export interface DoubaoMessage { + Role: 'User' | 'Assistant' | 'System'; + Text: string; + HasMeetingCard: boolean; +} + +export interface DoubaoMeetingInfo { + title: string; + time: string; +} + +export function parseDoubaoConversationId(input: string): string { + const match = input.match(/(\d{10,})/); + return match ? match[1] : input; +} + +function getConversationDetailScript(): string { + return ` + (() => { + const clean = (v) => (v || '').replace(/\\u00a0/g, ' ').replace(/\\n{3,}/g, '\\n\\n').trim(); + + const messageList = document.querySelector('[data-testid="message-list"]'); + if (!messageList) return { messages: [], meeting: null }; + + const meetingCard = messageList.querySelector('[data-testid="meeting-minutes-card"]'); + let meeting = null; + if (meetingCard) { + const raw = clean(meetingCard.textContent || ''); + const match = raw.match(/^(.+?)(?:会议时间:|\\s*$)(.*)/); + meeting = { + title: match ? match[1].trim() : raw, + time: match && match[2] ? match[2].trim() : '', + }; + } + + const unions = Array.from(messageList.querySelectorAll('[data-testid="union_message"]')); + const messages = unions.map(u => { + const isSend = !!u.querySelector('[data-testid="send_message"]'); + const isReceive = !!u.querySelector('[data-testid="receive_message"]'); + const textEl = u.querySelector('[data-testid="message_text_content"]'); + const text = textEl ? clean(textEl.innerText || textEl.textContent || '') : ''; + return { + role: isSend ? 'User' : isReceive ? 'Assistant' : 'System', + text, + hasMeetingCard: !!u.querySelector('[data-testid="meeting-minutes-card"]'), + }; + }).filter(m => m.text); + + return { messages, meeting }; + })() + `; +} + +export async function navigateToConversation(page: IPage, conversationId: string): Promise { + const url = `${DOUBAO_CHAT_URL}/${conversationId}`; + const currentUrl = await page.evaluate('window.location.href').catch(() => ''); + if (typeof currentUrl === 'string' && currentUrl.includes(`/chat/${conversationId}`)) { + await page.wait(1); + return; + } + await page.goto(url, { waitUntil: 'load', settleMs: 3000 }); + await page.wait(2); +} + +export async function getConversationDetail( + page: IPage, + conversationId: string, +): Promise<{ messages: DoubaoMessage[]; meeting: DoubaoMeetingInfo | null }> { + await navigateToConversation(page, conversationId); + const raw = await page.evaluate(getConversationDetailScript()) as { + messages: Array<{ role: string; text: string; hasMeetingCard: boolean }>; + meeting: { title: string; time: string } | null; + }; + + const messages: DoubaoMessage[] = (raw.messages || []).map((m) => ({ + Role: m.role as 'User' | 'Assistant' | 'System', + Text: m.text, + HasMeetingCard: m.hasMeetingCard, + })); + + return { messages, meeting: raw.meeting }; +} + +// --------------------------------------------------------------------------- +// Meeting minutes panel helpers +// --------------------------------------------------------------------------- + +function clickMeetingCardScript(): string { + return ` + (() => { + const card = document.querySelector('[data-testid="meeting-minutes-card"]'); + if (!card) return false; + card.click(); + return true; + })() + `; +} + +function readMeetingSummaryScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return { error: 'no panel' }; + + const summary = panel.querySelector('[data-testid="meeting-summary-todos"]'); + const summaryText = summary + ? (summary.innerText || summary.textContent || '').trim() + : ''; + + return { summary: summaryText }; + })() + `; +} + +function clickTextNotesTabScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return false; + const tabs = panel.querySelectorAll('[role="tab"], .semi-tabs-tab'); + for (const tab of tabs) { + if ((tab.textContent || '').trim().includes('文字')) { + tab.click(); + return true; + } + } + return false; + })() + `; +} + +function readTextNotesScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return ''; + const textNotes = panel.querySelector('[data-testid="meeting-text-notes"]'); + if (!textNotes) return ''; + return (textNotes.innerText || textNotes.textContent || '').trim(); + })() + `; +} + +function normalizeTranscriptLines(text: string): string[] { + return text + .split('\n') + .map(line => line.trim()) + .filter(Boolean); +} + +function containsLineSequence(haystack: string[], needle: string[]): boolean { + if (needle.length === 0) return true; + if (needle.length > haystack.length) return false; + + for (let start = 0; start <= haystack.length - needle.length; start += 1) { + let matched = true; + for (let offset = 0; offset < needle.length; offset += 1) { + if (haystack[start + offset] !== needle[offset]) { + matched = false; + break; + } + } + if (matched) return true; + } + + return false; +} + +export function mergeTranscriptSnapshots(existing: string, incoming: string): string { + const currentLines = normalizeTranscriptLines(existing); + const nextLines = normalizeTranscriptLines(incoming); + + if (nextLines.length === 0) return currentLines.join('\n'); + if (currentLines.length === 0) return nextLines.join('\n'); + if (containsLineSequence(currentLines, nextLines)) return currentLines.join('\n'); + + const maxOverlap = Math.min(currentLines.length, nextLines.length); + for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { + let matched = true; + for (let index = 0; index < overlap; index += 1) { + if (currentLines[currentLines.length - overlap + index] !== nextLines[index]) { + matched = false; + break; + } + } + if (matched) { + return [...currentLines, ...nextLines.slice(overlap)].join('\n'); + } + } + + return [...currentLines, ...nextLines].join('\n'); +} + +function clickChapterTabScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return false; + const tabs = panel.querySelectorAll('[role="tab"], .semi-tabs-tab'); + for (const tab of tabs) { + if ((tab.textContent || '').trim().includes('章节')) { + tab.click(); + return true; + } + } + return false; + })() + `; +} + +function readChapterScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return ''; + const chapter = panel.querySelector('[data-testid="meeting-ai-chapter"]'); + if (!chapter) return ''; + return (chapter.innerText || chapter.textContent || '').trim(); + })() + `; +} + +function triggerTranscriptDownloadScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return { error: 'no panel' }; + + const downloadIcon = panel.querySelector('[class*="DownloadMeetingAudio"] span[role="img"]'); + if (!downloadIcon) return { error: 'no download icon' }; + + downloadIcon.click(); + return { clicked: 'icon' }; + })() + `; +} + +function clickTranscriptDownloadBtnScript(): string { + return ` + (() => { + const btn = document.querySelector('[data-testid="minutes-download-text-btn"]'); + if (!btn) return { error: 'no download text btn' }; + btn.click(); + return { clicked: 'transcript' }; + })() + `; +} + +export async function openMeetingPanel(page: IPage, conversationId: string): Promise { + await navigateToConversation(page, conversationId); + const clicked = await page.evaluate(clickMeetingCardScript()) as boolean; + if (!clicked) return false; + await page.wait(2); + return true; +} + +export async function getMeetingSummary(page: IPage): Promise { + const result = await page.evaluate(readMeetingSummaryScript()) as { summary?: string; error?: string }; + return result.summary || ''; +} + +export async function getMeetingChapters(page: IPage): Promise { + await page.evaluate(clickChapterTabScript()); + await page.wait(1.5); + return await page.evaluate(readChapterScript()) as string; +} + +function scrollTextNotesPanelScript(): string { + return ` + (() => { + const panel = document.querySelector('[data-testid="canvas_panel_container"]'); + if (!panel) return 0; + const textNotes = panel.querySelector('[data-testid="meeting-text-notes"]'); + if (!textNotes) return 0; + + const scrollable = textNotes.closest('[class*="overflow"]') + || textNotes.parentElement + || textNotes; + const maxScroll = scrollable.scrollHeight - scrollable.clientHeight; + if (maxScroll > 0) { + scrollable.scrollTop = scrollable.scrollHeight; + } + return maxScroll; + })() + `; +} + +export async function getMeetingTranscript(page: IPage): Promise { + await page.evaluate(clickTextNotesTabScript()); + await page.wait(2); + + let merged = ''; + let stableRounds = 0; + for (let i = 0; i < 10; i++) { + await page.evaluate(scrollTextNotesPanelScript()); + await page.wait(1); + const snapshot = await page.evaluate(readTextNotesScript()) as string; + const nextMerged = mergeTranscriptSnapshots(merged, snapshot); + + if (nextMerged === merged && snapshot.length > 0) { + stableRounds += 1; + if (stableRounds >= 2) break; + } else { + stableRounds = 0; + merged = nextMerged; + } + } + + return merged; +} + +export async function triggerTranscriptDownload(page: IPage): Promise { + const iconResult = await page.evaluate(triggerTranscriptDownloadScript()) as { clicked?: string; error?: string }; + if (iconResult.error) return false; + await page.wait(1); + + const btnResult = await page.evaluate(clickTranscriptDownloadBtnScript()) as { clicked?: string; error?: string }; + return !btnResult.error; +} + export async function startNewDoubaoChat(page: IPage): Promise { await ensureDoubaoChatPage(page); const clickedLabel = await page.evaluate(clickNewChatScript()) as string;