add: gemini-web-generate skill(整合 CLI + skill)

This commit is contained in:
2026-05-14 14:45:51 +08:00
parent a23b9a5272
commit 5df8934000
11 changed files with 3498 additions and 0 deletions
+2
View File
@@ -1,3 +1,5 @@
*.skill
__pycache__/
.DS_Store
node_modules/
output/
+166
View File
@@ -0,0 +1,166 @@
---
name: gemini-web-generate
description: "Generate images via Gemini web interface using a headless browser automation CLI. Supports text-to-image, image-to-image, multi-image reference, multi-turn conversations, and session management. Use when: (1) User wants to generate images with Gemini, (2) User says 'generate with Gemini' or 'Gemini 生图', (3) Image-to-image or style transfer tasks, (4) Continuing an existing Gemini image conversation."
---
# Gemini Web Image Generation
Generate images through Gemini's web interface using a bundled Puppeteer-based CLI that automates the full workflow: navigation, image upload, prompt submission, download, and cleanup.
## Architecture
| Component | Path |
|------|------|
| CLI entry | `scripts/cli.js` |
| Node binary | `/home/dazhi/.nvm/versions/node/v22.22.0/bin/node` |
| Browser CDP | `http://127.0.0.1:9223` (managed by `browser` tool) |
## Quick Start
All commands use absolute paths for reliability:
```bash
NODE="/home/dazhi/.nvm/versions/node/v22.22.0/bin/node"
CLI="<skill-dir>/scripts/cli.js"
```
### Text-to-Image (default: single mode)
```bash
$NODE $CLI generate --prompt "a cute cat" --mode single
```
### Image-to-Image
```bash
$NODE $CLI generate --prompt "convert to watercolor style" --image /path/to/ref.png --mode single
```
### Multi-image Reference
```bash
$NODE $CLI generate --prompt "blend these images" --images "/path/a.png,/path/b.png" --mode single
```
### Multi-turn Conversation
```bash
# First turn (session stays open)
$NODE $CLI generate --prompt "draw a sunset"
# Continue
$NODE $CLI generate --session <id> --prompt "add a boat"
# Last turn (auto-close)
$NODE $CLI generate --session <id> --prompt "make it night" --mode single
```
## Core Workflow
### 1. Ensure Browser is Running
```
browser action=start
```
### 2. Execute Generation
Default to `--mode single` (auto-close after download). Only omit `--mode` for multi-turn sessions.
```bash
$NODE $CLI generate --prompt "..." --mode single
```
CLI handles: open tab → navigate to Gemini → paste reference images → type prompt → submit → wait for generation → download to `output/originals/` → close tab (single mode).
Default timeouts: generation 300s (5min), download 120s.
### 3. Handle Results
**On success**: Move downloaded image and clean up:
```bash
# Move latest generated image
LATEST=$(ls -t <skill-dir>/scripts/output/originals/ | head -1)
mv "<skill-dir>/scripts/output/originals/$LATEST" ~/.openclaw/workspace/media/generated/
# Clean up moved files
for f in Gemini_Generated_Image_*.png generated-*.png; do
if [ -f "/home/dazhi/.openclaw/workspace/media/generated/$f" ]; then
rm "<skill-dir>/scripts/output/originals/$f" 2>/dev/null
fi
done
```
**On timeout or error**: Check status:
```bash
$NODE $CLI status --session <id> --wait
```
If status returns `done`, run download:
```bash
$NODE $CLI download --session <id>
```
**Add `--screenshot` for diagnostics**: When errors/timeouts are expected, add `--screenshot` to auto-capture a screenshot on failure.
### 4. Deliver to User
Use `message` tool with `media` parameter to send the final image.
## Secondary Workflows
### Continue from Chat URL
```bash
$NODE $CLI generate --chatUrl "https://gemini.google.com/app/xxxx" --prompt "new instruction"
```
### List Active Sessions
```bash
$NODE $CLI sessions
```
### Find Lost Session
```bash
$NODE $CLI find_session --chatUrl "https://gemini.google.com/app/xxxx" --open
```
### Close a Session
```bash
$NODE $CLI close --session <id>
```
## Error Handling
| Status | Action |
|------|------|
| CLI exits `success` | Move image from `output/originals/` |
| CLI exits `timeout` | Run `status --session <id> --wait` to check if still generating |
| Status returns `done` | Run `download --session <id>` |
| Status returns `error` | Report error, suggest retry with modified prompt |
| Status returns `generating` | Continue waiting with `--wait` |
| Session lost | Use `find_session --chatUrl <url> --open` to recover |
| Browser not started | Run `browser action=start` first |
| Downloaded image not found | Check `output/originals/` directory, verify filename and size |
## Critical Rules
1. **Browser management**: Use `browser` tool for lifecycle; CLI only handles Gemini interaction
2. **Always use absolute paths** for node and CLI paths
3. **Default to `--mode single`**: Auto-close after download unless in multi-turn conversation
4. **Multi-turn continuation**: Do NOT add `--mode` when continuing; close with `--mode single` on last turn or `close` command
5. **Reference image path handling**:
- ❌ Never use absolute paths with spaces — shell splits them
-`cd` to reference image directory first, then use relative paths
- ✅ Use `ls | grep` to dynamically get filenames
6. **Avoid numbered reference images** (e.g., `01-xxx.jpg`) — Gemini may rate-limit repeated use
7. **Stop after 3 consecutive failures** — investigate root cause instead of retrying
8. **Always move downloaded images** to `~/media/generated/` and clean up originals
9. **Verify downloads**: Check filename and file size in originals before moving
10. **Use `--screenshot`** for diagnostic capture on errors
11. **No `--json` needed**: Read CLI text output directly
12. **Don't use screenshot previews**: CLI downloads original images directly
@@ -0,0 +1 @@
output/
+557
View File
@@ -0,0 +1,557 @@
#!/usr/bin/env node
import { connectBrowser, navigateToGemini, log, checkLogin, listSessions, closeSession, captureChatUrl, findSessionByChatUrl } from './src/browser.js';
import { generate } from './src/generator.js';
import { downloadViaButtons, takeScreenshot } from './src/screenshot.js';
import { checkStatus } from './src/status.js';
import { config } from './config.js';
import fs from 'fs';
import path from 'path';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* NDJSON event emitter.
* In JSON mode, emits structured progress events to stdout as newline-delimited JSON.
* Human-readable logs always go to stderr.
*/
let jsonMode = false;
/**
* Emit an NDJSON event to stdout.
* In JSON mode, only stdout is used (no stderr logging).
* In human mode, only stderr logging is used (no JSON).
* @param {string} type - "progress" | "success" | "error"
* @param {object} data
*/
function emit(type, data) {
// JSON mode: emit NDJSON to stdout only
if (jsonMode) {
process.stdout.write(JSON.stringify({ type, ...data }) + '\n');
return;
}
// Human mode: log to stderr, and output JSON for terminal events
if (type === 'progress' && data.message) {
log(data.message);
} else if (type === 'success') {
console.log(JSON.stringify(data, null, 2));
} else if (type === 'error' && data.message) {
log('ERROR:', data.message);
console.log(JSON.stringify({ type, ...data }, null, 2));
}
}
/**
* Output a single JSON object to stdout.
* @param {object} data
*/
function output(data) {
if (jsonMode) {
process.stdout.write(JSON.stringify(data) + '\n');
} else {
console.log(JSON.stringify(data, null, 2));
}
}
/**
* Output error and exit.
* @param {string} message
*/
function error(message) {
emit('error', { message });
process.exit(1);
}
/**
* Parse command line arguments.
* @returns {{command: string, args: object}}
*/
function parseArgs() {
const args = process.argv.slice(2);
const command = args[0];
const parsed = { command, args: {} };
for (let i = 1; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2);
const value = args[i + 1]?.startsWith('--') ? undefined : args[i + 1];
parsed.args[key] = value === undefined ? true : value;
if (value !== undefined) i++;
}
}
return parsed;
}
function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => resolve(data.trim()));
process.stdin.on('error', reject);
// Timeout if no data within 1s — treat as empty input
setTimeout(() => {
if (!data) resolve('');
}, 1000);
});
}
/**
* Resolve image path - handle relative paths.
* @param {string} imagePath
* @returns {string}
*/
function resolveImagePath(imagePath) {
if (path.isAbsolute(imagePath)) return imagePath;
return path.resolve(process.cwd(), imagePath);
}
/**
* Check login and output error if not logged in.
* @param {import('puppeteer-core').Page} page
*/
async function requireLogin(page) {
const loginStatus = await checkLogin(page);
if (!loginStatus.loggedIn) {
emit('error', {
type: 'not_logged_in',
message: 'Not logged into Gemini. Please log in and try again.',
action_required: 'open_browser_and_login',
url: config.geminiUrl,
});
process.exit(0);
}
if (loginStatus.account) {
emit('progress', { step: 'login', message: `Logged in as: ${loginStatus.account}` });
}
}
async function main() {
const { command, args } = parseArgs();
// Check for JSON mode flag
jsonMode = args.json === true || args.json === 'true';
if (jsonMode) {
process.env.GEMINI_JSON_MODE = '1';
}
if (!command || command === 'help' || command === '--help' || command === '-h') {
const helpText = `
gemini-web-cli — 通过 CLI 驱动 Gemini 网页 AI 生图
用法:
node cli.js <command> [options]
命令:
generate 向 Gemini 发送提示词(可选参考图),等待图片生成
download 从当前 Gemini 标签页下载已生成的图片
status 检查页面状态(空闲 / 生成中 / 完成 / 异常)
sessions 列出所有活跃的 Gemini 会话(标签页)
find_session 通过 Gemini 对话链接找回已丢失的 session
close 关闭指定会话的标签页
generate 参数:
--prompt "文本" 发送给 Gemini 的提示词(必填),支持 "stdin" 从管道读取
--prompt-file <路径> 从文件读取提示词(支持换行)
--image <路径> 单张参考图片路径
--images <路径1,路径2,...> 多张参考图片,逗号分隔,最多 10 张
--session <id> 复用已有会话的标签页
--chatUrl <url> 打开指定的 Gemini 对话链接(创建新会话)
--mode <single|multi> "single" 生成后关闭标签页,"multi" 保持标签页打开(默认 multi)
--tool <工具名> 发送前选择指定工具(默认选择"制作图片")
--timeout <毫秒> 生成最大等待时间(默认 300000ms = 5分钟)
--download-timeout <毫秒> 下载最大等待时间(默认 120000ms = 2分钟)
--screenshot 出错或超时时截图(低质量 JPEG),保存路径写入输出
--json 启用 NDJSON 模式:每行输出一个 JSON 事件
download 参数:
--timeout <毫秒> 下载最大等待时间(默认 120000ms = 2分钟)
--session <id> 复用已有会话的标签页
status 参数:
--session <id> 检查指定会话的标签页
--wait 持续轮询,直到完成 / 出错
--timeout <毫秒> --wait 模式下的最大等待时间(默认 300000ms)
--screenshot 状态异常时截图(低质量 JPEG),保存路径写入输出
sessions(无参数)
find_session 参数:
--chatUrl <url> Gemini 对话链接(必填)
--open 如果没找到匹配的标签页,自动打开新标签页并导航
close 参数:
--session <id> 要关闭的会话 ID(必填)
示例:
# 提示词 + 参考图生图
node cli.js generate --prompt "一只可爱的猫" --image ./cat.png
# NDJSON 模式(适合程序解析)
node cli.js generate --prompt "一只可爱的猫" --image ./cat.png --json
# single 模式,生成后自动关闭标签页
node cli.js generate --prompt "日落风景" --mode single
# 轮询状态,直到完成
node cli.js status --session 7ac6cfff --wait
# 快速检查状态(立即返回)
node cli.js status --session 7ac6cfff
# 出错/超时时自动截图(路径写入输出 JSON)
node cli.js generate --prompt "日落风景" --screenshot
node cli.js status --session 7ac6cfff --wait --screenshot
# 下载已生成的图片
node cli.js download --session 7ac6cfff
# 列出所有活跃的会话
node cli.js sessions
# 通过对话链接找回 session
node cli.js find_session --chatUrl "https://gemini.google.com/app/4c089f364e1cf745"
# 没找到时自动打开新标签页
node cli.js find_session --chatUrl "https://gemini.google.com/app/4c089f364e1cf745" --open
# 关闭指定会话的标签页
node cli.js close --session 7ac6cfff
# 通过之前的对话链接继续生图
node cli.js generate --chatUrl "https://gemini.google.com/app/4c089f364e1cf745" --prompt "换成晚上场景"
环境变量:
CDP_URL Chrome DevTools Protocol 连接地址(默认 http://127.0.0.1:9222
`.trim();
process.stdout.write(helpText + '\n');
return;
}
// Sessions command
if (command === 'sessions') {
const sessions = await listSessions();
output({ type: 'sessions', sessions });
return;
}
// Find session command — recover a lost session by matching chatUrl
if (command === 'find_session') {
if (!args.chatUrl) {
error('--chatUrl is required');
}
const openIfNotFound = args.open === true || args.open === 'true';
const result = await findSessionByChatUrl({ chatUrl: args.chatUrl, openIfNotFound });
output({ type: 'find_session', ...result });
return;
}
// Close command
if (command === 'close') {
if (!args.session) {
error('--session is required to close a tab');
}
const result = await closeSession(args.session);
output({ type: 'close', ...result });
return;
}
// Ensure output directories exist
fs.mkdirSync(config.outputDir, { recursive: true });
fs.mkdirSync(config.screenshotDir, { recursive: true });
fs.mkdirSync(config.downloadDir, { recursive: true });
let browser;
try {
emit('progress', { step: 'start', message: `Command: ${command}` });
const sessionId = args.session || undefined;
// Default to 'multi' mode (keep tab open for subsequent calls)
const mode = (args.mode === 'multi' || args.mode === 'single') ? args.mode : 'multi';
switch (command) {
case 'generate': {
// Resolve prompt: --prompt-file > --prompt (no value = stdin) > --prompt literal
let prompt = args.prompt;
if (args.promptFile) {
const p = resolveImagePath(args.promptFile);
if (!fs.existsSync(p)) {
error(`Prompt file not found: ${p}`);
}
prompt = fs.readFileSync(p, 'utf8').trim();
} else if (prompt === true || prompt === 'stdin') {
// --prompt 后面没有值,或显式写了 stdin,从管道/heredoc 读取
prompt = await readStdin();
}
if (!prompt) {
error('--prompt is required');
}
// Support --images path1,path2,... or --image single
let imagePaths = [];
const imagesArg = args.images;
if (imagesArg) {
imagePaths = imagesArg.split(',').map(p => resolveImagePath(p.trim()));
} else if (args.image) {
imagePaths = [resolveImagePath(args.image)];
}
if (imagePaths.length > 10) {
imagePaths = imagePaths.slice(0, 10);
}
for (const p of imagePaths) {
if (!fs.existsSync(p)) {
error(`Image file not found: ${p}`);
}
}
const timeout = args.timeout ? parseInt(args.timeout, 10) : config.timeout;
emit('progress', { step: 'connect', message: 'Connecting to browser...' });
const { browser: b, page, cdp, sessionId: sid, isNew } = await connectBrowser({ sessionId });
browser = b;
let continuedSession = false;
if (sessionId && !isNew) {
emit('progress', { step: 'connect', message: `Continuing session: ${sessionId} (mode: ${mode})` });
// Don't navigate — stay on the current chat page for multi-round
continuedSession = true;
} else if (args.chatUrl) {
emit('progress', { step: 'navigate', message: `Navigating to chat URL: ${args.chatUrl}` });
await page.goto(args.chatUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Wait for conversation content to load — check for download buttons or existing images
await page.waitForFunction(
() => document.querySelectorAll('button.generated-image-button').length > 0 ||
document.querySelectorAll('[contenteditable="true"]').length > 0,
{ timeout: 30000 }
).catch(() => {});
await sleep(3000);
// Chat URL opens an existing conversation, treat as continued
continuedSession = true;
} else {
emit('progress', { step: 'navigate', message: `New session created (mode: ${mode})` });
await navigateToGemini(page);
}
emit('progress', { step: 'login', message: 'Checking login status...' });
const loginStatus = await checkLogin(page);
if (!loginStatus.loggedIn) {
emit('error', {
message: 'Not logged into Gemini. Please log in at ' + config.geminiUrl + ' and try again.',
action_required: 'open_browser_and_login',
sessionId: sid,
});
return;
}
if (loginStatus.account) {
emit('progress', { step: 'login', message: `Logged in as: ${loginStatus.account}` });
}
// generate() handles its own progress events
const genStartTime = Date.now();
const result = await generate(page, {
prompt: prompt,
images: imagePaths,
timeout,
isContinuedSession: continuedSession,
tool: args.tool,
});
// Capture the Gemini chat URL for recovery
const chatUrl = await captureChatUrl(sid, page);
const elapsed = ((Date.now() - genStartTime) / 1000).toFixed(1);
// Add session info to result
result.sessionId = sid;
result.chatUrl = chatUrl;
result.mode = mode;
if (result.status === 'success') {
emit('progress', {
step: 'generate',
state: 'DONE',
elapsed: parseFloat(elapsed),
message: `Generation completed (${elapsed}s)`,
});
// Only attempt download if this was an image generation
try {
const downloadTimeout = parseInt(args.downloadTimeout, 10) || 120000;
const downloaded = await downloadViaButtons(page, cdp, {
existingButtonCount: result.existingButtonCount || 0,
timeout: downloadTimeout,
});
if (downloaded.length > 0) {
result.images = downloaded;
for (const img of downloaded) {
const fileName = img.path.split('/').pop();
emit('progress', { step: 'download', message: `Downloaded: ${fileName}` });
}
}
} catch (e) {
emit('progress', { step: 'warning', message: `Failed to download generated images: ${e.message}` });
emit('progress', { step: 'download', message: `Download failed: ${e.message}` });
}
// Remove internal fields from output
delete result.existingButtonCount;
// Emit final success event
emit('success', {
sessionId: sid,
chatUrl,
mode,
images: result.images?.map(i => i.path) || [],
});
} else if (result.status === 'text_only') {
// Text-only response means image generation failed
emit('error', {
message: result.message,
sessionId: sid,
status: 'text_only',
});
} else {
// Error or timeout — emit error as terminal event
emit('error', {
message: result.message,
sessionId: sid,
status: result.status,
timeout: result.timeout,
});
}
// Capture screenshot on error or timeout
if (args.screenshot && result.status && result.status !== 'success') {
try {
const ss = await takeScreenshot(page);
result.screenshot = ss.path;
} catch (e) {
emit('progress', { step: 'warning', message: `Failed to take screenshot: ${e.message}` });
}
}
// Single mode: close the tab
if (mode === 'single') {
await closeSession(sid);
}
break;
}
case 'download': {
emit('progress', { step: 'connect', message: 'Connecting to browser...' });
const { browser: b, page, cdp, sessionId: sid, isNew } = await connectBrowser({ sessionId });
browser = b;
// Don't navigate — stay on the current chat page
if (isNew) {
emit('progress', { step: 'navigate', message: 'Navigating to Gemini...' });
await navigateToGemini(page);
}
emit('progress', { step: 'login', message: 'Checking login status...' });
await requireLogin(page);
emit('progress', { step: 'download', message: 'Downloading generated images...' });
const downloadTimeout = parseInt(args.downloadTimeout, 10) || parseInt(args.timeout, 10) || 120000;
const downloadResult = await downloadViaButtons(page, cdp, { newestOnly: true, timeout: downloadTimeout });
if (downloadResult.length === 0) {
error('No new images found to download.');
}
for (const img of downloadResult) {
const fileName = img.path.split('/').pop();
emit('progress', { step: 'download', message: `Downloaded: ${fileName}` });
}
emit('success', {
sessionId: sid,
path: downloadResult[0].path,
});
if (mode === 'single') {
await closeSession(sid);
}
break;
}
case 'status': {
emit('progress', { step: 'connect', message: 'Connecting to browser...' });
const { browser: b, page, sessionId: sid, isNew } = await connectBrowser({ sessionId });
browser = b;
// Navigate if a new tab was created (either no session requested,
// or requested session was lost and replaced)
if (isNew) {
emit('progress', { step: 'navigate', message: 'Navigating to Gemini...' });
await navigateToGemini(page);
}
emit('progress', { step: 'login', message: 'Checking login status...' });
const loginStatus = await checkLogin(page);
if (!loginStatus.loggedIn) {
emit('error', {
message: 'Not logged into Gemini',
action_required: 'open_browser_and_login',
sessionId: sid,
});
return;
}
const waitForCompletion = args.wait === true || args.wait === 'true';
const pollTimeout = args.timeout ? parseInt(args.timeout, 10) : undefined;
if (waitForCompletion) {
emit('progress', { step: 'status', message: 'Waiting for generation to complete...' });
}
const result = await checkStatus(page, {
waitForCompletion,
timeout: pollTimeout,
onStateChange: (state, elapsed) => {
emit('progress', { step: 'status', state, elapsed, message: `State: ${state} (${elapsed}s)` });
},
});
result.sessionId = sid;
// Capture screenshot on error or unclear states
if (args.screenshot && result.state && ['error', 'page_error', 'generating', 'idle'].includes(result.state)) {
try {
const ss = await takeScreenshot(page);
result.screenshot = ss.path;
} catch (e) {
emit('progress', { step: 'warning', message: `Failed to take screenshot: ${e.message}` });
}
}
output({ type: 'status', ...result });
break;
}
default:
error(`Unknown command: ${command}. Available commands: generate, download, status, sessions, close`);
}
} catch (err) {
// CDP connection failures
if (err.message.includes('ECONNREFUSED') || err.message.includes('connect')) {
error(`Cannot connect to browser at ${config.cdpUrl}. Make sure Chrome is running with --remote-debugging-port.`);
}
error(err.message);
} finally {
// Don't close the browser - it's managed externally (OpenClaw, etc.)
// Just release the CDP connection
if (browser) {
try {
await browser.disconnect();
} catch {
// Already disconnected
}
}
}
}
main();
@@ -0,0 +1,15 @@
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const config = {
geminiUrl: 'https://gemini.google.com/app',
cdpUrl: process.env.CDP_URL || 'http://127.0.0.1:9223',
timeout: 300000,
pollInterval: 2000,
outputDir: path.join(__dirname, 'output'),
screenshotDir: path.join(__dirname, 'output', 'screenshots'),
downloadDir: path.join(__dirname, 'output', 'originals'),
};
+936
View File
@@ -0,0 +1,936 @@
{
"name": "gemini-web-generate",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gemini-web-generate",
"version": "1.0.0",
"dependencies": {
"puppeteer-core": "^24.0.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.13.0",
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-2.13.0.tgz",
"integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.3",
"extract-zip": "^2.0.1",
"progress": "^2.0.3",
"proxy-agent": "^6.5.0",
"semver": "^7.7.4",
"tar-fs": "^3.1.1",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/cjs/main-cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmmirror.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ast-types": {
"version": "0.13.4",
"resolved": "https://registry.npmmirror.com/ast-types/-/ast-types-0.13.4.tgz",
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmmirror.com/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.0.tgz",
"integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.8.7",
"resolved": "https://registry.npmmirror.com/bare-os/-/bare-os-3.8.7.tgz",
"integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==",
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.13.0",
"resolved": "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.0.tgz",
"integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==",
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.0.tgz",
"integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/basic-ftp": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/basic-ftp/-/basic-ftp-5.2.2.tgz",
"integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/chromium-bidi": {
"version": "14.0.0",
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-14.0.0.tgz",
"integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
"integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/degenerator": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/degenerator/-/degenerator-5.0.1.tgz",
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.13.4",
"escodegen": "^2.1.0",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1581282",
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-uri": {
"version": "6.0.5",
"resolved": "https://registry.npmmirror.com/get-uri/-/get-uri-6.0.5.tgz",
"integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
"license": "MIT",
"dependencies": {
"basic-ftp": "^5.0.2",
"data-uri-to-buffer": "^6.0.2",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/netmask": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/netmask/-/netmask-2.1.1.tgz",
"integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
"integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
"license": "MIT",
"dependencies": {
"@tootallnate/quickjs-emscripten": "^0.23.0",
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"get-uri": "^6.0.1",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.6",
"pac-resolver": "^7.0.1",
"socks-proxy-agent": "^8.0.5"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/pac-resolver/-/pac-resolver-7.0.1.tgz",
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
"license": "MIT",
"dependencies": {
"degenerator": "^5.0.0",
"netmask": "^2.0.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/proxy-agent": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/proxy-agent/-/proxy-agent-6.5.0.tgz",
"integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"http-proxy-agent": "^7.0.1",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^7.14.1",
"pac-proxy-agent": "^7.1.0",
"proxy-from-env": "^1.1.0",
"socks-proxy-agent": "^8.0.5"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/puppeteer-core": {
"version": "24.40.0",
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-24.40.0.tgz",
"integrity": "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.13.0",
"chromium-bidi": "14.0.0",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1581282",
"typed-query-selector": "^2.12.1",
"webdriver-bidi-protocol": "0.4.1",
"ws": "^8.19.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamx": {
"version": "2.25.0",
"resolved": "https://registry.npmmirror.com/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-3.1.2.tgz",
"integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.1.8",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typed-query-selector": {
"version": "2.12.1",
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
"integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT",
"optional": true
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,15 @@
{
"name": "gemini-web-cli",
"version": "1.0.0",
"description": "CLI tool for AI image generation and session management via Gemini web interface",
"type": "module",
"main": "cli.js",
"scripts": {
"generate": "node cli.js generate",
"download": "node cli.js download",
"status": "node cli.js status"
},
"dependencies": {
"puppeteer-core": "^24.0.0"
}
}
@@ -0,0 +1,412 @@
import puppeteer from 'puppeteer-core';
import { config } from '../config.js';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const SESSIONS_FILE = path.join(process.cwd(), 'sessions.json');
/**
* Session management for multi-tab concurrency.
* Each session maps to a unique Gemini chat tab.
*/
/**
* Load or initialize sessions registry.
* @returns {Record<string, {tabId: string}>}
*/
function loadSessions() {
try {
if (fs.existsSync(SESSIONS_FILE)) {
return JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
}
} catch {}
return {};
}
/**
* Save sessions registry.
* @param {Record<string, {tabId: string}>} sessions
*/
function saveSessions(sessions) {
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2));
}
/**
* Generate a unique session ID.
*/
function generateSessionId() {
return crypto.randomBytes(4).toString('hex');
}
/**
* Connect to an existing Chrome instance via CDP.
* Does NOT launch or manage the browser.
* @param {object} options
* @param {string} [options.sessionId] - reuse an existing session/tab
* @param {boolean} [options.newTab] - force create a new tab
* @returns {Promise<{browser, page, cdp, sessionId: string, isNew: boolean}>}
*/
export async function connectBrowser({ sessionId, newTab } = {}) {
const browser = await puppeteer.connect({
browserURL: config.cdpUrl,
defaultViewport: null,
});
let page;
let isNew = false;
const sessions = loadSessions();
if (sessionId && sessions[sessionId] && !newTab) {
// Reuse existing session: find the matching tab
const pages = await browser.pages();
const info = sessions[sessionId];
page = pages.find(p => p.target()._targetId === info.tabId);
if (page) {
const cdp = await page.createCDPSession();
await setupDownloadBehavior(cdp);
log('Reused session', sessionId);
return { browser, page, cdp, sessionId, isNew: false };
}
// Tab lost - try to recover via chatUrl
if (info.chatUrl) {
log('Session', sessionId, 'tab lost, recovering via chat URL');
page = await browser.newPage();
const cdp = await page.createCDPSession();
await setupDownloadBehavior(cdp);
try {
await page.goto(info.chatUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
await sleep(3000);
log('Recovered session', sessionId, 'via chat URL');
const newTabId = page.target()._targetId;
sessions[sessionId].tabId = newTabId;
sessions[sessionId].url = info.chatUrl;
saveSessions(sessions);
return { browser, page, cdp, sessionId, isNew: false };
} catch (e) {
log('Chat URL recovery failed, creating fresh tab');
sessions[sessionId].tabId = page.target()._targetId;
saveSessions(sessions);
return { browser, page, cdp, sessionId, isNew: true };
}
}
log('Session', sessionId, 'tab not found, creating new one');
}
// Always create a new tab to avoid context pollution from previous conversations
page = await browser.newPage();
isNew = true;
// Anti-detection: hide automation signals from websites (especially Gemini)
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
window.chrome = { runtime: {} };
});
const cdp = await page.createCDPSession();
await setupDownloadBehavior(cdp);
// Generate new session ID if not provided
sessionId = sessionId || generateSessionId();
const tabId = page.target()._targetId;
sessions[sessionId] = { tabId, url: page.url(), chatUrl: '' };
saveSessions(sessions);
log('Connected to tab', tabId, isNew ? '(new tab)' : '(existing Gemini tab)');
return { browser, page, cdp, sessionId, isNew };
}
/**
* Extract and save the Gemini chat URL from the current page.
* After a response, Gemini navigates to a conversation URL like:
* https://gemini.google.com/app/{conversation_id}
* @param {string} sessionId
* @param {import('puppeteer-core').Page} page
*/
export async function captureChatUrl(sessionId, page) {
const url = page.url();
const sessions = loadSessions();
if (sessions[sessionId]) {
sessions[sessionId].url = url;
// Only capture if it looks like a conversation URL
if (url.includes('/app/') && !url.endsWith('/app')) {
sessions[sessionId].chatUrl = url;
}
saveSessions(sessions);
}
return url;
}
/**
* Close a session's tab and remove from registry.
* @param {string} sessionId
* @returns {Promise<{success: boolean, message: string}>}
*/
export async function closeSession(sessionId) {
const sessions = loadSessions();
if (!sessions[sessionId]) {
return { success: false, message: `Session ${sessionId} not found` };
}
const browser = await puppeteer.connect({
browserURL: config.cdpUrl,
defaultViewport: null,
});
try {
const pages = await browser.pages();
const info = sessions[sessionId];
const page = pages.find(p => p.target()._targetId === info.tabId);
if (page) {
await page.close();
}
delete sessions[sessionId];
saveSessions(sessions);
log('Session closed:', sessionId);
return { success: true, message: `Session ${sessionId} closed` };
} finally {
await browser.disconnect();
}
}
/**
* Find an open tab matching a chatUrl and register it as a new session.
* Useful when the session record was lost but the browser tab is still open.
* @param {object} options
* @param {string} options.chatUrl - Full Gemini chat URL to match against
* @param {boolean} [options.openIfNotFound] - open a new tab if no match found
* @returns {Promise<{success: boolean, sessionId?: string, tabId?: string, url?: string, message?: string, isNew?: boolean}>}
*/
export async function findSessionByChatUrl({ chatUrl, openIfNotFound }) {
const browser = await puppeteer.connect({
browserURL: config.cdpUrl,
defaultViewport: null,
});
try {
const pages = await browser.pages();
// Try exact match on conversation ID
for (const page of pages) {
const url = page.url();
if (!url.includes('gemini.google.com/app/')) continue;
const urlConvId = url.match(/\/app\/([a-f0-9]+)/i)?.[1];
const chatConvId = chatUrl.match(/\/app\/([a-f0-9]+)/i)?.[1];
if (urlConvId && chatConvId && urlConvId === chatConvId) {
const tabId = page.target()._targetId;
const sessionId = generateSessionId();
const sessions = loadSessions();
sessions[sessionId] = { tabId, url, chatUrl };
saveSessions(sessions);
return {
success: true,
sessionId,
tabId,
url,
};
}
}
// Not found — optionally open a new tab
if (openIfNotFound) {
const page = await browser.newPage();
const cdp = await page.createCDPSession();
try {
await cdp.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: config.downloadDir,
eventsEnabled: true,
});
} catch {}
log('Opening new tab for chatUrl:', chatUrl);
await page.goto(chatUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
await sleep(3000);
const tabId = page.target()._targetId;
const sessionId = generateSessionId();
const sessions = loadSessions();
sessions[sessionId] = { tabId, url: chatUrl, chatUrl };
saveSessions(sessions);
return {
success: true,
sessionId,
tabId,
url: chatUrl,
isNew: true,
};
}
return {
success: false,
message: `No open tab found matching chatUrl: ${chatUrl}`,
};
} finally {
await browser.disconnect();
}
}
/**
* List all active sessions.
* @returns {Promise<Array<{sessionId: string, url: string, chatUrl: string}>>}
*/
export async function listSessions() {
const sessions = loadSessions();
const result = [];
try {
const browser = await puppeteer.connect({
browserURL: config.cdpUrl,
defaultViewport: null,
});
const targets = await browser.targets();
const tabIds = new Set(targets.map(t => t._targetId));
for (const [id, info] of Object.entries(sessions)) {
result.push({
sessionId: id,
url: info.url || 'navigating',
chatUrl: info.chatUrl || '',
active: tabIds.has(info.tabId),
});
}
await browser.disconnect();
} catch {}
return result;
}
async function setupDownloadBehavior(cdp) {
try {
await cdp.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: config.downloadDir,
eventsEnabled: true,
});
} catch (e) {
// May fail if not supported
}
}
/**
* Check if user is logged into Gemini.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<{loggedIn: boolean, account?: string}>}
*/
export async function checkLogin(page) {
const result = await page.evaluate(() => {
// Check for Google account avatar / menu
const accountBtn = document.querySelector(
'[aria-label="Google 账号"], [aria-label="Google Account"], ' +
'div[data-account-index], c-wiz[jsmodel][data-l] img'
);
// Check for sign-in prompts
const signInText = document.body.innerText.includes('Sign in') ||
document.body.innerText.includes('登录') ||
document.body.innerText.includes('登入');
// Check URL for sign-in redirect
const isSignInPage = window.location.href.includes('accounts.google.com') ||
window.location.href.includes('ServiceLogin');
return {
hasAccountBtn: !!accountBtn,
hasSignInPrompt: signInText,
isSignInPage,
url: window.location.href,
title: document.title,
};
});
if (result.isSignInPage || (result.hasSignInPrompt && !result.hasAccountBtn)) {
return { loggedIn: false };
}
// Try to get account name
const account = await page.evaluate(() => {
// Gemini shows account info in the sidebar or top-right
const avatar = document.querySelector(
'img[jsname], button[data-account] img, ' +
'[data-profile-index] img, [jsname] img'
);
// Try aria-label on avatar button
const avatarBtn = document.querySelector(
'button img[jsname]'
)?.closest('button');
if (avatarBtn) {
return avatarBtn.getAttribute('aria-label') || '';
}
return '';
});
return {
loggedIn: true,
account: account || undefined,
};
}
/**
* Navigate to Gemini page and wait for it to be ready.
* @param {import('puppeteer-core').Page} page
*/
export async function navigateToGemini(page) {
// If already on Gemini, don't navigate away
if (page.url().includes('gemini.google.com')) {
// Wait for editor to be ready
try {
await page.waitForSelector('[contenteditable="true"]', { timeout: 15000, visible: true });
} catch {
// Editor not found, let the caller handle it
log('Warning: Editor not found on existing Gemini page');
}
await sleep(2000);
return;
}
// New tab: navigate to Gemini with relaxed timeout
await page.goto(config.geminiUrl, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for editor to be ready
try {
await page.waitForSelector('[contenteditable="true"]', { timeout: 30000, visible: true });
log('Editor found after navigation');
} catch {
log('Warning: Editor not found after navigation, page may need more time');
}
// Wait for page to settle
await sleep(3000);
}
export function log(...args) {
// In JSON mode, suppress stderr logs — all output goes to stdout as NDJSON
if (process.env.GEMINI_JSON_MODE === '1') return;
process.stderr.write('[gemini-web-cli] ' + args.join(' ') + '\n');
}
/**
* Emit a structured progress event as NDJSON to stdout.
* In JSON mode: writes NDJSON line to stdout.
* In human mode: writes human-readable message to stderr via log().
* @param {object} event - { type: "progress"|"success"|"error", step?, state?, message?, ... }
*/
export function emit(event) {
if (process.env.GEMINI_JSON_MODE === '1') {
process.stdout.write(JSON.stringify(event) + '\n');
return;
}
// Human mode: just log the message
if (event.message) {
log(event.message);
}
}
@@ -0,0 +1,847 @@
import { pageStatus, detectPageState } from './status.js';
import { log, emit } from './browser.js';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* Wait for the Gemini chat editor to be visible.
* @param {import('puppeteer-core').Page} page
* @param {number} timeout
* @returns {Promise<import('puppeteer-core').ElementHandle>}
*/
async function waitForEditor(page, timeout = 30000) {
try {
const el = await page.waitForSelector('[contenteditable="true"]', { timeout, visible: true });
return el;
} catch {
// Dump DOM state for debugging
const dump = await page.evaluate(() => {
const body = document.body;
return {
title: document.title,
url: window.location.href,
hasContentEditable: document.querySelectorAll('[contenteditable="true"]').length,
bodyText: body.innerText.substring(0, 500),
};
});
log('Editor not found. DOM state:', JSON.stringify(dump, null, 2));
throw new Error('Gemini chat editor not found. The page may not be fully loaded.');
}
}
/**
* Type prompt into Gemini chat input.
* @param {import('puppeteer-core').Page} page
* @param {string} prompt
*/
export async function typePrompt(page, prompt) {
// Wait for editor to be ready first
await waitForEditor(page, 15000);
const selectors = [
'textarea',
'[contenteditable="true"]',
'div[contenteditable]',
];
for (const selector of selectors) {
try {
const el = await page.$(selector);
if (el && await el.isVisible()) {
await el.click({ clickCount: 3 });
// el.type() sends Enter for \n which submits the form.
// Split on newlines and type each line, using Shift+Enter for line breaks.
const lines = prompt.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i]) {
await page.keyboard.type(lines[i], { delay: 20 });
}
if (i < lines.length - 1) {
await page.keyboard.down('Shift');
await page.keyboard.press('Enter');
await page.keyboard.up('Shift');
}
}
return;
}
} catch (e) {
// try next selector
}
}
throw new Error('Chat input element not found');
}
/**
* Upload reference images by dispatching paste events on the Quill clipboard div.
* Gemini's editor uses Quill, which has a .ql-clipboard element for paste handling.
*
* @param {import('puppeteer-core').Page} page
* @param {string[]} imagePaths - array of local file paths (max 5)
*/
export async function uploadImages(page, imagePaths) {
const MAX_IMAGES = 5;
const paths = imagePaths.slice(0, MAX_IMAGES);
if (paths.length > MAX_IMAGES) {
log(`Warning: Only first ${MAX_IMAGES} images will be used`);
}
// Wait for editor to be ready
const editor = await waitForEditor(page);
await editor.click();
await sleep(1000);
// Dispatch paste for each image
for (let i = 0; i < paths.length; i++) {
const imagePath = paths[i];
const { readFileSync } = await import('fs');
const imageBuffer = readFileSync(imagePath);
const base64 = imageBuffer.toString('base64');
const ext = imagePath.split('.').pop().toLowerCase();
const mimeType = ext === 'png' ? 'image/png' :
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
ext === 'gif' ? 'image/gif' :
ext === 'webp' ? 'image/webp' : 'image/png';
const fileName = imagePath.split('/').pop();
// Dispatch paste on both the editor and the ql-clipboard
const result = await page.evaluate(({ base64, mimeType, fileName }) => {
const binary = atob(base64);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
array[i] = binary.charCodeAt(i);
}
const blob = new Blob([array], { type: mimeType });
const file = new File([blob], fileName, { type: mimeType });
const dt = new DataTransfer();
dt.items.add(file);
// Dispatch on the contenteditable editor
const editor = document.querySelector('[contenteditable="true"]');
if (!editor) return 'no editor';
// Also dispatch on the Quill clipboard div
const clipboard = document.querySelector('.ql-clipboard');
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(pasteEvent);
if (clipboard) {
clipboard.dispatchEvent(pasteEvent);
}
return 'ok';
}, { base64, mimeType, fileName });
if (result !== 'ok') {
throw new Error('Failed to paste image: ' + result);
}
emit({ type: 'progress', step: 'paste', message: `Pasted image ${i + 1}/${paths.length} (${fileName})` });
// Wait between pastes for Gemini to process each one
await sleep(2000);
}
// Wait for ALL attachments to appear and Gemini to fully register them
log('Waiting for attachments to be ready...');
const ok = await waitForAttachmentsReady(page, paths.length);
if (!ok) {
const dump = await page.evaluate(() => {
const editor = document.querySelector('[contenteditable="true"]');
const clipboard = document.querySelector('.ql-clipboard');
let result = `editor children: ${editor?.children.length || 0}\n`;
result += `editor HTML: ${editor?.innerHTML.substring(0, 500) || 'none'}\n`;
result += `clipboard HTML: ${clipboard?.innerHTML.substring(0, 500) || 'none'}\n`;
result += `.attachment-preview-wrapper: ${document.querySelectorAll('.attachment-preview-wrapper').length}\n`;
result += `uploader-file-preview: ${document.querySelectorAll('uploader-file-preview').length}\n`;
result += `[class*="uploader"]: ${document.querySelectorAll('[class*="uploader"]').length}\n`;
result += `.file-preview-chip: ${document.querySelectorAll('.file-preview-chip').length}\n`;
return result;
});
log('DOM dump:\n' + dump);
throw new Error(`Image upload failed: expected ${paths.length} but none appeared`);
}
emit({ type: 'progress', step: 'attachments_ready', message: `All ${paths.length} image(s) confirmed attached and rendered` });
}
/**
* Poll until attachment previews appear and Gemini has finished processing them.
* The key indicator: each image shows a cancel-button AND the preview img has
* a valid blob: src — meaning Gemini has fully registered the attachment.
* @param {import('puppeteer-core').Page} page
* @param {number} expectedCount
* @param {number} timeout
* @returns {Promise<boolean>}
*/
async function waitForAttachmentsReady(page, expectedCount, timeout = 30000) {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const ready = await page.evaluate((expected) => {
// Get all file preview containers
const previews = document.querySelectorAll('uploader-file-preview');
if (previews.length < expected) {
return false;
}
// Each preview must have BOTH: a cancel button and an img with a valid blob: src
let readyCount = 0;
for (const preview of previews) {
const hasCancel = preview.querySelector('button[data-test-id="cancel-button"]');
const img = preview.querySelector('img[data-test-id="image-preview"]');
const hasBlobSrc = img && img.src && img.src.startsWith('blob:');
if (hasCancel && hasBlobSrc) {
readyCount++;
}
}
if (readyCount >= expected) return true;
// Fallback: check the wrapper-level structure
const wrappers = document.querySelectorAll('.attachment-preview-wrapper');
const fallbackCount = Array.from(wrappers).filter(w => {
const hasCancel = w.querySelector('[data-test-id="cancel-button"], button[aria-label*="移除文件"]');
const img = w.querySelector('img[data-test-id="image-preview"]');
const hasBlobSrc = img && img.src && img.src.startsWith('blob:');
return hasCancel && hasBlobSrc;
}).length;
if (fallbackCount >= expected) return true;
return false;
}, expectedCount);
if (ready) {
// Extra settle time after all buttons detected
await sleep(2000);
return true;
}
await sleep(500);
}
return false;
}
/**
* Click the send button to submit the prompt.
* @param {import('puppeteer-core').Page} page
*/
export async function sendPrompt(page) {
const sent = await page.evaluate(() => {
// Priority 1: Send button by aria-label
const buttons = Array.from(document.querySelectorAll('button'));
for (const b of buttons) {
const label = b.getAttribute('aria-label') || b.getAttribute('title') || '';
if (label.includes('Send') || label.includes('发送') ||
label.includes('Arrow') || label.includes('arrow')) {
// Check if button is enabled (not disabled)
if (!b.disabled && b.offsetParent !== null) {
b.click();
return 'clicked';
}
}
}
// Priority 2: SVG-based send button (common in Material Design)
for (const b of buttons) {
const svg = b.querySelector('svg');
if (svg && b.offsetParent !== null && !b.disabled) {
// Check if near the textarea
const textarea = document.querySelector('textarea') ||
document.querySelector('[contenteditable="true"]');
if (textarea) {
const textareaRect = textarea.getBoundingClientRect();
const btnRect = b.getBoundingClientRect();
const dist = Math.abs(textareaRect.top - btnRect.top);
if (dist < 100) {
b.click();
return 'clicked';
}
}
}
}
// Priority 3: Press Enter in the textarea
const textarea = document.querySelector('textarea');
if (textarea) {
textarea.focus();
textarea.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
bubbles: true,
}));
return 'enter';
}
return 'none';
});
if (sent === 'none') {
throw new Error('Could not find send button or input area');
}
await sleep(1500);
}
/**
* Select the image generation tool from the toolbox drawer.
* Uses the icon name "photo_prints" to identify the tool, which is language-independent.
* Falls back to text matching for "制作图片" / "Generate image" / etc.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<boolean>} - whether the tool was selected successfully
*/
export async function selectTool(page) {
const result = await page.evaluate(() => {
const toolboxBtn = document.querySelector('.toolbox-drawer-button');
if (!toolboxBtn) {
return { success: false, error: 'Toolbox button not found' };
}
// Check if already selected
if (toolboxBtn.className.includes('has-selected-item')) {
return { success: true, alreadySelected: true };
}
// Click to open toolbox drawer
toolboxBtn.click();
return { success: true, opened: true };
});
if (!result.success) {
log('selectTool failed:', result.error);
return false;
}
if (result.alreadySelected) {
return true;
}
// Wait for the menu to open
await sleep(500);
// Click the image tool item — match by icon first (language-independent), then by text
const selected = await page.evaluate(() => {
const toolItems = Array.from(document.querySelectorAll('.toolbox-drawer-item-list-button'));
for (const item of toolItems) {
const icon = item.querySelector('mat-icon[data-mat-icon-name="photo_prints"]');
if (icon) {
item.click();
return 'icon';
}
}
// Fallback: match by text patterns
for (const item of toolItems) {
const text = item.innerText?.trim().toLowerCase();
if (text && (text.includes('制作图片') || text.includes('生成图片') ||
text.includes('generate image') || text.includes('create image') ||
text.includes('imagen'))) {
item.click();
return 'text';
}
}
return null;
});
if (selected) {
await sleep(1000);
return true;
}
log('Image generation tool not found in toolbox drawer');
return false;
}
/**
* Select a specific tool by name from the toolbox drawer.
* @param {import('puppeteer-core').Page} page
* @param {string} toolName - e.g. "制作图片", "Canvas", "Deep Research"
* @returns {Promise<boolean>}
*/
export async function selectToolByName(page, toolName) {
const result = await page.evaluate(() => {
const toolboxBtn = document.querySelector('.toolbox-drawer-button');
if (!toolboxBtn) {
return { success: false, error: 'Toolbox button not found' };
}
// Check if already selected
if (toolboxBtn.className.includes('has-selected-item')) {
return { success: true, alreadySelected: true };
}
toolboxBtn.click();
return { success: true, opened: true };
});
if (!result.success) {
log('selectToolByName failed:', result.error);
return false;
}
if (result.alreadySelected) {
return true;
}
await sleep(500);
const selected = await page.evaluate((toolName) => {
const toolItems = Array.from(document.querySelectorAll('.toolbox-drawer-item-list-button'));
for (const item of toolItems) {
const text = item.innerText?.trim();
if (text && text.includes(toolName)) {
item.click();
return true;
}
}
return false;
}, toolName);
if (selected) {
await sleep(1000);
return true;
}
log(`Tool "${toolName}" not found in toolbox drawer`);
return false;
}
/**
* Full generation flow.
* Order: 1) upload images → 2) select image tool → 3) type prompt → 4) send
* @param {import('puppeteer-core').Page} page
* @param {{prompt: string, images?: string[], timeout?: number, tool?: string}} options
* @returns {Promise<{status: string, message: string, preview?: string}>}
*/
export async function generate(page, { prompt, images, timeout, isContinuedSession, tool }) {
// Capture existing download buttons before this generation (for continued sessions)
const btnSnapshot = await snapshotDownloadButtons(page);
// Step 1: Upload images first
if (images && images.length > 0) {
await uploadImages(page, images);
}
// Step 2: Select image generation tool (default), or specified tool
if (tool) {
await selectToolByName(page, tool);
} else {
await selectTool(page);
}
// Step 3: Type prompt after images are attached
emit({ type: 'progress', step: 'typing', message: 'Typing prompt...' });
await typePrompt(page, prompt);
// Step 4: Send
emit({ type: 'progress', step: 'sending', message: 'Sending prompt...' });
await sendPrompt(page);
emit({ type: 'progress', step: 'waiting', message: 'Waiting for generation to complete...' });
const result = await waitForGenerationComplete(page, timeout, isContinuedSession, btnSnapshot.count, btnSnapshot.bottomY);
// Return the pre-generation button count so the caller knows how many
// buttons existed before this generation. This is needed to distinguish
// old download buttons from newly generated ones.
result.existingButtonCount = btnSnapshot.count;
return result;
}
/**
* Wait for generation to complete.
* @param {import('puppeteer-core').Page} page
* @param {number} timeout
* @param {boolean} isContinuedSession - if true, wait for NEW images below existing ones
* @param {number} [prevImgCount] - existing image count before this generation
* @param {number} [prevBtnBottomY] - bottom Y position of existing download buttons
*/
async function waitForGenerationComplete(page, timeout = 120000, isContinuedSession = false, prevImgCount, prevBtnBottomY) {
const startTime = Date.now();
let prevState = pageStatus.IDLE;
// For continued sessions: Gemini keeps ALL previous responses visible, including
// their download buttons. We can't wait for old buttons to disappear — they stay.
// Instead, poll for NEW content appearing below the existing results.
if (isContinuedSession) {
log(`Monitoring for new generation (existing images: ${prevImgCount || 0}, btn bottomY: ${prevBtnBottomY?.toFixed(0) || 'none'})`);
const maxWait = timeout;
while (Date.now() - startTime < maxWait) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const info = await page.evaluate((prevCount, prevBottomY) => {
// Count all generated images
const allImages = document.querySelectorAll('img');
let generatedImageCount = 0;
let maxImgBottom = 0;
let newImageBelowOld = false;
for (const img of allImages) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReference = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReference) {
generatedImageCount++;
const rect = img.getBoundingClientRect();
if (rect.bottom > maxImgBottom) maxImgBottom = rect.bottom;
// Check if this image is below the old bottom boundary
if (prevBottomY > 0 && rect.top > prevBottomY + 200) {
newImageBelowOld = true;
}
}
}
// Count download buttons
const allBtns = document.querySelectorAll('button.generated-image-button');
const visibleBtns = Array.from(allBtns).filter(b => b.offsetParent !== null);
let newBtnBelowOld = false;
let maxBtnBottom = 0;
for (const btn of visibleBtns) {
const rect = btn.getBoundingClientRect();
if (rect.bottom > maxBtnBottom) maxBtnBottom = rect.bottom;
if (prevBottomY > 0 && rect.top > prevBottomY + 100) {
newBtnBelowOld = true;
}
}
// Check if we see a new download button that wasn't there before
const btnCountIncreased = prevCount !== undefined && visibleBtns.length > prevCount;
return {
generatedImageCount,
btnCount: visibleBtns.length,
btnCountIncreased,
newBtnBelowOld,
newImageBelowOld,
maxImgBottom,
maxBtnBottom,
hasNewBtn: visibleBtns.length > 0,
inputReady: !!(document.querySelector('[contenteditable="true"]') ||
document.querySelector('textarea')),
// Check if the LATEST response footer shows "complete"
// In multi-turn sessions there may be multiple responses — we need the bottom one.
isComplete: (() => {
const footers = Array.from(document.querySelectorAll('.response-footer'));
const latest = footers[footers.length - 1];
return !!(latest && latest.classList.contains('complete'));
})(),
// Check for <generated-image> DOM elements in the latest response
hasGeneratedImageElement: (() => {
const latestResponse = document.querySelector('model-response:last-of-type, .response-container:last-of-type');
const container = latestResponse || document;
return container.querySelectorAll('generated-image').length > 0;
})(),
// Check if generation is still in progress
isGenerating: !!document.querySelector('bard-avatar .thinking, bard-avatar.thinking') ||
!!document.querySelector('.avatar_spinner_animation[style*="opacity: 1"], .avatar_spinner_animation[style*="visibility: visible"]') ||
!!document.querySelector('.send-button.stop, .stop-icon, button[aria-label="停止回答"]'),
// Check for error indicators — only from conversation area
text: (() => { const e = document.querySelector('.content-container, .main-content, main'); return (e ? e.innerText : document.body.innerText).substring(0, 200); })(),
title: document.title,
};
}, prevImgCount || 0, prevBtnBottomY || 0);
// Check for errors — use specific regex patterns to avoid false positives
// from generic UI text like tooltips, footers, and sidebar links
const errorPatterns = [
/Something went wrong/i,
/出了点问题/,
/Unable to generate/i,
/Something unexpected happened/i,
/发生意外情况/,
];
let hasError = false;
let errorType = '';
for (const pattern of errorPatterns) {
if (info.text.match(pattern)) {
hasError = true;
errorType = pattern.source;
break;
}
}
if (hasError) {
log(`Detected error: ${errorType}`);
return {
status: 'error',
message: info.title || `Generation failed: ${errorType}`,
};
}
// Determine state for logging
let state = prevState;
if (info.btnCountIncreased || info.newBtnBelowOld) {
state = 'DONE';
} else if (info.newImageBelowOld) {
state = 'GENERATING';
}
if (state !== prevState) {
emit({ type: 'progress', step: 'state', prevState, state, elapsed: parseFloat(elapsed),
message: `State: ${prevState}${state} (${elapsed}s)` });
prevState = state;
}
// DONE: new download button appeared below old content AND input is ready
if (info.btnCountIncreased || info.newBtnBelowOld) {
if (info.inputReady) {
log(`Generation completed - new button detected (count: ${prevImgCount || 0}${info.btnCount})`);
await waitForAllButtonsReady(page, info.btnCount, 15000);
return {
status: 'success',
message: 'Image generation completed',
};
}
}
// DONE: response footer shows "complete" AND generated-image elements exist AND input is ready
if (info.isComplete && info.inputReady && (info.btnCount > 0 || info.hasGeneratedImageElement)) {
log(`Generation completed (footer complete, btnCount: ${info.btnCount}, hasGenElement: ${info.hasGeneratedImageElement})`);
await waitForAllButtonsReady(page, info.btnCount, 15000);
return {
status: 'success',
message: 'Image generation completed',
};
}
// TEXT_ONLY: input is ready, generation is NOT in progress (stop button gone),
// footer shows "complete", and NO <generated-image> elements exist
// AND no new images appeared
const isStillGenerating = info.isGenerating;
if (info.inputReady && !isStillGenerating && info.isComplete && !info.hasGeneratedImageElement &&
!info.btnCountIncreased && !info.newBtnBelowOld && !info.newImageBelowOld) {
const text = await getLatestResponseText(page);
if (text && text.length > 50) {
return {
status: 'text_only',
message: 'Only text was generated, no images. Response: ' + text,
};
}
}
await sleep(2000);
}
return {
status: 'timeout',
message: `Generation did not complete within ${timeout / 1000}s`,
};
}
while (Date.now() - startTime < timeout) {
const state = await detectPageState(page);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (state !== prevState) {
emit({ type: 'progress', step: 'state', prevState, state, elapsed: parseFloat(elapsed),
message: `State: ${prevState}${state} (${elapsed}s)` });
prevState = state;
}
if (state === pageStatus.DONE) {
emit({ type: 'progress', step: 'complete', message: 'Generation completed successfully' });
// Wait for all download buttons to stabilize before returning
await waitForAllButtonsReady(page, 0, 15000);
return {
status: 'success',
message: 'Image generation completed',
};
}
if (state === pageStatus.TEXT_ONLY) {
const text = await getLatestResponseText(page);
return {
status: 'text_only',
message: 'Only text was generated, no images. Response: ' + text,
};
}
if (state === pageStatus.ERROR) {
log('Generation failed with error');
return {
status: 'error',
message: await getErrorMessage(page),
};
}
if (state === pageStatus.PAGE_ERROR) {
log('Page encountered an error');
return {
status: 'page_error',
message: 'The page encountered an error. It may need to be refreshed.',
};
}
await sleep(2000);
}
log('Generation timed out');
return {
status: 'timeout',
message: `Generation did not complete within ${timeout / 1000}s`,
};
}
/**
* Snapshot the current state of generated images on the page.
* Call this BEFORE starting a new generation to distinguish
* old images from newly generated ones.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<Set<string>>} - set of image srcs that existed before generation
*/
export async function snapshotExistingImages(page) {
return page.evaluate(() => {
const images = document.querySelectorAll('img');
const srcs = new Set();
for (const img of images) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReferenceUpload = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReferenceUpload && img.src) {
srcs.add(img.src);
}
}
return [...srcs];
});
}
/**
* Wait until the count of download buttons stabilizes — i.e., no new buttons
* appear within the settle window. This ensures all generated images have
* finished rendering their download buttons before we proceed to the download phase.
* @param {import('puppeteer-core').Page} page
* @param {number} initialCount - button count when DONE was first detected
* @param {number} timeout - max time to wait for buttons to stabilize
*/
async function waitForAllButtonsReady(page, initialCount, timeout = 15000) {
const startTime = Date.now();
let lastCount = initialCount;
let stableSince = Date.now();
const STABLE_WINDOW = 2000; // 2s with no new buttons = stable
while (Date.now() - startTime < timeout) {
await sleep(1000);
const count = await page.evaluate(() => {
const buttons = document.querySelectorAll('button.generated-image-button');
return Array.from(buttons).filter(b => b.offsetParent !== null).length;
});
if (count > lastCount) {
lastCount = count;
stableSince = Date.now();
} else if (Date.now() - stableSince >= STABLE_WINDOW) {
break; // stable for long enough
}
}
}
/**
* Count existing download buttons and their vertical positions.
* Used to detect when NEW buttons appear below the existing ones.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<{count: number, bottomY: number}>}
*/
export async function snapshotDownloadButtons(page) {
return page.evaluate(() => {
const buttons = document.querySelectorAll('button.generated-image-button');
const visible = Array.from(buttons).filter(b => b.offsetParent !== null);
if (visible.length === 0) {
return { count: 0, bottomY: 0 };
}
const bottomY = Math.max(...visible.map(b => b.getBoundingClientRect().bottom));
return { count: visible.length, bottomY };
});
}
/**
* Extract generated image URLs from the page.
* @param {import('puppeteer-core').Page} page
* @param {Set<string>} [existingSrcs] - srcs of images that existed before this generation
* @returns {Promise<Array<{url: string, width: number, height: number}>>}
*/
export async function extractGeneratedImages(page, existingSrcs) {
return page.evaluate((excludeSrcs) => {
const images = document.querySelectorAll('img');
const exclude = new Set(excludeSrcs || []);
const results = [];
for (const img of images) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReferenceUpload = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReferenceUpload) {
// Skip images that existed before this generation
if (exclude.has(img.src)) continue;
results.push({
url: img.src,
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}
return results;
}, existingSrcs || []);
}
/**
* Get error message from page.
*/
async function getErrorMessage(page) {
return page.evaluate(() => {
const title = document.title;
// Content policy blocks - use title as message
if (title.includes('限制') || title.includes('blocked') ||
title.includes('policy') || title.includes('Blocked')) {
return title;
}
return 'Unknown error during generation';
});
}
/**
* Extract the latest AI response text from the page.
* Walks through message containers and returns the last substantial response.
*/
async function getLatestResponseText(page) {
return page.evaluate(() => {
// Try Gemini's response containers first
const responseContainers = document.querySelectorAll(
'[class*="message-content"], [class*="response"], [class*="assistant"], ' +
'mat-card, c-wiz[jsname]'
);
// Get the last one with substantial text
for (let i = responseContainers.length - 1; i >= 0; i--) {
const text = responseContainers[i].innerText?.trim();
if (text && text.length > 20) {
return text;
}
}
// Fallback: conversation area text only, excluding sidebar
const el = document.querySelector('.content-container, .main-content, main');
return (el ? el.innerText : document.body.innerText)?.trim() || '';
});
}
export { pageStatus, detectPageState };
@@ -0,0 +1,156 @@
import path from 'path';
import fs from 'fs';
import { config } from '../config.js';
import { log, emit } from './browser.js';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* Take a screenshot of the current Gemini page.
* @param {import('puppeteer-core').Page} page
* @param {object} options
* @param {boolean} [options.full] - full page screenshot
* @param {string} [options.outputPath] - custom output path
* @returns {Promise<{path: string}>}
*/
export async function takeScreenshot(page, { full = false, outputPath, quality = 60 } = {}) {
fs.mkdirSync(config.screenshotDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `screenshot-${timestamp}.jpg`;
const filePath = outputPath || path.join(config.screenshotDir, filename);
await page.screenshot({
path: filePath,
fullPage: full,
type: 'jpeg',
quality,
});
return { path: filePath };
}
/**
* Count existing download buttons and return their count.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<{count: number}>}
*/
export async function snapshotDownloadButtons(page) {
const buttons = await page.$$('button.generated-image-button');
const visible = [];
for (const btn of buttons) {
if (await btn.isVisible()) {
visible.push(btn);
}
}
return { count: visible.length };
}
/**
* Download generated images by clicking download buttons.
* Waits for all images to finish loading before clicking, then scrolls
* gently to each button one at a time.
* @param {import('puppeteer-core').Page} page
* @param {import('puppeteer-core').CDPSession} cdp
* @param {object} options
* @param {number} [options.existingButtonCount] - number of download buttons before this generation
* @param {number} [options.timeout] - max wait per download in ms (default 120000)
* @returns {Promise<Array<{path: string}>>}
*/
export async function downloadViaButtons(page, cdp, { existingButtonCount = 0, newestOnly = false, timeout = 120000 } = {}) {
fs.mkdirSync(config.downloadDir, { recursive: true });
// Get list of files before download
const filesBefore = new Set(fs.readdirSync(config.downloadDir));
// Wait for all images on the page to finish loading and decoding.
// This is critical: Gemini generates multiple large images, and interacting
// with the page while they're still decoding can cause tab crashes.
log('Waiting for all images to finish loading...');
try {
await page.evaluate(async () => {
const images = Array.from(document.images);
const promises = images
.filter(img => img.src && !img.complete)
.map(img => img.decode().catch(() => {}));
await Promise.all(promises);
});
emit({ type: 'progress', step: 'images_ready', message: 'All images loaded' });
} catch {
log('Image loading check timed out, proceeding anyway');
}
// Find all download buttons — both visible and in the DOM
const allBtns = await page.$$('button.generated-image-button');
if (allBtns.length === 0) {
throw new Error(
'No generated image download button found. ' +
'Make sure an image has been generated first.'
);
}
// Determine which buttons to click
let targetBtns;
if (newestOnly) {
// Only click the very last (newest) button
targetBtns = allBtns.slice(-1);
} else {
// Skip the ones that existed before generation
targetBtns = allBtns.slice(existingButtonCount);
}
if (targetBtns.length === 0) {
log('No new download buttons detected (existing count:', existingButtonCount + ')');
return [];
}
log(`Clicking ${targetBtns.length} download button(s) (total in DOM: ${allBtns.length})`);
emit({ type: 'progress', step: 'download', buttonCount: targetBtns.length,
message: `Clicking ${targetBtns.length} download button(s)` });
// Click each button and wait for the download to complete
const results = [];
for (let i = 0; i < targetBtns.length; i++) {
const btn = targetBtns[i];
log(`Clicking download button ${i + 1}/${targetBtns.length}...`);
// Gently scroll the button into view before clicking
await btn.evaluate(el => el.scrollIntoView({ behavior: 'auto', block: 'center' }));
await sleep(500);
await btn.click();
// Poll for new file in download directory
const downloadPath = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Download timed out after ${timeout / 1000}s`));
}, timeout);
const poll = setInterval(() => {
const filesAfter = fs.readdirSync(config.downloadDir);
const newFiles = filesAfter.filter(f => !filesBefore.has(f) && !f.endsWith('.crdownload'));
if (newFiles.length > 0) {
clearInterval(poll);
clearTimeout(timer);
// Wait a bit for file to finish writing
setTimeout(() => {
// Update filesBefore so next download is tracked separately
for (const f of newFiles) {
filesBefore.add(f);
}
resolve(path.join(config.downloadDir, newFiles[0]));
}, 1000);
}
}, 500);
});
results.push({ path: downloadPath });
log(`Downloaded: ${downloadPath}`);
// Small delay between downloads
await sleep(500);
}
return results;
}
@@ -0,0 +1,391 @@
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
export const pageStatus = {
IDLE: 'IDLE',
GENERATING: 'GENERATING',
DONE: 'DONE',
TEXT_ONLY: 'TEXT_ONLY',
ERROR: 'ERROR',
PAGE_ERROR: 'PAGE_ERROR',
TIMEOUT: 'TIMEOUT',
};
/**
* Detect current page state — the single source of truth.
* @param {import('puppeteer-core').Page} page
* @returns {Promise<string>}
*/
export async function detectPageState(page) {
return page.evaluate(() => {
// Only extract text from the main conversation area, excluding the sidebar.
// Sidebar lives outside .content-container (its parent is chat-app).
const mainContentEl = document.querySelector('.content-container, .main-content, main');
const mainText = mainContentEl ? (mainContentEl.innerText || '') : document.body.innerText;
const bodyText = mainText;
// Page-level errors (check full page title for crashes)
if (document.title.includes('Crash') || document.title.includes('Aw, Snap') ||
document.querySelector('.crashed') || document.querySelector('.error-page')) {
return 'PAGE_ERROR';
}
// Content policy / safety blocks (these show in page title)
if (document.title.includes('限制') || document.title.includes('blocked') ||
document.title.includes('policy') || document.title.includes('Blocked')) {
return 'ERROR';
}
// Check if the prompt was returned to the input box with no conversation.
// This happens when Gemini rejects/resets the page — text is in the input
// but no user message was recorded in the chat area.
const inputEl = document.querySelector('[contenteditable="true"]') || document.querySelector('textarea');
const inputText = (inputEl?.innerText || inputEl?.value || '').trim();
const hasConversation = document.querySelector('[id*="user-query"], [id*="model-response"], [class*="message-content"]');
if (inputText && !hasConversation) {
// Page reset: prompt is in the input box but no chat was created
return 'ERROR';
}
// Try to find the latest AI response in the chat area.
// If we can't isolate it, fall back to full body text (less accurate).
let chatText = '';
// Gemini's chat responses are in specific containers
const responseContainers = [
// Try to find the last message-like container
...Array.from(document.querySelectorAll(
'[class*="message-content"], [class*="response"], [class*="assistant"], ' +
'mat-card, c-wiz[jsname]'
)),
];
// Get the last one that has substantial text
for (let i = responseContainers.length - 1; i >= 0; i--) {
const c = responseContainers[i];
const t = c.innerText?.trim();
if (t && t.length > 20) {
chatText = t;
break;
}
}
// If we couldn't find a specific response container, use body text
// but exclude the input area (bottom of page)
if (!chatText) {
chatText = bodyText;
}
// Generation in progress — check DOM-based indicators first, then fall back to text.
const thinkingAvatar = document.querySelector('bard-avatar .thinking, bard-avatar.thinking');
const visibleSpinner = document.querySelector('.avatar_spinner_animation[style*="opacity: 1"], .avatar_spinner_animation[style*="visibility: visible"]');
if (thinkingAvatar || visibleSpinner) {
return 'GENERATING';
}
// Check send button state: during generation it becomes a "stop" button
const stopBtn = document.querySelector('.send-button.stop, .stop-icon, button[aria-label="停止回答"]');
if (stopBtn) {
return 'GENERATING';
}
// Check if the LATEST response footer has the "complete" class.
// In multi-turn sessions there may be multiple responses — we need the bottom one.
const responseFooters = Array.from(document.querySelectorAll('.response-footer'));
const latestFooter = responseFooters[responseFooters.length - 1];
const isComplete = latestFooter && latestFooter.classList.contains('complete');
const ariaBusyFalse = document.querySelector('[aria-busy="false"]');
const generatingIndicators = [
'Generating', '生成中', 'Thinking', '思考中',
'Creating', '创作中', 'Loading', '加载中',
'Working on it', '正在处理',
];
for (const indicator of generatingIndicators) {
if (chatText.includes(indicator)) {
return 'GENERATING';
}
}
// If the footer shows "complete" and input is ready → DONE or still rendering
if (isComplete) {
const inputReady = document.querySelector('[contenteditable="true"]') ||
document.querySelector('textarea');
if (inputReady) {
// Check if there are generated images
const images = document.querySelectorAll('img');
let hasGeneratedImage = false;
let hasGeneratedImageElement = false;
for (const img of images) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReferenceUpload = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReferenceUpload) {
hasGeneratedImage = true;
break;
}
}
// Also check for <generated-image> DOM elements within the latest response
// (appear before <img> loads). Scope to latest response to avoid false
// positives from old responses in multi-turn sessions.
if (!hasGeneratedImage) {
const latestResponse = document.querySelector('model-response:last-of-type, .response-container:last-of-type');
const container = latestResponse || document;
const genEls = container.querySelectorAll('generated-image');
if (genEls.length > 0) {
hasGeneratedImageElement = true;
}
}
if (hasGeneratedImage) {
return 'DONE';
}
// <generated-image> element exists but image not loaded yet — keep polling
if (hasGeneratedImageElement) {
return 'GENERATING';
}
// No images and no <generated-image> element — text-only response
return 'TEXT_ONLY';
}
}
// Generation errors — use specific, multi-word patterns to avoid false positives
// from generic UI text like tooltips and footer links.
const errorIndicators = [
'Something went wrong',
'出了点问题',
'Unable to generate',
'Something unexpected happened',
'发生意外情况',
];
for (const indicator of errorIndicators) {
if (chatText.includes(indicator)) {
return 'ERROR';
}
}
// Check for generated image + download button
const images = document.querySelectorAll('img');
let hasGeneratedImage = false;
let hasDownloadButton = false;
for (const img of images) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReferenceUpload = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReferenceUpload) {
hasGeneratedImage = true;
}
}
const downloadBtn = document.querySelector('button.generated-image-button');
if (downloadBtn && downloadBtn.offsetParent !== null) {
hasDownloadButton = true;
}
// Only DONE when both generated image AND download button exist
if (hasGeneratedImage && hasDownloadButton) {
const inputReady = document.querySelector('[contenteditable="true"]') ||
document.querySelector('textarea');
if (inputReady) {
return 'DONE';
}
}
// Text-only generation: input is ready but no images appeared
// This means Gemini generated only text, which is an image generation failure
if (!hasGeneratedImage) {
const inputReady = document.querySelector('[contenteditable="true"]') ||
document.querySelector('textarea');
if (inputReady) {
// Check if there's substantive text response in the chat area
// that doesn't match generating indicators
let hasTextResponse = false;
if (chatText && chatText.length > 50) {
const generating = generatingIndicators.some(ind => chatText.includes(ind));
const hasErrors = errorIndicators.some(ind => chatText.includes(ind));
if (!generating && !hasErrors) {
hasTextResponse = true;
}
}
if (hasTextResponse) {
return 'TEXT_ONLY';
}
}
}
if (hasGeneratedImage) {
return 'GENERATING';
}
return 'IDLE';
});
}
/**
* Check the current state of the Gemini page.
* @param {import('puppeteer-core').Page} page
* @param {object} options
* @param {boolean} [options.waitForCompletion] - poll until DONE/ERROR
* @param {number} [options.timeout] - max ms to wait (default 300000)
* @param {function} [options.onStateChange] - callback(state, elapsed) called on each state transition
* @returns {Promise<{state: string, message: string, imageCount?: number, hasDownloadButton?: boolean, errorMessage?: string, polled?: boolean}>}
*/
export async function checkStatus(page, { waitForCompletion, timeout, onStateChange } = {}) {
if (waitForCompletion) {
const maxTime = timeout || 300000;
const startTime = Date.now();
let polls = 0;
let lastState = '';
while (Date.now() - startTime < maxTime) {
const result = await getFullStatus(page);
polls++;
if (result.state !== lastState) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
process.stderr.write(`[gemini-web-cli] Status: ${lastState || 'unknown'}${result.state} (${elapsed}s)\n`);
onStateChange?.(result.state, parseFloat(elapsed));
lastState = result.state;
}
if (result.state === 'done' || result.state === 'text_only' || result.state === 'error' || result.state === 'page_error') {
result.polled = true;
result.polls = polls;
result.elapsedMs = Date.now() - startTime;
return result;
}
await sleep(3000);
}
return {
state: 'generating',
message: `Still generating after ${maxTime / 1000}s, timed out`,
polled: true,
polls,
elapsedMs: maxTime,
};
}
return getFullStatus(page);
}
/**
* Get a full status report from the page.
*/
async function getFullStatus(page) {
// Scroll to top to trigger lazy-loaded images into the DOM
await page.evaluate(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
});
await sleep(1000);
const [state, details] = await Promise.all([
detectPageState(page),
getDetails(page),
]);
const messages = {
IDLE: 'Page is idle, waiting for input',
GENERATING: 'AI is currently generating',
DONE: 'Image generation completed successfully',
TEXT_ONLY: 'Only text was generated, no images',
ERROR: 'An error occurred during generation',
PAGE_ERROR: 'The page encountered an error',
};
const result = {
state: state.toLowerCase(),
message: details.errorMessage || messages[state] || 'Unknown state',
};
if (state === 'DONE') {
result.imageCount = details.imageCount;
result.hasDownloadButton = details.hasDownloadButton;
}
return result;
}
/**
* Get details from the page: image count, download button, error message.
*/
async function getDetails(page) {
return page.evaluate(() => {
let imageCount = 0;
let hasDownloadButton = false;
const images = document.querySelectorAll('img');
for (const img of images) {
if (img.naturalWidth < 300 || img.naturalHeight < 300) continue;
const isGenerated = img.closest('single-image.generated-image') ||
img.closest('.generated-image') ||
img.closest('generated-image') ||
img.closest('[class*="generated-image"]');
const isReferenceUpload = img.closest('.attachment-preview-wrapper') ||
img.closest('uploader-file-preview') ||
img.closest('[class*="file-preview"]');
if (isGenerated && !isReferenceUpload) {
imageCount++;
}
}
const downloadBtn = document.querySelector('button.generated-image-button');
if (downloadBtn && downloadBtn.offsetParent !== null) {
hasDownloadButton = true;
}
// Extract error text from the conversation area only, excluding sidebar
const contentEl = document.querySelector('.content-container, .main-content, main');
const text = contentEl ? (contentEl.innerText || '') : document.body.innerText;
let errorMessage = '';
const errorPatterns = [
/Something went wrong[:\s]*(.*)/i,
/出了点问题[:\s]*(.*)/,
/Unable to generate[:\s]*(.*)/i,
/无法生成[:\s]*(.*)/,
/Network error[:\s]*(.*)/i,
/网络错误[:\s]*(.*)/,
/Server error[:\s]*(.*)/i,
/服务器错误[:\s]*(.*)/,
/Too many requests[:\s]*(.*)/i,
/请求过多[:\s]*(.*)/,
/生成限制[:\s]*(.*)/,
/内容政策[:\s]*(.*)/,
/安全政策[:\s]*(.*)/,
/content policy[:\s]*(.*)/i,
];
for (const pattern of errorPatterns) {
const match = text.match(pattern);
if (match && match[1]) {
errorMessage = match[1].trim().substring(0, 200);
break;
}
}
if (!errorMessage && (document.title.includes('限制') || document.title.includes('blocked') ||
document.title.includes('policy') || document.title.includes('Blocked'))) {
errorMessage = document.title;
}
// If no error text found and the page was reset (input has text, no conversation),
// use the input content as the error context
const inputEl2 = document.querySelector('[contenteditable="true"]') || document.querySelector('textarea');
const inputText2 = (inputEl2?.innerText || inputEl2?.value || '').trim();
const hasConversation2 = document.querySelector('[id*="user-query"], [id*="model-response"], [class*="message-content"]');
if (!errorMessage && inputText2 && !hasConversation2) {
errorMessage = `Page was reset, prompt returned to input: ${inputText2.substring(0, 100)}`;
}
return { imageCount, hasDownloadButton, errorMessage };
});
}