From 3b13b25f1a641d281a8d98dcd91bc61462267047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Sat, 17 Jan 2026 19:00:27 -0600 Subject: [PATCH] chore: release v0.8.2 --- .claude-plugin/marketplace.json | 2 +- .claude/skills/release-skills/SKILL.md | 2 + CHANGELOG.md | 10 + CHANGELOG.zh.md | 10 + skills/baoyu-gemini-web/SKILL.md | 83 +- .../baoyu-gemini-web/scripts/chrome-auth.ts | 340 -------- skills/baoyu-gemini-web/scripts/client.ts | 527 ------------- .../baoyu-gemini-web/scripts/cookie-store.ts | 140 ---- skills/baoyu-gemini-web/scripts/executor.ts | 445 ----------- .../scripts/gemini-webapi/client.ts | 627 +++++++++++++++ .../gemini-webapi/components/gem-mixin.ts | 179 +++++ .../scripts/gemini-webapi/components/index.ts | 2 + .../scripts/gemini-webapi/constants.ts | 107 +++ .../scripts/gemini-webapi/exceptions.ts | 56 ++ .../scripts/gemini-webapi/index.ts | 8 + .../scripts/gemini-webapi/types/candidate.ts | 45 ++ .../scripts/gemini-webapi/types/gem.ts | 75 ++ .../scripts/gemini-webapi/types/grpc.ts | 16 + .../scripts/gemini-webapi/types/image.ts | 114 +++ .../scripts/gemini-webapi/types/index.ts | 6 + .../gemini-webapi/types/modeloutput.ts | 35 + .../gemini-webapi/utils/cookie-file.ts | 82 ++ .../scripts/gemini-webapi/utils/decorators.ts | 34 + .../gemini-webapi/utils/get-access-token.ts | 192 +++++ .../scripts/gemini-webapi/utils/http.ts | 57 ++ .../scripts/gemini-webapi/utils/index.ts | 20 + .../utils/load-browser-cookies.ts | 271 +++++++ .../scripts/gemini-webapi/utils/logger.ts | 42 + .../scripts/gemini-webapi/utils/parsing.ts | 44 ++ .../{ => gemini-webapi/utils}/paths.ts | 1 + .../gemini-webapi/utils/rotate-1psidts.ts | 46 ++ .../gemini-webapi/utils/upload-file.ts | 41 + skills/baoyu-gemini-web/scripts/get-cookie.ts | 21 - skills/baoyu-gemini-web/scripts/index.ts | 2 - skills/baoyu-gemini-web/scripts/main.ts | 743 ++++++++++-------- .../baoyu-gemini-web/scripts/session-store.ts | 90 --- skills/baoyu-gemini-web/scripts/types.ts | 25 - 37 files changed, 2557 insertions(+), 1983 deletions(-) delete mode 100644 skills/baoyu-gemini-web/scripts/chrome-auth.ts delete mode 100644 skills/baoyu-gemini-web/scripts/client.ts delete mode 100644 skills/baoyu-gemini-web/scripts/cookie-store.ts delete mode 100644 skills/baoyu-gemini-web/scripts/executor.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/client.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/components/gem-mixin.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/components/index.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/constants.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/exceptions.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/index.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/candidate.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/gem.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/grpc.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/image.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/index.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/types/modeloutput.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/cookie-file.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/decorators.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/get-access-token.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/http.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/index.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/logger.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/parsing.ts rename skills/baoyu-gemini-web/scripts/{ => gemini-webapi/utils}/paths.ts (99%) create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/rotate-1psidts.ts create mode 100644 skills/baoyu-gemini-web/scripts/gemini-webapi/utils/upload-file.ts delete mode 100644 skills/baoyu-gemini-web/scripts/get-cookie.ts delete mode 100644 skills/baoyu-gemini-web/scripts/index.ts delete mode 100644 skills/baoyu-gemini-web/scripts/session-store.ts delete mode 100644 skills/baoyu-gemini-web/scripts/types.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e89e653..8d536fe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "0.8.0" + "version": "0.8.2" }, "plugins": [ { diff --git a/.claude/skills/release-skills/SKILL.md b/.claude/skills/release-skills/SKILL.md index f38d8bc..a3c4f96 100644 --- a/.claude/skills/release-skills/SKILL.md +++ b/.claude/skills/release-skills/SKILL.md @@ -121,6 +121,8 @@ git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marke git commit -m "chore: release v{NEW_VERSION}" ``` +**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution. + ### Step 7: Create Version Tag ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index f99b1b1..062add5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ English | [中文](./CHANGELOG.zh.md) +## 0.8.2 - 2026-01-17 + +### Refactor +- `baoyu-gemini-web`: reorganizes script architecture—moves modular files into `gemini-webapi/` subdirectory and updates SKILL.md with `${SKILL_DIR}` path references. + +## 0.8.1 - 2026-01-17 + +### Refactor +- `baoyu-gemini-web`: refactors script architecture—consolidates 10 separate files into a structured `gemini-webapi/` module (TypeScript port of gemini_webapi Python library). + ## 0.8.0 - 2026-01-17 ### Features diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 2ac70bf..cec1096 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -2,6 +2,16 @@ [English](./CHANGELOG.md) | 中文 +## 0.8.2 - 2026-01-17 + +### 重构 +- `baoyu-gemini-web`:重组脚本架构——将模块文件移至 `gemini-webapi/` 子目录,并更新 SKILL.md 使用 `${SKILL_DIR}` 路径引用。 + +## 0.8.1 - 2026-01-17 + +### 重构 +- `baoyu-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。 + ## 0.8.0 - 2026-01-17 ### 新功能 diff --git a/skills/baoyu-gemini-web/SKILL.md b/skills/baoyu-gemini-web/SKILL.md index ff40c97..1ba83e9 100644 --- a/skills/baoyu-gemini-web/SKILL.md +++ b/skills/baoyu-gemini-web/SKILL.md @@ -8,9 +8,8 @@ description: Image generation skill using Gemini Web. Generates images from text Supports: - Text generation - Image generation (download + save) -- Reference image upload (attach images for vision tasks) -- Multi-turn conversations within the same executor instance (`keepSession`) -- Experimental video generation (`generateVideo`) — Gemini may return an async placeholder; download might require Gemini web UI +- Reference images for vision input (attach local images) +- Multi-turn conversations via persisted `--sessionId` ## Script Directory @@ -25,74 +24,71 @@ Supports: | Script | Purpose | |--------|---------| | `scripts/main.ts` | CLI entry point for text/image generation | -| `scripts/executor.ts` | Programmatic Gemini executor API | +| `scripts/gemini-webapi/*` | TypeScript port of `gemini_webapi` (GeminiClient, types, utils) | ## Quick start ```bash -npx -y bun scripts/main.ts "Hello, Gemini" -npx -y bun scripts/main.ts --prompt "Explain quantum computing" -npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png -npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png +npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello, Gemini" +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Explain quantum computing" +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png +npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png # Multi-turn conversation (agent generates unique sessionId) -npx -y bun scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123 -npx -y bun scripts/main.ts "What number?" --sessionId my-unique-id-123 +npx -y bun ${SKILL_DIR}/scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123 +npx -y bun ${SKILL_DIR}/scripts/main.ts "What number?" --sessionId my-unique-id-123 ``` -## Executor options (programmatic) - -This skill is typically consumed via `createGeminiWebExecutor(geminiOptions)` (see `scripts/executor.ts`). - -Key options in `GeminiWebOptions`: -- `referenceImages?: string | string[]` Upload local images as references (vision input). -- `keepSession?: boolean` Reuse Gemini `chatMetadata` to continue the same conversation across calls (required if you want reference images to persist across multiple messages). -- `generateVideo?: string` Generate a video and (best-effort) download to the given path. Gemini may return `video_gen_chip` (async); in that case you must open Gemini web UI to download the result. - -Notes: -- `generateVideo` cannot be combined with `generateImage` / `editImage`. -- When `keepSession=true` and `referenceImages` is set, reference images are uploaded once per executor instance. - ## Commands ### Text generation ```bash # Simple prompt (positional) -npx -y bun scripts/main.ts "Your prompt here" +npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt here" # Explicit prompt flag -npx -y bun scripts/main.ts --prompt "Your prompt here" -npx -y bun scripts/main.ts -p "Your prompt here" +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt here" +npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Your prompt here" # With model selection -npx -y bun scripts/main.ts -p "Hello" -m gemini-2.5-pro +npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Hello" -m gemini-2.5-pro # Pipe from stdin -echo "Summarize this" | npx -y bun scripts/main.ts +echo "Summarize this" | npx -y bun ${SKILL_DIR}/scripts/main.ts ``` ### Image generation ```bash # Generate image with default path (./generated.png) -npx -y bun scripts/main.ts --prompt "A sunset over mountains" --image +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image # Generate image with custom path -npx -y bun scripts/main.ts --prompt "A cute robot" --image robot.png +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute robot" --image robot.png # Shorthand -npx -y bun scripts/main.ts "A dragon" --image=dragon.png +npx -y bun ${SKILL_DIR}/scripts/main.ts "A dragon" --image=dragon.png +``` + +### Vision input (reference images) + +```bash +# Text + image -> text +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Describe this image" --reference a.png + +# Text + image -> image +npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Generate a variation" --reference a.png --image out.png ``` ### Output formats ```bash # Plain text (default) -npx -y bun scripts/main.ts "Hello" +npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" # JSON output -npx -y bun scripts/main.ts "Hello" --json +npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json ``` ## Options @@ -103,6 +99,7 @@ npx -y bun scripts/main.ts "Hello" --json | `--promptfiles ` | Read prompt from files (concatenated in order) | | `--model `, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash | | `--image [path]` | Generate image, save to path (default: generated.png) | +| `--reference `, `--ref ` | Reference images for vision input | | `--sessionId ` | Session ID for multi-turn conversation (agent generates unique ID) | | `--list-sessions` | List saved sessions (max 100, sorted by update time) | | `--json` | Output as JSON | @@ -111,7 +108,7 @@ npx -y bun scripts/main.ts "Hello" --json | `--profile-dir ` | Chrome profile directory | | `--help`, `-h` | Show help | -CLI note: `scripts/main.ts` supports text generation, image generation, and multi-turn conversations via `--sessionId`. Reference images and video generation are exposed via the executor API. +CLI note: `scripts/main.ts` supports text generation, image generation, reference images (`--reference/--ref`), and multi-turn conversations via `--sessionId`. ## Models @@ -125,7 +122,7 @@ First run opens Chrome to authenticate with Google. Cookies are cached for subse ```bash # Force cookie refresh -npx -y bun scripts/main.ts --login +npx -y bun ${SKILL_DIR}/scripts/main.ts --login ``` ## Environment variables @@ -141,36 +138,36 @@ npx -y bun scripts/main.ts --login ### Generate text response ```bash -npx -y bun scripts/main.ts "What is the capital of France?" +npx -y bun ${SKILL_DIR}/scripts/main.ts "What is the capital of France?" ``` ### Generate image ```bash -npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png +npx -y bun ${SKILL_DIR}/scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png ``` ### Get JSON output for parsing ```bash -npx -y bun scripts/main.ts "Hello" --json | jq '.text' +npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json | jq '.text' ``` ### Generate image from prompt files ```bash # Concatenate system.md + content.md as prompt -npx -y bun scripts/main.ts --promptfiles system.md content.md --image output.png +npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image output.png ``` ### Multi-turn conversation ```bash # Start a session with unique ID (agent generates this) -npx -y bun scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123 +npx -y bun ${SKILL_DIR}/scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123 # Continue the conversation (remembers context) -npx -y bun scripts/main.ts "What is 2+2?" --sessionId task-abc123 -npx -y bun scripts/main.ts "Now multiply that by 10" --sessionId task-abc123 +npx -y bun ${SKILL_DIR}/scripts/main.ts "What is 2+2?" --sessionId task-abc123 +npx -y bun ${SKILL_DIR}/scripts/main.ts "Now multiply that by 10" --sessionId task-abc123 # List recent sessions (max 100, sorted by update time) -npx -y bun scripts/main.ts --list-sessions +npx -y bun ${SKILL_DIR}/scripts/main.ts --list-sessions ``` Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-web/sessions/.json` and contain: diff --git a/skills/baoyu-gemini-web/scripts/chrome-auth.ts b/skills/baoyu-gemini-web/scripts/chrome-auth.ts deleted file mode 100644 index 3581003..0000000 --- a/skills/baoyu-gemini-web/scripts/chrome-auth.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; -import process from 'node:process'; - -import { fetchGeminiAccessToken } from './client.js'; -import type { GeminiWebLog } from './cookie-store.js'; -import { buildGeminiCookieMap, hasRequiredGeminiCookies } from './cookie-store.js'; -import { resolveGeminiWebChromeProfileDir } from './paths.js'; - -const GEMINI_URL = 'https://gemini.google.com/app'; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port for Chrome debugging.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); - }); -} - -function findChromeExecutable(): string | undefined { - const override = process.env.GEMINI_WEB_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ); - break; - case 'win32': - candidates.push( - 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', - 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', - 'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', - 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', - ); - break; - default: - candidates.push( - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - '/usr/bin/microsoft-edge', - '/usr/bin/microsoft-edge-stable', - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return undefined; -} - -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) { - throw new Error(`Request failed: ${res.status} ${res.statusText} (${url})`); - } - return (await res.json()) as T; -} - -async function waitForChromeDebugPort( - port: number, - timeoutMs: number, -): Promise<{ webSocketDebuggerUrl: string }> { - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( - `http://127.0.0.1:${port}/json/version`, - ); - if (version.webSocketDebuggerUrl) { - return { webSocketDebuggerUrl: version.webSocketDebuggerUrl }; - } - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - - await sleep(200); - } - - throw new Error( - `Chrome debugging endpoint did not become ready within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`, - ); -} - -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map< - number, - { resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType | null } - >(); - - private constructor(ws: WebSocket) { - this.ws = ws; - this.ws.addEventListener('message', (event) => { - try { - const data = (() => { - if (typeof event.data === 'string') return event.data; - if (event.data instanceof ArrayBuffer) { - return new TextDecoder().decode(new Uint8Array(event.data)); - } - if (ArrayBuffer.isView(event.data)) { - return new TextDecoder().decode(event.data); - } - return String(event.data); - })(); - const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; - if (!msg.id) return; - const pending = this.pending.get(msg.id); - if (!pending) return; - this.pending.delete(msg.id); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error?.message) pending.reject(new Error(msg.error.message)); - else pending.resolve(msg.result); - } catch { - // ignore malformed events - } - }); - - this.ws.addEventListener('close', () => { - for (const [id, pending] of this.pending.entries()) { - this.pending.delete(id); - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error('Chrome DevTools connection closed.')); - } - }); - } - - static async connect(url: string, timeoutMs: number): Promise { - const ws = new WebSocket(url); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools.')), timeoutMs); - ws.addEventListener('open', () => { - clearTimeout(timer); - resolve(); - }); - ws.addEventListener('error', () => { - clearTimeout(timer); - reject(new Error('Failed to connect to Chrome DevTools.')); - }); - }); - return new CdpConnection(ws); - } - - async send( - method: string, - params?: Record, - options?: { sessionId?: string; timeoutMs?: number }, - ): Promise { - const id = (this.nextId += 1); - const message: Record = { id, method }; - if (params) message.params = params; - if (options?.sessionId) message.sessionId = options.sessionId; - - const timeoutMs = options?.timeoutMs ?? 15_000; - - const result = await new Promise((resolve, reject) => { - const timer = - timeoutMs > 0 - ? setTimeout(() => { - this.pending.delete(id); - reject(new Error(`CDP command timeout (${method}) after ${timeoutMs}ms.`)); - }, timeoutMs) - : null; - this.pending.set(id, { - resolve, - reject: (reason) => reject(reason), - timer, - }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { - this.ws.close(); - } catch { - // ignore - } - } -} - -export async function getGeminiCookieMapViaChrome(options?: { - timeoutMs?: number; - debugConnectTimeoutMs?: number; - tokenCheckTimeoutMs?: number; - pollIntervalMs?: number; - log?: GeminiWebLog; - userDataDir?: string; - chromePath?: string; -}): Promise> { - const log = options?.log; - const timeoutMs = options?.timeoutMs ?? 5 * 60_000; - const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000; - const tokenCheckTimeoutMs = options?.tokenCheckTimeoutMs ?? 30_000; - const pollIntervalMs = options?.pollIntervalMs ?? 2_000; - const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir(); - - const chromePath = options?.chromePath ?? findChromeExecutable(); - if (!chromePath) { - throw new Error( - 'Unable to locate a Chrome/Chromium executable. Install Google Chrome or set GEMINI_WEB_CHROME_PATH.', - ); - } - - await mkdir(userDataDir, { recursive: true }); - - const port = await getFreePort(); - log?.(`[gemini-web] Launching Chrome for cookie sync (profile: ${userDataDir})`); - - const chrome = spawn( - chromePath, - [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${userDataDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--start-maximized', - GEMINI_URL, - ], - { stdio: 'ignore' }, - ); - - let cdp: CdpConnection | null = null; - try { - const { webSocketDebuggerUrl } = await waitForChromeDebugPort(port, debugConnectTimeoutMs); - cdp = await CdpConnection.connect(webSocketDebuggerUrl, debugConnectTimeoutMs); - - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: GEMINI_URL }); - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { - targetId, - flatten: true, - }); - - await cdp.send('Page.enable', {}, { sessionId }); - await cdp.send('Network.enable', {}, { sessionId }); - - log?.('[gemini-web] Please log in to Gemini in the opened browser window.'); - log?.('[gemini-web] Waiting for cookies to become available...'); - - const start = Date.now(); - let lastTokenError: string | null = null; - while (Date.now() - start < timeoutMs) { - const response = await cdp.send<{ cookies?: unknown[] }>( - 'Network.getCookies', - { urls: [GEMINI_URL, 'https://google.com/'] }, - { sessionId, timeoutMs: 10_000 }, - ); - - const rawCookies = Array.isArray(response.cookies) ? response.cookies : []; - const cookieMap = buildGeminiCookieMap( - rawCookies.filter( - (cookie): cookie is { name?: string; value?: string; domain?: string; path?: string; url?: string } => - Boolean(cookie && typeof cookie === 'object'), - ), - ); - - if (hasRequiredGeminiCookies(cookieMap)) { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), tokenCheckTimeoutMs); - try { - await fetchGeminiAccessToken(cookieMap, controller.signal); - } finally { - clearTimeout(timer); - } - log?.('[gemini-web] Gemini cookies detected.'); - return cookieMap; - } catch (error) { - lastTokenError = error instanceof Error ? error.message : String(error); - } - } - - await sleep(pollIntervalMs); - } - - throw new Error( - `Timed out waiting for Gemini cookies after ${timeoutMs}ms${lastTokenError ? ` (last error: ${lastTokenError})` : ''}.`, - ); - } finally { - if (cdp) { - try { - await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); - } catch { - // ignore - } - cdp.close(); - } - - const killTimer = setTimeout(() => { - if (!chrome.killed) { - try { - chrome.kill('SIGKILL'); - } catch { - // ignore - } - } - }, 2_000); - killTimer.unref?.(); - try { - chrome.kill('SIGTERM'); - } catch { - // ignore - } - } -} diff --git a/skills/baoyu-gemini-web/scripts/client.ts b/skills/baoyu-gemini-web/scripts/client.ts deleted file mode 100644 index f2061ff..0000000 --- a/skills/baoyu-gemini-web/scripts/client.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -export type GeminiWebModelId = 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash'; - -export interface GeminiWebRunInput { - prompt: string; - files?: string[]; - model: GeminiWebModelId; - cookieMap: Record; - chatMetadata?: unknown; - signal?: AbortSignal; -} - -export interface GeminiWebCandidateImage { - url: string; - title?: string; - alt?: string; - kind: 'web' | 'generated' | 'raw'; -} - -export interface GeminiWebRunOutput { - rawResponseText: string; - text: string; - thoughts: string | null; - metadata: unknown; - images: GeminiWebCandidateImage[]; - errorCode?: number; - errorMessage?: string; -} - -const USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; - -const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb'; -const MODEL_HEADERS: Record = { - 'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]', - 'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]', - 'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]', -}; - -const GEMINI_APP_URL = 'https://gemini.google.com/app'; -const GEMINI_STREAM_GENERATE_URL = - 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate'; -const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload'; -const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz'; - -function getNestedValue(value: unknown, pathParts: Array, fallback: T): T { - let current: unknown = value; - for (const part of pathParts) { - if (current == null) return fallback; - if (typeof part === 'number') { - if (!Array.isArray(current)) return fallback; - current = current[part]; - } else { - if (typeof current !== 'object') return fallback; - current = (current as Record)[part]; - } - } - return (current as T) ?? fallback; -} - -function buildCookieHeader(cookieMap: Record): string { - return Object.entries(cookieMap) - .filter(([, value]) => typeof value === 'string' && value.length > 0) - .map(([name, value]) => `${name}=${value}`) - .join('; '); -} - -function getSetCookieHeaders(res: Response): string[] { - const headers = res.headers as unknown as { getSetCookie?: () => string[] }; - if (typeof headers.getSetCookie === 'function') { - try { - return headers.getSetCookie(); - } catch { - return []; - } - } - const raw = res.headers.get('set-cookie'); - return raw ? [raw] : []; -} - -function applySetCookiesToMap(setCookies: string[], cookieMap: Record): void { - for (const raw of setCookies) { - const first = raw.split(';')[0]?.trim(); - if (!first) continue; - const idx = first.indexOf('='); - if (idx <= 0) continue; - const name = first.slice(0, idx).trim(); - const value = first.slice(idx + 1).trim(); - if (!name) continue; - cookieMap[name] = value; - } -} - -async function fetchWithCookieJar( - url: string, - init: Omit & { headers?: Record }, - cookieMap: Record, - signal?: AbortSignal, - maxRedirects = 20, -): Promise { - let current = url; - for (let i = 0; i <= maxRedirects; i += 1) { - const cookieHeader = buildCookieHeader(cookieMap); - const headers: Record = { - ...(init.headers ?? {}), - ...(cookieHeader ? { cookie: cookieHeader } : {}), - 'user-agent': USER_AGENT, - }; - - const res = await fetch(current, { ...init, redirect: 'manual', signal, headers }); - applySetCookiesToMap(getSetCookieHeaders(res), cookieMap); - - if (res.status >= 300 && res.status < 400) { - const location = res.headers.get('location'); - if (!location) return res; - current = new URL(location, current).toString(); - continue; - } - - return res; - } - - throw new Error(`Too many redirects while fetching ${url} (>${maxRedirects}).`); -} - -export async function fetchGeminiAccessToken( - cookieMap: Record, - signal?: AbortSignal, -): Promise { - const res = await fetchWithCookieJar(GEMINI_APP_URL, { method: 'GET' }, cookieMap, signal); - const html = await res.text(); - - const tokens = ['SNlM0e', 'thykhd'] as const; - for (const key of tokens) { - const match = html.match(new RegExp(`"${key}":"(.*?)"`)); - if (match?.[1]) return match[1]; - } - throw new Error( - 'Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).', - ); -} - -function trimGeminiJsonEnvelope(text: string): string { - const start = text.indexOf('['); - const end = text.lastIndexOf(']'); - if (start === -1 || end === -1 || end <= start) { - throw new Error('Gemini response did not contain a JSON payload.'); - } - return text.slice(start, end + 1); -} - -function extractErrorCode(responseJson: unknown): number | undefined { - const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1); - return typeof code === 'number' && code >= 0 ? code : undefined; -} - -function extractGgdlUrls(rawText: string): string[] { - const matches = - rawText.match(/https?:\/\/[^/\s"']*googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? []; - const seen = new Set(); - const urls: string[] = []; - for (const match of matches) { - if (seen.has(match)) continue; - seen.add(match); - urls.push(match); - } - return urls; -} - -function extractImageGenerationContentUrls(rawText: string): string[] { - const matches = - rawText.match(/https?:\/\/googleusercontent\.com\/image_generation_content\/\d+/g) ?? []; - const seen = new Set(); - const urls: string[] = []; - for (const match of matches) { - if (seen.has(match)) continue; - seen.add(match); - urls.push(match); - } - return urls; -} - -function ensureFullSizeImageUrl(url: string): string { - const trimmed = url.trim(); - let normalized = trimmed; - const backslashIndex = normalized.indexOf('\\'); - if (backslashIndex >= 0) normalized = normalized.slice(0, backslashIndex); - // Some Gemini responses embed a size suffix as "/=s2048" which breaks downloads. - normalized = normalized.replace(/\/=s(?=\d+(?:$|[?#]))/, '=s'); - normalized = normalized.replace(/\/=s(?=$|[?#])/, '=s'); - if (normalized.endsWith('/')) normalized = normalized.slice(0, -1); - if (normalized.includes('=s2048')) return normalized; - if (normalized.includes('=s')) return normalized; - return `${normalized}=s2048`; -} - -async function fetchWithCookiePreservingRedirects( - url: string, - init: Omit, - signal?: AbortSignal, - maxRedirects = 10, -): Promise { - let current = url; - for (let i = 0; i <= maxRedirects; i += 1) { - const res = await fetch(current, { ...init, redirect: 'manual', signal }); - if (res.status >= 300 && res.status < 400) { - const location = res.headers.get('location'); - if (!location) return res; - current = new URL(location, current).toString(); - continue; - } - return res; - } - throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`); -} - -export async function downloadGeminiImage( - url: string, - cookieMap: Record, - outputPath: string, - signal?: AbortSignal, -): Promise { - const cookieHeader = buildCookieHeader(cookieMap); - const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), { - headers: { - cookie: cookieHeader, - 'user-agent': USER_AGENT, - }, - }, signal); - if (!res.ok) { - throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`); - } - - const data = new Uint8Array(await res.arrayBuffer()); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, data); -} - -async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise<{ id: string; name: string }> { - const absPath = path.resolve(process.cwd(), filePath); - const data = await readFile(absPath); - const fileName = path.basename(absPath); - const form = new FormData(); - form.append('file', new Blob([data]), fileName); - - const res = await fetch(GEMINI_UPLOAD_URL, { - method: 'POST', - redirect: 'follow', - signal, - headers: { - 'push-id': GEMINI_UPLOAD_PUSH_ID, - 'user-agent': USER_AGENT, - }, - body: form, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`); - } - return { id: text, name: fileName }; -} - -function guessMimeType(fileName: string): string { - const ext = path.extname(fileName).toLowerCase(); - switch (ext) { - case '.png': - return 'image/png'; - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.webp': - return 'image/webp'; - case '.gif': - return 'image/gif'; - case '.mp4': - return 'video/mp4'; - case '.mov': - return 'video/quicktime'; - case '.webm': - return 'video/webm'; - default: - return 'application/octet-stream'; - } -} - -function buildGeminiFReqPayload( - prompt: string, - uploaded: Array<{ id: string; name: string }>, - chatMetadata: unknown, -): string { - const promptPayload = - uploaded.length > 0 - ? [ - prompt, - 0, - null, - // Matches gemini-web payload format: [[[fileId, 1, null, mimeType], fileName]] for an attachment. - uploaded.map((file) => [[file.id, 1, null, guessMimeType(file.name)], file.name]), - ] - : [prompt]; - - const innerList: unknown[] = [promptPayload, null, chatMetadata ?? null]; - return JSON.stringify([null, JSON.stringify(innerList)]); -} - -export function parseGeminiStreamGenerateResponse(rawText: string): { - metadata: unknown; - text: string; - thoughts: string | null; - images: GeminiWebCandidateImage[]; - errorCode?: number; -} { - const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText)) as unknown; - const errorCode = extractErrorCode(responseJson); - - const parts = Array.isArray(responseJson) ? responseJson : []; - let bodyIndex = 0; - let body: unknown = null; - for (let i = 0; i < parts.length; i += 1) { - const partBody = getNestedValue(parts[i], [2], null); - if (!partBody) continue; - try { - const parsed = JSON.parse(partBody) as unknown; - const candidateList = getNestedValue(parsed, [4], []); - if (Array.isArray(candidateList) && candidateList.length > 0) { - bodyIndex = i; - body = parsed; - break; - } - } catch { - // ignore - } - } - - const candidateList = getNestedValue(body, [4], []); - const firstCandidate = candidateList[0]; - const textRaw = getNestedValue(firstCandidate, [1, 0], ''); - const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw); - const text = cardContent - ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw) - : textRaw; - const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null); - const conversationMeta = getNestedValue(body, [1], []); - const conversationId = - typeof conversationMeta[0] === 'string' && conversationMeta[0].length > 0 - ? conversationMeta[0] - : null; - const responseId = - typeof conversationMeta[1] === 'string' && conversationMeta[1].length > 0 - ? conversationMeta[1] - : null; - const choiceIdRaw = getNestedValue(firstCandidate, [0], null); - const choiceId = typeof choiceIdRaw === 'string' && choiceIdRaw.length > 0 ? choiceIdRaw : null; - const metadata = - conversationId && responseId && choiceId ? [conversationId, responseId, choiceId] : conversationMeta; - - const images: GeminiWebCandidateImage[] = []; - - const webImages = getNestedValue(firstCandidate, [12, 1], []); - for (const webImage of webImages) { - const url = getNestedValue(webImage, [0, 0, 0], null); - if (!url) continue; - images.push({ - kind: 'web', - url, - title: getNestedValue(webImage, [7, 0], undefined), - alt: getNestedValue(webImage, [0, 4], undefined), - }); - } - - const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null)); - if (hasGenerated) { - let imgBody: unknown = null; - for (let i = bodyIndex; i < parts.length; i += 1) { - const partBody = getNestedValue(parts[i], [2], null); - if (!partBody) continue; - try { - const parsed = JSON.parse(partBody) as unknown; - const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null); - if (candidateImages != null) { - imgBody = parsed; - break; - } - } catch { - // ignore - } - } - - const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null); - - const generated = getNestedValue(imgCandidate, [12, 7, 0], []); - for (const genImage of generated) { - const url = getNestedValue(genImage, [0, 3, 3], null); - if (!url) continue; - images.push({ - kind: 'generated', - url, - title: '[Generated Image]', - alt: '', - }); - } - } - - return { metadata, text, thoughts, images, errorCode }; -} - -export function isGeminiModelUnavailable(errorCode: number | undefined): boolean { - return errorCode === 1052; -} - -export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise { - const at = await fetchGeminiAccessToken(input.cookieMap, input.signal); - const cookieHeader = buildCookieHeader(input.cookieMap); - - const uploaded: Array<{ id: string; name: string }> = []; - for (const file of input.files ?? []) { - if (input.signal?.aborted) { - throw new Error('Gemini web run aborted before upload.'); - } - uploaded.push(await uploadGeminiFile(file, input.signal)); - } - - const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null); - const params = new URLSearchParams(); - params.set('at', at); - params.set('f.req', fReq); - - const res = await fetch(GEMINI_STREAM_GENERATE_URL, { - method: 'POST', - redirect: 'follow', - signal: input.signal, - headers: { - 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', - origin: 'https://gemini.google.com', - referer: 'https://gemini.google.com/', - 'x-same-domain': '1', - 'user-agent': USER_AGENT, - cookie: cookieHeader, - [MODEL_HEADER_NAME]: MODEL_HEADERS[input.model], - }, - body: params.toString(), - }); - - const rawResponseText = await res.text(); - if (!res.ok) { - return { - rawResponseText, - text: '', - thoughts: null, - metadata: input.chatMetadata ?? null, - images: [], - errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`, - }; - } - - try { - const parsed = parseGeminiStreamGenerateResponse(rawResponseText); - return { - rawResponseText, - text: parsed.text ?? '', - thoughts: parsed.thoughts, - metadata: parsed.metadata, - images: parsed.images, - errorCode: parsed.errorCode, - }; - } catch (error) { - let responseJson: unknown = null; - try { - responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText)) as unknown; - } catch { - responseJson = null; - } - const errorCode = extractErrorCode(responseJson); - - return { - rawResponseText, - text: '', - thoughts: null, - metadata: input.chatMetadata ?? null, - images: [], - errorCode: typeof errorCode === 'number' ? errorCode : undefined, - errorMessage: error instanceof Error ? error.message : String(error ?? ''), - }; - } -} - -export async function runGeminiWebWithFallback( - input: Omit & { model: GeminiWebModelId }, -): Promise { - const attempt = await runGeminiWebOnce(input); - if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') { - const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' }); - return { ...fallback, effectiveModel: 'gemini-2.5-flash' }; - } - return { ...attempt, effectiveModel: input.model }; -} - -export async function saveFirstGeminiImageFromOutput( - output: GeminiWebRunOutput, - cookieMap: Record, - outputPath: string, - signal?: AbortSignal, -): Promise<{ saved: boolean; imageCount: number }> { - const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0]; - if (generatedOrWeb?.url) { - await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal); - return { saved: true, imageCount: output.images.length }; - } - - const ggdl = extractGgdlUrls(`${output.text}\n${output.rawResponseText}`); - const preferred = ggdl.length > 0 ? ggdl[ggdl.length - 1] : null; - if (preferred) { - await downloadGeminiImage(preferred, cookieMap, outputPath, signal); - return { saved: true, imageCount: ggdl.length }; - } - - const imageGen = extractImageGenerationContentUrls(`${output.text}\n${output.rawResponseText}`); - const imageGenPreferred = imageGen.length > 0 ? imageGen[imageGen.length - 1] : null; - if (imageGenPreferred) { - await downloadGeminiImage(imageGenPreferred, cookieMap, outputPath, signal); - return { saved: true, imageCount: imageGen.length }; - } - - return { saved: false, imageCount: 0 }; -} diff --git a/skills/baoyu-gemini-web/scripts/cookie-store.ts b/skills/baoyu-gemini-web/scripts/cookie-store.ts deleted file mode 100644 index 7806d4e..0000000 --- a/skills/baoyu-gemini-web/scripts/cookie-store.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { resolveGeminiWebCookiePath } from './paths.js'; - -export type GeminiWebLog = (message: string) => void; - -export const GEMINI_COOKIE_NAMES = [ - '__Secure-1PSID', - '__Secure-1PSIDTS', - '__Secure-1PSIDCC', - '__Secure-1PAPISID', - 'NID', - 'AEC', - 'SOCS', - '__Secure-BUCKET', - '__Secure-ENID', - 'SID', - 'HSID', - 'SSID', - 'APISID', - 'SAPISID', - '__Secure-3PSID', - '__Secure-3PSIDTS', - '__Secure-3PAPISID', - 'SIDCC', -] as const; - -export const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'] as const; - -export interface GeminiCookieFileV1 { - version: 1; - updatedAt: string; - cookieMap: Record; -} - -export function hasRequiredGeminiCookies(cookieMap: Record): boolean { - return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name])); -} - -function resolveCookieDomain(cookie: { domain?: string; url?: string }): string | null { - const rawDomain = cookie.domain?.trim(); - if (rawDomain) { - return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain; - } - const rawUrl = cookie.url?.trim(); - if (rawUrl) { - try { - return new URL(rawUrl).hostname; - } catch { - return null; - } - } - return null; -} - -function pickCookieValue( - cookies: T[], - name: string, -): string | undefined { - const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string'); - if (matches.length === 0) return undefined; - - const preferredDomain = matches.find((cookie) => { - const domain = resolveCookieDomain(cookie); - return domain === 'google.com' && (cookie.path ?? '/') === '/'; - }); - const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com')); - return (preferredDomain ?? googleDomain ?? matches[0])?.value; -} - -export function buildGeminiCookieMap< - T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }, ->(cookies: T[]): Record { - const cookieMap: Record = {}; - for (const name of GEMINI_COOKIE_NAMES) { - const value = pickCookieValue(cookies, name); - if (value) cookieMap[name] = value; - } - return cookieMap; -} - -export async function readGeminiCookieMapFromDisk(options?: { - cookiePath?: string; - log?: GeminiWebLog; -}): Promise> { - const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath(); - - try { - const raw = await readFile(cookiePath, 'utf8'); - const parsed = JSON.parse(raw) as Partial | Record; - - const cookieMap = - (parsed as Partial).version === 1 - ? (parsed as Partial).cookieMap - : (parsed as Record); - - if (!cookieMap || typeof cookieMap !== 'object') return {}; - const normalized: Record = {}; - for (const [key, value] of Object.entries(cookieMap)) { - if (typeof value === 'string' && value.trim()) { - normalized[key] = value; - } - } - - if (Object.keys(normalized).length > 0) { - options?.log?.(`[gemini-web] Loaded cookies from ${cookiePath}`); - } - - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code === 'ENOENT') return {}; - options?.log?.( - `[gemini-web] Failed to read cookies from ${cookiePath}: ${error instanceof Error ? error.message : String(error)}`, - ); - return {}; - } -} - -export async function writeGeminiCookieMapToDisk( - cookieMap: Record, - options?: { cookiePath?: string; log?: GeminiWebLog }, -): Promise { - const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath(); - await mkdir(path.dirname(cookiePath), { recursive: true }); - - const payload: GeminiCookieFileV1 = { - version: 1, - updatedAt: new Date().toISOString(), - cookieMap, - }; - - await writeFile(cookiePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 }); - try { - await chmod(cookiePath, 0o600); - } catch { - // ignore chmod failures (e.g. on Windows) - } - options?.log?.(`[gemini-web] Saved cookies to ${cookiePath}`); -} diff --git a/skills/baoyu-gemini-web/scripts/executor.ts b/skills/baoyu-gemini-web/scripts/executor.ts deleted file mode 100644 index 51de0f9..0000000 --- a/skills/baoyu-gemini-web/scripts/executor.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js'; -import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js'; -import type { GeminiWebModelId, GeminiWebRunOutput } from './client.js'; -import { - buildGeminiCookieMap, - hasRequiredGeminiCookies, - readGeminiCookieMapFromDisk, -} from './cookie-store.js'; -import type { GeminiWebOptions, GeminiWebResponse } from './types.js'; - -export { hasRequiredGeminiCookies } from './cookie-store.js'; - -const USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; - -function estimateTokenCount(text: string): number { - return Math.ceil(text.length / 4); -} - -function resolveInvocationPath(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - if (!trimmed) return undefined; - return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed); -} - -function normalizePathList(value: string | string[] | undefined): string[] { - if (!value) return []; - const raw = Array.isArray(value) ? value : [value]; - const out: string[] = []; - for (const entry of raw) { - if (typeof entry !== 'string') continue; - const resolved = resolveInvocationPath(entry); - if (!resolved) continue; - out.push(resolved); - } - return out; -} - -function dedupePaths(paths: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const item of paths) { - const trimmed = item.trim(); - if (!trimmed || seen.has(trimmed)) continue; - seen.add(trimmed); - out.push(trimmed); - } - return out; -} - -function buildCookieHeader(cookieMap: Record): string { - return Object.entries(cookieMap) - .filter(([, value]) => typeof value === 'string' && value.length > 0) - .map(([name, value]) => `${name}=${value}`) - .join('; '); -} - -async function fetchWithCookiePreservingRedirects( - url: string, - init: Omit, - signal?: AbortSignal, - maxRedirects = 10, -): Promise { - let current = url; - for (let i = 0; i <= maxRedirects; i += 1) { - const res = await fetch(current, { ...init, redirect: 'manual', signal }); - if (res.status >= 300 && res.status < 400) { - const location = res.headers.get('location'); - if (!location) return res; - current = new URL(location, current).toString(); - continue; - } - return res; - } - throw new Error(`Too many redirects while downloading media (>${maxRedirects}).`); -} - -async function downloadGeminiMedia( - url: string, - cookieMap: Record, - outputPath: string, - signal?: AbortSignal, -): Promise { - const cookieHeader = buildCookieHeader(cookieMap); - const res = await fetchWithCookiePreservingRedirects( - url, - { - headers: { - cookie: cookieHeader, - 'user-agent': USER_AGENT, - }, - }, - signal, - ); - if (!res.ok) { - throw new Error(`Failed to download media: ${res.status} ${res.statusText} (${res.url})`); - } - - const data = new Uint8Array(await res.arrayBuffer()); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, data); -} - -function extractGgdlUrls(rawText: string): string[] { - const matches = - rawText.match(/https?:\/\/[^/\s"']*googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? []; - const seen = new Set(); - const urls: string[] = []; - for (const match of matches) { - if (seen.has(match)) continue; - seen.add(match); - urls.push(match); - } - return urls; -} - -async function saveFirstGeminiVideoFromOutput( - output: GeminiWebRunOutput, - cookieMap: Record, - outputPath: string, - signal?: AbortSignal, -): Promise<{ saved: boolean; videoCount: number }> { - const ggdl = extractGgdlUrls(output.rawResponseText); - if (!ggdl[0]) return { saved: false, videoCount: 0 }; - - const videoCandidates = ggdl.filter((url) => /\.(mp4|webm|mov)(?:$|[?#])/i.test(url)); - const preferred = - (videoCandidates.length > 0 ? videoCandidates[videoCandidates.length - 1] : null) ?? - ggdl.find((url) => /video/i.test(url)) ?? - ggdl[ggdl.length - 1]; - await downloadGeminiMedia(preferred, cookieMap, outputPath, signal); - return { saved: true, videoCount: ggdl.length }; -} - -function resolveGeminiWebModel( - desiredModel: string | null | undefined, - log?: BrowserLogger, -): GeminiWebModelId { - const desired = typeof desiredModel === 'string' ? desiredModel.trim() : ''; - if (!desired) return 'gemini-3-pro'; - - switch (desired) { - case 'gemini-3-pro': - case 'gemini-3.0-pro': - return 'gemini-3-pro'; - case 'gemini-2.5-pro': - return 'gemini-2.5-pro'; - case 'gemini-2.5-flash': - return 'gemini-2.5-flash'; - default: - if (desired.startsWith('gemini-')) { - log?.( - `[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`, - ); - } - return 'gemini-3-pro'; - } -} - -function buildInlineCookiesFromEnv(): CookieParam[] { - const cookies: CookieParam[] = []; - const psid = process.env.GEMINI_SECURE_1PSID?.trim(); - const psidts = process.env.GEMINI_SECURE_1PSIDTS?.trim(); - - if (psid) { - cookies.push({ name: '__Secure-1PSID', value: psid, domain: 'google.com', path: '/' }); - } - if (psidts) { - cookies.push({ name: '__Secure-1PSIDTS', value: psidts, domain: 'google.com', path: '/' }); - } - - return cookies; -} - -async function loadGeminiCookiesFromInline( - browserConfig: BrowserRunOptions['config'], - log?: BrowserLogger, -): Promise> { - const inline = browserConfig?.inlineCookies; - if (!inline || inline.length === 0) return {}; - - const cookieMap = buildGeminiCookieMap( - inline.filter((cookie): cookie is CookieParam => Boolean(cookie?.name && typeof cookie.value === 'string')), - ); - - if (Object.keys(cookieMap).length > 0) { - const source = browserConfig?.inlineCookiesSource ?? 'inline'; - log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`); - } else { - log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.'); - } - - return cookieMap; -} - -export async function loadGeminiCookies( - browserConfig: BrowserRunOptions['config'], - log?: BrowserLogger, -): Promise> { - const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log); - if (hasRequiredGeminiCookies(inlineMap)) return inlineMap; - - const diskMap = await readGeminiCookieMapFromDisk({ log }); - const merged = { ...diskMap, ...inlineMap }; - if (hasRequiredGeminiCookies(merged)) return merged; - - if (browserConfig?.cookieSync === false) { - log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.'); - return merged; - } - - log?.( - '[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and refresh cookies.', - ); - return merged; -} - -export async function loadGeminiCookieMap(log?: BrowserLogger): Promise> { - const diskMap = await readGeminiCookieMapFromDisk({ log }); - const inlineCookies = buildInlineCookiesFromEnv(); - const envMap = buildGeminiCookieMap(inlineCookies); - return { ...diskMap, ...envMap }; -} - -export function createGeminiWebExecutor( - geminiOptions: GeminiWebOptions, -): (runOptions: BrowserRunOptions) => Promise { - let persistedChatMetadata: unknown | null = null; - let referenceImagesUploaded = false; - - return async (runOptions: BrowserRunOptions): Promise => { - const startTime = Date.now(); - const log = runOptions.log; - - log?.('[gemini-web] Starting Gemini web executor (TypeScript)'); - - const cookieMap = await loadGeminiCookies(runOptions.config, log); - if (!hasRequiredGeminiCookies(cookieMap)) { - throw new Error( - 'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and save cookies.', - ); - } - - const configTimeout = - typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs) - ? Math.max(1_000, runOptions.config.timeoutMs) - : null; - - const generateVideoPath = resolveInvocationPath(geminiOptions.generateVideo); - - const defaultTimeoutMs = geminiOptions.youtube - ? 240_000 - : generateVideoPath - ? 900_000 - : geminiOptions.generateImage || geminiOptions.editImage - ? 300_000 - : 120_000; - - const timeoutCapMs = generateVideoPath ? 1_800_000 : 600_000; - const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, timeoutCapMs); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - - const keepSession = geminiOptions.keepSession === true; - - const generateImagePath = resolveInvocationPath(geminiOptions.generateImage); - const editImagePath = resolveInvocationPath(geminiOptions.editImage); - const outputPath = resolveInvocationPath(geminiOptions.outputPath); - const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path); - const referenceImagePaths = normalizePathList(geminiOptions.referenceImages); - const requestFilePaths = dedupePaths( - keepSession ? attachmentPaths : [...referenceImagePaths, ...attachmentPaths], - ); - - if (generateVideoPath && (generateImagePath || editImagePath)) { - throw new Error('Gemini web executor: generateVideo cannot be combined with generateImage/editImage options.'); - } - - let prompt = runOptions.prompt; - if (geminiOptions.aspectRatio && (generateImagePath || editImagePath || generateVideoPath)) { - prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`; - } - if (geminiOptions.youtube) { - prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`; - } - if (generateImagePath && !editImagePath) { - prompt = `Generate an image: ${prompt}`; - } - if (generateVideoPath) { - prompt = `Generate a video: ${prompt}`; - } - - const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log); - let response: GeminiWebResponse; - let videoSaveSummary: { saved: boolean; videoCount: number; outputPath: string } | null = null; - - try { - let chatMetadata: unknown = keepSession ? persistedChatMetadata : null; - - if (keepSession && referenceImagePaths.length > 0 && !referenceImagesUploaded) { - const intro = await runGeminiWebWithFallback({ - prompt: 'Here are reference images for future messages.', - files: referenceImagePaths, - model, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - chatMetadata = intro.metadata; - persistedChatMetadata = intro.metadata; - referenceImagesUploaded = true; - } - - if (editImagePath) { - const intro = await runGeminiWebWithFallback({ - prompt: 'Here is an image to edit', - files: [editImagePath], - model, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - const editPrompt = `Use image generation tool to ${prompt}`; - const out = await runGeminiWebWithFallback({ - prompt: editPrompt, - files: requestFilePaths, - model, - cookieMap, - chatMetadata: intro.metadata, - signal: controller.signal, - }); - if (keepSession) persistedChatMetadata = out.metadata; - response = { - text: out.text ?? null, - thoughts: geminiOptions.showThoughts ? out.thoughts : null, - has_images: false, - image_count: 0, - }; - - const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png'; - const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal); - response.has_images = imageSave.saved; - response.image_count = imageSave.imageCount; - if (!imageSave.saved) { - throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); - } - } else if (generateImagePath) { - const out = await runGeminiWebWithFallback({ - prompt, - files: requestFilePaths, - model, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - if (keepSession) persistedChatMetadata = out.metadata; - response = { - text: out.text ?? null, - thoughts: geminiOptions.showThoughts ? out.thoughts : null, - has_images: false, - image_count: 0, - }; - const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal); - response.has_images = imageSave.saved; - response.image_count = imageSave.imageCount; - if (!imageSave.saved) { - throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); - } - } else if (generateVideoPath) { - const out = await runGeminiWebWithFallback({ - prompt, - files: requestFilePaths, - model, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - if (keepSession) persistedChatMetadata = out.metadata; - response = { - text: out.text ?? null, - thoughts: geminiOptions.showThoughts ? out.thoughts : null, - has_images: false, - image_count: 0, - }; - - const resolvedOutputPath = generateVideoPath ?? outputPath ?? 'generated.mp4'; - const save = await saveFirstGeminiVideoFromOutput(out, cookieMap, resolvedOutputPath, controller.signal); - videoSaveSummary = { ...save, outputPath: resolvedOutputPath }; - } else { - const out = await runGeminiWebWithFallback({ - prompt, - files: requestFilePaths, - model, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - if (keepSession) persistedChatMetadata = out.metadata; - response = { - text: out.text ?? null, - thoughts: geminiOptions.showThoughts ? out.thoughts : null, - has_images: out.images.length > 0, - image_count: out.images.length, - }; - } - } finally { - clearTimeout(timeout); - } - - const answerText = response.text ?? ''; - let answerMarkdown = answerText; - - if (geminiOptions.showThoughts && response.thoughts) { - answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`; - } - - if (response.has_images && response.image_count > 0) { - const imagePath = generateImagePath || outputPath || 'generated.png'; - answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`; - } - if (videoSaveSummary) { - if (videoSaveSummary.saved) { - answerMarkdown += `\n\n*Generated ${videoSaveSummary.videoCount || 1} video(s). Saved to: ${videoSaveSummary.outputPath}*`; - } else if (/video_gen_chip/.test(answerMarkdown) || /video_gen_chip/.test(response.text ?? '')) { - answerMarkdown += '\n\n*Video generation is asynchronous. Check Gemini web UI to download the result.*'; - } else { - answerMarkdown += '\n\n*No downloadable video URL found in Gemini response.*'; - } - } - - const tookMs = Date.now() - startTime; - log?.(`[gemini-web] Completed in ${tookMs}ms`); - - return { - answerText, - answerMarkdown, - tookMs, - answerTokens: estimateTokenCount(answerText), - answerChars: answerText.length, - }; - }; -} diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/client.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/client.ts new file mode 100644 index 0000000..6603511 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/client.ts @@ -0,0 +1,627 @@ +import { Endpoint, ErrorCode, Headers, Model } from './constants.js'; +import { GemMixin } from './components/gem-mixin.js'; +import { + APIError, + AuthError, + GeminiError, + ImageGenerationError, + ModelInvalid, + TemporarilyBlocked, + TimeoutError, + UsageLimitExceeded, +} from './exceptions.js'; +import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js'; +import { + extract_json_from_response, + get_access_token, + get_nested_value, + logger, + parse_file_name, + rotate_1psidts, + rotate_tasks, + fetch_with_timeout, + sleep, + upload_file, + write_cookie_file, + resolveGeminiWebCookiePath, +} from './utils/index.js'; + +type InitOptions = { + timeout?: number; + auto_close?: boolean; + close_delay?: number; + auto_refresh?: boolean; + refresh_interval?: number; + verbose?: boolean; +}; + +type RequestKwargs = RequestInit & { timeout_ms?: number }; + +function normalize_headers(h?: HeadersInit): Record { + if (!h) return {}; + if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v])); + if (h instanceof Headers) { + const out: Record = {}; + h.forEach((v, k) => { + out[k] = v; + }); + return out; + } + return { ...(h as Record) }; +} + +function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] { + const out: string[] = []; + const seen = new Set(); + const stack: unknown[] = [root]; + + while (stack.length > 0 && out.length < limit) { + const v = stack.pop(); + if (typeof v === 'string') { + if (accept(v) && !seen.has(v)) { + seen.add(v); + out.push(v); + } + continue; + } + + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) stack.push(v[i]); + continue; + } + + if (v && typeof v === 'object') { + for (const val of Object.values(v as Record)) stack.push(val); + } + } + + return out; +} + +export class GeminiClient extends GemMixin { + public cookies: Record = {}; + public proxy: string | null = null; + public _running: boolean = false; + public access_token: string | null = null; + public timeout: number = 300; + public auto_close: boolean = false; + public close_delay: number = 300; + public auto_refresh: boolean = true; + public refresh_interval: number = 540; + public kwargs: RequestInit; + + private close_timer: ReturnType | null = null; + private refresh_abort: AbortController | null = null; + + constructor( + secure_1psid: string | null = null, + secure_1psidts: string | null = null, + proxy: string | null = null, + kwargs: RequestInit = {}, + ) { + super(); + this.proxy = proxy; + this.kwargs = kwargs; + + if (secure_1psid) { + this.cookies['__Secure-1PSID'] = secure_1psid; + if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts; + } + } + + async init( + timeoutOrOpts: number | InitOptions = 300, + auto_close: boolean = false, + close_delay: number = 300, + auto_refresh: boolean = true, + refresh_interval: number = 540, + verbose: boolean = true, + ): Promise { + const opts: InitOptions = + typeof timeoutOrOpts === 'object' + ? timeoutOrOpts + : { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose }; + + const timeout = opts.timeout ?? 300; + const ac = opts.auto_close ?? false; + const cd = opts.close_delay ?? 300; + const ar = opts.auto_refresh ?? true; + const ri = opts.refresh_interval ?? 540; + const vb = opts.verbose ?? true; + + try { + const [token, valid] = await get_access_token(this.cookies, this.proxy, vb); + this.access_token = token; + this.cookies = valid; + this._running = true; + + this.timeout = timeout; + this.auto_close = ac; + this.close_delay = cd; + if (this.auto_close) await this.reset_close_task(); + + this.auto_refresh = ar; + this.refresh_interval = ri; + + const sid = this.cookies['__Secure-1PSID']; + if (sid) { + const existing = rotate_tasks.get(sid); + if (existing && existing instanceof AbortController) existing.abort(); + rotate_tasks.delete(sid); + } + + if (this.auto_refresh && sid) { + const ctl = new AbortController(); + this.refresh_abort?.abort(); + this.refresh_abort = ctl; + rotate_tasks.set(sid, ctl); + void this.start_auto_refresh(ctl.signal); + } + + await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {}); + + if (vb) logger.success('Gemini client initialized successfully.'); + } catch (e) { + await this.close(); + throw e; + } + } + + async close(delay: number = 0): Promise { + if (delay > 0) await sleep(delay * 1000); + this._running = false; + + if (this.close_timer) { + clearTimeout(this.close_timer); + this.close_timer = null; + } + + this.refresh_abort?.abort(); + this.refresh_abort = null; + + const sid = this.cookies['__Secure-1PSID']; + const t = sid ? rotate_tasks.get(sid) : null; + if (t && t instanceof AbortController) t.abort(); + if (sid) rotate_tasks.delete(sid); + } + + async reset_close_task(): Promise { + if (this.close_timer) { + clearTimeout(this.close_timer); + this.close_timer = null; + } + + this.close_timer = setTimeout(() => { + void this.close(0); + }, this.close_delay * 1000); + this.close_timer.unref?.(); + } + + async start_auto_refresh(signal: AbortSignal): Promise { + while (!signal.aborted) { + let newTs: string | null = null; + try { + newTs = await rotate_1psidts(this.cookies, this.proxy); + } catch (e) { + if (e instanceof AuthError) { + logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.'); + return; + } + logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`); + } + + if (newTs) { + this.cookies['__Secure-1PSIDTS'] = newTs; + await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {}); + logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.'); + } + + await sleep(this.refresh_interval * 1000, signal); + } + } + + protected async _run(fn: () => Promise, retry: number): Promise { + try { + if (!this._running) { + await this.init({ + timeout: this.timeout, + auto_close: this.auto_close, + close_delay: this.close_delay, + auto_refresh: this.auto_refresh, + refresh_interval: this.refresh_interval, + verbose: false, + }); + + if (!this._running) { + throw new APIError('Client initialization failed.'); + } + } + + return await fn(); + } catch (e) { + let r = retry; + if (e instanceof ImageGenerationError) r = Math.min(1, r); + if (e instanceof APIError && r > 0) { + await sleep(1000); + return await this._run(fn, r - 1); + } + throw e; + } + } + + async generate_content( + prompt: string, + files: string[] | null = null, + model: Model | string | Record = Model.UNSPECIFIED, + gem: Gem | string | null = null, + chat: ChatSession | null = null, + kwargs: RequestKwargs = {}, + ): Promise { + return await this._run(async () => { + if (!prompt) throw new Error('Prompt cannot be empty.'); + + let mdl: Model; + if (typeof model === 'string') mdl = Model.from_name(model); + else if (model instanceof Model) mdl = model; + else if (model && typeof model === 'object') mdl = Model.from_dict(model); + else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`); + + const gem_id = gem instanceof Gem ? gem.id : gem; + + if (this.auto_close) await this.reset_close_task(); + + if (!this.access_token) throw new APIError('Missing access token.'); + + const f = files?.length ? files : null; + const uploaded = + f && + (await Promise.all( + f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]), + )); + + const first = uploaded ? [prompt, 0, null, uploaded] : [prompt]; + const inner: unknown[] = [first, null, chat ? chat.metadata : null]; + + if (gem_id) { + for (let i = 0; i < 16; i++) inner.push(null); + inner.push(gem_id); + } + + const f_req = JSON.stringify([null, JSON.stringify(inner)]); + const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString(); + + const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') }; + const h1 = { ...h0, ...normalize_headers(kwargs.headers) }; + + let res: Response; + try { + const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000; + const { timeout_ms: _t, ...rest } = kwargs; + res = await fetch_with_timeout(Endpoint.GENERATE, { + method: 'POST', + headers: h1, + body, + redirect: 'follow', + ...this.kwargs, + ...rest, + timeout_ms, + }); + } catch (e) { + throw new TimeoutError( + `Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`, + ); + } + + if (res.status !== 200) { + await this.close(); + throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`); + } + + const txt = await res.text(); + const response_json = extract_json_from_response(txt); + + let body_json: unknown[] | null = null; + let body_index = 0; + + try { + if (!Array.isArray(response_json)) throw new Error('Invalid JSON'); + for (let part_index = 0; part_index < response_json.length; part_index++) { + const part = response_json[part_index]; + if (!Array.isArray(part)) continue; + const part_body = get_nested_value(part, [2], null); + if (!part_body) continue; + try { + const part_json = JSON.parse(part_body) as unknown[]; + if (get_nested_value(part_json, [4], null)) { + body_index = part_index; + body_json = part_json; + break; + } + } catch {} + } + if (!body_json) throw new Error('No body'); + } catch { + await this.close(); + try { + const code = get_nested_value(response_json, [0, 5, 2, 0, 1, 0], -1); + if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) { + throw new UsageLimitExceeded( + `Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`, + ); + } + if (code === ErrorCode.MODEL_INCONSISTENT) { + throw new ModelInvalid( + 'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.', + ); + } + if (code === ErrorCode.MODEL_HEADER_INVALID) { + throw new ModelInvalid( + 'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.', + ); + } + if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) { + throw new TemporarilyBlocked( + 'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.', + ); + } + } catch (e) { + if (e instanceof GeminiError) throw e; + } + + logger.debug(`Invalid response: ${txt.slice(0, 500)}`); + throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.'); + } + + try { + const candidate_list = get_nested_value(body_json, [4], []); + const out: Candidate[] = []; + + for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) { + const candidate = candidate_list[candidate_index]; + if (!Array.isArray(candidate)) continue; + + const rcid = get_nested_value(candidate, [0], null); + if (!rcid) continue; + + let text = String(get_nested_value(candidate, [1, 0], '')); + if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) { + text = String(get_nested_value(candidate, [22, 0], text)); + } + + const thoughts = get_nested_value(candidate, [37, 0, 0], null); + + const web_images: WebImage[] = []; + for (const w of get_nested_value(candidate, [12, 1], [])) { + if (!Array.isArray(w)) continue; + const url = get_nested_value(w, [0, 0, 0], null); + if (!url) continue; + web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy)); + } + + const generated_images: GeneratedImage[] = []; + const wants_generated = + get_nested_value(candidate, [12, 7, 0], null) != null || + /http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text); + + if (wants_generated) { + let img_body: unknown[] | null = null; + for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) { + const part = (response_json as unknown[])[part_index]; + if (!Array.isArray(part)) continue; + const part_body = get_nested_value(part, [2], null); + if (!part_body) continue; + try { + const part_json = JSON.parse(part_body) as unknown[]; + const cand = get_nested_value(part_json, [4, candidate_index], null); + if (!cand) continue; + + const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1); + if (urls.length > 0) { + img_body = part_json; + break; + } + } catch {} + } + + if (!img_body) { + throw new ImageGenerationError( + 'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.', + ); + } + + const img_candidate = get_nested_value(img_body, [4, candidate_index], []); + const finished = get_nested_value(img_candidate, [1, 0], null); + if (finished) { + text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd(); + } + + const gen = get_nested_value(img_candidate, [12, 7, 0], []); + for (let img_index = 0; img_index < gen.length; img_index++) { + const g = gen[img_index]; + if (!Array.isArray(g)) continue; + const url = get_nested_value(g, [0, 3, 3], null); + if (!url) continue; + const img_num = get_nested_value(g, [3, 6], null); + const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]'; + const alt_list = get_nested_value(g, [3, 5], []); + const alt = + (typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ?? + (typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ?? + ''; + generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies)); + } + + if (generated_images.length === 0) { + const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4); + for (const url of urls) { + generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies)); + } + } + } + + out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images })); + } + + if (out.length === 0) { + throw new GeminiError('Failed to generate contents. No output data found in response.'); + } + + const metadata = get_nested_value(body_json, [1], []); + const output = new ModelOutput({ metadata, candidates: out }); + + if (chat instanceof ChatSession) chat.last_output = output; + return output; + } catch (e) { + if (e instanceof GeminiError || e instanceof APIError) throw e; + throw new APIError('Failed to parse response body. Data structure is invalid.'); + } + }, 2); + } + + async generateContent( + prompt: string, + files?: string[] | null, + model?: Model | string | Record, + gem?: Gem | string | null, + chat?: ChatSession | null, + kwargs?: RequestKwargs, + ): Promise { + return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {}); + } + + start_chat(opts?: ConstructorParameters[1]): ChatSession { + return new ChatSession(this, opts); + } + + startChat(opts?: ConstructorParameters[1]): ChatSession { + return this.start_chat(opts); + } + + protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise { + if (!this.access_token) throw new APIError('Missing access token.'); + + const f_req = JSON.stringify([payloads.map((p) => p.serialize())]); + const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString(); + + const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') }; + const h1 = { ...h0, ...normalize_headers(opts.headers) }; + + const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, { + method: 'POST', + headers: h1, + body, + redirect: 'follow', + ...this.kwargs, + ...opts, + timeout_ms: this.timeout * 1000, + }); + + if (res.status !== 200) { + await this.close(); + throw new APIError(`Batch execution failed with status code ${res.status}`); + } + + return res; + } +} + +export class ChatSession { + private __metadata: Array = [null, null, null]; + public geminiclient: GeminiClient; + private _last_output: ModelOutput | null = null; + public model: Model | string | Record; + public gem: Gem | string | null; + + constructor( + geminiclient: GeminiClient, + opts: { + metadata?: Array; + cid?: string | null; + rid?: string | null; + rcid?: string | null; + model?: Model | string | Record; + gem?: Gem | string | null; + } = {}, + ) { + this.geminiclient = geminiclient; + this.model = opts.model ?? Model.UNSPECIFIED; + this.gem = opts.gem ?? null; + + if (opts.metadata) this.metadata = opts.metadata; + if (opts.cid) this.cid = opts.cid; + if (opts.rid) this.rid = opts.rid; + if (opts.rcid) this.rcid = opts.rcid; + } + + toString(): string { + return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`; + } + + get last_output(): ModelOutput | null { + return this._last_output; + } + + set last_output(v: ModelOutput | null) { + this._last_output = v; + if (v) { + this.metadata = (v.metadata ?? []) as Array; + this.rcid = v.rcid; + } + } + + async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise { + return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs); + } + + async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise { + return await this.send_message(prompt, files ?? null, kwargs ?? {}); + } + + choose_candidate(index: number): ModelOutput { + if (!this.last_output) throw new Error('No previous output data found in this chat session.'); + if (index >= this.last_output.candidates.length) { + throw new Error(`Index ${index} exceeds the number of candidates in last model output.`); + } + this.last_output.chosen = index; + this.rcid = this.last_output.rcid; + return this.last_output; + } + + chooseCandidate(index: number): ModelOutput { + return this.choose_candidate(index); + } + + get metadata(): Array { + return this.__metadata; + } + + set metadata(v: Array) { + if (v.length > 3) throw new Error('metadata cannot exceed 3 elements'); + this.__metadata = [null, null, null]; + for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null; + } + + get cid(): string | null { + return this.__metadata[0]; + } + + set cid(v: string | null) { + this.__metadata[0] = v; + } + + get rid(): string | null { + return this.__metadata[1]; + } + + set rid(v: string | null) { + this.__metadata[1] = v; + } + + get rcid(): string | null { + return this.__metadata[2]; + } + + set rcid(v: string | null) { + this.__metadata[2] = v; + } +} diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/components/gem-mixin.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/components/gem-mixin.ts new file mode 100644 index 0000000..184f453 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/components/gem-mixin.ts @@ -0,0 +1,179 @@ +import { GRPC } from '../constants.js'; +import { APIError } from '../exceptions.js'; +import { Gem, GemJar, RPCData } from '../types/index.js'; +import { logger } from '../utils/logger.js'; +import { extract_json_from_response, get_nested_value } from '../utils/parsing.js'; + +export abstract class GemMixin { + protected _gems: GemJar | null = null; + + protected abstract _run(fn: () => Promise, retry: number): Promise; + protected abstract _batch_execute(payloads: RPCData[], opts?: RequestInit): Promise; + protected abstract close(delay?: number): Promise; + + get gems(): GemJar { + if (this._gems == null) { + throw new Error( + 'Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com.', + ); + } + return this._gems; + } + + async fetch_gems(include_hidden: boolean = false, opts?: RequestInit): Promise { + return await this._run(async () => { + const res = await this._batch_execute( + [ + new RPCData(GRPC.LIST_GEMS, include_hidden ? '[4]' : '[3]', 'system'), + new RPCData(GRPC.LIST_GEMS, '[2]', 'custom'), + ], + opts, + ); + + let response_json: unknown; + try { + response_json = extract_json_from_response(await res.text()); + if (!Array.isArray(response_json)) throw new Error('Invalid response'); + } catch { + await this.close(); + throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.'); + } + + let predefined: unknown[] = []; + let custom: unknown[] = []; + + try { + for (const part of response_json as unknown[]) { + if (!Array.isArray(part)) continue; + const ident = part[part.length - 1]; + const body = get_nested_value(part, [2], null); + if (!body) continue; + + if (ident === 'system') { + const parsed = JSON.parse(body) as unknown[]; + predefined = (Array.isArray(parsed) ? (parsed[2] as unknown[]) : []) ?? []; + } else if (ident === 'custom') { + const parsed = JSON.parse(body) as unknown[] | null; + if (parsed) custom = (parsed[2] as unknown[]) ?? []; + } + } + + if (predefined.length === 0 && custom.length === 0) throw new Error('No gems'); + } catch { + await this.close(); + logger.debug('Invalid response while parsing gems'); + throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.'); + } + + const entries: [string, Gem][] = []; + + for (const gem of predefined) { + if (!Array.isArray(gem)) continue; + const id = String(get_nested_value(gem, [0], '')); + if (!id) continue; + entries.push([ + id, + new Gem( + id, + String(get_nested_value(gem, [1, 0], '')), + get_nested_value(gem, [1, 1], null), + get_nested_value(gem, [2, 0], null), + true, + ), + ]); + } + + for (const gem of custom) { + if (!Array.isArray(gem)) continue; + const id = String(get_nested_value(gem, [0], '')); + if (!id) continue; + entries.push([ + id, + new Gem( + id, + String(get_nested_value(gem, [1, 0], '')), + get_nested_value(gem, [1, 1], null), + get_nested_value(gem, [2, 0], null), + false, + ), + ]); + } + + this._gems = new GemJar(entries); + return this._gems; + }, 2); + } + + async create_gem(name: string, prompt: string, description: string = ''): Promise { + return await this._run(async () => { + const payload = JSON.stringify([ + [ + name, + description, + prompt, + null, + null, + null, + null, + null, + 0, + null, + 1, + null, + null, + null, + [], + ], + ]); + + const res = await this._batch_execute([new RPCData(GRPC.CREATE_GEM, payload)]); + try { + const response_json = extract_json_from_response(await res.text()) as unknown[]; + const gem_id = JSON.parse(String((response_json[0] as unknown[])[2]))[0] as string; + return new Gem(gem_id, name, description, prompt, false); + } catch { + await this.close(); + throw new APIError('Failed to create gem. Invalid response data received. Client will try to re-initialize on next request.'); + } + }, 2); + } + + async update_gem(gem: Gem | string, name: string, prompt: string, description: string = ''): Promise { + return await this._run(async () => { + const gem_id = typeof gem === 'string' ? gem : gem.id; + const payload = JSON.stringify([ + gem_id, + [ + name, + description, + prompt, + null, + null, + null, + null, + null, + 0, + null, + 1, + null, + null, + null, + [], + 0, + ], + ]); + + await this._batch_execute([new RPCData(GRPC.UPDATE_GEM, payload)]); + return new Gem(gem_id, name, description, prompt, false); + }, 2); + } + + async delete_gem(gem: Gem | string, opts?: RequestInit): Promise { + return await this._run(async () => { + const gem_id = typeof gem === 'string' ? gem : gem.id; + const payload = JSON.stringify([gem_id]); + await this._batch_execute([new RPCData(GRPC.DELETE_GEM, payload)], opts); + }, 2); + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/components/index.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/components/index.ts new file mode 100644 index 0000000..9bd76e0 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/components/index.ts @@ -0,0 +1,2 @@ +export { GemMixin } from './gem-mixin.js'; + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/constants.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/constants.ts new file mode 100644 index 0000000..73916f9 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/constants.ts @@ -0,0 +1,107 @@ +export const Endpoint = { + GOOGLE: 'https://www.google.com', + INIT: 'https://gemini.google.com/app', + GENERATE: + 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate', + ROTATE_COOKIES: 'https://accounts.google.com/RotateCookies', + UPLOAD: 'https://content-push.googleapis.com/upload', + BATCH_EXEC: 'https://gemini.google.com/_/BardChatUi/data/batchexecute', +} as const; + +export const GRPC = { + LIST_CHATS: 'MaZiqc', + READ_CHAT: 'hNvQHb', + LIST_GEMS: 'CNgdBe', + CREATE_GEM: 'oMH3Zd', + UPDATE_GEM: 'kHv0Vd', + DELETE_GEM: 'UXcSJb', +} as const; + +export const Headers = { + GEMINI: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Host: 'gemini.google.com', + Origin: 'https://gemini.google.com', + Referer: 'https://gemini.google.com/', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'X-Same-Domain': '1', + }, + ROTATE_COOKIES: { + 'Content-Type': 'application/json', + }, + UPLOAD: { + 'Push-ID': 'feeds/mcudyrk2a4khkz', + }, +} as const; + +export const ErrorCode = { + TEMPORARY_ERROR_1013: 1013, + USAGE_LIMIT_EXCEEDED: 1037, + MODEL_INCONSISTENT: 1050, + MODEL_HEADER_INVALID: 1052, + IP_TEMPORARILY_BLOCKED: 1060, +} as const; + +export class Model { + static readonly UNSPECIFIED = new Model('unspecified', {}, false); + static readonly G_3_0_PRO = new Model( + 'gemini-3.0-pro', + { 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' }, + false, + ); + static readonly G_2_5_PRO = new Model( + 'gemini-2.5-pro', + { 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' }, + false, + ); + static readonly G_2_5_FLASH = new Model( + 'gemini-2.5-flash', + { 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' }, + false, + ); + + constructor( + public readonly model_name: string, + public readonly model_header: Record, + public readonly advanced_only: boolean, + ) {} + + static from_name(name: string): Model { + for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) { + if (model.model_name === name) return model; + } + + throw new Error( + `Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH] + .map((m) => m.model_name) + .join(', ')}`, + ); + } + + static from_dict(model_dict: { model_name?: unknown; model_header?: unknown }): Model { + if (!model_dict || typeof model_dict !== 'object') { + throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided."); + } + + if (!('model_name' in model_dict) || !('model_header' in model_dict)) { + throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided."); + } + + if (typeof model_dict.model_name !== 'string' || !model_dict.model_name.trim()) { + throw new Error("When passing a custom model as a dictionary, 'model_name' must be a non-empty string."); + } + + if (!model_dict.model_header || typeof model_dict.model_header !== 'object') { + throw new Error("When passing a custom model as a dictionary, 'model_header' must be a dictionary containing valid header strings."); + } + + const header: Record = {}; + for (const [k, v] of Object.entries(model_dict.model_header as Record)) { + if (typeof v === 'string') header[k] = v; + } + + return new Model(model_dict.model_name, header, false); + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/exceptions.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/exceptions.ts new file mode 100644 index 0000000..bd06a5c --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/exceptions.ts @@ -0,0 +1,56 @@ +export class AuthError extends Error { + constructor(message = 'AuthError') { + super(message); + this.name = 'AuthError'; + } +} + +export class APIError extends Error { + constructor(message = 'APIError') { + super(message); + this.name = 'APIError'; + } +} + +export class ImageGenerationError extends APIError { + constructor(message = 'ImageGenerationError') { + super(message); + this.name = 'ImageGenerationError'; + } +} + +export class GeminiError extends Error { + constructor(message = 'GeminiError') { + super(message); + this.name = 'GeminiError'; + } +} + +export class TimeoutError extends GeminiError { + constructor(message = 'TimeoutError') { + super(message); + this.name = 'TimeoutError'; + } +} + +export class UsageLimitExceeded extends GeminiError { + constructor(message = 'UsageLimitExceeded') { + super(message); + this.name = 'UsageLimitExceeded'; + } +} + +export class ModelInvalid extends GeminiError { + constructor(message = 'ModelInvalid') { + super(message); + this.name = 'ModelInvalid'; + } +} + +export class TemporarilyBlocked extends GeminiError { + constructor(message = 'TemporarilyBlocked') { + super(message); + this.name = 'TemporarilyBlocked'; + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/index.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/index.ts new file mode 100644 index 0000000..b5e4a54 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/index.ts @@ -0,0 +1,8 @@ +export { GeminiClient, ChatSession } from './client.js'; + +export * from './exceptions.js'; +export * from './types/index.js'; +export * from './constants.js'; +export { logger, set_log_level, setLogLevel } from './utils/logger.js'; +export * as utils from './utils/index.js'; + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/candidate.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/candidate.ts new file mode 100644 index 0000000..f4e1f94 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/candidate.ts @@ -0,0 +1,45 @@ +import { GeneratedImage, type Image, WebImage } from './image.js'; + +function decode_html(s: string | null | undefined): string | null | undefined { + if (s == null) return s; + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))); +} + +export class Candidate { + public rcid: string; + public text: string; + public thoughts: string | null; + public web_images: WebImage[]; + public generated_images: GeneratedImage[]; + + constructor(params: { + rcid: string; + text: string; + thoughts?: string | null; + web_images?: WebImage[]; + generated_images?: GeneratedImage[]; + }) { + this.rcid = params.rcid; + this.text = decode_html(params.text) ?? ''; + this.thoughts = decode_html(params.thoughts) ?? null; + this.web_images = params.web_images ?? []; + this.generated_images = params.generated_images ?? []; + } + + toString(): string { + return this.text; + } + + get images(): Image[] { + return [...this.web_images, ...this.generated_images]; + } +} diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/gem.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/gem.ts new file mode 100644 index 0000000..c867f65 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/gem.ts @@ -0,0 +1,75 @@ +export class Gem { + constructor( + public id: string, + public name: string, + public description: string | null, + public prompt: string | null, + public predefined: boolean, + ) {} + + toString(): string { + return `Gem(id='${this.id}', name='${this.name}', description='${this.description}', prompt='${this.prompt}', predefined=${this.predefined})`; + } +} + +export class GemJar implements Iterable { + private m = new Map(); + + constructor(entries?: Iterable<[string, Gem]>) { + if (entries) for (const [id, gem] of entries) this.m.set(id, gem); + } + + [Symbol.iterator](): Iterator { + return this.m.values(); + } + + entries(): IterableIterator<[string, Gem]> { + return this.m.entries(); + } + + values(): IterableIterator { + return this.m.values(); + } + + has(id: string): boolean { + return this.m.has(id); + } + + set(id: string, gem: Gem): this { + this.m.set(id, gem); + return this; + } + + get(id?: string | null, name?: string | null, def: Gem | null = null): Gem | null { + if (id == null && name == null) { + throw new Error('At least one of gem id or name must be provided.'); + } + + if (id != null) { + const g = this.m.get(id) ?? null; + if (!g) return def; + if (name != null) return g.name === name ? g : def; + return g; + } + + if (name != null) { + for (const g of this.m.values()) { + if (g.name === name) return g; + } + return def; + } + + return def; + } + + filter(predefined: boolean | null = null, name: string | null = null): GemJar { + const out: [string, Gem][] = []; + for (const [id, gem] of this.m.entries()) { + if (predefined != null && gem.predefined !== predefined) continue; + if (name != null && gem.name !== name) continue; + out.push([id, gem]); + } + return new GemJar(out); + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/grpc.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/grpc.ts new file mode 100644 index 0000000..c5800c7 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/grpc.ts @@ -0,0 +1,16 @@ +export class RPCData { + constructor( + public rpcid: string, + public payload: string, + public identifier: string = 'generic', + ) {} + + toString(): string { + return `GRPC(rpcid='${this.rpcid}', payload='${this.payload}', identifier='${this.identifier}')`; + } + + serialize(): unknown[] { + return [this.rpcid, this.payload, null, this.identifier]; + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/image.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/image.ts new file mode 100644 index 0000000..98595bc --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/image.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import { logger } from '../utils/logger.js'; +import { cookie_header, fetch_with_timeout } from '../utils/http.js'; + +export class Image { + constructor( + public url: string, + public title = '[Image]', + public alt = '', + public proxy: string | null = null, + ) {} + + toString(): string { + const u = this.url.length <= 20 ? this.url : `${this.url.slice(0, 8)}...${this.url.slice(-12)}`; + return `Image(title='${this.title}', alt='${this.alt}', url='${u}')`; + } + + async save( + p: string = 'temp', + filename: string | null = null, + cookies: Record | null = null, + verbose: boolean = false, + skip_invalid_filename: boolean = false, + ): Promise { + filename = filename ?? this.url.split('/').pop()?.split('?')[0] ?? 'image'; + const m = filename.match(/^(.*\.\w+)/); + if (m) filename = m[1]!; + else { + if (verbose) logger.warning(`Invalid filename: ${filename}`); + if (skip_invalid_filename) return null; + } + + const headers: Record = { + 'User-Agent': 'Mozilla/5.0', + Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + Referer: 'https://gemini.google.com/', + }; + if (cookies) headers.Cookie = cookie_header(cookies); + + let url = this.url; + let res: Response | null = null; + for (let i = 0; i < 10; i++) { + res = await fetch_with_timeout(url, { + method: 'GET', + headers, + redirect: 'manual', + timeout_ms: 30_000, + }); + + if (res.status >= 300 && res.status < 400) { + const loc = res.headers.get('location'); + if (!loc) break; + url = new URL(loc, url).toString(); + continue; + } + + break; + } + + if (!res) throw new Error('Image download failed: no response'); + + if (!res.ok) { + throw new Error(`Error downloading image: ${res.status} ${res.statusText}`); + } + + const ct = res.headers.get('content-type'); + if (ct && !ct.includes('image')) { + logger.warning(`Content type of ${filename} is not image, but ${ct}.`); + } + + const dir = path.resolve(p); + await mkdir(dir, { recursive: true }); + + const dest = path.join(dir, filename); + const buf = Buffer.from(await res.arrayBuffer()); + await writeFile(dest, buf); + + if (verbose) logger.info(`Image saved as ${dest}`); + return dest; + } +} + +export class WebImage extends Image {} + +export class GeneratedImage extends Image { + constructor( + url: string, + title: string, + alt: string, + proxy: string | null, + public cookies: Record, + ) { + super(url, title, alt, proxy); + if (!cookies || Object.keys(cookies).length === 0) { + throw new Error('GeneratedImage is designed to be initialized with same cookies as GeminiClient.'); + } + } + + async save( + p: string = 'temp', + filename: string | null = null, + cookies: Record | null = null, + verbose: boolean = false, + skip_invalid_filename: boolean = false, + full_size: boolean = true, + ): Promise { + const u = full_size ? `${this.url}=s2048` : this.url; + const f = filename ?? `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}_${u.slice(-10)}.png`; + const img = new Image(u, this.title, this.alt, this.proxy); + return await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename); + } +} diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/index.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/index.ts new file mode 100644 index 0000000..ec68e33 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/index.ts @@ -0,0 +1,6 @@ +export { Candidate } from './candidate.js'; +export { Gem, GemJar } from './gem.js'; +export { RPCData } from './grpc.js'; +export { GeneratedImage, Image, WebImage } from './image.js'; +export { ModelOutput } from './modeloutput.js'; + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/types/modeloutput.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/modeloutput.ts new file mode 100644 index 0000000..fa4814e --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/types/modeloutput.ts @@ -0,0 +1,35 @@ +import type { Image } from './image.js'; +import type { Candidate } from './candidate.js'; + +export class ModelOutput { + public metadata: string[]; + public candidates: Candidate[]; + public chosen: number; + + constructor(params: { metadata: string[]; candidates: Candidate[]; chosen?: number }) { + this.metadata = params.metadata; + this.candidates = params.candidates; + this.chosen = params.chosen ?? 0; + } + + toString(): string { + return this.text; + } + + get text(): string { + return this.candidates[this.chosen]?.text ?? ''; + } + + get thoughts(): string | null { + return this.candidates[this.chosen]?.thoughts ?? null; + } + + get images(): Image[] { + return this.candidates[this.chosen]?.images ?? []; + } + + get rcid(): string { + return this.candidates[this.chosen]?.rcid ?? ''; + } +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/cookie-file.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/cookie-file.ts new file mode 100644 index 0000000..7362182 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/cookie-file.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +import { resolveGeminiWebCookiePath } from './paths.js'; + +export type CookieMap = Record; + +export type CookieFileData = + | { + cookies: CookieMap; + updated_at: number; + source?: string; + } + | { + version: number; + updatedAt: string; + cookieMap: CookieMap; + source?: string; + }; + +export async function read_cookie_file(p: string = resolveGeminiWebCookiePath()): Promise { + try { + if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null; + const raw = await readFile(p, 'utf8'); + const data = JSON.parse(raw) as unknown; + + if (data && typeof data === 'object' && 'cookies' in (data as any)) { + const cookies = (data as any).cookies as unknown; + if (cookies && typeof cookies === 'object') { + const out: CookieMap = {}; + for (const [k, v] of Object.entries(cookies as Record)) { + if (typeof v === 'string') out[k] = v; + } + return out; + } + } + + if (data && typeof data === 'object' && 'cookieMap' in (data as any)) { + const cookies = (data as any).cookieMap as unknown; + if (cookies && typeof cookies === 'object') { + const out: CookieMap = {}; + for (const [k, v] of Object.entries(cookies as Record)) { + if (typeof v === 'string') out[k] = v; + } + return Object.keys(out).length > 0 ? out : null; + } + } + + if (data && typeof data === 'object') { + const out: CookieMap = {}; + for (const [k, v] of Object.entries(data as Record)) { + if (typeof v === 'string') out[k] = v; + } + return Object.keys(out).length > 0 ? out : null; + } + + return null; + } catch { + return null; + } +} + +export async function write_cookie_file( + cookies: CookieMap, + p: string = resolveGeminiWebCookiePath(), + source?: string, +): Promise { + const dir = path.dirname(p); + await mkdir(dir, { recursive: true }); + + const payload: CookieFileData = { + version: 1, + updatedAt: new Date().toISOString(), + cookieMap: cookies, + source, + }; + await writeFile(p, JSON.stringify(payload, null, 2), 'utf8'); +} + +export const readCookieFile = read_cookie_file; +export const writeCookieFile = write_cookie_file; diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/decorators.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/decorators.ts new file mode 100644 index 0000000..ee8b64c --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/decorators.ts @@ -0,0 +1,34 @@ +import { APIError, ImageGenerationError } from '../exceptions.js'; +import { sleep } from './http.js'; + +export function running(retry: number = 0) { + return ( + fn: (client: any, ...args: TArgs) => Promise, + ): ((client: any, ...args: TArgs) => Promise) => { + const wrap = async (client: any, ...args: TArgs): Promise => { + try { + if (!client?._running) { + await client.init?.({ + timeout: client.timeout, + auto_close: client.auto_close, + close_delay: client.close_delay, + auto_refresh: client.auto_refresh, + refresh_interval: client.refresh_interval, + verbose: false, + }); + } + return await fn(client, ...args); + } catch (e) { + let r = retry; + if (e instanceof ImageGenerationError) r = Math.min(1, r); + if (e instanceof APIError && r > 0) { + await sleep(1000); + return await running(r - 1)(fn)(client, ...args); + } + throw e; + } + }; + return wrap; + }; +} + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/get-access-token.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/get-access-token.ts new file mode 100644 index 0000000..9272972 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/get-access-token.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import { Endpoint, Headers } from '../constants.js'; +import { AuthError } from '../exceptions.js'; +import { cookie_header, extract_set_cookie_value, fetch_with_timeout } from './http.js'; +import { logger } from './logger.js'; +import { read_cookie_file, write_cookie_file } from './cookie-file.js'; +import { resolveGeminiWebDataDir, resolveGeminiWebCookiePath } from './paths.js'; +import { load_browser_cookies } from './load-browser-cookies.js'; + +async function send_request(cookies: Record, verbose: boolean): Promise<[string, Record]> { + const res = await fetch_with_timeout(Endpoint.INIT, { + method: 'GET', + headers: { ...Headers.GEMINI, Cookie: cookie_header(cookies) }, + redirect: 'follow', + timeout_ms: 30_000, + }); + + if (!res.ok) throw new Error(`Init failed: ${res.status} ${res.statusText}`); + const text = await res.text(); + const m = text.match(/\"SNlM0e\":\"(.*?)\"/); + if (!m) throw new Error('Missing SNlM0e in response'); + if (verbose) logger.debug('Init succeeded. Initializing client...'); + return [m[1]!, cookies]; +} + +function merge_cookie_maps(...maps: Array | null | undefined>): Record { + const out: Record = {}; + for (const m of maps) { + if (!m) continue; + for (const [k, v] of Object.entries(m)) { + if (typeof v === 'string' && v.length > 0) out[k] = v; + } + } + return out; +} + +function read_cached_1psidts_file(dir: string, sid: string): string | null { + try { + const p = path.join(dir, `.cached_1psidts_${sid}.txt`); + if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null; + const v = fs.readFileSync(p, 'utf8').trim(); + return v || null; + } catch { + return null; + } +} + +function list_cached_1psidts(dir: string): Array<{ sid: string; sidts: string }> { + const out: Array<{ sid: string; sidts: string }> = []; + try { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return out; + for (const f of fs.readdirSync(dir)) { + if (!f.startsWith('.cached_1psidts_') || !f.endsWith('.txt')) continue; + const sid = f.slice('.cached_1psidts_'.length, -'.txt'.length); + if (!sid) continue; + const sidts = read_cached_1psidts_file(dir, sid); + if (sidts) out.push({ sid, sidts }); + } + } catch {} + return out; +} + +async function fetch_google_extra_cookies(proxy: string | null, verbose: boolean): Promise> { + void proxy; + try { + const res = await fetch_with_timeout(Endpoint.GOOGLE, { timeout_ms: 15_000 }); + const setCookie = res.headers.get('set-cookie'); + const nid = extract_set_cookie_value(setCookie, 'NID'); + if (nid) return { NID: nid }; + } catch (e) { + if (verbose) logger.debug(`Skipping google.com preflight: ${e instanceof Error ? e.message : String(e)}`); + } + return {}; +} + +export async function get_access_token( + base_cookies: Record, + proxy: string | null = null, + verbose: boolean = false, +): Promise<[string, Record]> { + const extra = await fetch_google_extra_cookies(proxy, verbose); + + const cacheDir = resolveGeminiWebDataDir(); + const candidates: Record[] = []; + + const cookieFilePath = resolveGeminiWebCookiePath(); + const cachedFile = await read_cookie_file(cookieFilePath); + const forceLogin = !!(process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim()); + const shouldUseChromeFirst = forceLogin || (!cachedFile && !base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']); + + if (shouldUseChromeFirst) { + try { + const browser = await load_browser_cookies('google.com', verbose); + for (const cookies of Object.values(browser)) { + candidates.push(merge_cookie_maps(extra, cookies)); + } + } catch (e) { + if (verbose) logger.warning(`Failed to load cookies via Chrome CDP: ${e instanceof Error ? e.message : String(e)}`); + } + } + + if (base_cookies['__Secure-1PSID'] && base_cookies['__Secure-1PSIDTS']) { + candidates.push(merge_cookie_maps(extra, base_cookies)); + } else if (verbose) { + logger.debug('Skipping loading base cookies. Either __Secure-1PSID or __Secure-1PSIDTS is not provided.'); + } + + if (cachedFile) { + candidates.push(merge_cookie_maps(extra, cachedFile)); + } + + if (base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']) { + const sid = base_cookies['__Secure-1PSID']; + const sidts = read_cached_1psidts_file(cacheDir, sid); + if (sidts) { + candidates.push(merge_cookie_maps(extra, base_cookies, { '__Secure-1PSIDTS': sidts })); + } else if (verbose) { + logger.debug('Skipping loading cached cookies. Cache file not found or empty.'); + } + } else if (!base_cookies['__Secure-1PSID']) { + const caches = list_cached_1psidts(cacheDir); + for (const c of caches) { + candidates.push(merge_cookie_maps(extra, { '__Secure-1PSID': c.sid, '__Secure-1PSIDTS': c.sidts })); + } + if (caches.length === 0 && verbose) { + logger.debug('Skipping loading cached cookies. Cookies will be cached after successful initialization.'); + } + } + + const unique: Record[] = []; + const seen = new Set(); + for (const c of candidates) { + const key = `${c['__Secure-1PSID'] ?? ''}:${c['__Secure-1PSIDTS'] ?? ''}:${c.NID ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(c); + } + + const try_candidates = async (): Promise<[string, Record]> => { + if (unique.length === 0) throw new Error('no candidates'); + const attempts = unique.map(async (c, i) => { + try { + if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length})...`); + return await send_request(c, verbose); + } catch (e) { + if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length}) failed: ${e instanceof Error ? e.message : String(e)}`); + throw e; + } + }); + return (await Promise.any(attempts)) as [string, Record]; + }; + + try { + const [token, cookies] = await try_candidates(); + await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {}); + return [token, cookies]; + } catch { + if (verbose) logger.debug('Cookie attempts failed. Falling back to Chrome CDP cookie load...'); + } + + const browser = await load_browser_cookies('google.com', verbose); + let valid = 0; + for (const cookies of Object.values(browser)) { + if (cookies['__Secure-1PSID']) valid++; + if (base_cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] !== base_cookies['__Secure-1PSID']) { + if (verbose) logger.debug('Skipping loaded browser cookies: __Secure-1PSID does not match the one provided.'); + continue; + } + unique.push(merge_cookie_maps(extra, cookies)); + } + + if (valid === 0) { + throw new AuthError( + 'No valid cookies available for initialization. Please pass __Secure-1PSID and __Secure-1PSIDTS manually.', + ); + } + + try { + const [token, cookies] = await try_candidates(); + await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {}); + return [token, cookies]; + } catch { + throw new AuthError( + `Failed to initialize client. SECURE_1PSIDTS could get expired frequently, please make sure cookie values are up to date. (Failed initialization attempts: ${unique.length})`, + ); + } +} + +export const getAccessToken = get_access_token; diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/http.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/http.ts new file mode 100644 index 0000000..2cf1b3f --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/http.ts @@ -0,0 +1,57 @@ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(t); + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + } + }); +} + +export function cookie_header(cookies: Record): string { + return Object.entries(cookies) + .filter(([, v]) => typeof v === 'string' && v.length > 0) + .map(([k, v]) => `${k}=${v}`) + .join('; '); +} + +export const cookieHeader = cookie_header; + +export function extract_set_cookie_value(setCookie: string | null, name: string): string | null { + if (!setCookie) return null; + const re = new RegExp(`(?:^|[;,\\s])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}=([^;]+)`, 'i'); + const m = setCookie.match(re); + if (!m) return null; + return m[1] ?? null; +} + +export async function fetch_with_timeout( + url: string, + init: RequestInit & { timeout_ms?: number } = {}, +): Promise { + const { timeout_ms, ...rest } = init; + if (!timeout_ms || timeout_ms <= 0) return fetch(url, rest); + + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), timeout_ms); + try { + return await fetch(url, { ...rest, signal: ctl.signal }); + } finally { + clearTimeout(t); + } +} + +export const fetchWithTimeout = fetch_with_timeout; diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/index.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/index.ts new file mode 100644 index 0000000..6a7e7f1 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/index.ts @@ -0,0 +1,20 @@ +export { running } from './decorators.js'; +export { get_access_token, getAccessToken } from './get-access-token.js'; +export { load_browser_cookies, loadBrowserCookies } from './load-browser-cookies.js'; +export { logger, set_log_level, setLogLevel } from './logger.js'; +export { extract_json_from_response, extractJsonFromResponse, get_nested_value, getNestedValue } from './parsing.js'; +export { rotate_1psidts, rotate1psidts } from './rotate-1psidts.js'; +export { upload_file, uploadFile, parse_file_name, parseFileName } from './upload-file.js'; +export { read_cookie_file, readCookieFile, write_cookie_file, writeCookieFile } from './cookie-file.js'; +export { + resolveUserDataRoot, + resolveGeminiWebChromeProfileDir, + resolveGeminiWebCookiePath, + resolveGeminiWebDataDir, + resolveGeminiWebSessionPath, + resolveGeminiWebSessionsDir, +} from './paths.js'; +export { cookie_header, cookieHeader, fetch_with_timeout, fetchWithTimeout, sleep } from './http.js'; + +export const rotate_tasks = new Map(); + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts new file mode 100644 index 0000000..b4216d9 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts @@ -0,0 +1,271 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import net from 'node:net'; +import process from 'node:process'; + +import { logger } from './logger.js'; +import { fetch_with_timeout, sleep } from './http.js'; +import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js'; +import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; + +type CdpSendOptions = { sessionId?: string; timeoutMs?: number }; + +class CdpConnection { + private ws: WebSocket; + private nextId = 0; + private pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType | null } + >(); + + private constructor(ws: WebSocket) { + this.ws = ws; + this.ws.addEventListener('message', (event) => { + try { + const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } }; + if (msg.id) { + const p = this.pending.get(msg.id); + if (p) { + this.pending.delete(msg.id); + if (p.timer) clearTimeout(p.timer); + if (msg.error?.message) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + } + } + } catch {} + }); + this.ws.addEventListener('close', () => { + for (const [id, p] of this.pending.entries()) { + this.pending.delete(id); + if (p.timer) clearTimeout(p.timer); + p.reject(new Error('CDP connection closed.')); + } + }); + } + + static async connect(url: string, timeoutMs: number): Promise { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs); + ws.addEventListener('open', () => { + clearTimeout(t); + resolve(); + }); + ws.addEventListener('error', () => { + clearTimeout(t); + reject(new Error('CDP connection failed.')); + }); + }); + return new CdpConnection(ws); + } + + async send(method: string, params?: Record, opts?: CdpSendOptions): Promise { + const id = ++this.nextId; + const msg: Record = { id, method }; + if (params) msg.params = params; + if (opts?.sessionId) msg.sessionId = opts.sessionId; + + const timeoutMs = opts?.timeoutMs ?? 15_000; + const out = await new Promise((resolve, reject) => { + const t = + timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP timeout: ${method}`)); + }, timeoutMs) + : null; + this.pending.set(id, { resolve, reject, timer: t }); + this.ws.send(JSON.stringify(msg)); + }); + return out as T; + } + + close(): void { + try { + this.ws.close(); + } catch {} + } +} + +async function get_free_port(): Promise { + return await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (!addr || typeof addr === 'string') { + srv.close(() => reject(new Error('Unable to allocate a free TCP port.'))); + return; + } + const port = addr.port; + srv.close((err) => (err ? reject(err) : resolve(port))); + }); + }); +} + +function find_chrome_executable(): string | null { + const override = process.env.GEMINI_WEB_CHROME_PATH?.trim(); + if (override && fs.existsSync(override)) return override; + + const candidates: string[] = []; + switch (process.platform) { + case 'darwin': + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ); + break; + case 'win32': + candidates.push( + 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + 'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', + 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', + ); + break; + default: + candidates.push( + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ); + break; + } + + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return null; +} + +async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 }); + if (!res.ok) throw new Error(`status=${res.status}`); + const j = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; + } catch {} + await sleep(200); + } + throw new Error('Chrome debug port not ready'); +} + +async function launch_chrome(profileDir: string, port: number): Promise { + const chrome = find_chrome_executable(); + if (!chrome) throw new Error('Chrome executable not found.'); + + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-popup-blocking', + 'https://gemini.google.com/app', + ]; + + return spawn(chrome, args, { stdio: 'ignore' }); +} + +async function fetch_google_cookies_via_cdp( + profileDir: string, + timeoutMs: number, + verbose: boolean, +): Promise { + await mkdir(profileDir, { recursive: true }); + + const port = await get_free_port(); + const chrome = await launch_chrome(profileDir, port); + + let cdp: CdpConnection | null = null; + try { + const wsUrl = await wait_for_chrome_debug_port(port, 30_000); + cdp = await CdpConnection.connect(wsUrl, 15_000); + + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { + url: 'https://gemini.google.com/app', + newWindow: true, + }); + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }); + await cdp.send('Network.enable', {}, { sessionId }); + + if (verbose) { + logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for cookies...'); + } + + const start = Date.now(); + let last: CookieMap = {}; + + while (Date.now() - start < timeoutMs) { + const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>( + 'Network.getCookies', + { urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] }, + { sessionId, timeoutMs: 10_000 }, + ); + + const m: CookieMap = {}; + for (const c of cookies) { + if (c?.name && typeof c.value === 'string') m[c.name] = c.value; + } + + last = m; + if (m['__Secure-1PSID'] && (m['__Secure-1PSIDTS'] || Date.now() - start > 10_000)) { + return m; + } + + await sleep(1000); + } + + throw new Error(`Timed out waiting for Google cookies. Last keys: ${Object.keys(last).join(', ')}`); + } finally { + if (cdp) { + try { + await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); + } catch {} + cdp.close(); + } + + try { + chrome.kill('SIGTERM'); + } catch {} + setTimeout(() => { + if (!chrome.killed) { + try { + chrome.kill('SIGKILL'); + } catch {} + } + }, 2_000).unref?.(); + } +} + +export async function load_browser_cookies(domain_name: string = '', verbose: boolean = true): Promise> { + const force = process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim(); + if (!force) { + const cached = await read_cookie_file(); + if (cached) return { chrome: cached }; + } + + const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir(); + const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose); + + const filtered: CookieMap = {}; + for (const [k, v] of Object.entries(cookies)) { + if (typeof v === 'string' && v.length > 0) filtered[k] = v; + } + + await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp'); + void domain_name; + return { chrome: filtered }; +} + +export const loadBrowserCookies = load_browser_cookies; diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/logger.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/logger.ts new file mode 100644 index 0000000..7923d23 --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/logger.ts @@ -0,0 +1,42 @@ +export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | number; + +const lvl: Record, number> = { + TRACE: 0, + DEBUG: 1, + INFO: 2, + WARNING: 3, + ERROR: 4, + CRITICAL: 5, +}; + +let cur = lvl.INFO; + +function toNum(level: LogLevel): number { + if (typeof level === 'number') return level; + return lvl[level] ?? lvl.INFO; +} + +export function set_log_level(level: LogLevel): void { + cur = toNum(level); +} + +export const setLogLevel = set_log_level; + +function emit(level: Exclude, args: unknown[]): void { + if (lvl[level] < cur) return; + const prefix = `[gemini_webapi] ${level}:`; + + if (level === 'WARNING') console.warn(prefix, ...args); + else if (level === 'ERROR' || level === 'CRITICAL') console.error(prefix, ...args); + else console.log(prefix, ...args); +} + +export const logger = { + trace: (...args: unknown[]) => emit('TRACE', args), + debug: (...args: unknown[]) => emit('DEBUG', args), + info: (...args: unknown[]) => emit('INFO', args), + warning: (...args: unknown[]) => emit('WARNING', args), + error: (...args: unknown[]) => emit('ERROR', args), + success: (...args: unknown[]) => emit('INFO', args), +}; + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/parsing.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/parsing.ts new file mode 100644 index 0000000..ab5521c --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/parsing.ts @@ -0,0 +1,44 @@ +import { logger } from './logger.js'; + +export function get_nested_value(data: unknown, path: number[], def?: T): T { + let cur: unknown = data; + for (let i = 0; i < path.length; i++) { + const k = path[i]!; + if (!Array.isArray(cur)) { + logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`); + return def as T; + } + cur = cur[k]; + if (cur === undefined) { + logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`); + return def as T; + } + } + + if (cur == null && def !== undefined) return def as T; + return cur as T; +} + +export function extract_json_from_response(text: string): unknown { + if (typeof text !== 'string') { + throw new TypeError(`Input text is expected to be a string, got ${typeof text} instead.`); + } + + let last: unknown = undefined; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + last = JSON.parse(trimmed) as unknown; + } catch {} + } + + if (last === undefined) { + throw new Error('Could not find a valid JSON object or array in the response.'); + } + + return last; +} + +export const extractJsonFromResponse = extract_json_from_response; +export const getNestedValue = get_nested_value; diff --git a/skills/baoyu-gemini-web/scripts/paths.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/paths.ts similarity index 99% rename from skills/baoyu-gemini-web/scripts/paths.ts rename to skills/baoyu-gemini-web/scripts/gemini-webapi/utils/paths.ts index 406bd1f..5eff4de 100644 --- a/skills/baoyu-gemini-web/scripts/paths.ts +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/paths.ts @@ -43,3 +43,4 @@ export function resolveGeminiWebSessionPath(name: string): string { const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_'); return path.join(resolveGeminiWebSessionsDir(), `${sanitized}.json`); } + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/rotate-1psidts.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/rotate-1psidts.ts new file mode 100644 index 0000000..0657b0b --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/rotate-1psidts.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import { Endpoint, Headers } from '../constants.js'; +import { AuthError } from '../exceptions.js'; +import { cookie_header, extract_set_cookie_value, fetch_with_timeout } from './http.js'; +import { resolveGeminiWebDataDir } from './paths.js'; + +export async function rotate_1psidts(cookies: Record, _proxy?: string | null): Promise { + const p = resolveGeminiWebDataDir(); + await mkdir(p, { recursive: true }); + + const sid = cookies['__Secure-1PSID']; + if (!sid) throw new Error('Missing __Secure-1PSID cookie.'); + + const cachePath = path.join(p, `.cached_1psidts_${sid}.txt`); + + try { + const st = fs.statSync(cachePath); + if (Date.now() - st.mtimeMs <= 60_000) return null; + } catch {} + + const res = await fetch_with_timeout(Endpoint.ROTATE_COOKIES, { + method: 'POST', + headers: { ...Headers.ROTATE_COOKIES, Cookie: cookie_header(cookies) }, + body: '[000,"-0000000000000000000"]', + redirect: 'follow', + timeout_ms: 30_000, + }); + + if (res.status === 401) throw new AuthError('Failed to refresh cookies (401).'); + if (!res.ok) throw new Error(`RotateCookies failed: ${res.status} ${res.statusText}`); + + const setCookie = res.headers.get('set-cookie'); + const v = extract_set_cookie_value(setCookie, '__Secure-1PSIDTS'); + if (v) { + await writeFile(cachePath, v, 'utf8'); + return v; + } + + return null; +} + +export const rotate1psidts = rotate_1psidts; + diff --git a/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/upload-file.ts b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/upload-file.ts new file mode 100644 index 0000000..634126c --- /dev/null +++ b/skills/baoyu-gemini-web/scripts/gemini-webapi/utils/upload-file.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import { Endpoint, Headers } from '../constants.js'; + +export async function upload_file(file: string, _proxy?: string | null): Promise { + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + throw new Error(`${file} is not a valid file.`); + } + + const filename = path.basename(file); + const content = await readFile(file); + + const form = new FormData(); + form.append('file', new Blob([content]), filename); + + const res = await fetch(Endpoint.UPLOAD, { + method: 'POST', + headers: { ...Headers.UPLOAD }, + body: form, + redirect: 'follow', + }); + + if (!res.ok) { + throw new Error(`Upload failed: ${res.status} ${res.statusText}`); + } + + return await res.text(); +} + +export function parse_file_name(file: string): string { + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + throw new Error(`${file} is not a valid file.`); + } + return path.basename(file); +} + +export const uploadFile = upload_file; +export const parseFileName = parse_file_name; + diff --git a/skills/baoyu-gemini-web/scripts/get-cookie.ts b/skills/baoyu-gemini-web/scripts/get-cookie.ts deleted file mode 100644 index d3d9216..0000000 --- a/skills/baoyu-gemini-web/scripts/get-cookie.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env -S npx -y bun - -import process from 'node:process'; - -import { getGeminiCookieMapViaChrome } from './chrome-auth.js'; -import { writeGeminiCookieMapToDisk } from './cookie-store.js'; -import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; - -async function main(): Promise { - const cookiePath = resolveGeminiWebCookiePath(); - const profileDir = resolveGeminiWebChromeProfileDir(); - - const log = (msg: string) => console.log(msg); - const cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log }); - await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log }); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/skills/baoyu-gemini-web/scripts/index.ts b/skills/baoyu-gemini-web/scripts/index.ts deleted file mode 100644 index 0443578..0000000 --- a/skills/baoyu-gemini-web/scripts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createGeminiWebExecutor } from './executor.js'; -export type { GeminiWebOptions, GeminiWebResponse } from './types.js'; diff --git a/skills/baoyu-gemini-web/scripts/main.ts b/skills/baoyu-gemini-web/scripts/main.ts index 8e3f06c..6048ca3 100644 --- a/skills/baoyu-gemini-web/scripts/main.ts +++ b/skills/baoyu-gemini-web/scripts/main.ts @@ -1,21 +1,65 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; -import { fetchGeminiAccessToken, runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js'; -import { getGeminiCookieMapViaChrome } from './chrome-auth.js'; -import { - hasRequiredGeminiCookies, - readGeminiCookieMapFromDisk, - writeGeminiCookieMapToDisk, -} from './cookie-store.js'; -import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; -import { readSession, writeSession, listSessions } from './session-store.js'; +import { GeminiClient, GeneratedImage, Model, type ModelOutput } from './gemini-webapi/index.js'; +import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath, resolveGeminiWebSessionPath, resolveGeminiWebSessionsDir } from './gemini-webapi/utils/index.js'; -function printUsage(exitCode = 0): never { - const cookiePath = resolveGeminiWebCookiePath(); - const profileDir = resolveGeminiWebChromeProfileDir(); +type CliArgs = { + prompt: string | null; + promptFiles: string[]; + modelId: string; + json: boolean; + imagePath: string | null; + referenceImages: string[]; + sessionId: string | null; + listSessions: boolean; + login: boolean; + cookiePath: string | null; + profileDir: string | null; + help: boolean; +}; +type SessionRecord = { + id: string; + metadata: Array; + messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp: string; error?: string }>; + createdAt: string; + updatedAt: string; +}; + +type LegacySessionV1 = { + version?: number; + sessionId?: string; + updatedAt?: string; + conversationId?: string | null; + responseId?: string | null; + choiceId?: string | null; + chatMetadata?: unknown; +}; + +function normalizeSessionMetadata(input: unknown): Array { + if (Array.isArray(input)) { + const out: Array = []; + for (const v of input.slice(0, 3)) out.push(typeof v === 'string' ? v : null); + return out.length > 0 ? out : [null, null, null]; + } + + if (input && typeof input === 'object') { + const v1 = input as LegacySessionV1; + if (Array.isArray(v1.chatMetadata)) return normalizeSessionMetadata(v1.chatMetadata); + + const conv = typeof v1.conversationId === 'string' ? v1.conversationId : null; + const rid = typeof v1.responseId === 'string' ? v1.responseId : null; + const rcid = typeof v1.choiceId === 'string' ? v1.choiceId : null; + if (conv || rid || rcid) return [conv, rid, rcid]; + } + + return [null, null, null]; +} + +function printUsage(cookiePath: string, profileDir: string): void { console.log(`Usage: npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "Hello" npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello" @@ -33,6 +77,7 @@ Options: --json Output JSON --image [path] Generate an image and save it (default: ./generated.png) --reference Reference images for vision input + --ref Alias for --reference --sessionId Session ID for multi-turn conversation (agent should generate unique ID) --list-sessions List saved sessions (max 100, sorted by update time) --login Only refresh cookies, then exit @@ -41,405 +86,405 @@ Options: -h, --help Show help Env overrides: - GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH -`); - - process.exit(exitCode); + GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`); } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = { + prompt: null, + promptFiles: [], + modelId: 'gemini-3-pro', + json: false, + imagePath: null, + referenceImages: [], + sessionId: null, + listSessions: false, + login: false, + cookiePath: null, + profileDir: null, + help: false, + }; -async function readPromptFromStdin(): Promise { - if (process.stdin.isTTY) return null; - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const text = Buffer.concat(chunks).toString('utf8').trim(); - return text ? text : null; -} - -function readPromptFiles(filePaths: string[]): string { - const contents: string[] = []; - for (const filePath of filePaths) { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); - if (!fs.existsSync(resolved)) { - throw new Error(`Prompt file not found: ${resolved}`); - } - const content = fs.readFileSync(resolved, 'utf8').trim(); - contents.push(content); - } - return contents.join('\n\n'); -} - -function parseArgs(argv: string[]): { - prompt?: string; - promptFiles?: string[]; - model?: string; - json?: boolean; - imagePath?: string; - loginOnly?: boolean; - cookiePath?: string; - profileDir?: string; - referenceImages?: string[]; - sessionId?: string; - listSessions?: boolean; -} { - const out: ReturnType = {}; const positional: string[] = []; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i] ?? ''; - if (arg === '--help' || arg === '-h') printUsage(0); - if (arg === '--json') { + const takeMany = (i: number): { items: string[]; next: number } => { + const items: string[] = []; + let j = i + 1; + while (j < argv.length) { + const v = argv[j]!; + if (v.startsWith('-')) break; + items.push(v); + j++; + } + return { items, next: j - 1 }; + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + + if (a === '--help' || a === '-h') { + out.help = true; + continue; + } + + if (a === '--json') { out.json = true; continue; } - if (arg === '--image' || arg === '--generate-image') { - const next = argv[i + 1]; - if (next && !next.startsWith('-')) { - out.imagePath = next; - i += 1; - } else { - out.imagePath = 'generated.png'; - } - continue; - } - if (arg.startsWith('--image=')) { - out.imagePath = arg.slice('--image='.length); - continue; - } - if (arg.startsWith('--generate-image=')) { - out.imagePath = arg.slice('--generate-image='.length); - continue; - } - if (arg === '--login') { - out.loginOnly = true; - continue; - } - if (arg === '--prompt' || arg === '-p') { - out.prompt = argv[i + 1] ?? ''; - i += 1; - continue; - } - if (arg.startsWith('--prompt=')) { - out.prompt = arg.slice('--prompt='.length); - continue; - } - if (arg === '--promptfiles') { - out.promptFiles = []; - while (i + 1 < argv.length) { - const next = argv[i + 1]; - if (next && !next.startsWith('-')) { - out.promptFiles.push(next); - i += 1; - } else { - break; - } - } - continue; - } - if (arg === '--model' || arg === '-m') { - out.model = argv[i + 1] ?? ''; - i += 1; - continue; - } - if (arg.startsWith('--model=')) { - out.model = arg.slice('--model='.length); - continue; - } - if (arg === '--cookie-path') { - out.cookiePath = argv[i + 1] ?? ''; - i += 1; - continue; - } - if (arg.startsWith('--cookie-path=')) { - out.cookiePath = arg.slice('--cookie-path='.length); - continue; - } - if (arg === '--profile-dir') { - out.profileDir = argv[i + 1] ?? ''; - i += 1; - continue; - } - if (arg.startsWith('--profile-dir=')) { - out.profileDir = arg.slice('--profile-dir='.length); - continue; - } - if (arg === '--reference' || arg === '--ref') { - out.referenceImages = []; - while (i + 1 < argv.length) { - const next = argv[i + 1]; - if (next && !next.startsWith('-')) { - out.referenceImages.push(next); - i += 1; - } else { - break; - } - } - continue; - } - if (arg === '--sessionId' || arg === '--session-id') { - out.sessionId = argv[i + 1] ?? ''; - i += 1; - continue; - } - if (arg.startsWith('--sessionId=') || arg.startsWith('--session-id=')) { - out.sessionId = arg.split('=')[1] ?? ''; - continue; - } - if (arg === '--list-sessions') { + + if (a === '--list-sessions') { out.listSessions = true; continue; } - if (arg.startsWith('-')) { - throw new Error(`Unknown option: ${arg}`); + if (a === '--login') { + out.login = true; + continue; } - positional.push(arg); + + if (a === '--prompt' || a === '-p') { + const v = argv[++i]; + if (!v) throw new Error(`Missing value for ${a}`); + out.prompt = v; + continue; + } + + if (a === '--promptfiles') { + const { items, next } = takeMany(i); + if (items.length === 0) throw new Error('Missing files for --promptfiles'); + out.promptFiles.push(...items); + i = next; + continue; + } + + if (a === '--model' || a === '-m') { + const v = argv[++i]; + if (!v) throw new Error(`Missing value for ${a}`); + out.modelId = v; + continue; + } + + if (a === '--sessionId') { + const v = argv[++i]; + if (!v) throw new Error('Missing value for --sessionId'); + out.sessionId = v; + continue; + } + + if (a === '--cookie-path') { + const v = argv[++i]; + if (!v) throw new Error('Missing value for --cookie-path'); + out.cookiePath = v; + continue; + } + + if (a === '--profile-dir') { + const v = argv[++i]; + if (!v) throw new Error('Missing value for --profile-dir'); + out.profileDir = v; + continue; + } + + if (a === '--image' || a.startsWith('--image=')) { + let v: string | null = null; + if (a.startsWith('--image=')) { + v = a.slice('--image='.length).trim(); + } else { + const maybe = argv[i + 1]; + if (maybe && !maybe.startsWith('-')) { + v = maybe; + i++; + } + } + + out.imagePath = v && v.length > 0 ? v : 'generated.png'; + continue; + } + + if (a === '--reference' || a === '--ref') { + const { items, next } = takeMany(i); + if (items.length === 0) throw new Error(`Missing files for ${a}`); + out.referenceImages.push(...items); + i = next; + continue; + } + + if (a.startsWith('-')) { + throw new Error(`Unknown option: ${a}`); + } + + positional.push(a); } - if (!out.prompt && positional.length > 0) { - out.prompt = positional.join(' ').trim(); + if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) { + out.prompt = positional.join(' '); } - if (out.prompt != null) out.prompt = out.prompt.trim(); - if (out.model != null) out.model = out.model.trim(); - if (out.imagePath != null) out.imagePath = out.imagePath.trim(); - if (out.cookiePath != null) out.cookiePath = out.cookiePath.trim(); - if (out.profileDir != null) out.profileDir = out.profileDir.trim(); - - if (out.imagePath === '') delete out.imagePath; - if (out.cookiePath === '') delete out.cookiePath; - if (out.profileDir === '') delete out.profileDir; - if (out.promptFiles?.length === 0) delete out.promptFiles; - if (out.referenceImages?.length === 0) delete out.referenceImages; - if (out.sessionId != null) out.sessionId = out.sessionId.trim(); - if (out.sessionId === '') delete out.sessionId; - return out; } -async function isCookieMapValid(cookieMap: Record): Promise { - if (!hasRequiredGeminiCookies(cookieMap)) return false; +function resolveModel(id: string): Model { + const k = id.trim(); + if (k === 'gemini-3-pro') return Model.G_3_0_PRO; + if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO; + if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO; + if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH; + return Model.from_name(k); +} - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 30_000); +async function readPromptFromFiles(files: string[]): Promise { + const parts: string[] = []; + for (const f of files) { + parts.push(await readFile(f, 'utf8')); + } + return parts.join('\n\n'); +} + +async function readPromptFromStdin(): Promise { + if (process.stdin.isTTY) return null; try { - await fetchGeminiAccessToken(cookieMap, controller.signal); - return true; + // Bun provides Bun.stdin; Node-compatible read can be flaky across runtimes. + const t = await Bun.stdin.text(); + const v = t.trim(); + return v.length > 0 ? v : null; } catch { - return false; - } finally { - clearTimeout(timer); + return null; } } -async function ensureGeminiCookieMap(options: { - cookiePath: string; - profileDir: string; -}): Promise> { - const log = (msg: string) => console.error(msg); - - let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log }); - if (await isCookieMapValid(cookieMap)) return cookieMap; - - log('[gemini-web] No valid cookies found. Opening browser to sync Gemini cookies...'); - cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: options.profileDir, log }); - await writeGeminiCookieMapToDisk(cookieMap, { cookiePath: options.cookiePath, log }); - return cookieMap; +function normalizeOutputImagePath(p: string): string { + const full = path.resolve(p); + const ext = path.extname(full); + if (ext) return full; + return `${full}.png`; } -function resolveModel(value: string): 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash' { - const desired = value.trim(); - if (!desired) return 'gemini-3-pro'; - switch (desired) { - case 'gemini-3-pro': - case 'gemini-3.0-pro': - return 'gemini-3-pro'; - case 'gemini-2.5-pro': - return 'gemini-2.5-pro'; - case 'gemini-2.5-flash': - return 'gemini-2.5-flash'; - default: - console.error(`[gemini-web] Unsupported model "${desired}", falling back to gemini-3-pro.`); - return 'gemini-3-pro'; - } -} - -function resolveImageOutputPath(value: string | undefined): string | null { - if (value == null) return null; - const trimmed = value.trim(); - const raw = trimmed || 'generated.png'; - const resolved = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw); - - if (resolved.endsWith(path.sep)) return path.join(resolved, 'generated.png'); +async function loadSession(id: string): Promise { + const p = resolveGeminiWebSessionPath(id); try { - if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { - return path.join(resolved, 'generated.png'); + const raw = await readFile(p, 'utf8'); + const j = JSON.parse(raw) as unknown; + if (!j || typeof j !== 'object') return null; + + const sid = (typeof (j as any).id === 'string' && (j as any).id.trim()) || (typeof (j as any).sessionId === 'string' && (j as any).sessionId.trim()) || id; + const metadata = normalizeSessionMetadata((j as any).metadata ?? (j as any).chatMetadata ?? j); + const messages = Array.isArray((j as any).messages) ? ((j as any).messages as SessionRecord['messages']) : []; + const createdAt = + typeof (j as any).createdAt === 'string' + ? ((j as any).createdAt as string) + : typeof (j as any).updatedAt === 'string' + ? ((j as any).updatedAt as string) + : new Date().toISOString(); + const updatedAt = typeof (j as any).updatedAt === 'string' ? ((j as any).updatedAt as string) : createdAt; + + return { + id: sid, + metadata, + messages, + createdAt, + updatedAt, + }; + } catch { + return null; + } +} + +async function saveSession(rec: SessionRecord): Promise { + const dir = resolveGeminiWebSessionsDir(); + await mkdir(dir, { recursive: true }); + const p = resolveGeminiWebSessionPath(rec.id); + const tmp = `${p}.tmp.${Date.now()}`; + await writeFile(tmp, JSON.stringify(rec, null, 2), 'utf8'); + await fs.promises.rename(tmp, p); +} + +async function listSessions(): Promise { + const dir = resolveGeminiWebSessionsDir(); + try { + const names = await readdir(dir); + const items: Array<{ path: string; st: number }> = []; + for (const n of names) { + if (!n.endsWith('.json')) continue; + const p = path.join(dir, n); + try { + const s = await stat(p); + items.push({ path: p, st: s.mtimeMs }); + } catch {} } + + items.sort((a, b) => b.st - a.st); + const out: SessionRecord[] = []; + for (const it of items.slice(0, 100)) { + try { + const raw = await readFile(it.path, 'utf8'); + const j = JSON.parse(raw) as any; + const id = + (typeof j?.id === 'string' && j.id.trim()) || + (typeof j?.sessionId === 'string' && j.sessionId.trim()) || + path.basename(it.path, '.json'); + out.push({ + id, + metadata: normalizeSessionMetadata(j?.metadata ?? j?.chatMetadata ?? j), + messages: Array.isArray(j?.messages) ? j.messages : [], + createdAt: + typeof j?.createdAt === 'string' + ? j.createdAt + : typeof j?.updatedAt === 'string' + ? j.updatedAt + : new Date(it.st).toISOString(), + updatedAt: typeof j?.updatedAt === 'string' ? j.updatedAt : new Date(it.st).toISOString(), + }); + } catch {} + } + + out.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + return out.slice(0, 100); } catch { - // ignore + return []; } - return resolved; +} + +function formatJson(out: ModelOutput, extra?: Record): string { + const candidates = out.candidates.map((c) => ({ + rcid: c.rcid, + text: c.text, + thoughts: c.thoughts, + images: c.images.map((img) => ({ + url: img.url, + title: img.title, + alt: img.alt, + kind: img instanceof GeneratedImage ? 'generated' : 'web', + })), + })); + + return JSON.stringify( + { + text: out.text, + thoughts: out.thoughts, + metadata: out.metadata, + chosen: out.chosen, + candidates, + ...extra, + }, + null, + 2, + ); } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); - const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath(); - const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir(); + + if (args.cookiePath) process.env.GEMINI_WEB_COOKIE_PATH = args.cookiePath; + if (args.profileDir) process.env.GEMINI_WEB_CHROME_PROFILE_DIR = args.profileDir; + + const cookiePath = resolveGeminiWebCookiePath(); + const profileDir = resolveGeminiWebChromeProfileDir(); + + if (args.help) { + printUsage(cookiePath, profileDir); + return; + } if (args.listSessions) { - const sessions = await listSessions(); - if (sessions.length === 0) { - console.log('No saved sessions.'); - } else { - for (const { id, updatedAt } of sessions) { - console.log(`${id}\t${updatedAt}`); - } + const ss = await listSessions(); + for (const s of ss) { + const n = s.messages.length; + const last = s.messages.slice(-1)[0]; + const lastLine = last?.content ? String(last.content).split('\n')[0] : ''; + console.log(`${s.id}\t${s.updatedAt}\t${n}\t${lastLine}`); } return; } - if (args.loginOnly) { - await ensureGeminiCookieMap({ cookiePath, profileDir }); + if (args.login) { + process.env.GEMINI_WEB_LOGIN = '1'; + const c = new GeminiClient(); + await c.init({ verbose: true }); + await c.close(); + if (!args.json) console.log(`Cookie refreshed: ${cookiePath}`); + else console.log(JSON.stringify({ ok: true, cookiePath }, null, 2)); return; } - const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null; - const promptFromArgs = promptFromFiles || args.prompt; - const prompt = promptFromArgs || (await readPromptFromStdin()); - if (!prompt) printUsage(1); + let prompt: string | null = args.prompt; + if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles); + if (!prompt) prompt = await readPromptFromStdin(); - const sessionData = args.sessionId ? await readSession(args.sessionId) : null; - const chatMetadata = sessionData?.metadata ?? null; + if (!prompt) { + printUsage(cookiePath, profileDir); + process.exitCode = 1; + return; + } - let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir }); - - const desiredModel = resolveModel(args.model || 'gemini-3-pro'); - const imagePath = resolveImageOutputPath(args.imagePath); - const referenceImages = (args.referenceImages ?? []).map((p) => - path.isAbsolute(p) ? p : path.resolve(process.cwd(), p), - ); + const model = resolveModel(args.modelId); + const c = new GeminiClient(); + await c.init({ verbose: false }); try { - const controller = new AbortController(); - const timeoutMs = imagePath ? 300_000 : 120_000; - const timeout = setTimeout(() => controller.abort(), timeoutMs); + let sess: SessionRecord | null = null; + let chat = null as any; - try { - const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt; - const out = await runGeminiWebWithFallback({ - prompt: effectivePrompt, - files: referenceImages, - model: desiredModel, - cookieMap, - chatMetadata, - signal: controller.signal, - }); - - if (args.sessionId && out.metadata) { - await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage); - } - - let imageSaved = false; - let imageCount = 0; - if (imagePath) { - const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal); - imageSaved = save.saved; - imageCount = save.imageCount; - if (!imageSaved) { - throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); - } - } - - if (args.json) { - const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) }; - process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`); - if (out.errorMessage) process.exit(1); - return; - } - - if (out.errorMessage) { - throw new Error(out.errorMessage); - } - - process.stdout.write(out.text ?? ''); - if (!out.text?.endsWith('\n')) process.stdout.write('\n'); - if (imagePath) { - process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`); - } - return; - } finally { - clearTimeout(timeout); + if (args.sessionId) { + sess = (await loadSession(args.sessionId)) ?? { + id: args.sessionId, + metadata: [null, null, null], + messages: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + chat = c.start_chat({ metadata: sess.metadata, model }); } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('Unable to locate Gemini access token')) { - console.error('[gemini-web] Cookies may be expired. Re-opening browser to refresh cookies...'); - await sleep(500); - cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log: (m) => console.error(m) }); - await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.error(m) }); + const files = args.referenceImages.length > 0 ? args.referenceImages : null; - const controller = new AbortController(); - const timeoutMs = imagePath ? 300_000 : 120_000; - const timeout = setTimeout(() => controller.abort(), timeoutMs); + let out: ModelOutput; + if (chat) out = await chat.send_message(prompt, files); + else out = await c.generate_content(prompt, files, model); - try { - const out = await runGeminiWebWithFallback({ - prompt: imagePath ? `Generate an image: ${prompt}` : prompt, - files: referenceImages, - model: desiredModel, - cookieMap, - chatMetadata, - signal: controller.signal, - }); + let savedImage: string | null = null; + if (args.imagePath) { + const p = normalizeOutputImagePath(args.imagePath); + const dir = path.dirname(p); + await mkdir(dir, { recursive: true }); - if (args.sessionId && out.metadata) { - await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage); - } + const img = out.images[0]; + if (!img) { + throw new Error('No image returned in response.'); + } - let imageSaved = false; - let imageCount = 0; - if (imagePath) { - const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal); - imageSaved = save.saved; - imageCount = save.imageCount; - if (!imageSaved) { - throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`); - } - } + const fn = path.basename(p); + const dp = dir; - if (args.json) { - const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) }; - process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`); - if (out.errorMessage) process.exit(1); - return; - } - - if (out.errorMessage) { - throw new Error(out.errorMessage); - } - - process.stdout.write(out.text ?? ''); - if (!out.text?.endsWith('\n')) process.stdout.write('\n'); - if (imagePath) { - process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`); - } - return; - } finally { - clearTimeout(timeout); + if (img instanceof GeneratedImage) { + savedImage = await img.save(dp, fn, undefined, false, false, true); + } else { + savedImage = await img.save(dp, fn, c.cookies, false, false); } } - throw error; + if (sess && args.sessionId) { + const now = new Date().toISOString(); + sess.updatedAt = now; + sess.metadata = (chat?.metadata ?? sess.metadata).slice(0, 3); + sess.messages.push({ role: 'user', content: prompt, timestamp: now }); + sess.messages.push({ role: 'assistant', content: out.text ?? '', timestamp: now }); + await saveSession(sess); + } + + if (args.json) { + console.log(formatJson(out, { savedImage, sessionId: args.sessionId, model: model.model_name })); + } else if (args.imagePath) { + console.log(savedImage ?? ''); + } else { + console.log(out.text); + } + } finally { + await c.close(); } } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); +main().catch((e) => { + const msg = e instanceof Error ? e.message : String(e); + console.error(msg); process.exit(1); }); diff --git a/skills/baoyu-gemini-web/scripts/session-store.ts b/skills/baoyu-gemini-web/scripts/session-store.ts deleted file mode 100644 index fa3836e..0000000 --- a/skills/baoyu-gemini-web/scripts/session-store.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { mkdir, readFile, writeFile, readdir, stat } from 'node:fs/promises'; -import path from 'node:path'; -import { resolveGeminiWebSessionsDir, resolveGeminiWebSessionPath } from './paths.js'; - -export interface SessionMessage { - role: 'user' | 'assistant'; - content: string; - timestamp: string; - error?: string; -} - -export interface SessionData { - id: string; - metadata: unknown; - messages: SessionMessage[]; - createdAt: string; - updatedAt: string; -} - -export interface SessionListItem { - id: string; - updatedAt: string; -} - -export async function readSession(id: string): Promise { - const sessionPath = resolveGeminiWebSessionPath(id); - try { - const content = await readFile(sessionPath, 'utf8'); - return JSON.parse(content) as SessionData; - } catch { - return null; - } -} - -export async function writeSession( - id: string, - metadata: unknown, - userMessage: string, - assistantMessage: string, - error?: string, -): Promise { - const sessionPath = resolveGeminiWebSessionPath(id); - const sessionsDir = resolveGeminiWebSessionsDir(); - await mkdir(sessionsDir, { recursive: true }); - - const existing = await readSession(id); - const now = new Date().toISOString(); - - const newMessages: SessionMessage[] = [ - { role: 'user', content: userMessage, timestamp: now }, - { role: 'assistant', content: assistantMessage, timestamp: now, ...(error && { error }) }, - ]; - - const data: SessionData = { - id, - metadata, - messages: [...(existing?.messages ?? []), ...newMessages], - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; - await writeFile(sessionPath, JSON.stringify(data, null, 2)); -} - -export async function listSessions(limit = 100): Promise { - const sessionsDir = resolveGeminiWebSessionsDir(); - try { - const files = await readdir(sessionsDir); - const jsonFiles = files.filter((f) => f.endsWith('.json')); - - const items: { id: string; updatedAt: string; mtime: number }[] = []; - for (const file of jsonFiles) { - const filePath = path.join(sessionsDir, file); - try { - const stats = await stat(filePath); - items.push({ - id: file.slice(0, -5), - updatedAt: stats.mtime.toISOString(), - mtime: stats.mtime.getTime(), - }); - } catch { - continue; - } - } - - items.sort((a, b) => b.mtime - a.mtime); - return items.slice(0, limit).map(({ id, updatedAt }) => ({ id, updatedAt })); - } catch { - return []; - } -} diff --git a/skills/baoyu-gemini-web/scripts/types.ts b/skills/baoyu-gemini-web/scripts/types.ts deleted file mode 100644 index bf8d50f..0000000 --- a/skills/baoyu-gemini-web/scripts/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface GeminiWebOptions { - youtube?: string; - generateImage?: string; - editImage?: string; - generateVideo?: string; - outputPath?: string; - showThoughts?: boolean; - aspectRatio?: string; - /** - * One or more local image paths to upload as persistent reference images. - * - If `keepSession` is enabled, they are uploaded once per executor session. - * - Otherwise, they are attached to each request. - */ - referenceImages?: string | string[]; - /** Preserve Gemini chat metadata to continue multi-turn conversations within the same executor instance. */ - keepSession?: boolean; -} - -export interface GeminiWebResponse { - text: string | null; - thoughts: string | null; - has_images: boolean; - image_count: number; - error?: string; -}