414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
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<string, string>;
|
|
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<GeminiWebModelId, string> = {
|
|
'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<T>(value: unknown, pathParts: Array<string | number>, 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<string, unknown>)[part];
|
|
}
|
|
}
|
|
return (current as T) ?? fallback;
|
|
}
|
|
|
|
function buildCookieHeader(cookieMap: Record<string, string>): 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<string, string>,
|
|
signal?: AbortSignal,
|
|
): Promise<string> {
|
|
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<number>(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<string>();
|
|
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<RequestInit, 'redirect'>,
|
|
signal?: AbortSignal,
|
|
maxRedirects = 10,
|
|
): Promise<Response> {
|
|
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<string, string>,
|
|
outputPath: string,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
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<string | null>(parts[i], [2], null);
|
|
if (!partBody) continue;
|
|
try {
|
|
const parsed = JSON.parse(partBody) as unknown;
|
|
const candidateList = getNestedValue<unknown[]>(parsed, [4], []);
|
|
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
|
bodyIndex = i;
|
|
body = parsed;
|
|
break;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const candidateList = getNestedValue<unknown[]>(body, [4], []);
|
|
const firstCandidate = candidateList[0];
|
|
const textRaw = getNestedValue<string>(firstCandidate, [1, 0], '');
|
|
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
|
const text = cardContent
|
|
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
|
: textRaw;
|
|
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
|
const metadata = getNestedValue<unknown>(body, [1], []);
|
|
|
|
const images: GeminiWebCandidateImage[] = [];
|
|
|
|
const webImages = getNestedValue<unknown[]>(firstCandidate, [12, 1], []);
|
|
for (const webImage of webImages) {
|
|
const url = getNestedValue<string | null>(webImage, [0, 0, 0], null);
|
|
if (!url) continue;
|
|
images.push({
|
|
kind: 'web',
|
|
url,
|
|
title: getNestedValue<string | undefined>(webImage, [7, 0], undefined),
|
|
alt: getNestedValue<string | undefined>(webImage, [0, 4], undefined),
|
|
});
|
|
}
|
|
|
|
const hasGenerated = Boolean(getNestedValue<unknown>(firstCandidate, [12, 7, 0], null));
|
|
if (hasGenerated) {
|
|
let imgBody: unknown = null;
|
|
for (let i = bodyIndex; i < parts.length; i += 1) {
|
|
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
|
if (!partBody) continue;
|
|
try {
|
|
const parsed = JSON.parse(partBody) as unknown;
|
|
const candidateImages = getNestedValue<unknown | null>(parsed, [4, 0, 12, 7, 0], null);
|
|
if (candidateImages != null) {
|
|
imgBody = parsed;
|
|
break;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const imgCandidate = getNestedValue<unknown>(imgBody ?? body, [4, 0], null);
|
|
|
|
const generated = getNestedValue<unknown[]>(imgCandidate, [12, 7, 0], []);
|
|
for (const genImage of generated) {
|
|
const url = getNestedValue<string | null>(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<GeminiWebRunOutput> {
|
|
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<GeminiWebRunInput, 'model'> & { model: GeminiWebModelId },
|
|
): Promise<GeminiWebRunOutput & { effectiveModel: GeminiWebModelId }> {
|
|
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<string, string>,
|
|
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 };
|
|
}
|