Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

2690 lines
103 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CommandExecutionError } from '@jackwener/opencli/errors';
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',
];
const GEMINI_RESPONSE_NOISE_PATTERNS = [
/Gemini can make mistakes\.?/gi,
/Google Terms/gi,
/Google Privacy Policy/gi,
/Opens in a new window/gi,
];
const GEMINI_TRANSCRIPT_CHROME_MARKERS = ['gemini', '我的内容', '对话', 'google terms', 'google privacy policy'];
const GEMINI_COMPOSER_SELECTORS = [
'.ql-editor[contenteditable="true"]',
'.ql-editor[role="textbox"]',
'.ql-editor[aria-label*="Gemini"]',
'[contenteditable="true"][aria-label*="Gemini"]',
'[aria-label="Enter a prompt for Gemini"]',
'[aria-label*="prompt for Gemini"]',
];
const GEMINI_COMPOSER_MARKER_ATTR = 'data-opencli-gemini-composer';
const GEMINI_COMPOSER_PREPARE_ATTEMPTS = 4;
const GEMINI_COMPOSER_PREPARE_WAIT_SECONDS = 1;
function isObjectRecord(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function unwrapGeminiEvaluateResult(value, context) {
if (isObjectRecord(value) && Object.prototype.hasOwnProperty.call(value, 'session')) {
if (Object.prototype.hasOwnProperty.call(value, 'data')) {
return value.data;
}
throw new CommandExecutionError(`${context} returned a malformed Browser Bridge envelope`);
}
return value;
}
function requireGeminiArrayResult(value, context) {
const unwrapped = unwrapGeminiEvaluateResult(value, context);
if (!Array.isArray(unwrapped)) {
throw new CommandExecutionError(`${context} returned a malformed result`);
}
return unwrapped;
}
function requireGeminiObjectResult(value, context) {
const unwrapped = unwrapGeminiEvaluateResult(value, context);
if (!isObjectRecord(unwrapped)) {
throw new CommandExecutionError(`${context} returned a malformed result`);
}
return unwrapped;
}
function buildGeminiComposerLocatorScript() {
const selectorsJson = JSON.stringify(GEMINI_COMPOSER_SELECTORS);
const markerAttrJson = JSON.stringify(GEMINI_COMPOSER_MARKER_ATTR);
return `
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const markerAttr = ${markerAttrJson};
const clearComposerMarkers = (active) => {
document.querySelectorAll('[' + markerAttr + ']').forEach((node) => {
if (node !== active) node.removeAttribute(markerAttr);
});
};
const markComposer = (node) => {
if (!(node instanceof HTMLElement)) return null;
clearComposerMarkers(node);
node.setAttribute(markerAttr, '1');
return node;
};
const findComposer = () => {
const marked = document.querySelector('[' + markerAttr + '="1"]');
if (marked instanceof HTMLElement && isVisible(marked)) return marked;
const selectors = ${selectorsJson};
for (const selector of selectors) {
const node = Array.from(document.querySelectorAll(selector)).find((candidate) => candidate instanceof HTMLElement && isVisible(candidate));
if (node instanceof HTMLElement) return markComposer(node);
}
return null;
};
`;
}
export function resolveGeminiLabels(value, fallback) {
const label = String(value ?? '').trim();
return label ? [label] : fallback;
}
export function parseGeminiTitleMatchMode(value, fallback = 'contains') {
const raw = String(value ?? fallback).trim().toLowerCase();
if (raw === 'contains' || raw === 'exact')
return raw;
return null;
}
export function parseGeminiConversationUrl(value) {
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) {
return value.replace(/\s+/g, ' ').trim().toLowerCase();
}
export function pickGeminiConversationByTitle(conversations, query, mode = 'contains') {
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, query, mode) {
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, promptText) {
let sanitized = value;
for (const pattern of GEMINI_RESPONSE_NOISE_PATTERNS) {
sanitized = sanitized.replace(pattern, '');
}
sanitized = sanitized.trim();
const prompt = promptText.trim();
if (!prompt)
return sanitized;
if (sanitized === prompt)
return '';
for (const separator of ['\n\n', '\n', '\r\n\r\n', '\r\n']) {
const prefix = `${prompt}${separator}`;
if (sanitized.startsWith(prefix)) {
return sanitized.slice(prefix.length).trim();
}
}
return sanitized;
}
export function collectGeminiTranscriptAdditions(beforeLines, currentLines, promptText) {
const beforeSet = new Set(beforeLines);
const additions = currentLines
.filter((line) => !beforeSet.has(line))
.map((line) => extractGeminiTranscriptLineCandidate(line, promptText))
.filter((line) => line && line !== promptText);
return additions.join('\n').trim();
}
export function collapseAdjacentGeminiTurns(turns) {
const collapsed = [];
for (const turn of turns) {
if (!turn || typeof turn.Role !== 'string' || typeof turn.Text !== 'string')
continue;
const previous = collapsed.at(-1);
if (previous?.Role === turn.Role && previous.Text === turn.Text)
continue;
collapsed.push(turn);
}
return collapsed;
}
function hasGeminiTurnPrefix(before, current) {
if (before.length > current.length)
return false;
return before.every((turn, index) => (turn.Role === current[index]?.Role
&& turn.Text === current[index]?.Text));
}
function findLastMatchingGeminiTurnIndex(turns, target) {
if (!target)
return null;
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn?.Role === target.Role && turn.Text === target.Text) {
return index;
}
}
return null;
}
function diffTrustedStructuredTurns(before, current) {
if (!before.structuredTurnsTrusted || !current.structuredTurnsTrusted) {
return {
appendedTurns: [],
hasTrustedAppend: false,
hasNewUserTurn: false,
hasNewAssistantTurn: false,
};
}
if (!hasGeminiTurnPrefix(before.turns, current.turns)) {
return {
appendedTurns: [],
hasTrustedAppend: false,
hasNewUserTurn: false,
hasNewAssistantTurn: false,
};
}
const appendedTurns = current.turns.slice(before.turns.length);
return {
appendedTurns,
hasTrustedAppend: appendedTurns.length > 0,
hasNewUserTurn: appendedTurns.some((turn) => turn.Role === 'User'),
hasNewAssistantTurn: appendedTurns.some((turn) => turn.Role === 'Assistant'),
};
}
function diffTranscriptLines(before, current) {
const beforeLines = new Set(before.transcriptLines);
return current.transcriptLines.filter((line) => !beforeLines.has(line));
}
function isLikelyGeminiTranscriptChrome(line) {
const lower = line.toLowerCase();
const markerHits = GEMINI_TRANSCRIPT_CHROME_MARKERS.filter((marker) => lower.includes(marker)).length;
return markerHits >= 2;
}
function extractGeminiTranscriptLineCandidate(transcriptLine, promptText) {
const candidate = transcriptLine.trim();
if (!candidate)
return '';
const prompt = promptText.trim();
const sanitized = sanitizeGeminiResponseText(candidate, promptText);
if (!prompt)
return sanitized;
if (!candidate.includes(prompt))
return sanitized;
if (sanitized && sanitized !== prompt && sanitized !== candidate)
return sanitized;
if (isLikelyGeminiTranscriptChrome(candidate))
return '';
// Some transcript snapshots flatten "prompt + answer" into a single line.
// Recover the answer only when the line starts with the current prompt.
if (candidate.startsWith(prompt)) {
const tail = candidate.slice(prompt.length).replace(/^[\s:,-]+/, '').trim();
return tail ? sanitizeGeminiResponseText(tail, '') : '';
}
return sanitized;
}
function getStateScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const signInNode = Array.from(document.querySelectorAll('a, button')).find((node) => {
const text = (node.textContent || '').trim().toLowerCase();
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
const href = node.getAttribute('href') || '';
return text === 'sign in'
|| aria === 'sign in'
|| text === '登录'
|| aria === '登录'
|| href.includes('accounts.google.com/ServiceLogin');
});
const composer = findComposer();
return {
url: window.location.href,
title: document.title || '',
isSignedIn: signInNode ? false : (composer ? true : null),
composerLabel: composer?.getAttribute('aria-label') || '',
canSend: !!composer,
};
})()
`;
}
function readGeminiSnapshotScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
const composerText = composer?.textContent?.replace(/\\u00a0/g, ' ').trim() || '';
const isGenerating = !!Array.from(document.querySelectorAll('button, [role="button"]')).find((node) => {
const text = (node.textContent || '').trim().toLowerCase();
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
return text === 'stop response'
|| aria === 'stop response'
|| text === '停止回答'
|| aria === '停止回答';
});
const turns = ${getTurnsScript().trim()};
const transcriptLines = ${getTranscriptLinesScript().trim()};
return {
url: window.location.href,
turns,
transcriptLines,
composerHasText: composerText.length > 0,
isGenerating,
structuredTurnsTrusted: turns.length > 0 || transcriptLines.length === 0,
};
})()
`;
}
function isGeminiConversationUrl(url) {
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() {
return `
(() => {
const clean = (value) => (value || '')
.replace(/\\u00a0/g, ' ')
.replace(/\\n{3,}/g, '\\n\\n')
.trim();
const main = document.querySelector('main') || document.body;
const root = main.cloneNode(true);
const removableSelectors = [
'button',
'nav',
'header',
'footer',
'[aria-label="Enter a prompt for Gemini"]',
'[aria-label*="prompt for Gemini"]',
'.input-area-container',
'.input-wrapper',
'.textbox-container',
'.ql-toolbar',
'.send-button',
'.main-menu-button',
'.sign-in-button',
];
for (const selector of removableSelectors) {
root.querySelectorAll(selector).forEach((node) => node.remove());
}
root.querySelectorAll('script, style, noscript').forEach((node) => node.remove());
const stopLines = new Set([
'Gemini',
'Google Terms',
'Google Privacy Policy',
'Meet Gemini, your personal AI assistant',
'Conversation with Gemini',
'Ask Gemini 3',
'Write',
'Plan',
'Research',
'Learn',
'Fast',
'send',
'Microphone',
'Main menu',
'New chat',
'Sign in',
'Google Terms Opens in a new window',
'Google Privacy Policy Opens in a new window',
]);
const noisyPatterns = [
/^Google Terms$/,
/^Google Privacy Policy$/,
/^Gemini is AI and can make mistakes\.?$/,
/^and the$/,
/^apply\.$/,
/^Opens in a new window$/,
/^Open mode picker$/,
/^Open upload file menu$/,
/^Tools$/,
];
return clean(root.innerText || root.textContent || '')
.split('\\n')
.map((line) => clean(line))
.filter((line) => line
&& line.length <= 4000
&& !stopLines.has(line)
&& !noisyPatterns.some((pattern) => pattern.test(line)));
})()
`;
}
function getTurnsScript() {
return `
(() => {
const clean = (value) => (value || '')
.replace(/\\u00a0/g, ' ')
.replace(/\\n{3,}/g, '\\n\\n')
.trim();
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const selectors = [
'[data-testid*="message"]',
'[data-test-id*="message"]',
'[class*="message"]',
'[class*="conversation-turn"]',
'[class*="query-text"]',
'[class*="response-text"]',
];
const roots = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
const unique = roots
.filter((el, index, all) => all.indexOf(el) === index)
.filter(isVisible)
.sort((left, right) => {
if (left === right) return 0;
const relation = left.compareDocumentPosition(right);
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
const turns = unique.map((el) => {
const text = clean(el.innerText || el.textContent || '');
if (!text) return null;
const roleAttr = [
el.getAttribute('data-message-author-role'),
el.getAttribute('data-role'),
el.getAttribute('aria-label'),
el.getAttribute('class'),
].filter(Boolean).join(' ').toLowerCase();
let role = '';
if (roleAttr.includes('user') || roleAttr.includes('query')) role = 'User';
else if (roleAttr.includes('assistant') || roleAttr.includes('model') || roleAttr.includes('response') || roleAttr.includes('gemini')) role = 'Assistant';
return role ? { Role: role, Text: text } : null;
}).filter(Boolean);
return turns;
})()
`;
}
function prepareComposerScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
if (!(composer instanceof HTMLElement)) {
return { ok: false, reason: 'Could not find Gemini composer' };
}
try {
composer.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(composer);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
composer.textContent = '';
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteContentBackward' }));
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
return {
ok: true,
label: composer.getAttribute('aria-label') || '',
};
})()
`;
}
function composerHasTextScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
return {
hasText: !!(composer && ((composer.textContent || '').trim() || (composer.innerText || '').trim())),
};
})()
`;
}
function insertComposerTextFallbackScript(text) {
return `
((inputText) => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
if (!(composer instanceof HTMLElement)) {
return { hasText: false, reason: 'Could not find Gemini composer' };
}
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(composer);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
composer.focus();
composer.textContent = '';
const execResult = typeof document.execCommand === 'function'
? document.execCommand('insertText', false, inputText)
: false;
if (!execResult) {
const paragraph = document.createElement('p');
const lines = String(inputText).split(/\\n/);
for (const [index, line] of lines.entries()) {
if (index > 0) paragraph.appendChild(document.createElement('br'));
paragraph.appendChild(document.createTextNode(line));
}
composer.replaceChildren(paragraph);
}
composer.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new InputEvent('input', { bubbles: true, data: inputText, inputType: 'insertText' }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
return {
hasText: !!((composer.textContent || '').trim() || (composer.innerText || '').trim()),
};
})(${JSON.stringify(text)})
`;
}
function submitComposerScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
if (!(composer instanceof HTMLElement)) {
throw new Error('Could not find Gemini composer');
}
const composerRect = composer.getBoundingClientRect();
const rootCandidates = [
composer.closest('form'),
composer.closest('[role="form"]'),
composer.closest('.input-area-container'),
composer.closest('.textbox-container'),
composer.closest('.input-wrapper'),
composer.parentElement,
composer.parentElement?.parentElement,
].filter(Boolean);
const seen = new Set();
const buttons = [];
for (const root of rootCandidates) {
root.querySelectorAll('button, [role="button"]').forEach((node) => {
if (!(node instanceof HTMLElement)) return;
if (seen.has(node)) return;
seen.add(node);
buttons.push(node);
});
}
const excludedPattern = /main menu|主菜单|microphone|麦克风|upload|上传|mode|模式|tools|工具|settings|临时对话|new chat|新对话/i;
const submitPattern = /send|发送|傳送|submit|提交/i;
let bestButton = null;
let bestScore = -1;
for (const button of buttons) {
if (!isVisible(button)) continue;
if (button instanceof HTMLButtonElement && button.disabled) continue;
if (button.getAttribute('aria-disabled') === 'true') continue;
const label = ((button.getAttribute('aria-label') || '') + ' ' + ((button.textContent || '').trim())).trim();
if (excludedPattern.test(label)) continue;
const rect = button.getBoundingClientRect();
const verticalDistance = Math.abs((rect.top + rect.bottom) / 2 - (composerRect.top + composerRect.bottom) / 2);
if (verticalDistance > 160) continue;
let score = 0;
if (submitPattern.test(label)) score += 10;
if (rect.left >= composerRect.right - 160) score += 3;
if (rect.left >= composerRect.left) score += 1;
if (rect.width <= 96 && rect.height <= 96) score += 1;
if (score > bestScore) {
bestScore = score;
bestButton = button;
}
}
if (bestButton instanceof HTMLElement && bestScore >= 3) {
bestButton.click();
return 'button';
}
return 'enter';
})()
`;
}
function dispatchComposerEnterScript() {
return `
(() => {
${buildGeminiComposerLocatorScript()}
const composer = findComposer();
if (!(composer instanceof HTMLElement)) {
throw new Error('Could not find Gemini composer');
}
composer.focus();
composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
composer.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
return 'enter';
})()
`;
}
function clickNewChatScript() {
return `
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const candidates = Array.from(document.querySelectorAll('button, a')).filter((node) => {
const text = (node.textContent || '').trim().toLowerCase();
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
return isVisible(node) && (
text === 'new chat'
|| aria === 'new chat'
|| text === '发起新对话'
|| aria === '发起新对话'
|| text === '新对话'
|| aria === '新对话'
);
});
const target = candidates.find((node) => !node.hasAttribute('disabled')) || candidates[0];
if (target instanceof HTMLElement) {
target.click();
return 'clicked';
}
return 'navigate';
})()
`;
}
function openGeminiToolsMenuScript() {
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) {
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) {
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() {
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;
})()
`;
}
// Gemini collapses the sidebar's "最近" / "Recents" section by default, so the
// /app/<id> conversation anchors are not present in the DOM (or are hidden)
// until that section is expanded. The script below opens the sidebar if it is
// collapsed and expands the Recents toggle, returning true if it likely
// changed the DOM and the caller should retry extraction.
function expandGeminiRecentScript() {
return `
(() => {
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
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) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
let changed = false;
const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible);
// 1. Open the sidebar if it is collapsed (button only renders when closed).
const openSidebar = buttons.find((b) => {
const label = normalize(b.getAttribute('aria-label') || '');
return /打开边栏|open sidebar|open navigation|展开边栏/i.test(label);
});
if (openSidebar) { openSidebar.click(); changed = true; }
// 2. Expand the "最近" / "Recents" toggle if it is currently collapsed.
const recentToggle = buttons.find((b) => {
const label = normalize(b.getAttribute('aria-label') || '');
const expanded = (b.getAttribute('aria-expanded') || '').toLowerCase();
return /最近|recent/i.test(label) && (
/展开|收起|expand|collapse|toggle|show|hide/i.test(label)
|| expanded === 'false'
|| expanded === 'true'
);
});
if (recentToggle) {
const label = normalize(recentToggle.getAttribute('aria-label') || '');
const expanded = (recentToggle.getAttribute('aria-expanded') || '').toLowerCase();
const explicitExpandLabel = /展开|显示|expand|show/i.test(label) && !/收起|collapse|hide/i.test(label);
if (expanded === 'false' || (expanded !== 'true' && explicitExpandLabel)) {
recentToggle.click();
changed = true;
}
}
return changed;
})()
`;
}
function clickGeminiConversationByTitleScript(query) {
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() {
return 'window.location.href';
}
export async function isOnGemini(page) {
const url = await page.evaluate(currentUrlScript()).catch(() => '');
if (typeof url !== 'string' || !url)
return false;
try {
const hostname = new URL(url).hostname;
return hostname === GEMINI_DOMAIN || hostname.endsWith(`.${GEMINI_DOMAIN}`);
}
catch {
return false;
}
}
export async function ensureGeminiPage(page) {
if (!(await isOnGemini(page))) {
await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
}
}
export async function getCurrentGeminiUrl(page) {
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) {
await ensureGeminiPage(page);
const opened = await page.evaluate(openGeminiToolsMenuScript());
if (opened) {
await page.wait(0.5);
return true;
}
return false;
}
export async function selectGeminiTool(page, labels) {
await ensureGeminiPage(page);
await openGeminiToolsMenu(page);
const matched = await page.evaluate(selectGeminiToolScript(labels));
return typeof matched === 'string' ? matched : '';
}
export async function waitForGeminiConfirmButton(page, labels, timeoutSeconds) {
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));
if (typeof matched === 'string' && matched)
return matched;
}
return '';
}
export async function getGeminiPageState(page) {
await ensureGeminiPage(page);
return requireGeminiObjectResult(await page.evaluate(getStateScript()), 'Gemini status');
}
export async function startNewGeminiChat(page) {
await ensureGeminiPage(page);
const action = await page.evaluate(clickNewChatScript());
if (action === 'navigate') {
await page.goto(GEMINI_APP_URL, { waitUntil: 'load', settleMs: 2500 });
}
await page.wait(1);
return action;
}
export async function getGeminiConversationList(page) {
await ensureGeminiPage(page);
let rows = [];
// Gemini collapses the sidebar "最近"/Recents section by default, hiding
// the conversation anchors. Retry extraction after expanding it.
for (let attempt = 0; attempt < 3; attempt += 1) {
const raw = requireGeminiArrayResult(await page.evaluate(getGeminiConversationListScript()), 'Gemini conversation list');
rows = raw.flatMap((item) => {
if (!isObjectRecord(item) || typeof item.title !== 'string' || typeof item.url !== 'string') {
throw new CommandExecutionError('Gemini conversation list returned a malformed row');
}
if (!isGeminiConversationUrl(item.url))
return [];
return { Title: item.title, Url: item.url };
});
if (rows.length > 0)
break;
const changedRaw = unwrapGeminiEvaluateResult(await page.evaluate(expandGeminiRecentScript()), 'Gemini sidebar expand');
const changed = changedRaw === true;
// Give the React sidebar a moment to render the expanded anchors,
// longer on the first expansion (sidebar slide-in is async).
await page.wait(attempt === 0 ? 1.2 : 0.6);
if (!changed && attempt > 0)
break;
}
return rows;
}
export async function clickGeminiConversationByTitle(page, query) {
await ensureGeminiPage(page);
const normalizedQuery = normalizeGeminiTitle(query);
if (!normalizedQuery)
return false;
const clicked = await page.evaluate(clickGeminiConversationByTitleScript(normalizedQuery));
if (clicked)
await page.wait(1);
return !!clicked;
}
export async function getGeminiVisibleTurns(page) {
const turns = await getGeminiStructuredTurns(page);
if (Array.isArray(turns) && turns.length > 0)
return turns;
const lines = await getGeminiTranscriptLines(page);
return lines.map((line) => ({ Role: 'System', Text: line }));
}
async function getGeminiStructuredTurns(page) {
await ensureGeminiPage(page);
const raw = requireGeminiArrayResult(await page.evaluate(getTurnsScript()), 'Gemini visible turns');
for (const turn of raw) {
if (!isObjectRecord(turn) || typeof turn.Role !== 'string' || typeof turn.Text !== 'string') {
throw new CommandExecutionError('Gemini visible turns returned a malformed row');
}
}
const turns = collapseAdjacentGeminiTurns(raw);
return Array.isArray(turns) ? turns : [];
}
export async function getGeminiTranscriptLines(page) {
await ensureGeminiPage(page);
const lines = requireGeminiArrayResult(await page.evaluate(getTranscriptLinesScript()), 'Gemini transcript lines');
if (!lines.every((line) => typeof line === 'string')) {
throw new CommandExecutionError('Gemini transcript lines returned a malformed row');
}
return lines;
}
export async function waitForGeminiTranscript(page, attempts = 5) {
let lines = [];
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) {
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) {
await ensureGeminiPage(page);
const snapshot = requireGeminiObjectResult(await page.evaluate(readGeminiSnapshotScript()), 'Gemini page snapshot');
if (!Array.isArray(snapshot.turns) ||
!Array.isArray(snapshot.transcriptLines) ||
typeof snapshot.composerHasText !== 'boolean' ||
typeof snapshot.isGenerating !== 'boolean' ||
typeof snapshot.structuredTurnsTrusted !== 'boolean') {
throw new CommandExecutionError('Gemini page snapshot returned a malformed result');
}
return snapshot;
}
function findLastUserTurnIndex(turns) {
for (let index = turns.length - 1; index >= 0; index -= 1) {
if (turns[index]?.Role === 'User')
return index;
}
return null;
}
function findLastUserTurn(turns) {
const index = findLastUserTurnIndex(turns);
return index === null ? null : turns[index] ?? null;
}
export async function waitForGeminiSubmission(page, before, timeoutSeconds) {
const preSendAssistantCount = before.turns.filter((turn) => turn.Role === 'Assistant').length;
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds));
for (let index = 0; index < maxPolls; index += 1) {
await page.wait(index === 0 ? 0.5 : 1);
const current = await readGeminiSnapshot(page);
const structuredAppend = diffTrustedStructuredTurns(before, current);
const transcriptDelta = diffTranscriptLines(before, current);
if (structuredAppend.hasTrustedAppend && structuredAppend.hasNewUserTurn) {
return {
snapshot: current,
preSendAssistantCount,
userAnchorTurn: findLastUserTurn(current.turns),
reason: 'user_turn',
};
}
if (!current.composerHasText && current.isGenerating) {
return {
snapshot: current,
preSendAssistantCount,
userAnchorTurn: findLastUserTurn(current.turns),
reason: 'composer_generating',
};
}
// 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,
userAnchorTurn: findLastUserTurn(current.turns),
reason: 'composer_transcript',
};
}
}
return null;
}
export async function sendGeminiMessage(page, text) {
await ensureGeminiPage(page);
let prepared;
for (let attempt = 0; attempt < GEMINI_COMPOSER_PREPARE_ATTEMPTS; attempt += 1) {
prepared = await page.evaluate(prepareComposerScript());
if (prepared?.ok)
break;
if (attempt < GEMINI_COMPOSER_PREPARE_ATTEMPTS - 1)
await page.wait(GEMINI_COMPOSER_PREPARE_WAIT_SECONDS);
}
if (!prepared?.ok) {
throw new CommandExecutionError(prepared?.reason || 'Could not find Gemini composer');
}
let hasText = false;
if (page.nativeType) {
try {
await page.nativeType(text);
await page.wait(0.2);
const nativeState = await page.evaluate(composerHasTextScript());
hasText = !!nativeState?.hasText;
}
catch { }
}
if (!hasText) {
const fallbackState = await page.evaluate(insertComposerTextFallbackScript(text));
hasText = !!fallbackState?.hasText;
}
if (!hasText) {
throw new CommandExecutionError('Failed to insert text into Gemini composer');
}
const submitAction = await page.evaluate(submitComposerScript());
if (submitAction === 'button') {
await page.wait(1);
return 'button';
}
if (page.nativeKeyPress) {
try {
await page.nativeKeyPress('Enter');
}
catch {
await page.evaluate(dispatchComposerEnterScript());
}
}
else {
await page.evaluate(dispatchComposerEnterScript());
}
await page.wait(1);
return 'enter';
}
function normalizeGeminiExportUrls(value) {
if (!Array.isArray(value))
return [];
const seen = new Set();
const urls = [];
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, currentUrl) {
let bestScore = -Infinity;
let bestUrl = '';
let bestSource = 'none';
const sourceWeight = {
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) {
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) {
if (!Array.isArray(tabs))
return '';
for (const tab of tabs) {
if (!tab || typeof tab !== 'object')
continue;
const url = String(tab.url ?? '').trim();
if (/^https:\/\/docs\.google\.com\/document\//i.test(url))
return url;
}
return '';
}
export async function exportGeminiDeepResearchReport(page, timeoutSeconds = 120) {
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);
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);
}
// ── Thinking-level selection ─────────────────────────────────────────
/**
* Browser evaluate script that opens the Gemini model-picker menu.
* Returns { ok: true } on success, { ok: false } on failure.
*/
function openModelPickerForThinkingScript() {
return `
(() => {
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 || style.pointerEvents === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const VERSION_LABEL_RE = /\\d+\\.\\d+/;
const MODE_SELECTOR_PATTERNS = [
/模式选择器/i,
/mode[\\s-]*selector/i,
/model[\\s-]*selector/i,
/model[\\s-]*picker/i,
/选择模型/i,
/select[\\s-]+model/i,
/choose[\\s-]+model/i,
/switch[\\s-]+model/i,
/change[\\s-]+model/i,
];
const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i;
const findModelPicker = () => {
const buttons = Array.from(
document.querySelectorAll('button, [role="button"]')
).filter(isVisible);
// Method 1: Detect model/mode selector via aria-label patterns.
for (const button of buttons) {
const aria = normalize(button.getAttribute('aria-label') || '');
for (const pattern of MODE_SELECTOR_PATTERNS) {
if (pattern.test(aria)) return button;
}
}
// Method 2: Detect buttons whose text contains a model-version pattern.
const versionCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || '');
return VERSION_LABEL_RE.test(text) && text.length < 80;
});
versionCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (versionCandidates.length > 0) return versionCandidates[0];
// Method 3: Detect buttons showing a known model variant as their
// sole text (e.g. "Pro", "Flash", "Flash-Lite").
const variantCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '');
return MODEL_VARIANT_RE.test(text) && text.length < 30;
});
variantCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (variantCandidates.length > 0) return variantCandidates[0];
// Method 4: Fallback — look for any element with model-related attributes.
const attrEls = Array.from(
document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]')
).filter(isVisible);
if (attrEls.length > 0) return attrEls[0];
return null;
};
const picker = findModelPicker();
if (!picker) return { ok: false, reason: 'Model picker not found' };
try {
picker.click();
return { ok: true };
} catch (_) {
return { ok: false, reason: 'Failed to click model picker' };
}
})()
`;
}
/**
* Browser evaluate script that clicks the "思考等级" / "Thinking level"
* toggle inside an already-open model-picker menu to expand thinking options.
* Returns true if the toggle was found and clicked.
*/
function clickThinkingToggleInMenuScript() {
return `
(() => {
const isVisible = (el) => {
if (!el) return false;
if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false;
if (el.hidden || el.closest('[hidden]')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const MENU_SELECTORS = [
'[role="menu"] [role="menuitem"]',
'[role="menu"] [role="menuitemradio"]',
'[role="listbox"] [role="option"]',
'[role="menu"] button',
'[role="listbox"] button',
'[role="menu"] li',
'[role="listbox"] li',
'[role="dialog"] [role="menuitem"]',
'[role="dialog"] [role="option"]',
'[aria-modal="true"] [role="menuitem"]',
'[aria-modal="true"] [role="option"]',
'gem-menu-item',
'GEM-MENU-ITEM',
];
let menuItems = [];
for (const sel of MENU_SELECTORS) {
const items = Array.from(document.querySelectorAll(sel)).filter(isVisible);
if (items.length >= 2) { menuItems = items; break; }
}
if (menuItems.length === 0) {
const containers = Array.from(
document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]')
).filter(isVisible);
for (const container of containers) {
const children = Array.from(
container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)')
).filter(isVisible);
if (children.length >= 2) { menuItems = children; break; }
}
}
const thinkingToggle = menuItems.find((item) => {
const text = (item.textContent || '').replace(/\\s+/g, ' ').trim();
return /思考等级|thinking level|thinking mode/i.test(text);
});
if (thinkingToggle) {
try { thinkingToggle.click(); } catch (_) { return false; }
return true;
}
return false;
})()
`;
}
/**
* Browser evaluate script that selects a specific thinking level from an
* already-open model-picker menu (after the thinking section has been
* expanded). Closes the menu after selection (or on failure).
*
* @param {string} thinkingValue - 'standard' or 'extended'
* @returns a function string for page.evaluate() that returns the matched
* label text, or empty string on failure
*/
function selectThinkingInMenuScript(thinkingValue) {
const normalizedValue = String(thinkingValue || '').trim().toLowerCase();
if (!normalizedValue) return '(() => "")';
const labelMap = JSON.stringify({
standard: ['standard', '标准', '標準'],
extended: ['extended', '扩展', '擴展', '拡張'],
});
return `
(() => {
const LABEL_MAP = ${labelMap};
const candidateLabels = LABEL_MAP['${normalizedValue}'] || ['${normalizedValue}'];
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 || style.pointerEvents === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const textOf = (node) => [
node?.textContent || '',
node instanceof HTMLElement ? (node.innerText || '') : '',
node?.getAttribute?.('aria-label') || '',
node?.getAttribute?.('title') || '',
node?.getAttribute?.('data-tooltip') || '',
].join(' ');
const matchesThinking = (node) => {
const combined = normalize(textOf(node));
if (!combined) return false;
return candidateLabels.some((label) => combined.includes(label));
};
// Search inside visible menu containers first.
const containers = Array.from(
document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]')
).filter(isVisible);
const SELECTORS = [
'button', '[role="button"]', '[role="menuitem"]', '[role="menuitemradio"]',
'[role="option"]', '[role="radio"]', '[role="switch"]', 'li',
];
let candidates = [];
for (const container of containers) {
for (const sel of SELECTORS) {
const nodes = Array.from(container.querySelectorAll(sel))
.filter((node) => isVisible(node) && matchesThinking(node));
candidates.push(...nodes);
}
}
// Fallback: search the entire page.
if (candidates.length === 0) {
for (const sel of SELECTORS) {
const nodes = Array.from(document.querySelectorAll(sel))
.filter((node) => isVisible(node) && matchesThinking(node));
candidates.push(...nodes);
}
}
if (candidates.length === 0) {
try { document.body.click(); } catch (_) {}
return '';
}
// Prefer exact match over substring.
candidates.sort((a, b) => {
const aText = normalize(textOf(a));
const bText = normalize(textOf(b));
const aExact = candidateLabels.some((l) => aText === l);
const bExact = candidateLabels.some((l) => bText === l);
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return 0;
});
const target = candidates[0];
let matchedLabel = '';
try {
target.click();
matchedLabel = (target.textContent || '').trim();
} catch (_) {
try { document.body.click(); } catch (_) {}
return '';
}
// Close the menu after successful selection.
try { document.body.click(); } catch (_) {}
return matchedLabel;
})()
`;
}
/**
* Select a Gemini thinking level in the web UI.
*
* Multi-step approach matching modelsCommand.func:
* 1. Open the model-picker menu.
* 2. Wait for React to render the menu.
* 3. Click the "思考等级"/"Thinking level" toggle to expand thinking options.
* 4. Wait for the expanded items to render.
* 5. Select the requested thinking level.
* 6. Close the menu.
*
* @param {object} page - Puppeteer page object
* @param {string} thinkingValue - 'standard' or 'extended'
* @returns {Promise<string>} matched label text, or empty string on failure
*/
export async function selectGeminiThinking(page, thinkingValue) {
await ensureGeminiPage(page);
// Step 1: Open the picker menu.
const pickerRaw = await page.evaluate(openModelPickerForThinkingScript()).catch(() => null);
const pickerResult = unwrapGeminiEvaluateResult(pickerRaw, 'Gemini thinking model picker');
if (!pickerResult || !pickerResult.ok) return '';
// Step 2: Wait for React to render the menu.
await page.wait(0.8);
// Step 3: Click the thinking toggle to expand thinking options.
const toggleRaw = await page.evaluate(clickThinkingToggleInMenuScript()).catch(() => false);
const toggleClicked = unwrapGeminiEvaluateResult(toggleRaw, 'Gemini thinking toggle');
if (toggleClicked) {
// Step 4: Wait for the expanded thinking items to render.
await page.wait(0.5);
}
// Step 5: Select the requested thinking level (also closes the menu).
const matchedRaw = await page.evaluate(selectThinkingInMenuScript(thinkingValue)).catch(() => '');
const matched = unwrapGeminiEvaluateResult(matchedRaw, 'Gemini thinking selection');
if (typeof matched === 'string' && matched) {
await page.wait(0.3);
return matched;
}
return '';
}
// ── Current model detection ─────────────────────────────────────────
/**
* Browser evaluate script that reads the currently selected model from the
* Gemini model-picker button and returns its canonical model ID.
*
* Uses the same canonicalModelId logic as discoverModelsScript so that
* the returned ID can be matched against discovered model entries.
*
* @returns a function string for page.evaluate()
*/
function getCurrentGeminiModelScript() {
return `
(() => {
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 || style.pointerEvents === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const VERSION_LABEL_RE = /\\d+\\.\\d+/;
const MODE_SELECTOR_PATTERNS = [
/模式选择器/i,
/mode[\\s-]*selector/i,
/model[\\s-]*selector/i,
/model[\\s-]*picker/i,
/选择模型/i,
/select[\\s-]+model/i,
/choose[\\s-]+model/i,
/switch[\\s-]+model/i,
/change[\\s-]+model/i,
];
const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i;
const findModelPicker = () => {
const buttons = Array.from(
document.querySelectorAll('button, [role="button"]')
).filter(isVisible);
// Method 1: Detect model/mode selector via aria-label patterns.
for (const button of buttons) {
const aria = normalize(button.getAttribute('aria-label') || '');
for (const pattern of MODE_SELECTOR_PATTERNS) {
if (pattern.test(aria)) return button;
}
}
// Method 2: Detect buttons whose text contains a model-version pattern.
const versionCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || '');
return VERSION_LABEL_RE.test(text) && text.length < 80;
});
versionCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (versionCandidates.length > 0) return versionCandidates[0];
// Method 3: Detect buttons showing a known model variant as their
// sole text (e.g. "Pro", "Flash", "Flash-Lite").
const variantCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '');
return MODEL_VARIANT_RE.test(text) && text.length < 30;
});
variantCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (variantCandidates.length > 0) return variantCandidates[0];
// Method 4: Fallback — look for any element with model-related attributes.
const attrEls = Array.from(
document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]')
).filter(isVisible);
if (attrEls.length > 0) return attrEls[0];
return null;
};
const canonicalModelId = (raw) => {
const text = normalize(raw);
if (!text) return '';
const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim();
const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i;
const match = cleaned.match(versionRe);
if (match) {
const version = match[2];
let variant = (match[3] || '').toLowerCase();
const extra = (match[4] || '').toLowerCase();
if (extra) variant = variant + '-' + extra;
return version + '-' + variant;
}
const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i;
const fallbackMatch = cleaned.match(fallbackRe);
if (fallbackMatch) {
return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase();
}
if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) {
return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, '');
}
return '';
};
const picker = findModelPicker();
if (!picker) return '';
const combined = normalize(picker.textContent || '') + ' ' + normalize(picker.getAttribute('aria-label') || '');
return canonicalModelId(combined);
})()
`;
}
/**
* Get the canonical ID of the currently selected Gemini model from the
* model-picker button in the web UI.
*
* @param {object} page - Puppeteer page object
* @returns {Promise<string>} canonical model ID, or empty string on failure
*/
export async function getCurrentGeminiModel(page) {
await ensureGeminiPage(page);
const modelId = await page.evaluate(getCurrentGeminiModelScript()).catch(() => '');
return typeof modelId === 'string' ? modelId : '';
}
export const __test__ = {
GEMINI_COMPOSER_SELECTORS,
GEMINI_COMPOSER_MARKER_ATTR,
collapseAdjacentGeminiTurns,
clickNewChatScript,
diffTranscriptLines,
diffTrustedStructuredTurns,
hasGeminiTurnPrefix,
readGeminiSnapshot,
readGeminiSnapshotScript,
expandGeminiRecentScript,
submitComposerScript,
insertComposerTextFallbackScript,
openModelPickerForThinkingScript,
clickThinkingToggleInMenuScript,
selectThinkingInMenuScript,
selectGeminiModelScript,
getCurrentGeminiModelScript,
};
export async function getGeminiVisibleImageUrls(page) {
await ensureGeminiPage(page);
return await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 32 && rect.height > 32;
};
const imgs = Array.from(document.querySelectorAll('main img')).filter((img) => img instanceof HTMLImageElement && isVisible(img));
const urls = [];
const seen = new Set();
for (const img of imgs) {
const src = img.currentSrc || img.src || '';
const alt = (img.getAttribute('alt') || '').toLowerCase();
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (!src) continue;
if (alt.includes('avatar') || alt.includes('logo') || alt.includes('icon')) continue;
if (width < 128 && height < 128) continue;
if (seen.has(src)) continue;
seen.add(src);
urls.push(src);
}
return urls;
})()
`);
}
export async function waitForGeminiImages(page, beforeUrls, timeoutSeconds) {
const beforeSet = new Set(beforeUrls);
const pollIntervalSeconds = 3;
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
let lastUrls = [];
let stableCount = 0;
for (let index = 0; index < maxPolls; index += 1) {
await page.wait(index === 0 ? 2 : pollIntervalSeconds);
const urls = (await getGeminiVisibleImageUrls(page)).filter((url) => !beforeSet.has(url));
if (urls.length === 0)
continue;
const key = urls.join('\n');
const prevKey = lastUrls.join('\n');
if (key == prevKey)
stableCount += 1;
else {
lastUrls = urls;
stableCount = 1;
}
if (stableCount >= 2 || index === maxPolls - 1)
return lastUrls;
}
return lastUrls;
}
export async function exportGeminiImages(page, urls) {
await ensureGeminiPage(page);
const urlsJson = JSON.stringify(urls);
return await page.evaluate(`
(async (targetUrls) => {
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read blob'));
reader.readAsDataURL(blob);
});
const inferMime = (value, fallbackUrl) => {
if (value) return value;
const lower = String(fallbackUrl || '').toLowerCase();
if (lower.includes('.png')) return 'image/png';
if (lower.includes('.webp')) return 'image/webp';
if (lower.includes('.gif')) return 'image/gif';
return 'image/jpeg';
};
const images = Array.from(document.querySelectorAll('main img'));
const results = [];
for (const targetUrl of targetUrls) {
const img = images.find((node) => (node.currentSrc || node.src || '') === targetUrl);
let dataUrl = '';
let mimeType = 'image/jpeg';
const width = img?.naturalWidth || img?.width || 0;
const height = img?.naturalHeight || img?.height || 0;
try {
if (String(targetUrl).startsWith('data:')) {
dataUrl = String(targetUrl);
mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
} else {
const res = await fetch(String(targetUrl), { credentials: 'include' });
if (res.ok) {
const blob = await res.blob();
mimeType = inferMime(blob.type, targetUrl);
dataUrl = await blobToDataUrl(blob);
}
}
} catch {}
if (!dataUrl && img instanceof HTMLImageElement) {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0);
dataUrl = canvas.toDataURL('image/png');
mimeType = 'image/png';
}
} catch {}
}
if (dataUrl) {
results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
}
}
return results;
})(${urlsJson})
`);
}
export async function waitForGeminiResponse(page, baseline, promptText, timeoutSeconds) {
if (timeoutSeconds <= 0)
return '';
// Reply ownership must survive Gemini prepending older history later.
// Re-anchor on the submitted user turn when possible, and otherwise only
// accept assistants that are appended to the exact submission snapshot.
const pickStructuredReplyCandidate = (current) => {
if (!current.structuredTurnsTrusted)
return '';
const userAnchorTurnIndex = findLastMatchingGeminiTurnIndex(current.turns, baseline.userAnchorTurn);
if (userAnchorTurnIndex !== null) {
const candidate = current.turns
.slice(userAnchorTurnIndex + 1)
.filter((turn) => turn.Role === 'Assistant')
.at(-1);
return candidate ? sanitizeGeminiResponseText(candidate.Text, promptText) : '';
}
if (hasGeminiTurnPrefix(baseline.snapshot.turns, current.turns)) {
const appendedAssistant = current.turns
.slice(baseline.snapshot.turns.length)
.filter((turn) => turn.Role === 'Assistant')
.at(-1);
if (appendedAssistant) {
return sanitizeGeminiResponseText(appendedAssistant.Text, promptText);
}
}
return '';
};
const pickFallbackGeminiTranscriptReply = (current) => current.transcriptLines
.filter((line) => !baseline.snapshot.transcriptLines.includes(line))
.map((line) => extractGeminiTranscriptLineCandidate(line, promptText))
.filter(Boolean)
.join('\n')
.trim();
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / 2));
let lastStructured = '';
let structuredStableCount = 0;
let lastTranscript = '';
let transcriptStableCount = 0;
let transcriptMissCount = 0;
for (let index = 0; index < maxPolls; index += 1) {
await page.wait(index === 0 ? 1 : 2);
const current = await readGeminiSnapshot(page);
const structuredCandidate = pickStructuredReplyCandidate(current);
if (structuredCandidate) {
if (structuredCandidate === lastStructured)
structuredStableCount += 1;
else {
lastStructured = structuredCandidate;
structuredStableCount = 1;
}
if (!current.isGenerating && structuredStableCount >= 2) {
return structuredCandidate;
}
continue;
}
transcriptMissCount += 1;
if (transcriptMissCount < 2)
continue;
const transcriptCandidate = pickFallbackGeminiTranscriptReply(current);
if (!transcriptCandidate)
continue;
if (transcriptCandidate === lastTranscript)
transcriptStableCount += 1;
else {
lastTranscript = transcriptCandidate;
transcriptStableCount = 1;
}
if (!current.isGenerating && transcriptStableCount >= 2) {
return transcriptCandidate;
}
}
return '';
}
/**
* Evaluate script that selects a specific Gemini model from the web UI
* model picker by canonical model id (e.g. "2.5-flash").
*
* Returns { ok: true } on success, or { ok: false, reason: "..." }
* when the picker or model item could not be found / clicked.
*/
/**
* Browser evaluate script that finds and clicks a model entry in an
* already-open Gemini model-picker menu. Does NOT open the picker.
*/
function selectModelInMenuScript(modelId) {
return `
(() => {
const targetModelId = ${JSON.stringify(modelId)};
if (!targetModelId) return { ok: false, reason: 'No model id provided' };
const isVisible = (el) => {
if (!(el instanceof HTMLElement) && !(el instanceof Element)) 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 || style.pointerEvents === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const canonicalModelId = (raw) => {
const text = normalize(raw);
if (!text) return '';
const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim();
const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i;
const match = cleaned.match(versionRe);
if (match) {
const version = match[2];
let variant = (match[3] || '').toLowerCase();
const extra = (match[4] || '').toLowerCase();
if (extra) variant = variant + '-' + extra;
return version + '-' + variant;
}
const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i;
const fallbackMatch = cleaned.match(fallbackRe);
if (fallbackMatch) {
return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase();
}
if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) {
return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, '');
}
return '';
};
const MENU_SELECTORS = [
'[role="menu"] [role="menuitem"]',
'[role="menu"] [role="menuitemradio"]',
'[role="listbox"] [role="option"]',
'[role="menu"] button',
'[role="listbox"] button',
'[role="menu"] li',
'[role="listbox"] li',
'[role="dialog"] [role="menuitem"]',
'[role="dialog"] [role="option"]',
'[aria-modal="true"] [role="menuitem"]',
'[aria-modal="true"] [role="option"]',
'gem-menu-item',
'GEM-MENU-ITEM',
];
let menuItems = [];
for (const sel of MENU_SELECTORS) {
const items = Array.from(document.querySelectorAll(sel)).filter(isVisible);
if (items.length >= 2) { menuItems = items; break; }
}
if (menuItems.length === 0) {
const containers = Array.from(
document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]')
).filter(isVisible);
for (const container of containers) {
const children = Array.from(
container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)')
).filter(isVisible);
if (children.length >= 2) { menuItems = children; break; }
}
}
let matched = null;
for (const item of menuItems) {
const id = canonicalModelId(item.textContent || '');
if (id === targetModelId) {
matched = item;
break;
}
}
if (!matched) {
try { document.body.click(); } catch (_) {}
return { ok: false, reason: 'Model "' + targetModelId + '" not found in picker menu' };
}
try {
matched.click();
} catch (_) {
try { document.body.click(); } catch (_) {}
return { ok: false, reason: 'Failed to click model menu item' };
}
return { ok: true };
})()
`;
}
/**
* Legacy wrapper kept for test compatibility — delegates to
* selectModelInMenuScript. New callers should prefer the split
* openModelPickerForThinkingScript + selectModelInMenuScript
* pattern with an explicit page.wait between them.
*/
export function selectGeminiModelScript(modelId) {
return selectModelInMenuScript(modelId);
}
/**
* Select a Gemini model by canonical id (e.g. "2.5-flash") in the
* web UI model picker. Opens the picker, waits for React, then clicks
* the matching entry. Throws CommandExecutionError on failure.
*/
export async function selectGeminiModel(page, modelId) {
await ensureGeminiPage(page);
// Open the picker menu.
const pickerRaw = await page.evaluate(openModelPickerForThinkingScript());
const pickerResult = unwrapGeminiEvaluateResult(pickerRaw, 'Gemini model picker');
if (!pickerResult || !pickerResult.ok) {
throw new CommandExecutionError(
pickerResult?.reason || 'Failed to open model picker for model selection'
);
}
// Wait for React to render the menu.
await page.wait(0.8);
// Find and click the matching menu item.
const raw = await page.evaluate(selectModelInMenuScript(modelId));
const result = unwrapGeminiEvaluateResult(raw, 'Gemini model selection');
if (!result || !result.ok) {
throw new CommandExecutionError(
result?.reason || 'Failed to select Gemini model "' + modelId + '"'
);
}
await page.wait(0.5);
const selectedModelId = await getCurrentGeminiModel(page);
if (selectedModelId !== modelId) {
throw new CommandExecutionError(
selectedModelId
? `Gemini model selection read-back returned "${selectedModelId}", expected "${modelId}"`
: `Gemini model selection did not expose selected model "${modelId}" after click`
);
}
}