JimLiu-baoyu-skills/skills/gemini-web/scripts/client.ts

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 };
}