surf-cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,637 @@
1
+ /**
2
+ * Gemini Web Client for surf-cli
3
+ *
4
+ * Cookie-based client for gemini.google.com (no API key required).
5
+ * Adapted from Oracle's gemini-web module.
6
+ */
7
+
8
+ const https = require("https");
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+
12
+ // ============================================================================
13
+ // Constants
14
+ // ============================================================================
15
+
16
+ const GEMINI_APP_URL = "https://gemini.google.com/app";
17
+ const GEMINI_STREAM_GENERATE_URL = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
18
+ const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
19
+ const GEMINI_UPLOAD_PUSH_ID = "feeds/mcudyrk2a4khkz";
20
+
21
+ 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";
22
+
23
+ const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
24
+ const MODEL_HEADERS = {
25
+ "gemini-3-pro": '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
26
+ "gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
27
+ "gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
28
+ };
29
+
30
+ const REQUIRED_COOKIES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
31
+
32
+ const ALL_COOKIE_NAMES = [
33
+ "__Secure-1PSID",
34
+ "__Secure-1PSIDTS",
35
+ "__Secure-1PSIDCC",
36
+ "__Secure-1PAPISID",
37
+ "NID",
38
+ "AEC",
39
+ "SOCS",
40
+ "__Secure-BUCKET",
41
+ "__Secure-ENID",
42
+ "SID",
43
+ "HSID",
44
+ "SSID",
45
+ "APISID",
46
+ "SAPISID",
47
+ "__Secure-3PSID",
48
+ "__Secure-3PSIDTS",
49
+ "__Secure-3PAPISID",
50
+ "SIDCC",
51
+ ];
52
+
53
+ // ============================================================================
54
+ // Utility Functions
55
+ // ============================================================================
56
+
57
+ function getNestedValue(value, pathParts, fallback) {
58
+ let current = value;
59
+ for (const part of pathParts) {
60
+ if (current == null) return fallback;
61
+ if (typeof part === "number") {
62
+ if (!Array.isArray(current)) return fallback;
63
+ current = current[part];
64
+ } else {
65
+ if (typeof current !== "object") return fallback;
66
+ current = current[part];
67
+ }
68
+ }
69
+ return current ?? fallback;
70
+ }
71
+
72
+ function buildCookieHeader(cookieMap) {
73
+ return Object.entries(cookieMap)
74
+ .filter(([, value]) => typeof value === "string" && value.length > 0)
75
+ .map(([name, value]) => `${name}=${value}`)
76
+ .join("; ");
77
+ }
78
+
79
+ function buildCookieMap(cookies) {
80
+ const cookieMap = {};
81
+ for (const name of ALL_COOKIE_NAMES) {
82
+ const cookie = cookies.find(c => c.name === name && c.value);
83
+ if (cookie) {
84
+ cookieMap[name] = cookie.value;
85
+ }
86
+ }
87
+ return cookieMap;
88
+ }
89
+
90
+ function hasRequiredCookies(cookieMap) {
91
+ return REQUIRED_COOKIES.every(name => Boolean(cookieMap[name]));
92
+ }
93
+
94
+ function delay(ms) {
95
+ return new Promise(resolve => setTimeout(resolve, ms));
96
+ }
97
+
98
+ // ============================================================================
99
+ // HTTP Helpers
100
+ // ============================================================================
101
+
102
+ function httpsGet(url, headers, binary = false) {
103
+ return new Promise((resolve, reject) => {
104
+ const urlObj = new URL(url);
105
+ const options = {
106
+ hostname: urlObj.hostname,
107
+ port: 443,
108
+ path: urlObj.pathname + urlObj.search,
109
+ method: "GET",
110
+ headers: {
111
+ "user-agent": USER_AGENT,
112
+ ...headers,
113
+ },
114
+ };
115
+
116
+ const req = https.request(options, (res) => {
117
+ const chunks = [];
118
+ res.on("data", chunk => chunks.push(chunk));
119
+ res.on("end", () => {
120
+ const buffer = Buffer.concat(chunks);
121
+ resolve({
122
+ status: res.statusCode,
123
+ headers: res.headers,
124
+ text: binary ? null : buffer.toString("utf-8"),
125
+ buffer: binary ? buffer : null,
126
+ });
127
+ });
128
+ });
129
+
130
+ req.on("error", reject);
131
+ req.end();
132
+ });
133
+ }
134
+
135
+ function httpsPost(url, headers, body) {
136
+ return new Promise((resolve, reject) => {
137
+ const urlObj = new URL(url);
138
+ const options = {
139
+ hostname: urlObj.hostname,
140
+ port: 443,
141
+ path: urlObj.pathname + urlObj.search,
142
+ method: "POST",
143
+ headers: {
144
+ "user-agent": USER_AGENT,
145
+ ...headers,
146
+ },
147
+ };
148
+
149
+ const req = https.request(options, (res) => {
150
+ let data = "";
151
+ res.on("data", chunk => data += chunk);
152
+ res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, text: data }));
153
+ });
154
+
155
+ req.on("error", reject);
156
+ if (body) req.write(body);
157
+ req.end();
158
+ });
159
+ }
160
+
161
+ async function fetchWithRedirects(url, headers, maxRedirects = 10, binary = false) {
162
+ let current = url;
163
+ for (let i = 0; i <= maxRedirects; i++) {
164
+ const res = await httpsGet(current, headers, binary);
165
+ if (res.status >= 300 && res.status < 400 && res.headers.location) {
166
+ current = new URL(res.headers.location, current).toString();
167
+ continue;
168
+ }
169
+ return res;
170
+ }
171
+ throw new Error(`Too many redirects (>${maxRedirects})`);
172
+ }
173
+
174
+ // ============================================================================
175
+ // Gemini API Functions
176
+ // ============================================================================
177
+
178
+ async function fetchGeminiAccessToken(cookieMap) {
179
+ const cookieHeader = buildCookieHeader(cookieMap);
180
+ const res = await fetchWithRedirects(GEMINI_APP_URL, { cookie: cookieHeader });
181
+ const html = res.text;
182
+
183
+ const tokens = ["SNlM0e", "thykhd"];
184
+ for (const key of tokens) {
185
+ const match = html.match(new RegExp(`"${key}":"(.*?)"`));
186
+ if (match?.[1]) return match[1];
187
+ }
188
+
189
+ throw new Error("Unable to authenticate with Gemini. Make sure you're signed into gemini.google.com in Chrome.");
190
+ }
191
+
192
+ function trimGeminiJsonEnvelope(text) {
193
+ const start = text.indexOf("[");
194
+ const end = text.lastIndexOf("]");
195
+ if (start === -1 || end === -1 || end <= start) {
196
+ throw new Error("Gemini response did not contain a JSON payload.");
197
+ }
198
+ return text.slice(start, end + 1);
199
+ }
200
+
201
+ function extractErrorCode(responseJson) {
202
+ const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
203
+ return typeof code === "number" && code >= 0 ? code : undefined;
204
+ }
205
+
206
+ function isModelUnavailable(errorCode) {
207
+ return errorCode === 1052;
208
+ }
209
+
210
+ function extractGgdlUrls(rawText) {
211
+ const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
212
+ const seen = new Set();
213
+ const urls = [];
214
+ for (const match of matches) {
215
+ if (seen.has(match)) continue;
216
+ seen.add(match);
217
+ urls.push(match);
218
+ }
219
+ return urls;
220
+ }
221
+
222
+ function ensureFullSizeImageUrl(url) {
223
+ if (url.includes("=s2048")) return url;
224
+ if (url.includes("=s")) return url;
225
+ return `${url}=s2048`;
226
+ }
227
+
228
+ function parseGeminiStreamGenerateResponse(rawText) {
229
+ const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
230
+ const errorCode = extractErrorCode(responseJson);
231
+
232
+ const parts = Array.isArray(responseJson) ? responseJson : [];
233
+ let bodyIndex = 0;
234
+ let body = null;
235
+
236
+ for (let i = 0; i < parts.length; i++) {
237
+ const partBody = getNestedValue(parts[i], [2], null);
238
+ if (!partBody) continue;
239
+ try {
240
+ const parsed = JSON.parse(partBody);
241
+ const candidateList = getNestedValue(parsed, [4], []);
242
+ if (Array.isArray(candidateList) && candidateList.length > 0) {
243
+ bodyIndex = i;
244
+ body = parsed;
245
+ break;
246
+ }
247
+ } catch {
248
+ // ignore
249
+ }
250
+ }
251
+
252
+ const candidateList = getNestedValue(body, [4], []);
253
+ const firstCandidate = candidateList[0];
254
+ const textRaw = getNestedValue(firstCandidate, [1, 0], "");
255
+ const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
256
+ const text = cardContent
257
+ ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
258
+ : textRaw;
259
+ const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
260
+ const metadata = getNestedValue(body, [1], []);
261
+
262
+ const images = [];
263
+
264
+ // Web images
265
+ const webImages = getNestedValue(firstCandidate, [12, 1], []);
266
+ for (const webImage of webImages) {
267
+ const url = getNestedValue(webImage, [0, 0, 0], null);
268
+ if (!url) continue;
269
+ images.push({
270
+ kind: "web",
271
+ url,
272
+ title: getNestedValue(webImage, [7, 0], undefined),
273
+ alt: getNestedValue(webImage, [0, 4], undefined),
274
+ });
275
+ }
276
+
277
+ // Generated images
278
+ const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
279
+ if (hasGenerated) {
280
+ let imgBody = null;
281
+ for (let i = bodyIndex; i < parts.length; i++) {
282
+ const partBody = getNestedValue(parts[i], [2], null);
283
+ if (!partBody) continue;
284
+ try {
285
+ const parsed = JSON.parse(partBody);
286
+ const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
287
+ if (candidateImages != null) {
288
+ imgBody = parsed;
289
+ break;
290
+ }
291
+ } catch {
292
+ // ignore
293
+ }
294
+ }
295
+
296
+ const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
297
+ const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
298
+ for (const genImage of generated) {
299
+ const url = getNestedValue(genImage, [0, 3, 3], null);
300
+ if (!url) continue;
301
+ images.push({
302
+ kind: "generated",
303
+ url,
304
+ title: "[Generated Image]",
305
+ alt: "",
306
+ });
307
+ }
308
+ }
309
+
310
+ return { metadata, text, thoughts, images, errorCode };
311
+ }
312
+
313
+ // ============================================================================
314
+ // File Upload
315
+ // ============================================================================
316
+
317
+ async function uploadGeminiFile(filePath) {
318
+ const absPath = path.resolve(process.cwd(), filePath);
319
+ const data = fs.readFileSync(absPath);
320
+ const fileName = path.basename(absPath);
321
+
322
+ // Build multipart form data manually
323
+ const boundary = "----FormBoundary" + Math.random().toString(36).slice(2);
324
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
325
+ const footer = `\r\n--${boundary}--\r\n`;
326
+
327
+ const body = Buffer.concat([
328
+ Buffer.from(header, "utf-8"),
329
+ data,
330
+ Buffer.from(footer, "utf-8"),
331
+ ]);
332
+
333
+ const res = await httpsPost(GEMINI_UPLOAD_URL, {
334
+ "content-type": `multipart/form-data; boundary=${boundary}`,
335
+ "push-id": GEMINI_UPLOAD_PUSH_ID,
336
+ }, body);
337
+
338
+ if (res.status < 200 || res.status >= 300) {
339
+ throw new Error(`File upload failed: ${res.status} (${res.text.slice(0, 200)})`);
340
+ }
341
+
342
+ return { id: res.text, name: fileName };
343
+ }
344
+
345
+ // ============================================================================
346
+ // Image Download
347
+ // ============================================================================
348
+
349
+ async function downloadGeminiImage(url, cookieMap, outputPath) {
350
+ const cookieHeader = buildCookieHeader(cookieMap);
351
+ const fullUrl = ensureFullSizeImageUrl(url);
352
+
353
+ // Use binary mode for image download
354
+ const res = await fetchWithRedirects(fullUrl, { cookie: cookieHeader }, 10, true);
355
+
356
+ if (res.status < 200 || res.status >= 300) {
357
+ throw new Error(`Failed to download image: ${res.status}`);
358
+ }
359
+
360
+ const dir = path.dirname(outputPath);
361
+ if (dir && !fs.existsSync(dir)) {
362
+ fs.mkdirSync(dir, { recursive: true });
363
+ }
364
+
365
+ // Write binary buffer directly
366
+ fs.writeFileSync(outputPath, res.buffer);
367
+ }
368
+
369
+ async function saveFirstGeminiImage(output, cookieMap, outputPath) {
370
+ // Try generated or web images first
371
+ const genOrWeb = output.images.find(img => img.kind === "generated") ?? output.images[0];
372
+ if (genOrWeb?.url) {
373
+ await downloadGeminiImage(genOrWeb.url, cookieMap, outputPath);
374
+ return { saved: true, imageCount: output.images.length };
375
+ }
376
+
377
+ // Fall back to gg-dl URLs in raw response
378
+ const ggdl = extractGgdlUrls(output.rawResponseText);
379
+ if (ggdl[0]) {
380
+ await downloadGeminiImage(ggdl[0], cookieMap, outputPath);
381
+ return { saved: true, imageCount: ggdl.length };
382
+ }
383
+
384
+ return { saved: false, imageCount: 0 };
385
+ }
386
+
387
+ // ============================================================================
388
+ // Core Request Function
389
+ // ============================================================================
390
+
391
+ function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
392
+ const promptPayload = uploaded.length > 0
393
+ ? [prompt, 0, null, uploaded.map(file => [[file.id, 1]])]
394
+ : [prompt];
395
+
396
+ const innerList = [promptPayload, null, chatMetadata ?? null];
397
+ return JSON.stringify([null, JSON.stringify(innerList)]);
398
+ }
399
+
400
+ async function runGeminiWebOnce(input) {
401
+ const { prompt, files, model, cookieMap, chatMetadata } = input;
402
+ const cookieHeader = buildCookieHeader(cookieMap);
403
+
404
+ // 1. Get access token
405
+ const at = await fetchGeminiAccessToken(cookieMap);
406
+
407
+ // 2. Upload files
408
+ const uploaded = [];
409
+ for (const file of files ?? []) {
410
+ uploaded.push(await uploadGeminiFile(file));
411
+ }
412
+
413
+ // 3. Build request
414
+ const fReq = buildGeminiFReqPayload(prompt, uploaded, chatMetadata);
415
+ const params = new URLSearchParams();
416
+ params.set("at", at);
417
+ params.set("f.req", fReq);
418
+
419
+ // 4. Send request
420
+ const res = await httpsPost(GEMINI_STREAM_GENERATE_URL, {
421
+ "content-type": "application/x-www-form-urlencoded;charset=utf-8",
422
+ "origin": "https://gemini.google.com",
423
+ "referer": "https://gemini.google.com/",
424
+ "x-same-domain": "1",
425
+ "cookie": cookieHeader,
426
+ [MODEL_HEADER_NAME]: MODEL_HEADERS[model] || MODEL_HEADERS["gemini-3-pro"],
427
+ }, params.toString());
428
+
429
+ const rawResponseText = res.text;
430
+
431
+ if (res.status < 200 || res.status >= 300) {
432
+ return {
433
+ rawResponseText,
434
+ text: "",
435
+ thoughts: null,
436
+ metadata: chatMetadata ?? null,
437
+ images: [],
438
+ errorMessage: `Gemini request failed: ${res.status}`,
439
+ };
440
+ }
441
+
442
+ try {
443
+ const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
444
+ return {
445
+ rawResponseText,
446
+ text: parsed.text ?? "",
447
+ thoughts: parsed.thoughts,
448
+ metadata: parsed.metadata,
449
+ images: parsed.images,
450
+ errorCode: parsed.errorCode,
451
+ };
452
+ } catch (error) {
453
+ let responseJson = null;
454
+ try {
455
+ responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
456
+ } catch {
457
+ responseJson = null;
458
+ }
459
+ const errorCode = extractErrorCode(responseJson);
460
+
461
+ return {
462
+ rawResponseText,
463
+ text: "",
464
+ thoughts: null,
465
+ metadata: chatMetadata ?? null,
466
+ images: [],
467
+ errorCode: typeof errorCode === "number" ? errorCode : undefined,
468
+ errorMessage: error instanceof Error ? error.message : String(error ?? ""),
469
+ };
470
+ }
471
+ }
472
+
473
+ async function runGeminiWebWithFallback(input) {
474
+ const attempt = await runGeminiWebOnce(input);
475
+
476
+ // Auto-fallback to flash if model unavailable
477
+ if (isModelUnavailable(attempt.errorCode) && input.model !== "gemini-2.5-flash") {
478
+ const fallback = await runGeminiWebOnce({ ...input, model: "gemini-2.5-flash" });
479
+ return { ...fallback, effectiveModel: "gemini-2.5-flash" };
480
+ }
481
+
482
+ return { ...attempt, effectiveModel: input.model };
483
+ }
484
+
485
+ // ============================================================================
486
+ // Main Query Function
487
+ // ============================================================================
488
+
489
+ async function query(options) {
490
+ const {
491
+ prompt,
492
+ model = "gemini-3-pro",
493
+ file,
494
+ generateImage,
495
+ editImage,
496
+ output,
497
+ youtube,
498
+ aspectRatio,
499
+ timeout = 300000,
500
+ getCookies,
501
+ log = () => {},
502
+ } = options;
503
+
504
+ const startTime = Date.now();
505
+ log("Starting Gemini query");
506
+
507
+ // 1. Get cookies from Chrome
508
+ const cookieResponse = await getCookies();
509
+ const cookies = cookieResponse?.cookies;
510
+ if (!Array.isArray(cookies)) {
511
+ throw new Error("Failed to get cookies from Chrome. Make sure the extension is loaded and Chrome is running.");
512
+ }
513
+ const cookieMap = buildCookieMap(cookies);
514
+
515
+ if (!hasRequiredCookies(cookieMap)) {
516
+ throw new Error("Gemini login required. Sign into gemini.google.com in Chrome and try again.");
517
+ }
518
+
519
+ log(`Got ${Object.keys(cookieMap).length} Gemini cookies`);
520
+
521
+ // 2. Resolve model
522
+ const resolvedModel = MODEL_HEADERS[model] ? model : "gemini-3-pro";
523
+
524
+ // 3. Build prompt
525
+ let fullPrompt = prompt || "";
526
+ if (aspectRatio && (generateImage || editImage)) {
527
+ fullPrompt = `${fullPrompt} (aspect ratio: ${aspectRatio})`;
528
+ }
529
+ if (youtube) {
530
+ fullPrompt = `${fullPrompt}\n\nYouTube video: ${youtube}`;
531
+ }
532
+ if (generateImage && !editImage) {
533
+ fullPrompt = `Generate an image: ${fullPrompt}`;
534
+ }
535
+
536
+ // 4. Collect files
537
+ const files = file ? [file] : [];
538
+
539
+ // 5. Execute request
540
+ let response;
541
+ let imagePath = null;
542
+
543
+ try {
544
+ if (editImage) {
545
+ // Two-turn conversation for image editing
546
+ log("Uploading image for editing...");
547
+ const intro = await runGeminiWebWithFallback({
548
+ prompt: "Here is an image to edit",
549
+ files: [editImage],
550
+ model: resolvedModel,
551
+ cookieMap,
552
+ chatMetadata: null,
553
+ });
554
+
555
+ log("Sending edit request...");
556
+ const editPrompt = `Use image generation tool to ${fullPrompt}`;
557
+ const out = await runGeminiWebWithFallback({
558
+ prompt: editPrompt,
559
+ files,
560
+ model: resolvedModel,
561
+ cookieMap,
562
+ chatMetadata: intro.metadata,
563
+ });
564
+
565
+ response = out;
566
+
567
+ // Save output image
568
+ const outputPath = output || generateImage || "edited.png";
569
+ const imageSave = await saveFirstGeminiImage(out, cookieMap, outputPath);
570
+ if (!imageSave.saved) {
571
+ throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
572
+ }
573
+ imagePath = outputPath;
574
+
575
+ } else if (generateImage) {
576
+ // Image generation
577
+ log("Generating image...");
578
+ const out = await runGeminiWebWithFallback({
579
+ prompt: fullPrompt,
580
+ files,
581
+ model: resolvedModel,
582
+ cookieMap,
583
+ chatMetadata: null,
584
+ });
585
+
586
+ response = out;
587
+
588
+ // Save output image
589
+ const imageSave = await saveFirstGeminiImage(out, cookieMap, generateImage);
590
+ if (!imageSave.saved) {
591
+ throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
592
+ }
593
+ imagePath = generateImage;
594
+
595
+ } else {
596
+ // Text query
597
+ log("Sending text query...");
598
+ const out = await runGeminiWebWithFallback({
599
+ prompt: fullPrompt,
600
+ files,
601
+ model: resolvedModel,
602
+ cookieMap,
603
+ chatMetadata: null,
604
+ });
605
+
606
+ response = out;
607
+ }
608
+ } catch (error) {
609
+ throw new Error(`Gemini request failed: ${error.message}`);
610
+ }
611
+
612
+ const tookMs = Date.now() - startTime;
613
+ log(`Completed in ${tookMs}ms`);
614
+
615
+ return {
616
+ response: response.text || "",
617
+ model: response.effectiveModel || resolvedModel,
618
+ tookMs,
619
+ imagePath,
620
+ thoughts: response.thoughts,
621
+ imageCount: response.images?.length || 0,
622
+ };
623
+ }
624
+
625
+ // ============================================================================
626
+ // Exports
627
+ // ============================================================================
628
+
629
+ module.exports = {
630
+ query,
631
+ hasRequiredCookies,
632
+ buildCookieMap,
633
+ parseGeminiStreamGenerateResponse,
634
+ REQUIRED_COOKIES,
635
+ ALL_COOKIE_NAMES,
636
+ GEMINI_APP_URL,
637
+ };