chore: rename baoyu-gemini-web to baoyu-danger-gemini-web

This commit is contained in:
Jim Liu 宝玉
2026-01-18 17:58:12 -06:00
parent 4998eaf8c2
commit 56d0485412
31 changed files with 43 additions and 43 deletions
@@ -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<string, string> {
if (!h) return {};
if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
if (h instanceof Headers) {
const out: Record<string, string> = {};
h.forEach((v, k) => {
out[k] = v;
});
return out;
}
return { ...(h as Record<string, string>) };
}
function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {
const out: string[] = [];
const seen = new Set<string>();
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<string, unknown>)) stack.push(val);
}
}
return out;
}
export class GeminiClient extends GemMixin {
public cookies: Record<string, string> = {};
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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<void> {
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<T>(fn: () => Promise<T>, retry: number): Promise<T> {
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<string, unknown> = Model.UNSPECIFIED,
gem: Gem | string | null = null,
chat: ChatSession | null = null,
kwargs: RequestKwargs = {},
): Promise<ModelOutput> {
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<string | null>(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<number>(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<unknown[]>(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<string | null>(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<string | null>(candidate, [37, 0, 0], null);
const web_images: WebImage[] = [];
for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {
if (!Array.isArray(w)) continue;
const url = get_nested_value<string | null>(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<string | null>(part, [2], null);
if (!part_body) continue;
try {
const part_json = JSON.parse(part_body) as unknown[];
const cand = get_nested_value<unknown>(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<unknown[]>(img_body, [4, candidate_index], []);
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
if (finished) {
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
}
const gen = get_nested_value<unknown[]>(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<string | null>(g, [0, 3, 3], null);
if (!url) continue;
const img_num = get_nested_value<number | null>(g, [3, 6], null);
const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';
const alt_list = get_nested_value<unknown[]>(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<string[]>(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<string, unknown>,
gem?: Gem | string | null,
chat?: ChatSession | null,
kwargs?: RequestKwargs,
): Promise<ModelOutput> {
return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});
}
start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
return new ChatSession(this, opts);
}
startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
return this.start_chat(opts);
}
protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {
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<string | null> = [null, null, null];
public geminiclient: GeminiClient;
private _last_output: ModelOutput | null = null;
public model: Model | string | Record<string, unknown>;
public gem: Gem | string | null;
constructor(
geminiclient: GeminiClient,
opts: {
metadata?: Array<string | null>;
cid?: string | null;
rid?: string | null;
rcid?: string | null;
model?: Model | string | Record<string, unknown>;
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<string | null>;
this.rcid = v.rcid;
}
}
async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {
return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);
}
async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {
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<string | null> {
return this.__metadata;
}
set metadata(v: Array<string | null>) {
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;
}
}
@@ -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<T>(fn: () => Promise<T>, retry: number): Promise<T>;
protected abstract _batch_execute(payloads: RPCData[], opts?: RequestInit): Promise<Response>;
protected abstract close(delay?: number): Promise<void>;
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<GemJar> {
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<string | null>(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<string | null>(gem, [1, 1], null),
get_nested_value<string | null>(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<string | null>(gem, [1, 1], null),
get_nested_value<string | null>(gem, [2, 0], null),
false,
),
]);
}
this._gems = new GemJar(entries);
return this._gems;
}, 2);
}
async create_gem(name: string, prompt: string, description: string = ''): Promise<Gem> {
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<Gem> {
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<void> {
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);
}
}
@@ -0,0 +1,2 @@
export { GemMixin } from './gem-mixin.js';
@@ -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<string, string>,
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<string, string> = {};
for (const [k, v] of Object.entries(model_dict.model_header as Record<string, unknown>)) {
if (typeof v === 'string') header[k] = v;
}
return new Model(model_dict.model_name, header, false);
}
}
@@ -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';
}
}
@@ -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';
@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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];
}
}
@@ -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<Gem> {
private m = new Map<string, Gem>();
constructor(entries?: Iterable<[string, Gem]>) {
if (entries) for (const [id, gem] of entries) this.m.set(id, gem);
}
[Symbol.iterator](): Iterator<Gem> {
return this.m.values();
}
entries(): IterableIterator<[string, Gem]> {
return this.m.entries();
}
values(): IterableIterator<Gem> {
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);
}
}
@@ -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];
}
}
@@ -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<string, string> | null = null,
verbose: boolean = false,
skip_invalid_filename: boolean = false,
): Promise<string | null> {
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<string, string> = {
'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<string, string>,
) {
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<string, string> | null = null,
verbose: boolean = false,
skip_invalid_filename: boolean = false,
full_size: boolean = true,
): Promise<string | null> {
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);
}
}
@@ -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';
@@ -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 ?? '';
}
}
@@ -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<string, string>;
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<CookieMap | null> {
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<string, unknown>)) {
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<string, unknown>)) {
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<string, unknown>)) {
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<void> {
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;
@@ -0,0 +1,34 @@
import { APIError, ImageGenerationError } from '../exceptions.js';
import { sleep } from './http.js';
export function running(retry: number = 0) {
return <TArgs extends unknown[], TResult>(
fn: (client: any, ...args: TArgs) => Promise<TResult>,
): ((client: any, ...args: TArgs) => Promise<TResult>) => {
const wrap = async (client: any, ...args: TArgs): Promise<TResult> => {
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;
};
}
@@ -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<string, string>, verbose: boolean): Promise<[string, Record<string, string>]> {
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<Record<string, string> | null | undefined>): Record<string, string> {
const out: Record<string, string> = {};
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<Record<string, string>> {
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<string, string>,
proxy: string | null = null,
verbose: boolean = false,
): Promise<[string, Record<string, string>]> {
const extra = await fetch_google_extra_cookies(proxy, verbose);
const cacheDir = resolveGeminiWebDataDir();
const candidates: Record<string, string>[] = [];
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<string, string>[] = [];
const seen = new Set<string>();
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<string, string>]> => {
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<string, string>];
};
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;
@@ -0,0 +1,57 @@
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
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, string>): 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<Response> {
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;
@@ -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<string, unknown>();
@@ -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<typeof setTimeout> | 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<CdpConnection> {
const ws = new WebSocket(url);
await new Promise<void>((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<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
const id = ++this.nextId;
const msg: Record<string, unknown> = { 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<unknown>((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<number> {
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<string> {
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<ChildProcess> {
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<CookieMap> {
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<Record<string, CookieMap>> {
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;
@@ -0,0 +1,42 @@
export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | number;
const lvl: Record<Exclude<LogLevel, number>, 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<LogLevel, number>, 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),
};
@@ -0,0 +1,44 @@
import { logger } from './logger.js';
export function get_nested_value<T = unknown>(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;
@@ -0,0 +1,46 @@
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
const APP_DATA_DIR = 'baoyu-skills';
const GEMINI_DATA_DIR = 'gemini-web';
const COOKIE_FILE_NAME = 'cookies.json';
const PROFILE_DIR_NAME = 'chrome-profile';
export function resolveUserDataRoot(): string {
if (process.platform === 'win32') {
return process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
}
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support');
}
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share');
}
export function resolveGeminiWebDataDir(): string {
const override = process.env.GEMINI_WEB_DATA_DIR?.trim();
if (override) return path.resolve(override);
return path.join(resolveUserDataRoot(), APP_DATA_DIR, GEMINI_DATA_DIR);
}
export function resolveGeminiWebCookiePath(): string {
const override = process.env.GEMINI_WEB_COOKIE_PATH?.trim();
if (override) return path.resolve(override);
return path.join(resolveGeminiWebDataDir(), COOKIE_FILE_NAME);
}
export function resolveGeminiWebChromeProfileDir(): string {
const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
if (override) return path.resolve(override);
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
}
export function resolveGeminiWebSessionsDir(): string {
return path.join(resolveGeminiWebDataDir(), 'sessions');
}
export function resolveGeminiWebSessionPath(name: string): string {
const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_');
return path.join(resolveGeminiWebSessionsDir(), `${sanitized}.json`);
}
@@ -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<string, string>, _proxy?: string | null): Promise<string | null> {
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;
@@ -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<string> {
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;