diff --git a/clis/gemini/deep-research-result.test.ts b/clis/gemini/deep-research-result.test.ts new file mode 100644 index 00000000..ed16235e --- /dev/null +++ b/clis/gemini/deep-research-result.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IPage } from '../../types.js'; + +const { + mockClickGeminiConversationByTitle, + mockExportGeminiDeepResearchReport, + mockGetGeminiConversationList, + mockGetGeminiPageState, + mockGetLatestGeminiAssistantResponse, + mockReadGeminiSnapshot, + mockResolveGeminiConversationForQuery, + mockWaitForGeminiTranscript, +} = vi.hoisted(() => ({ + mockClickGeminiConversationByTitle: vi.fn(), + mockExportGeminiDeepResearchReport: vi.fn(), + mockGetGeminiConversationList: vi.fn(), + mockGetGeminiPageState: vi.fn(), + mockGetLatestGeminiAssistantResponse: vi.fn(), + mockReadGeminiSnapshot: vi.fn(), + mockResolveGeminiConversationForQuery: vi.fn(), + mockWaitForGeminiTranscript: vi.fn(), +})); + +vi.mock('./utils.js', () => ({ + GEMINI_DOMAIN: 'gemini.google.com', + clickGeminiConversationByTitle: mockClickGeminiConversationByTitle, + exportGeminiDeepResearchReport: mockExportGeminiDeepResearchReport, + getGeminiConversationList: mockGetGeminiConversationList, + getGeminiPageState: mockGetGeminiPageState, + getLatestGeminiAssistantResponse: mockGetLatestGeminiAssistantResponse, + readGeminiSnapshot: mockReadGeminiSnapshot, + parseGeminiConversationUrl: (value: unknown) => { + const raw = String(value ?? '').trim(); + return raw.startsWith('https://gemini.google.com/app/') ? raw : null; + }, + parseGeminiTitleMatchMode: (value: unknown) => { + const raw = String(value ?? 'contains').trim().toLowerCase(); + if (raw === 'contains' || raw === 'exact') return raw; + return null; + }, + resolveGeminiConversationForQuery: mockResolveGeminiConversationForQuery, + waitForGeminiTranscript: mockWaitForGeminiTranscript, +})); + +import { deepResearchResultCommand } from './deep-research-result.js'; + +describe('gemini/deep-research-result', () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + } as unknown as IPage; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetGeminiPageState.mockResolvedValue({ isSignedIn: true }); + mockGetGeminiConversationList.mockResolvedValue([{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }]); + mockResolveGeminiConversationForQuery.mockReturnValue({ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }); + mockClickGeminiConversationByTitle.mockResolvedValue(true); + mockWaitForGeminiTranscript.mockResolvedValue(['line']); + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: 'https://files.example.com/report.md', source: 'network' }); + mockGetLatestGeminiAssistantResponse.mockResolvedValue('Final answer'); + mockReadGeminiSnapshot.mockResolvedValue({ + turns: [], + transcriptLines: [], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: true, + }); + }); + + it('uses latest conversation when query is empty', async () => { + const result = await deepResearchResultCommand.func!(page, { query: ' ' }); + + expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 }); + expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]); + }); + + it('falls back to current page response when query is empty and sidebar has no conversations', async () => { + mockGetGeminiConversationList.mockResolvedValue([]); + mockResolveGeminiConversationForQuery.mockReturnValue(null); + + const result = await deepResearchResultCommand.func!(page, { query: '' }); + + expect(page.goto).not.toHaveBeenCalled(); + expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]); + }); + + it('returns a validation message when match mode is invalid', async () => { + const result = await deepResearchResultCommand.func!(page, { query: 'A', match: 'prefix' }); + expect(result).toEqual([{ response: 'Invalid match mode. Use contains or exact.' }]); + }); + + it('returns a signed-out message when Gemini page state indicates logged out', async () => { + mockGetGeminiPageState.mockResolvedValue({ isSignedIn: false }); + const result = await deepResearchResultCommand.func!(page, { query: 'A' }); + expect(result).toEqual([{ response: 'Not signed in to Gemini.' }]); + }); + + it('opens matched conversation by URL and returns exported report url', async () => { + const result = await deepResearchResultCommand.func!(page, { query: 'A title', match: 'exact' }); + + expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 }); + expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]); + }); + + it('accepts a direct conversation URL and reads response from that page', async () => { + const url = 'https://gemini.google.com/app/direct-id'; + const result = await deepResearchResultCommand.func!(page, { query: url, match: 'contains' }); + + expect(page.goto).toHaveBeenCalledWith(url, { waitUntil: 'load', settleMs: 2500 }); + expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]); + }); + + it('passes query and mode into resolveGeminiConversationForQuery', async () => { + const result = await deepResearchResultCommand.func!(page, { query: 'title', match: 'contains' }); + + expect(mockResolveGeminiConversationForQuery).toHaveBeenCalledWith( + [{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }], + 'title', + 'contains', + ); + expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]); + }); + + it('falls back to click-by-title and returns not-found when click fails', async () => { + mockResolveGeminiConversationForQuery.mockReturnValue(null); + mockClickGeminiConversationByTitle.mockResolvedValue(false); + + const result = await deepResearchResultCommand.func!(page, { query: 'missing', match: 'contains' }); + + expect(result).toEqual([{ response: 'No conversation matched: missing' }]); + }); + + it('returns pending message when export url is unavailable and completion is not confirmed', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]); + }); + + it('returns waiting message when deep research is still generating', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + mockReadGeminiSnapshot.mockResolvedValue({ + turns: [], + transcriptLines: [], + composerHasText: false, + isGenerating: true, + structuredTurnsTrusted: true, + }); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]); + }); + + it('returns waiting message when assistant response indicates research in progress', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + mockGetLatestGeminiAssistantResponse.mockResolvedValue('正在研究中,请稍候。'); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]); + }); + + it('returns waiting message when transcript indicates in-progress status', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + mockGetLatestGeminiAssistantResponse.mockResolvedValue(''); + mockReadGeminiSnapshot.mockResolvedValue({ + turns: [], + transcriptLines: ['生成研究计划中,请稍候。'], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: true, + }); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]); + }); + + it('returns no-docs message when text indicates completed state', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + mockGetLatestGeminiAssistantResponse.mockResolvedValue('Researching websites... Completed'); + mockReadGeminiSnapshot.mockResolvedValue({ + turns: [], + transcriptLines: [], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: true, + }); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.' }]); + }); + + it('returns pending message when assistant response is empty', async () => { + mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' }); + mockGetLatestGeminiAssistantResponse.mockResolvedValue(''); + + const result = await deepResearchResultCommand.func!(page, { query: 'A title' }); + + expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]); + }); +}); diff --git a/clis/gemini/deep-research-result.ts b/clis/gemini/deep-research-result.ts new file mode 100644 index 00000000..3005f1f6 --- /dev/null +++ b/clis/gemini/deep-research-result.ts @@ -0,0 +1,116 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + GEMINI_DOMAIN, + clickGeminiConversationByTitle, + exportGeminiDeepResearchReport, + getLatestGeminiAssistantResponse, + getGeminiPageState, + parseGeminiConversationUrl, + parseGeminiTitleMatchMode, + readGeminiSnapshot, + resolveGeminiConversationForQuery, + waitForGeminiTranscript, + getGeminiConversationList, +} from './utils.js'; + +const DEEP_RESEARCH_WAITING_MESSAGE = 'Deep Research is still running. Please wait and retry later.'; +const DEEP_RESEARCH_NO_DOCS_MESSAGE = 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.'; +const DEEP_RESEARCH_PENDING_MESSAGE = 'Deep Research may still be running or preparing export. Please wait and retry later.'; + +function isDeepResearchInProgress(text: string): boolean { + return /\bresearching(?:\s+websites?)?\b|research in progress|working on your research|generating research plan|gathering sources|creating report|planning research|正在研究|研究中|调研中|生成研究计划|搜集资料|请稍候|稍候|请等待/i.test(text); +} + +function isDeepResearchCompleted(text: string): boolean { + return /\bcompleted\b|research complete|completed research|report completed|已完成|研究完成|完成了研究|报告已完成/i.test(text); +} + +async function resolveDeepResearchExportResponse(page: IPage, timeoutSeconds: number): Promise { + const exported = await exportGeminiDeepResearchReport(page, timeoutSeconds); + if (exported.url) return exported.url; + + const snapshot = await readGeminiSnapshot(page).catch(() => null); + if (snapshot?.isGenerating) return DEEP_RESEARCH_WAITING_MESSAGE; + + const latest = await getLatestGeminiAssistantResponse(page).catch(() => ''); + const turnTail = Array.isArray(snapshot?.turns) + ? snapshot.turns.slice(-6).map((turn) => String(turn?.Text ?? '')).join('\n') + : ''; + const transcriptTail = Array.isArray(snapshot?.transcriptLines) + ? snapshot.transcriptLines.slice(-30).join('\n') + : ''; + const statusText = [latest, turnTail, transcriptTail] + .map((value) => String(value ?? '').trim()) + .filter(Boolean) + .join('\n'); + + if (statusText && isDeepResearchInProgress(statusText) && !isDeepResearchCompleted(statusText)) { + return DEEP_RESEARCH_WAITING_MESSAGE; + } + + if (statusText && isDeepResearchCompleted(statusText)) { + return DEEP_RESEARCH_NO_DOCS_MESSAGE; + } + + return DEEP_RESEARCH_PENDING_MESSAGE; +} + +export const deepResearchResultCommand = cli({ + site: 'gemini', + name: 'deep-research-result', + description: 'Export Deep Research report URL from a Gemini conversation', + domain: GEMINI_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + defaultFormat: 'plain', + args: [ + { name: 'query', positional: true, required: false, help: 'Conversation title or URL (optional; defaults to latest conversation)' }, + { name: 'match', required: false, default: 'contains', choices: ['contains', 'exact'], help: 'Match mode' }, + { name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds to wait for Docs export (default: 120)' }, + ], + columns: ['response'], + func: async (page: IPage, kwargs: any) => { + const query = String(kwargs.query ?? '').trim(); + const matchMode = parseGeminiTitleMatchMode(kwargs.match); + const timeoutRaw = Number.parseInt(String(kwargs.timeout ?? ''), 10); + const timeoutSeconds = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 120; + if (!matchMode) { + return [{ response: 'Invalid match mode. Use contains or exact.' }]; + } + + const state = await getGeminiPageState(page); + if (state.isSignedIn === false) { + return [{ response: 'Not signed in to Gemini.' }]; + } + + const conversationUrl = parseGeminiConversationUrl(query); + if (conversationUrl) { + await page.goto(conversationUrl, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + await waitForGeminiTranscript(page); + return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }]; + } + + const conversations = await getGeminiConversationList(page); + const picked = resolveGeminiConversationForQuery(conversations, query, matchMode); + + if (picked?.Url) { + await page.goto(picked.Url, { waitUntil: 'load', settleMs: 2500 }); + await page.wait(1); + await waitForGeminiTranscript(page); + } else if (query) { + if (matchMode === 'exact') { + return [{ response: `No conversation matched: ${query}` }]; + } + const clicked = await clickGeminiConversationByTitle(page, query); + if (!clicked) { + return [{ response: `No conversation matched: ${query}` }]; + } + await waitForGeminiTranscript(page); + } + + return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }]; + }, +}); diff --git a/clis/gemini/deep-research.test.ts b/clis/gemini/deep-research.test.ts new file mode 100644 index 00000000..6ce02879 --- /dev/null +++ b/clis/gemini/deep-research.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IPage } from '../../types.js'; + +const { + mockGetCurrentGeminiUrl, + mockReadGeminiSnapshot, + mockSelectGeminiTool, + mockSendGeminiMessage, + mockStartNewGeminiChat, + mockWaitForGeminiSubmission, + mockWaitForGeminiConfirmButton, + mockGetLatestGeminiAssistantResponse, +} = vi.hoisted(() => ({ + mockGetCurrentGeminiUrl: vi.fn<() => Promise>(), + mockReadGeminiSnapshot: vi.fn<() => Promise>(), + mockSelectGeminiTool: vi.fn<() => Promise>(), + mockSendGeminiMessage: vi.fn<() => Promise>(), + mockStartNewGeminiChat: vi.fn<() => Promise>(), + mockWaitForGeminiSubmission: vi.fn<() => Promise>(), + mockWaitForGeminiConfirmButton: vi.fn<() => Promise>(), + mockGetLatestGeminiAssistantResponse: vi.fn<() => Promise>(), +})); + +vi.mock('./utils.js', () => ({ + GEMINI_DOMAIN: 'gemini.google.com', + GEMINI_APP_URL: 'https://gemini.google.com/app', + GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS: ['Deep Research', 'Deep research', '\u6df1\u5ea6\u7814\u7a76'], + GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS: [ + 'Start research', + 'Start Research', + 'Start deep research', + 'Start Deep Research', + 'Generate research plan', + 'Generate Research Plan', + 'Generate deep research plan', + 'Generate Deep Research Plan', + '\u5f00\u59cb\u7814\u7a76', + '\u5f00\u59cb\u6df1\u5ea6\u7814\u7a76', + '\u5f00\u59cb\u8c03\u7814', + '\u751f\u6210\u7814\u7a76\u8ba1\u5212', + '\u751f\u6210\u8c03\u7814\u8ba1\u5212', + ], + parseGeminiPositiveInt: (value: unknown, fallback: number) => { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + }, + resolveGeminiLabels: (value: unknown, fallback: string[]) => { + const label = String(value ?? '').trim(); + return label ? [label] : fallback; + }, + getCurrentGeminiUrl: mockGetCurrentGeminiUrl, + getLatestGeminiAssistantResponse: mockGetLatestGeminiAssistantResponse, + readGeminiSnapshot: mockReadGeminiSnapshot, + selectGeminiTool: mockSelectGeminiTool, + sendGeminiMessage: mockSendGeminiMessage, + startNewGeminiChat: mockStartNewGeminiChat, + waitForGeminiSubmission: mockWaitForGeminiSubmission, + waitForGeminiConfirmButton: mockWaitForGeminiConfirmButton, +})); + +import { deepResearchCommand } from './deep-research.js'; + +describe('gemini/deep-research', () => { + const page = {} as IPage; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/chat'); + mockReadGeminiSnapshot.mockResolvedValue({ + turns: [], + transcriptLines: [], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: true, + }); + mockSelectGeminiTool.mockResolvedValue('Deep Research'); + mockSendGeminiMessage.mockResolvedValue(); + mockStartNewGeminiChat.mockResolvedValue(); + mockWaitForGeminiSubmission.mockResolvedValue({ + snapshot: { turns: [], transcriptLines: [], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true }, + preSendAssistantCount: 0, + userAnchorTurn: null, + reason: 'user_turn', + }); + mockWaitForGeminiConfirmButton.mockResolvedValue('Start research'); + mockGetLatestGeminiAssistantResponse.mockResolvedValue(''); + }); + + it('starts a new chat by default, then sends prompt and confirms deep research', async () => { + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(mockStartNewGeminiChat).toHaveBeenCalledTimes(1); + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(1); + expect(mockSendGeminiMessage).toHaveBeenCalledWith(page, 'research this topic'); + expect(mockWaitForGeminiSubmission).toHaveBeenCalledTimes(1); + expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledWith( + page, + expect.arrayContaining(['Start research', 'Start deep research', 'Generate research plan', '\u751f\u6210\u7814\u7a76\u8ba1\u5212']), + 30, + ); + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/chat' }]); + }); + + it('returns tool-not-found when the tool cannot be selected', async () => { + mockSelectGeminiTool.mockResolvedValue(''); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(result).toEqual([{ status: 'tool-not-found', url: 'https://gemini.google.com/app/chat' }]); + expect(mockSendGeminiMessage).not.toHaveBeenCalled(); + expect(mockWaitForGeminiSubmission).not.toHaveBeenCalled(); + expect(mockWaitForGeminiConfirmButton).not.toHaveBeenCalled(); + }); + + it('retries send once when first submission cannot be confirmed', async () => { + mockWaitForGeminiSubmission.mockResolvedValueOnce(null).mockResolvedValueOnce({ + snapshot: { turns: [], transcriptLines: [], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true }, + preSendAssistantCount: 0, + userAnchorTurn: null, + reason: 'user_turn', + }); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2); + expect(mockReadGeminiSnapshot).toHaveBeenCalledTimes(2); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(2); + expect(mockWaitForGeminiSubmission).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/chat' }]); + }); + + it('returns submit-not-found when submission cannot be confirmed after retry', async () => { + mockWaitForGeminiSubmission.mockResolvedValue(null); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(2); + expect(mockWaitForGeminiConfirmButton).not.toHaveBeenCalled(); + expect(result).toEqual([{ status: 'submit-not-found', url: 'https://gemini.google.com/app/chat' }]); + }); + + it('returns confirm-not-found when no confirm button is found', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValue(''); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app/chat' }]); + }); + + it('returns started when confirm is missing but research appears to be running', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValue(''); + mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/abc123'); + mockGetLatestGeminiAssistantResponse.mockResolvedValue('Researching websites now'); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/abc123' }]); + }); + + it('does not treat conversation url alone as started when confirm is missing', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValue(''); + mockGetCurrentGeminiUrl.mockResolvedValue('https://gemini.google.com/app/abc999'); + mockGetLatestGeminiAssistantResponse.mockResolvedValue('I drafted a plan. Start research'); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic' }); + + expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app/abc999' }]); + }); + + it('retries once when stuck on root app URL and starts successfully on second confirm', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValueOnce('').mockResolvedValueOnce('Start research'); + mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app/retry123'); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' }); + + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1); + expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/retry123' }]); + }); + + it('treats root-url confirm as false-positive and retries', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValueOnce('Start research').mockResolvedValueOnce('Start research'); + mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app/retry456'); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' }); + + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1); + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/retry456' }]); + }); + + it('does not resend prompt during root-url retry to avoid duplicate chats', async () => { + mockWaitForGeminiConfirmButton.mockResolvedValueOnce('Start research').mockResolvedValueOnce(''); + mockGetCurrentGeminiUrl.mockResolvedValueOnce('https://gemini.google.com/app').mockResolvedValueOnce('https://gemini.google.com/app'); + mockGetLatestGeminiAssistantResponse.mockResolvedValue(''); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' }); + + expect(mockSelectGeminiTool).toHaveBeenCalledTimes(2); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1); + expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ status: 'confirm-not-found', url: 'https://gemini.google.com/app' }]); + }); + + it('attempts one more confirm click when still waiting for start research', async () => { + mockWaitForGeminiConfirmButton + .mockResolvedValueOnce('') + .mockResolvedValueOnce('Start research'); + mockGetCurrentGeminiUrl + .mockResolvedValueOnce('https://gemini.google.com/app/xyz123') + .mockResolvedValueOnce('https://gemini.google.com/app/xyz123'); + mockGetLatestGeminiAssistantResponse + .mockResolvedValueOnce('I drafted a plan. Start research') + .mockResolvedValueOnce('Researching websites now'); + + const result = await deepResearchCommand.func!(page, { prompt: 'research this topic', timeout: '20' }); + + expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledTimes(2); + expect(mockWaitForGeminiConfirmButton).toHaveBeenNthCalledWith( + 2, + page, + expect.arrayContaining(['Start research', 'Start deep research', '开始研究', '开始深度研究']), + 8, + ); + expect(mockSendGeminiMessage).toHaveBeenCalledTimes(1); + expect(result).toEqual([{ status: 'started', url: 'https://gemini.google.com/app/xyz123' }]); + }); + + it('uses custom tool/confirm labels when provided', async () => { + await deepResearchCommand.func!(page, { + prompt: 'research this topic', + tool: 'Custom Tool', + confirm: 'Custom Confirm', + timeout: '42', + }); + + expect(mockSelectGeminiTool).toHaveBeenCalledWith(page, ['Custom Tool']); + expect(mockWaitForGeminiConfirmButton).toHaveBeenCalledWith(page, ['Custom Confirm'], 42); + }); +}); diff --git a/clis/gemini/deep-research.ts b/clis/gemini/deep-research.ts new file mode 100644 index 00000000..79b906a7 --- /dev/null +++ b/clis/gemini/deep-research.ts @@ -0,0 +1,124 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS, + GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS, + GEMINI_APP_URL, + GEMINI_DOMAIN, + getCurrentGeminiUrl, + getLatestGeminiAssistantResponse, + parseGeminiPositiveInt, + readGeminiSnapshot, + resolveGeminiLabels, + selectGeminiTool, + sendGeminiMessage, + startNewGeminiChat, + waitForGeminiSubmission, + waitForGeminiConfirmButton, +} from './utils.js'; + +function isGeminiRootAppUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.origin + parsed.pathname.replace(/\/+$/, '') === GEMINI_APP_URL; + } catch { + return false; + } +} + +function parseDeepResearchProgress(text: string): { isResearching: boolean; waitingForStart: boolean } { + const isResearching = /\bresearching(?:\s+websites?)?\b|research in progress|working on your research|正在研究|研究中/i.test(text); + const waitingForStart = /\bstart(?:\s+deep)?\s+research\b|begin\s+research|generate(?:\s+deep)?\s+research\s+plan|开始研究|开始深度研究|开始调研|生成研究计划|生成调研计划|try again without deep research/i.test(text); + return { isResearching, waitingForStart }; +} + +export const deepResearchCommand = cli({ + site: 'gemini', + name: 'deep-research', + description: 'Start a Gemini Deep Research run and confirm it', + domain: GEMINI_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + defaultFormat: 'plain', + timeoutSeconds: 180, + args: [ + { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, + { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for confirm (default: 30)', default: 30 }, + { name: 'tool', required: false, help: 'Override tool label (default: Deep Research)' }, + { name: 'confirm', required: false, help: 'Override confirm button label (default: Start research)' }, + ], + columns: ['status', 'url'], + func: async (page: IPage, kwargs: any) => { + const prompt = kwargs.prompt as string; + const timeout = parseGeminiPositiveInt(kwargs.timeout, 30); + const submitTimeout = Math.min(Math.max(timeout, 6), 20); + await startNewGeminiChat(page); + + const toolLabels = resolveGeminiLabels(kwargs.tool, GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS); + const confirmLabels = resolveGeminiLabels(kwargs.confirm, GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS); + + const toolMatched = await selectGeminiTool(page, toolLabels); + if (!toolMatched) { + const url = await getCurrentGeminiUrl(page); + return [{ status: 'tool-not-found', url }]; + } + + let baseline = await readGeminiSnapshot(page); + await sendGeminiMessage(page, prompt); + let submitted = await waitForGeminiSubmission(page, baseline, submitTimeout); + if (!submitted) { + // Retry once when submit did not stick (e.g. composer swallowed Enter/click in this UI state). + await selectGeminiTool(page, toolLabels); + baseline = await readGeminiSnapshot(page); + await sendGeminiMessage(page, prompt); + submitted = await waitForGeminiSubmission(page, baseline, submitTimeout); + } + if (!submitted) { + const url = await getCurrentGeminiUrl(page); + return [{ status: 'submit-not-found', url }]; + } + + const confirmed = await waitForGeminiConfirmButton(page, confirmLabels, timeout); + let url = await getCurrentGeminiUrl(page); + if (confirmed && !isGeminiRootAppUrl(url)) { + return [{ status: 'started', url }]; + } + + // false-positive confirm click can happen on generic buttons while still at /app root. + { + // Retry once when we are still at the root app URL, which usually means submit did not stick. + if (isGeminiRootAppUrl(url)) { + await selectGeminiTool(page, toolLabels); + // Avoid resending prompt here: it can create a duplicate conversation thread. + const confirmedRetry = await waitForGeminiConfirmButton(page, confirmLabels, timeout); + url = await getCurrentGeminiUrl(page); + if (confirmedRetry && !isGeminiRootAppUrl(url)) { + return [{ status: 'started', url }]; + } + } + + let response = await getLatestGeminiAssistantResponse(page); + let { isResearching, waitingForStart } = parseDeepResearchProgress(response); + + // Some UIs render the plan card first; click confirm one more time without resending prompt. + if (!isResearching && waitingForStart) { + const fallbackConfirmLabels = Array.from(new Set([ + ...confirmLabels, + ...GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS, + ])); + const confirmedFallback = await waitForGeminiConfirmButton(page, fallbackConfirmLabels, Math.min(timeout, 8)); + if (confirmedFallback) { + url = await getCurrentGeminiUrl(page); + response = await getLatestGeminiAssistantResponse(page); + ({ isResearching, waitingForStart } = parseDeepResearchProgress(response)); + } + } + + if (isResearching && !waitingForStart) { + return [{ status: 'started', url }]; + } + return [{ status: 'confirm-not-found', url }]; + } + }, +}); diff --git a/clis/gemini/reply-state.test.ts b/clis/gemini/reply-state.test.ts index 2ca6ddd0..5ea81411 100644 --- a/clis/gemini/reply-state.test.ts +++ b/clis/gemini/reply-state.test.ts @@ -322,6 +322,40 @@ describe('Gemini submission state', () => { expect(result).toBeNull(); }); + it('does not confirm transcript-only submission while url remains /app root', async () => { + const page = createPageMock(); + const evaluate = vi.mocked(page.evaluate); + + evaluate + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce({ + url: 'https://gemini.google.com/app', + turns: [], + transcriptLines: ['baseline', 'prompt'], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: false, + }) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce({ + url: 'https://gemini.google.com/app', + turns: [], + transcriptLines: ['baseline', 'prompt'], + composerHasText: false, + isGenerating: false, + structuredTurnsTrusted: false, + }); + + const result = await waitForGeminiSubmission(page, snapshot({ + url: 'https://gemini.google.com/app', + transcriptLines: ['baseline'], + composerHasText: true, + structuredTurnsTrusted: false, + }), 2); + + expect(result).toBeNull(); + }); + it('keeps polling past ten seconds when the overall timeout budget still allows submission confirmation', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); diff --git a/clis/gemini/utils.test.ts b/clis/gemini/utils.test.ts index 342619b8..761d30ae 100644 --- a/clis/gemini/utils.test.ts +++ b/clis/gemini/utils.test.ts @@ -4,6 +4,7 @@ import type { GeminiTurn } from './utils.js'; import { __test__, collectGeminiTranscriptAdditions, + pickGeminiDeepResearchExportUrl, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js'; @@ -216,3 +217,31 @@ describe('gemini turn normalization', () => { ]); }); }); + +describe('pickGeminiDeepResearchExportUrl', () => { + it('prefers docs.google.com document url over sheets and noise endpoints', () => { + const picked = pickGeminiDeepResearchExportUrl( + [ + 'xhr::https://gemini.google.com/_/BardChatUi/data/batchexecute?rpcids=ESY5D', + 'performance::https://docs.google.com/spreadsheets/d/1abc/edit', + 'open::https://docs.google.com/document/d/1docid/edit', + ], + 'https://gemini.google.com/app/abc', + ); + expect(picked).toEqual({ + url: 'https://docs.google.com/document/d/1docid/edit', + source: 'window-open', + }); + }); + + it('returns none when only non-export telemetry urls are present', () => { + const picked = pickGeminiDeepResearchExportUrl( + [ + 'fetch::https://gemini.google.com/_/BardChatUi/cspreport', + 'performance::https://www.google-analytics.com/g/collect?v=2', + ], + 'https://gemini.google.com/app/abc', + ); + expect(picked).toEqual({ url: '', source: 'none' }); + }); +}); diff --git a/clis/gemini/utils.ts b/clis/gemini/utils.ts index 41703def..f60742ff 100644 --- a/clis/gemini/utils.ts +++ b/clis/gemini/utils.ts @@ -3,6 +3,22 @@ import type { IPage } from '../../types.js'; export const GEMINI_DOMAIN = 'gemini.google.com'; export const GEMINI_APP_URL = 'https://gemini.google.com/app'; +export const GEMINI_DEEP_RESEARCH_DEFAULT_TOOL_LABELS = ['Deep Research', 'Deep research', '\u6df1\u5ea6\u7814\u7a76']; +export const GEMINI_DEEP_RESEARCH_DEFAULT_CONFIRM_LABELS = [ + 'Start research', + 'Start Research', + 'Start deep research', + 'Start Deep Research', + 'Generate research plan', + 'Generate Research Plan', + 'Generate deep research plan', + 'Generate Deep Research Plan', + '\u5f00\u59cb\u7814\u7a76', + '\u5f00\u59cb\u6df1\u5ea6\u7814\u7a76', + '\u5f00\u59cb\u8c03\u7814', + '\u751f\u6210\u7814\u7a76\u8ba1\u5212', + '\u751f\u6210\u8c03\u7814\u8ba1\u5212', +]; export interface GeminiPageState { url: string; @@ -17,7 +33,15 @@ export interface GeminiTurn { Text: string; } +export interface GeminiConversation { + Title: string; + Url: string; +} + +export type GeminiTitleMatchMode = 'contains' | 'exact'; + export interface GeminiSnapshot { + url?: string; turns: GeminiTurn[]; transcriptLines: string[]; composerHasText: boolean; @@ -100,6 +124,74 @@ function buildGeminiComposerLocatorScript(): string { `; } +export function resolveGeminiLabels(value: unknown, fallback: string[]): string[] { + const label = String(value ?? '').trim(); + return label ? [label] : fallback; +} + +export function parseGeminiPositiveInt(value: unknown, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function parseGeminiTitleMatchMode(value: unknown, fallback: GeminiTitleMatchMode = 'contains'): GeminiTitleMatchMode | null { + const raw = String(value ?? fallback).trim().toLowerCase(); + if (raw === 'contains' || raw === 'exact') return raw; + return null; +} + +export function parseGeminiConversationUrl(value: unknown): string | null { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (url.hostname !== GEMINI_DOMAIN && !url.hostname.endsWith(`.${GEMINI_DOMAIN}`)) return null; + if (!url.pathname.startsWith('/app/')) return null; + return url.href; + } catch { + return null; + } +} + +export function normalizeGeminiTitle(value: string): string { + return value.replace(/\s+/g, ' ').trim().toLowerCase(); +} + +export function pickGeminiConversationByTitle( + conversations: GeminiConversation[], + query: string, + mode: GeminiTitleMatchMode = 'contains', +): GeminiConversation | null { + const normalizedQuery = normalizeGeminiTitle(query); + if (!normalizedQuery) return null; + + for (const conversation of conversations) { + const normalizedTitle = normalizeGeminiTitle(conversation.Title); + if (!normalizedTitle) continue; + if (mode === 'exact') { + if (normalizedTitle === normalizedQuery) return conversation; + continue; + } + if (normalizedTitle.includes(normalizedQuery)) return conversation; + } + + return null; +} + +export function resolveGeminiConversationForQuery( + conversations: GeminiConversation[], + query: string, + mode: GeminiTitleMatchMode, +): GeminiConversation | null { + const normalizedQuery = String(query ?? '').trim(); + if (!normalizedQuery) return conversations[0] ?? null; + + const exact = pickGeminiConversationByTitle(conversations, normalizedQuery, 'exact'); + if (exact) return exact; + if (mode === 'contains') return pickGeminiConversationByTitle(conversations, normalizedQuery, 'contains'); + return null; +} + export function sanitizeGeminiResponseText(value: string, promptText: string): string { let sanitized = value; for (const pattern of GEMINI_RESPONSE_NOISE_PATTERNS) { @@ -278,6 +370,7 @@ function readGeminiSnapshotScript(): string { const transcriptLines = ${getTranscriptLinesScript().trim()}; return { + url: window.location.href, turns, transcriptLines, composerHasText: composerText.length > 0, @@ -288,6 +381,17 @@ function readGeminiSnapshotScript(): string { `; } +function isGeminiConversationUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.hostname !== GEMINI_DOMAIN && !parsed.hostname.endsWith(`.${GEMINI_DOMAIN}`)) return false; + const pathname = parsed.pathname.replace(/\/+$/, ''); + return pathname.startsWith('/app/') && pathname !== '/app'; + } catch { + return false; + } +} + function getTranscriptLinesScript(): string { return ` (() => { @@ -639,6 +743,342 @@ function clickNewChatScript(): string { `; } +function openGeminiToolsMenuScript(): string { + return ` + (() => { + const labels = ['tools', 'tool', 'mode', '研究', 'deep research', 'deep-research', '工具']; + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const matchesLabel = (value) => { + const text = normalize(value); + return labels.some((label) => text.includes(label)); + }; + + const isDisabled = (el) => { + if (!(el instanceof HTMLElement)) return true; + if ('disabled' in el && el.disabled) return true; + if (el.hasAttribute('disabled')) return true; + const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase(); + return ariaDisabled === 'true'; + }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0) return false; + if (style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const isInteractable = (el) => isVisible(el) && !isDisabled(el); + + const roots = [ + document.querySelector('main'), + document.querySelector('[role="main"]'), + document.querySelector('header'), + document, + ].filter(Boolean); + + const isMenuTrigger = (node) => { + if (!(node instanceof HTMLElement)) return false; + const popupValue = (node.getAttribute('aria-haspopup') || '').toLowerCase(); + const hasPopup = popupValue === 'menu' || popupValue === 'listbox' || popupValue === 'true'; + const controls = (node.getAttribute('aria-controls') || '').toLowerCase(); + const hasControls = ['menu', 'listbox', 'popup'].some((token) => controls.includes(token)); + return hasPopup || hasControls; + }; + + const menuAlreadyOpen = () => { + const visibleMenus = Array.from(document.querySelectorAll('[role="menu"], [role="listbox"]')).filter(isVisible); + const labeledMenu = visibleMenus.some((menu) => { + const text = menu.textContent || ''; + const aria = menu.getAttribute('aria-label') || ''; + return matchesLabel(text) || matchesLabel(aria); + }); + if (labeledMenu) return true; + const expanded = Array.from(document.querySelectorAll('[aria-expanded="true"]')).filter(isVisible); + return expanded.some((node) => { + if (!(node instanceof HTMLElement)) return false; + const text = node.textContent || ''; + const aria = node.getAttribute('aria-label') || ''; + return isMenuTrigger(node) && (matchesLabel(text) || matchesLabel(aria)); + }); + }; + + if (menuAlreadyOpen()) return true; + + const pickTarget = (root) => { + const nodes = Array.from(root.querySelectorAll('button, [role="button"]')).filter(isInteractable); + const matches = nodes.filter((node) => { + const text = (node.textContent || '').trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + if (!text && !aria) return false; + return matchesLabel(text) || matchesLabel(aria); + }); + if (matches.length === 0) return null; + const menuMatches = matches.filter((node) => isMenuTrigger(node)); + return menuMatches[0] || matches[0]; + }; + + let target = null; + for (const root of roots) { + target = pickTarget(root); + if (target) break; + } + if (target instanceof HTMLElement) { + target.click(); + return true; + } + return false; + })() + `; +} + +function selectGeminiToolScript(labels: string[]): string { + const labelsJson = JSON.stringify(labels); + return ` + ((targetLabels) => { + const isDisabled = (el) => { + if (!(el instanceof HTMLElement)) return true; + if ('disabled' in el && el.disabled) return true; + if (el.hasAttribute('disabled')) return true; + const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase(); + return ariaDisabled === 'true'; + }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0) return false; + if (style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const isInteractable = (el) => isVisible(el) && !isDisabled(el); + + const normalized = Array.isArray(targetLabels) + ? targetLabels.map((label) => String(label || '').trim()).filter((label) => label) + : []; + const lowered = normalized.map((label) => label.toLowerCase()); + if (lowered.length === 0) return ''; + + const menuSelectors = [ + '[role="menu"]', + '[role="listbox"]', + '[aria-label*="tool" i]', + '[aria-label*="mode" i]', + '[aria-modal="true"]', + ]; + const menuRoots = Array.from(document.querySelectorAll(menuSelectors.join(','))).filter(isVisible); + if (menuRoots.length === 0) return ''; + const seen = new Set(); + + for (const root of menuRoots) { + const candidates = Array.from(root.querySelectorAll('button, [role="menuitem"], [role="option"], [role="button"], a, li')); + for (const node of candidates) { + if (seen.has(node)) continue; + seen.add(node); + if (!isInteractable(node)) continue; + const text = (node.textContent || '').trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + if (!text && !aria) continue; + const combined = \`\${text} \${aria}\`.trim(); + for (let index = 0; index < lowered.length; index += 1) { + const label = lowered[index]; + if (label && combined.includes(label)) { + if (node instanceof HTMLElement) node.click(); + return normalized[index]; + } + } + } + } + + return ''; + })(${labelsJson}) + `; +} + +function clickGeminiConfirmButtonScript(labels: string[]): string { + const labelsJson = JSON.stringify(labels); + return ` + ((targetLabels) => { + const isDisabled = (el) => { + if (!(el instanceof HTMLElement)) return true; + if ('disabled' in el && el.disabled) return true; + if (el.hasAttribute('disabled')) return true; + const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase(); + return ariaDisabled === 'true'; + }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0) return false; + if (style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const isInteractable = (el) => isVisible(el) && !isDisabled(el); + + const normalized = Array.isArray(targetLabels) + ? targetLabels.map((label) => String(label || '').trim()).filter((label) => label) + : []; + const lowered = normalized.map((label) => label.toLowerCase()); + if (lowered.length === 0) return ''; + + const dialogRoots = Array.from(document.querySelectorAll('[role="dialog"], [aria-modal="true"]')).filter(isVisible); + const mainRoot = document.querySelector('main'); + const primaryRoots = [...dialogRoots, mainRoot].filter(Boolean).filter(isVisible); + const rootGroups = primaryRoots.length > 0 ? [primaryRoots, [document]] : [[document]]; + const seen = new Set(); + + for (const roots of rootGroups) { + for (const root of roots) { + const candidates = Array.from(root.querySelectorAll('button, [role="button"]')); + for (const node of candidates) { + if (seen.has(node)) continue; + seen.add(node); + if (!isInteractable(node)) continue; + const text = (node.textContent || '').trim().toLowerCase(); + const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + if (!text && !aria) continue; + const combined = \`\${text} \${aria}\`.trim(); + for (let index = 0; index < lowered.length; index += 1) { + const label = lowered[index]; + if (label && combined.includes(label)) { + if (node instanceof HTMLElement) node.click(); + return normalized[index]; + } + } + } + } + } + + return ''; + })(${labelsJson}) + `; +} + +function getGeminiConversationListScript(): string { + return ` + (() => { + const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const clampText = (value, maxLength) => { + const normalized = normalizeText(value); + if (!normalized) return ''; + if (normalized.length <= maxLength) return normalized; + return normalized.slice(0, maxLength).trim(); + }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0) return false; + if (style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const selector = 'a[href*="/app"]'; + const navRoots = Array.from(document.querySelectorAll('nav, aside, [role="navigation"]')); + const rootsWithLinks = navRoots.filter((root) => root.querySelector(selector)); + const roots = rootsWithLinks.length > 0 ? rootsWithLinks : [document]; + + const results = []; + const seen = new Set(); + const maxLength = 200; + + for (const root of roots) { + const anchors = Array.from(root.querySelectorAll(selector)); + for (const anchor of anchors) { + if (!(anchor instanceof HTMLAnchorElement)) continue; + if (!isVisible(anchor)) continue; + const href = anchor.getAttribute('href') || ''; + if (!href) continue; + let url = ''; + try { + url = new URL(href, 'https://gemini.google.com').href; + } catch { + continue; + } + if (!url) continue; + const title = clampText(anchor.textContent || anchor.getAttribute('aria-label') || '', maxLength); + if (!title) continue; + const key = url + '::' + title; + if (seen.has(key)) continue; + seen.add(key); + results.push({ title, url }); + } + } + + return results; + })() + `; +} + +function clickGeminiConversationByTitleScript(query: string): string { + const normalizedQuery = normalizeGeminiTitle(query); + return ` + ((targetQuery) => { + const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const isDisabled = (el) => { + if (!(el instanceof HTMLElement)) return true; + if ('disabled' in el && el.disabled) return true; + if (el.hasAttribute('disabled')) return true; + const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase(); + return ariaDisabled === 'true'; + }; + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = (el.getAttribute('aria-hidden') || '').toLowerCase(); + if (ariaHidden === 'true' || el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const selector = 'nav a[href*="/app"], aside a[href*="/app"], [role="navigation"] a[href*="/app"], a[href*="/app"]'; + const anchors = Array.from(document.querySelectorAll(selector)); + + for (const anchor of anchors) { + if (!(anchor instanceof HTMLAnchorElement)) continue; + if (!isVisible(anchor)) continue; + if (isDisabled(anchor)) continue; + const title = normalizeText(anchor.textContent || anchor.getAttribute('aria-label') || ''); + if (!title || !targetQuery) continue; + if (!title.includes(targetQuery)) continue; + anchor.click(); + return true; + } + return false; + })(${JSON.stringify(normalizedQuery)}) + `; +} + function currentUrlScript(): string { return 'window.location.href'; } @@ -661,6 +1101,48 @@ export async function ensureGeminiPage(page: IPage): Promise { } } +export async function getCurrentGeminiUrl(page: IPage): Promise { + await ensureGeminiPage(page); + const url = await page.evaluate(currentUrlScript()).catch(() => ''); + if (typeof url === 'string' && url.trim()) return url; + return GEMINI_APP_URL; +} + +export async function openGeminiToolsMenu(page: IPage): Promise { + await ensureGeminiPage(page); + const opened = await page.evaluate(openGeminiToolsMenuScript()) as boolean; + if (opened) { + await page.wait(0.5); + return true; + } + return false; +} + +export async function selectGeminiTool(page: IPage, labels: string[]): Promise { + await ensureGeminiPage(page); + await openGeminiToolsMenu(page); + const matched = await page.evaluate(selectGeminiToolScript(labels)) as string; + return typeof matched === 'string' ? matched : ''; +} + +export async function waitForGeminiConfirmButton( + page: IPage, + labels: string[], + timeoutSeconds: number, +): Promise { + await ensureGeminiPage(page); + const pollIntervalSeconds = 1; + const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds)); + + for (let index = 0; index < maxPolls; index += 1) { + await page.wait(index === 0 ? 0.5 : pollIntervalSeconds); + const matched = await page.evaluate(clickGeminiConfirmButtonScript(labels)) as string; + if (typeof matched === 'string' && matched) return matched; + } + + return ''; +} + export async function getGeminiPageState(page: IPage): Promise { await ensureGeminiPage(page); return await page.evaluate(getStateScript()) as GeminiPageState; @@ -676,6 +1158,24 @@ export async function startNewGeminiChat(page: IPage): Promise<'clicked' | 'navi return action; } +export async function getGeminiConversationList(page: IPage): Promise { + await ensureGeminiPage(page); + const raw = await page.evaluate(getGeminiConversationListScript()) as Array<{ title: string; url: string }>; + if (!Array.isArray(raw)) return []; + return raw + .filter((item) => item && typeof item.title === 'string' && typeof item.url === 'string') + .map((item) => ({ Title: item.title, Url: item.url })); +} + +export async function clickGeminiConversationByTitle(page: IPage, query: string): Promise { + await ensureGeminiPage(page); + const normalizedQuery = normalizeGeminiTitle(query); + if (!normalizedQuery) return false; + const clicked = await page.evaluate(clickGeminiConversationByTitleScript(normalizedQuery)) as boolean; + if (clicked) await page.wait(1); + return !!clicked; +} + export async function getGeminiVisibleTurns(page: IPage): Promise { const turns = await getGeminiStructuredTurns(page); if (Array.isArray(turns) && turns.length > 0) return turns; @@ -695,6 +1195,27 @@ export async function getGeminiTranscriptLines(page: IPage): Promise { return await page.evaluate(getTranscriptLinesScript()) as string[]; } +export async function waitForGeminiTranscript(page: IPage, attempts = 5): Promise { + let lines: string[] = []; + for (let index = 0; index < attempts; index += 1) { + lines = await getGeminiTranscriptLines(page); + if (lines.length > 0) return lines; + if (index < attempts - 1) await page.wait(1); + } + return lines; +} + +export async function getLatestGeminiAssistantResponse(page: IPage): Promise { + await ensureGeminiPage(page); + const turns = await getGeminiVisibleTurns(page); + const assistantTurn = [...turns].reverse().find((turn) => turn.Role === 'Assistant'); + if (assistantTurn?.Text) { + return sanitizeGeminiResponseText(assistantTurn.Text, ''); + } + const lines = await getGeminiTranscriptLines(page); + return lines.join('\n').trim(); +} + export async function readGeminiSnapshot(page: IPage): Promise { await ensureGeminiPage(page); return await page.evaluate(readGeminiSnapshotScript()) as GeminiSnapshot; @@ -744,7 +1265,12 @@ export async function waitForGeminiSubmission( }; } - if (!current.composerHasText && transcriptDelta.length > 0) { + // Transcript-only growth is noisy on /app root. When URL is available, + // trust this signal only after Gemini has navigated into a concrete + // conversation URL. (Keep backwards compatibility for mocked snapshots + // that don't carry url.) + const transcriptSubmissionAllowed = !current.url || isGeminiConversationUrl(String(current.url)); + if (!current.composerHasText && transcriptDelta.length > 0 && transcriptSubmissionAllowed) { return { snapshot: current, preSendAssistantCount, @@ -808,6 +1334,522 @@ export async function sendGeminiMessage(page: IPage, text: string): Promise<'but return 'enter'; } +export interface GeminiDeepResearchExportResult { + url: string; + source: 'network' | 'window-open' | 'anchor' | 'performance' | 'blob' | 'tab' | 'none'; +} + +function normalizeGeminiExportUrls(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const urls: string[] = []; + for (const item of value) { + const raw = String(item ?? '').trim(); + if (!raw || seen.has(raw)) continue; + seen.add(raw); + urls.push(raw); + } + return urls; +} + +export function pickGeminiDeepResearchExportUrl(rawUrls: string[], currentUrl: string): GeminiDeepResearchExportResult { + let bestScore = -Infinity; + let bestUrl = ''; + let bestSource: GeminiDeepResearchExportResult['source'] = 'none'; + + const sourceWeight: Record = { + fetch: 50, + xhr: 45, + 'fetch-body': 72, + 'xhr-body': 72, + 'fetch-body-docs-id': 95, + 'xhr-body-docs-id': 95, + open: 55, + anchor: 55, + performance: 35, + }; + + for (const rawEntry of rawUrls) { + const match = rawEntry.match(/^([a-z-]+)::(.+)$/i); + const sourceKey = (match?.[1] ?? 'performance').toLowerCase(); + const rawUrl = (match?.[2] ?? rawEntry).trim(); + if (!rawUrl) continue; + + let parsedUrl = rawUrl; + let isBlob = false; + + if (rawUrl.startsWith('blob:')) { + isBlob = true; + } else { + try { + parsedUrl = new URL(rawUrl, currentUrl).href; + } catch { + continue; + } + } + + if (!isBlob) { + try { + const parsed = new URL(parsedUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) continue; + } catch { + continue; + } + } + + const hasMarkdownSignal = /\.md(?:$|[?#])/i.test(parsedUrl) || /markdown/i.test(parsedUrl); + const hasExportSignal = /export|download|attachment|file|save-report/i.test(parsedUrl); + const isGoogleDocUrl = /docs\.google\.com\/document\//i.test(parsedUrl); + const isGoogleSheetUrl = /docs\.google\.com\/spreadsheets\//i.test(parsedUrl); + const isNoiseEndpoint = /cspreport|allowlist|gen_204|telemetry|metrics|analytics|doubleclick|logging|collect|favicon/i.test(parsedUrl); + + let score = sourceWeight[sourceKey] ?? 20; + if (hasMarkdownSignal) score += 45; + if (hasExportSignal) score += 25; + if (isGoogleDocUrl) score += 100; + if (isGoogleSheetUrl) score -= 160; + if (/gemini\.google\.com\/app\//i.test(parsedUrl)) score -= 60; + if (/googleapis\.com|gstatic\.com|doubleclick\.net|google-analytics/i.test(parsedUrl)) score -= 40; + if (!hasMarkdownSignal && !hasExportSignal && !isBlob) score -= 40; + if (isNoiseEndpoint) score -= 120; + if (parsedUrl === currentUrl) score -= 80; + if (isBlob) score += 25; + + if (score > bestScore) { + bestScore = score; + bestUrl = parsedUrl; + if (isBlob) bestSource = 'blob'; + else if (sourceKey === 'open') bestSource = 'window-open'; + else if (sourceKey === 'anchor') bestSource = 'anchor'; + else if (sourceKey === 'performance') bestSource = 'performance'; + else bestSource = 'network'; + } + } + + if (!bestUrl || bestScore < 60) { + return { url: '', source: 'none' }; + } + return { url: bestUrl, source: bestSource }; +} + +function exportGeminiDeepResearchReportScript(maxWaitMs: number): string { + return ` + (async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const labels = { + actionMenu: ['open menu for conversation actions', 'conversation actions', '会话操作'], + share: ['share & export', 'share and export', 'share/export', '分享与导出', '分享和导出', '分享并导出', '共享和导出'], + shareConversation: ['share conversation', '分享会话', '分享对话'], + export: ['export', '导出'], + exportDocs: ['export to docs', 'export to google docs', 'export to doc', '导出到 docs', '导出到文档', '导出到 google docs'], + }; + + const recorderKey = '__opencliGeminiExportUrls'; + const patchedKey = '__opencliGeminiExportPatched'; + const trace = []; + const tracePush = (step, detail = '') => { + const entry = detail ? step + ':' + detail : step; + trace.push(entry); + if (trace.length > 80) trace.shift(); + }; + + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const normalizeLabels = (values) => { + if (!Array.isArray(values)) return []; + return values.map((value) => normalize(value)).filter(Boolean); + }; + const includesAny = (value, candidates) => { + const text = normalize(value); + if (!text) return false; + return candidates.some((candidate) => text.includes(candidate)); + }; + const labelsNormalized = { + actionMenu: normalizeLabels(labels.actionMenu), + share: normalizeLabels(labels.share), + shareConversation: normalizeLabels(labels.shareConversation), + export: normalizeLabels(labels.export), + exportDocs: normalizeLabels(labels.exportDocs), + }; + + const queryAllDeep = (roots, selector) => { + const seed = Array.isArray(roots) && roots.length > 0 ? roots : [document]; + const seenScopes = new Set(); + const seenElements = new Set(); + const out = []; + const queue = [...seed]; + while (queue.length > 0) { + const scope = queue.shift(); + const isValidScope = scope === document + || scope instanceof Document + || scope instanceof Element + || scope instanceof ShadowRoot; + if (!isValidScope || seenScopes.has(scope)) continue; + seenScopes.add(scope); + + let nodes = []; + try { + nodes = Array.from(scope.querySelectorAll(selector)); + } catch {} + + for (const node of nodes) { + if (!(node instanceof Element)) continue; + if (!seenElements.has(node)) { + seenElements.add(node); + out.push(node); + } + if (node.shadowRoot) queue.push(node.shadowRoot); + } + + let descendants = []; + try { + descendants = Array.from(scope.querySelectorAll('*')); + } catch {} + for (const child of descendants) { + if (child instanceof Element && child.shadowRoot) queue.push(child.shadowRoot); + } + } + return out; + }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = (el.getAttribute('aria-hidden') || '').toLowerCase(); + if (ariaHidden === 'true' || el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isDisabled = (el) => { + if (!(el instanceof HTMLElement)) return true; + if ('disabled' in el && el.disabled) return true; + if (el.hasAttribute('disabled')) return true; + return (el.getAttribute('aria-disabled') || '').toLowerCase() === 'true'; + }; + const isInteractable = (el) => isVisible(el) && !isDisabled(el); + const textOf = (node) => [ + node?.textContent || '', + node instanceof HTMLElement ? (node.innerText || '') : '', + node?.getAttribute?.('aria-label') || '', + node?.getAttribute?.('title') || '', + node?.getAttribute?.('data-tooltip') || '', + node?.getAttribute?.('mattooltip') || '', + ].join(' '); + const hasTokens = (value, tokens) => { + const normalized = normalize(value); + if (!normalized) return false; + return tokens.every((token) => normalized.includes(token)); + }; + const isKindMatch = (kind, combined, targetLabels) => { + if (includesAny(combined, targetLabels)) return true; + if (kind === 'share') return hasTokens(combined, ['share', 'export']) || hasTokens(combined, ['分享', '导出']); + if (kind === 'export') return hasTokens(combined, ['export']) || hasTokens(combined, ['导出']); + if (kind === 'export-docs') { + return hasTokens(combined, ['export', 'docs']) + || hasTokens(combined, ['导出', '文档']) + || hasTokens(combined, ['导出', 'docs']); + } + if (kind === 'action-menu') { + return hasTokens(combined, ['conversation', 'action']) || hasTokens(combined, ['会话', '操作']); + } + return false; + }; + const triggerClick = (node) => { + if (!(node instanceof HTMLElement)) return false; + try { node.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} + try { node.focus({ preventScroll: true }); } catch {} + try { + const EventCtor = window.PointerEvent || window.MouseEvent; + node.dispatchEvent(new EventCtor('pointerdown', { bubbles: true, cancelable: true, composed: true, button: 0 })); + } catch {} + try { node.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, composed: true, button: 0 })); } catch {} + try { node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, composed: true, button: 0 })); } catch {} + try { node.click(); } catch { return false; } + return true; + }; + + const ensureRecorder = () => { + if (!Array.isArray(window[recorderKey])) window[recorderKey] = []; + const push = (prefix, raw) => { + const url = String(raw || '').trim(); + if (!url) return; + window[recorderKey].push(prefix + '::' + url); + }; + const extractUrlsFromText = (rawText) => { + const text = String(rawText || ''); + const urls = []; + const direct = text.match(/https?:\\/\\/[^\\s"'<>\\\\]+/g) || []; + urls.push(...direct); + const escaped = text.match(/https?:\\\\\\/\\\\\\/[^\\s"'<>]+/g) || []; + for (const item of escaped) { + urls.push( + item + .split('\\\\/').join('/') + .split('\\\\u003d').join('=') + .split('\\\\u0026').join('&'), + ); + } + return Array.from(new Set(urls.map((value) => String(value || '').trim()).filter(Boolean))); + }; + const extractDocsIdsFromText = (rawText) => { + const text = String(rawText || ''); + const ids = []; + const patterns = [ + /"id"\\s*:\\s*"([a-zA-Z0-9_-]{15,})"/g, + /'id'\\s*:\\s*'([a-zA-Z0-9_-]{15,})'/g, + ]; + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(text)) !== null) { + const docId = String(match[1] || '').trim(); + if (docId) ids.push(docId); + } + } + return Array.from(new Set(ids)); + }; + const docsUrlFromId = (id) => { + const docId = String(id || '').trim(); + if (!/^[a-zA-Z0-9_-]{15,}$/.test(docId)) return ''; + return 'https://docs.google.com/document/d/' + docId + '/edit'; + }; + const isDriveDocCreateRequest = (url) => /\\/upload\\/drive\\/v3\\/files/i.test(String(url || '')); + + if (window[patchedKey]) return push; + window[patchedKey] = true; + + const originalFetch = window.fetch.bind(window); + window.fetch = (...args) => { + let reqUrl = ''; + try { + const input = args[0]; + reqUrl = typeof input === 'string' ? input : (input && input.url) || ''; + push('fetch', reqUrl); + } catch {} + return originalFetch(...args).then((response) => { + try { + response.clone().text().then((text) => { + const embeddedUrls = extractUrlsFromText(text); + for (const embeddedUrl of embeddedUrls) push('fetch-body', embeddedUrl); + if (isDriveDocCreateRequest(reqUrl)) { + const docIds = extractDocsIdsFromText(text); + for (const docId of docIds) { + const docUrl = docsUrlFromId(docId); + if (docUrl) push('fetch-body-docs-id', docUrl); + } + } + }).catch(() => {}); + } catch {} + return response; + }); + }; + + const originalXhrOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, ...rest) { + try { push('xhr', url); } catch {} + try { this.__opencliReqUrl = String(url || ''); } catch {} + return originalXhrOpen.call(this, method, url, ...rest); + }; + const originalXhrSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(...args) { + try { + this.addEventListener('load', () => { + try { + const embeddedUrls = extractUrlsFromText(this.responseText || ''); + for (const embeddedUrl of embeddedUrls) push('xhr-body', embeddedUrl); + const reqUrl = String(this.__opencliReqUrl || ''); + if (isDriveDocCreateRequest(reqUrl)) { + const docIds = extractDocsIdsFromText(this.responseText || ''); + for (const docId of docIds) { + const docUrl = docsUrlFromId(docId); + if (docUrl) push('xhr-body-docs-id', docUrl); + } + } + } catch {} + }); + } catch {} + return originalXhrSend.apply(this, args); + }; + + const originalOpen = window.open.bind(window); + window.open = (...args) => { + try { push('open', args[0]); } catch {} + return originalOpen(...args); + }; + + const originalAnchorClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function(...args) { + try { push('anchor', this.href || this.getAttribute('href')); } catch {} + return originalAnchorClick.apply(this, args); + }; + + return push; + }; + + const pushUrl = ensureRecorder(); + const collectUrls = () => { + try { + const entries = performance.getEntriesByType('resource'); + for (const entry of entries) { + if (!entry || !entry.name) continue; + pushUrl('performance', entry.name); + } + } catch {} + try { + const anchors = queryAllDeep([document], 'a[href]'); + for (const anchor of anchors) { + const href = anchor.getAttribute('href') || ''; + if (!href) continue; + if (/docs\\.google\\.com\\/document\\//i.test(href)) pushUrl('anchor', href); + } + } catch {} + const all = Array.isArray(window[recorderKey]) ? window[recorderKey] : []; + return Array.from(new Set(all.map((value) => String(value || '').trim()).filter(Boolean))); + }; + + const clickByLabels = (kind, targetLabels, roots) => { + const allRoots = Array.isArray(roots) && roots.length > 0 ? roots : [document]; + const selector = 'button, [role="button"], [role="menuitem"], [role="option"], a, li'; + + for (const root of allRoots) { + if (!(root instanceof Document || root instanceof Element)) continue; + let nodes = []; + try { + nodes = Array.from(root.querySelectorAll(selector)); + } catch { + continue; + } + + for (const node of nodes) { + if (!isInteractable(node)) continue; + const combined = normalize(textOf(node)); + if (!combined) continue; + if (!isKindMatch(kind, combined, targetLabels)) continue; + if (triggerClick(node)) { + const clickedText = (textOf(node) || targetLabels[0] || '').trim(); + tracePush('clicked', kind + '|' + clickedText.slice(0, 120)); + return clickedText; + } + } + } + tracePush('miss', kind); + return ''; + }; + + const getDialogRoots = () => + queryAllDeep([document], '[role="dialog"], [aria-modal="true"], [role="menu"], [role="listbox"]') + .filter((node) => isVisible(node)); + const buildRoots = () => { + const dialogRoots = getDialogRoots(); + if (dialogRoots.length > 0) return [...dialogRoots, document]; + return [document]; + }; + const clickWithRetry = async (kind, targetLabels, attempts, delayMs, includeDialogs = true) => { + for (let index = 0; index < attempts; index += 1) { + const roots = includeDialogs ? buildRoots() : [document]; + const clicked = clickByLabels(kind, targetLabels, roots); + if (clicked) return clicked; + await sleep(delayMs); + } + return ''; + }; + + tracePush('start', window.location.href); + let exportDocsBtn = await clickWithRetry('export-docs', labelsNormalized.exportDocs, 2, 250, true); + let share = ''; + if (!exportDocsBtn) { + share = await clickWithRetry('share', labelsNormalized.share, 4, 280, true); + } + if (!exportDocsBtn && !share) { + await clickWithRetry('action-menu', labelsNormalized.actionMenu, 2, 250, false); + await clickWithRetry('share-conversation', labelsNormalized.shareConversation, 2, 250, true); + share = await clickWithRetry('share', labelsNormalized.share, 4, 280, true); + } + if (!exportDocsBtn) { + await sleep(350); + exportDocsBtn = await clickWithRetry('export-docs', labelsNormalized.exportDocs, 8, 280, true); + } + if (!exportDocsBtn) { + const exportEntry = await clickWithRetry('export', labelsNormalized.export, 2, 220, true); + if (exportEntry) { + await sleep(240); + exportDocsBtn = await clickWithRetry('export-docs', labelsNormalized.exportDocs, 6, 280, true); + } + } + + if (!share && !exportDocsBtn) { + return { ok: false, step: 'share', currentUrl: window.location.href, trace, urls: collectUrls() }; + } + if (!exportDocsBtn) { + return { ok: false, step: 'export-docs', currentUrl: window.location.href, share, trace, urls: collectUrls() }; + } + + const deadline = Date.now() + ${Math.max(5000, Math.min(maxWaitMs, 180000))}; + while (Date.now() < deadline) { + const urls = collectUrls(); + const hasDocsSignal = urls.some((value) => /docs\\.google\\.com\\/document\\//i.test(String(value || ''))); + const sameTabDocs = /docs\\.google\\.com\\/document\\//i.test(window.location.href || ''); + if (hasDocsSignal) { + return { ok: true, step: 'done', currentUrl: window.location.href, share, exportDocs: exportDocsBtn, trace, urls }; + } + if (sameTabDocs) { + urls.push('open::' + window.location.href); + return { ok: true, step: 'same-tab-docs', currentUrl: window.location.href, share, exportDocs: exportDocsBtn, trace, urls }; + } + await sleep(300); + } + + return { ok: true, step: 'timeout', currentUrl: window.location.href, share, exportDocs: exportDocsBtn, trace, urls: collectUrls() }; + })() + `; +} + +function extractDocsUrlFromTabs(tabs: unknown): string { + if (!Array.isArray(tabs)) return ''; + for (const tab of tabs) { + if (!tab || typeof tab !== 'object') continue; + const url = String((tab as Record).url ?? '').trim(); + if (/^https:\/\/docs\.google\.com\/document\//i.test(url)) return url; + } + return ''; +} + +export async function exportGeminiDeepResearchReport( + page: IPage, + timeoutSeconds: number = 120, +): Promise { + await ensureGeminiPage(page); + const timeoutMs = Math.max(1, timeoutSeconds) * 1000; + const tabsBefore = await page.tabs().catch(() => []); + const exportScript = exportGeminiDeepResearchReportScript(timeoutMs); + + const raw = await page.evaluate(exportScript).catch(() => null) as { + urls?: unknown; + currentUrl?: unknown; + } | null; + + const tabsAfter = await page.tabs().catch(() => []); + const docsUrlFromTabs = extractDocsUrlFromTabs(tabsAfter) || extractDocsUrlFromTabs(tabsBefore); + if (docsUrlFromTabs) { + return { url: docsUrlFromTabs, source: 'tab' }; + } + + const docsUrlFromCurrent = typeof raw?.currentUrl === 'string' && /^https:\/\/docs\.google\.com\/document\//i.test(raw.currentUrl) + ? raw.currentUrl + : ''; + if (docsUrlFromCurrent) { + return { url: docsUrlFromCurrent, source: 'window-open' }; + } + + const urls = normalizeGeminiExportUrls(raw?.urls); + const currentUrl = typeof raw?.currentUrl === 'string' && raw.currentUrl + ? raw.currentUrl + : await getCurrentGeminiUrl(page); + + return pickGeminiDeepResearchExportUrl(urls, currentUrl); +} + export const __test__ = { GEMINI_COMPOSER_SELECTORS, GEMINI_COMPOSER_MARKER_ATTR,