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('; '); } export async function fetchGeminiAccessToken( cookieMap: Record, signal?: AbortSignal, ): Promise { const cookieHeader = buildCookieHeader(cookieMap); const res = await fetch(GEMINI_APP_URL, { redirect: 'follow', signal, headers: { cookie: cookieHeader, 'user-agent': USER_AGENT, }, }); 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:\/\/lh3\.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 ensureFullSizeImageUrl(url: string): string { if (url.includes('=s2048')) return url; if (url.includes('=s')) return url; return `${url}=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 buildGeminiFReqPayload( prompt: string, uploaded: Array<{ id: string; name: string }>, chatMetadata: unknown, ): string { const promptPayload = uploaded.length > 0 ? [ prompt, 0, null, // Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment. // Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file. uploaded.map((file) => [[file.id, 1]]), ] : [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 metadata = getNestedValue(body, [1], []); 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 cookieHeader = buildCookieHeader(input.cookieMap); const at = await fetchGeminiAccessToken(input.cookieMap, input.signal); 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.rawResponseText); if (ggdl[0]) { await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal); return { saved: true, imageCount: ggdl.length }; } return { saved: false, imageCount: 0 }; }