This is page 2 of 2. Use http://codebase.md/pleaseprompto/notebooklm-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── CHANGELOG.md
├── docs
│ ├── configuration.md
│ ├── tools.md
│ ├── troubleshooting.md
│ └── usage-guide.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── auth
│ │ └── auth-manager.ts
│ ├── config.ts
│ ├── errors.ts
│ ├── index.ts
│ ├── library
│ │ ├── notebook-library.ts
│ │ └── types.ts
│ ├── resources
│ │ └── resource-handlers.ts
│ ├── session
│ │ ├── browser-session.ts
│ │ ├── session-manager.ts
│ │ └── shared-context-manager.ts
│ ├── tools
│ │ ├── definitions
│ │ │ ├── ask-question.ts
│ │ │ ├── notebook-management.ts
│ │ │ ├── session-management.ts
│ │ │ └── system.ts
│ │ ├── definitions.ts
│ │ ├── handlers.ts
│ │ └── index.ts
│ ├── types.ts
│ └── utils
│ ├── cleanup-manager.ts
│ ├── cli-handler.ts
│ ├── logger.ts
│ ├── page-utils.ts
│ ├── settings-manager.ts
│ └── stealth-utils.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/utils/stealth-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Stealth utilities for human-like browser behavior
3 | *
4 | * This module provides functions to simulate realistic human interactions:
5 | * - Human-like typing (speed configurable via CONFIG.typingWpmMin/Max)
6 | * - Realistic mouse movements (Bezier curves with jitter)
7 | * - Random delays (normal distribution)
8 | * - Smooth scrolling
9 | * - Reading pauses
10 | *
11 | * Based on the Python implementation from stealth_utils.py
12 | */
13 |
14 | import type { Page } from "patchright";
15 | import { CONFIG } from "../config.js";
16 |
17 | // ============================================================================
18 | // Helper Functions
19 | // ============================================================================
20 |
21 | /**
22 | * Sleep for specified milliseconds
23 | */
24 | export async function sleep(ms: number): Promise<void> {
25 | return new Promise((resolve) => setTimeout(resolve, ms));
26 | }
27 |
28 | /**
29 | * Generate random integer between min and max (inclusive)
30 | */
31 | export function randomInt(min: number, max: number): number {
32 | return Math.floor(Math.random() * (max - min + 1)) + min;
33 | }
34 |
35 | /**
36 | * Generate random float between min and max
37 | */
38 | export function randomFloat(min: number, max: number): number {
39 | return Math.random() * (max - min) + min;
40 | }
41 |
42 | /**
43 | * Generate random character (for typos)
44 | */
45 | export function randomChar(): string {
46 | const chars = "qwertyuiopasdfghjklzxcvbnm";
47 | return chars[randomInt(0, chars.length - 1)];
48 | }
49 |
50 | /**
51 | * Generate Gaussian (normal) distributed random number
52 | * Uses Box-Muller transform
53 | */
54 | export function gaussian(mean: number, stdDev: number): number {
55 | const u1 = Math.random();
56 | const u2 = Math.random();
57 | const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
58 | return z0 * stdDev + mean;
59 | }
60 |
61 | // ============================================================================
62 | // Random Delays
63 | // ============================================================================
64 |
65 | /**
66 | * Add a random delay to simulate human thinking/reaction time
67 | * Uses normal distribution for more realistic delays
68 | *
69 | * @param minMs Minimum delay in milliseconds (default: from CONFIG)
70 | * @param maxMs Maximum delay in milliseconds (default: from CONFIG)
71 | */
72 | export async function randomDelay(
73 | minMs?: number,
74 | maxMs?: number
75 | ): Promise<void> {
76 | minMs = minMs ?? CONFIG.minDelayMs;
77 | maxMs = maxMs ?? CONFIG.maxDelayMs;
78 |
79 | if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
80 | // Fixed delay (average)
81 | const target = minMs === maxMs ? minMs : (minMs + maxMs) / 2;
82 | if (target > 0) {
83 | await sleep(target);
84 | }
85 | return;
86 | }
87 |
88 | // Use normal distribution for more realistic delays
89 | // Mean at 60% of range, standard deviation of 20% of range
90 | const mean = minMs + (maxMs - minMs) * 0.6;
91 | const stdDev = (maxMs - minMs) * 0.2;
92 |
93 | let delay = gaussian(mean, stdDev);
94 | delay = Math.max(minMs, Math.min(maxMs, delay)); // Clamp to range
95 |
96 | await sleep(delay);
97 | }
98 |
99 | // ============================================================================
100 | // Human Typing
101 | // ============================================================================
102 |
103 | /**
104 | * Type text in a human-like manner with variable speed and optional typos
105 | *
106 | * Simulates realistic typing patterns:
107 | * - Variable speed (45-65 WPM by default)
108 | * - Occasional typos (2% chance)
109 | * - Longer pauses after punctuation
110 | * - Realistic character delays
111 | *
112 | * @param page Playwright page instance
113 | * @param selector CSS selector of input element
114 | * @param text Text to type
115 | * @param options Typing options (wpm, withTypos)
116 | */
117 | export async function humanType(
118 | page: Page,
119 | selector: string,
120 | text: string,
121 | options: {
122 | wpm?: number;
123 | withTypos?: boolean;
124 | } = {}
125 | ): Promise<void> {
126 | if (!CONFIG.stealthEnabled || !CONFIG.stealthHumanTyping) {
127 | // Fast typing without stealth
128 | await page.fill(selector, text);
129 | return;
130 | }
131 |
132 | const wpm = options.wpm ?? randomInt(CONFIG.typingWpmMin, CONFIG.typingWpmMax);
133 | const withTypos = options.withTypos ?? true;
134 |
135 | // Calculate average delay per character (in ms)
136 | // WPM = (characters / 5) / minutes
137 | // Average word length ~5 characters
138 | const charsPerMinute = wpm * 5;
139 | const avgDelayMs = (60 * 1000) / charsPerMinute;
140 |
141 | // Clear existing text first
142 | await page.fill(selector, "");
143 | await randomDelay(30, 80);
144 |
145 | // Click to focus
146 | await page.click(selector);
147 | await randomDelay(20, 60);
148 |
149 | // Type each character
150 | let currentText = "";
151 | let i = 0;
152 |
153 | while (i < text.length) {
154 | const char = text[i];
155 |
156 | // Simulate very rare typo (0.3% chance) and shorter correction
157 | if (withTypos && Math.random() < 0.003 && i > 0) {
158 | // Type wrong character
159 | const wrongChar = randomChar();
160 | currentText += wrongChar;
161 | await page.fill(selector, currentText);
162 |
163 | // Shorter notice window for faster typing
164 | const noticeDelay = randomFloat(avgDelayMs * 0.6, avgDelayMs * 1.1);
165 | await sleep(noticeDelay);
166 |
167 | // Backspace
168 | currentText = currentText.slice(0, -1);
169 | await page.fill(selector, currentText);
170 | await randomDelay(20, 60);
171 | }
172 |
173 | // Type correct character
174 | currentText += char;
175 | await page.fill(selector, currentText);
176 |
177 | // Variable delay between characters – tuned for faster but still human-like typing
178 | let delay: number;
179 | if (char === "." || char === "!" || char === "?") {
180 | delay = randomFloat(avgDelayMs * 1.05, avgDelayMs * 1.4);
181 | } else if (char === " ") {
182 | delay = randomFloat(avgDelayMs * 0.5, avgDelayMs * 0.9);
183 | } else if (char === ",") {
184 | delay = randomFloat(avgDelayMs * 0.9, avgDelayMs * 1.2);
185 | } else {
186 | // Normal character
187 | const variation = randomFloat(0.5, 0.9);
188 | delay = avgDelayMs * variation;
189 | }
190 |
191 | await sleep(delay);
192 | i++;
193 | }
194 |
195 | // Small delay after finishing typing
196 | await randomDelay(50, 120);
197 | }
198 |
199 | // ============================================================================
200 | // Mouse Movement
201 | // ============================================================================
202 |
203 | /**
204 | * Move mouse in a realistic curved path to target coordinates
205 | * Uses Bezier-like curves with jitter for natural movement
206 | *
207 | * @param page Playwright page instance
208 | * @param targetX Target X coordinate (default: random)
209 | * @param targetY Target Y coordinate (default: random)
210 | * @param steps Number of steps in movement (default: random 10-25)
211 | */
212 | export async function randomMouseMovement(
213 | page: Page,
214 | targetX?: number,
215 | targetY?: number,
216 | steps?: number
217 | ): Promise<void> {
218 | if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
219 | return;
220 | }
221 |
222 | const viewport = page.viewportSize() || CONFIG.viewport;
223 |
224 | targetX = targetX ?? randomInt(100, viewport.width - 100);
225 | targetY = targetY ?? randomInt(100, viewport.height - 100);
226 | steps = steps ?? randomInt(10, 25);
227 |
228 | // Start from a random position (we don't know current position)
229 | const startX = randomInt(0, viewport.width);
230 | const startY = randomInt(0, viewport.height);
231 |
232 | // Generate curved path using Bezier-like curve
233 | for (let step = 0; step < steps; step++) {
234 | const progress = step / steps;
235 |
236 | // Add some randomness to create a natural curve
237 | const curveOffsetX = Math.sin(progress * Math.PI) * randomInt(-50, 50);
238 | const curveOffsetY = Math.cos(progress * Math.PI) * randomInt(-30, 30);
239 |
240 | let currentX = startX + (targetX - startX) * progress + curveOffsetX;
241 | let currentY = startY + (targetY - startY) * progress + curveOffsetY;
242 |
243 | // Add micro-jitter (humans never move in perfectly straight lines)
244 | const jitterX = randomFloat(-3, 3);
245 | const jitterY = randomFloat(-3, 3);
246 |
247 | currentX = Math.max(0, Math.min(viewport.width, currentX + jitterX));
248 | currentY = Math.max(0, Math.min(viewport.height, currentY + jitterY));
249 |
250 | await page.mouse.move(currentX, currentY);
251 |
252 | // Variable delay between movements (faster in middle, slower at ends)
253 | const delay = 10 + 20 * Math.abs(0.5 - progress);
254 | await sleep(delay);
255 | }
256 | }
257 |
258 | // ============================================================================
259 | // Realistic Click
260 | // ============================================================================
261 |
262 | /**
263 | * Click an element with realistic human behavior
264 | * Includes mouse movement, pause, and click
265 | *
266 | * @param page Playwright page instance
267 | * @param selector CSS selector of element to click
268 | * @param withMouseMovement Whether to move mouse first (default: true)
269 | */
270 | export async function realisticClick(
271 | page: Page,
272 | selector: string,
273 | withMouseMovement: boolean = true
274 | ): Promise<void> {
275 | if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
276 | await page.click(selector);
277 | return;
278 | }
279 |
280 | if (withMouseMovement) {
281 | // Move mouse to element
282 | const element = await page.$(selector);
283 | if (element) {
284 | const box = await element.boundingBox();
285 | if (box) {
286 | // Don't click exactly in center (humans are imperfect)
287 | const offsetX = randomFloat(-box.width * 0.2, box.width * 0.2);
288 | const offsetY = randomFloat(-box.height * 0.2, box.height * 0.2);
289 |
290 | const targetX = box.x + box.width / 2 + offsetX;
291 | const targetY = box.y + box.height / 2 + offsetY;
292 |
293 | await randomMouseMovement(page, targetX, targetY);
294 | }
295 | }
296 | }
297 |
298 | // Small pause before clicking
299 | await randomDelay(100, 300);
300 |
301 | // Click
302 | await page.click(selector);
303 |
304 | // Small pause after clicking
305 | await randomDelay(150, 400);
306 | }
307 |
308 | // ============================================================================
309 | // Smooth Scrolling
310 | // ============================================================================
311 |
312 | /**
313 | * Scroll the page smoothly like a human
314 | * Uses multiple small steps for smooth animation
315 | *
316 | * @param page Playwright page instance
317 | * @param amount Scroll amount in pixels (default: random 100-400)
318 | * @param direction Scroll direction ("down" or "up")
319 | */
320 | export async function smoothScroll(
321 | page: Page,
322 | amount?: number,
323 | direction: "up" | "down" = "down"
324 | ): Promise<void> {
325 | amount = amount ?? randomInt(100, 400);
326 | amount = Math.abs(amount);
327 | if (direction === "up") {
328 | amount = -amount;
329 | }
330 |
331 | if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
332 | await page.evaluate((scrollAmount) => {
333 | // @ts-expect-error - window exists in browser context
334 | window.scrollBy({ top: scrollAmount, behavior: "auto" });
335 | }, amount);
336 | return;
337 | }
338 |
339 | // Scroll in multiple small steps for smoothness
340 | const steps = randomInt(8, 15);
341 | const stepAmount = amount / steps;
342 |
343 | for (let i = 0; i < steps; i++) {
344 | await page.evaluate((step) => {
345 | // @ts-expect-error - window exists in browser context
346 | window.scrollBy({ top: step, behavior: "smooth" });
347 | }, stepAmount);
348 | await sleep(randomFloat(20, 50));
349 | }
350 |
351 | // Pause after scrolling (humans look at content)
352 | await randomDelay(300, 800);
353 | }
354 |
355 | // ============================================================================
356 | // Reading Simulation
357 | // ============================================================================
358 |
359 | /**
360 | * Pause as if reading text, based on length
361 | * Calculates realistic reading time based on text length and WPM
362 | *
363 | * @param textLength Number of characters to "read"
364 | * @param wpm Reading speed in words per minute (default: random 200-250)
365 | */
366 | export async function readingPause(textLength: number, wpm?: number): Promise<void> {
367 | if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
368 | return;
369 | }
370 |
371 | wpm = wpm ?? randomInt(200, 250);
372 |
373 | // Calculate reading time
374 | // Average word length ~5 characters
375 | const wordCount = textLength / 5;
376 | const minutes = wordCount / wpm;
377 | let seconds = minutes * 60;
378 |
379 | // Add some randomness (humans don't read at constant speed)
380 | seconds *= randomFloat(0.8, 1.2);
381 |
382 | // Cap at reasonable maximum (3 seconds)
383 | seconds = Math.min(seconds, 3.0);
384 |
385 | await sleep(seconds * 1000);
386 | }
387 |
388 | // ============================================================================
389 | // Random Mouse Jitter
390 | // ============================================================================
391 |
392 | /**
393 | * Add small random mouse movements to simulate natural fidgeting
394 | *
395 | * @param page Playwright page instance
396 | * @param iterations Number of small movements (default: 3)
397 | */
398 | export async function randomMouseJitter(
399 | page: Page,
400 | iterations: number = 3
401 | ): Promise<void> {
402 | if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
403 | return;
404 | }
405 |
406 | const viewport = page.viewportSize() || CONFIG.viewport;
407 |
408 | for (let i = 0; i < iterations; i++) {
409 | const targetX = randomInt(0, viewport.width);
410 | const targetY = randomInt(0, viewport.height);
411 | await page.mouse.move(targetX, targetY, { steps: randomInt(2, 4) });
412 | await sleep(randomFloat(100, 300));
413 | }
414 | }
415 |
416 | // ============================================================================
417 | // Hover Element
418 | // ============================================================================
419 |
420 | /**
421 | * Hover over an element with realistic mouse movement
422 | *
423 | * @param page Playwright page instance
424 | * @param selector CSS selector of element to hover
425 | */
426 | export async function hoverElement(page: Page, selector: string): Promise<void> {
427 | if (!CONFIG.stealthEnabled || !CONFIG.stealthMouseMovements) {
428 | await page.hover(selector);
429 | return;
430 | }
431 |
432 | const element = await page.$(selector);
433 | if (element) {
434 | const box = await element.boundingBox();
435 | if (box) {
436 | // Move to center of element
437 | const targetX = box.x + box.width / 2;
438 | const targetY = box.y + box.height / 2;
439 |
440 | await randomMouseMovement(page, targetX, targetY);
441 | await page.hover(selector);
442 | await randomDelay(200, 500);
443 | }
444 | }
445 | }
446 |
447 | // ============================================================================
448 | // Simulate Reading Page
449 | // ============================================================================
450 |
451 | /**
452 | * Simulate reading a page with scrolling and pauses
453 | * Adds realistic behavior of scrolling and reading content
454 | *
455 | * @param page Playwright page instance
456 | */
457 | export async function simulateReadingPage(page: Page): Promise<void> {
458 | if (!CONFIG.stealthEnabled || !CONFIG.stealthRandomDelays) {
459 | return;
460 | }
461 |
462 | // Random number of scroll actions
463 | const scrollCount = randomInt(1, 3);
464 |
465 | for (let i = 0; i < scrollCount; i++) {
466 | // Scroll down
467 | await smoothScroll(page, undefined, "down");
468 |
469 | // "Read" the visible content
470 | await randomDelay(800, 1500);
471 |
472 | // Sometimes scroll up a bit (humans do this)
473 | if (Math.random() < 0.3) {
474 | await smoothScroll(page, randomInt(50, 150), "up");
475 | await randomDelay(400, 800);
476 | }
477 | }
478 | }
479 |
480 | // ============================================================================
481 | // Exports
482 | // ============================================================================
483 |
484 | export default {
485 | sleep,
486 | randomInt,
487 | randomFloat,
488 | randomChar,
489 | gaussian,
490 | randomDelay,
491 | humanType,
492 | randomMouseMovement,
493 | realisticClick,
494 | smoothScroll,
495 | readingPause,
496 | randomMouseJitter,
497 | hoverElement,
498 | simulateReadingPage,
499 | };
500 |
```
--------------------------------------------------------------------------------
/src/session/shared-context-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Shared Context Manager with Persistent Chrome Profile
3 | *
4 | * Manages ONE global persistent BrowserContext for ALL sessions.
5 | * This is critical for avoiding bot detection:
6 | *
7 | * - Google tracks browser fingerprints (Canvas, WebGL, Audio, Fonts, etc.)
8 | * - Multiple contexts = Multiple fingerprints = Suspicious!
9 | * - ONE persistent context = ONE consistent fingerprint = Normal user
10 | * - Persistent user_data_dir = SAME fingerprint across all app restarts!
11 | *
12 | * Based on the Python implementation from shared_context_manager.py
13 | */
14 |
15 | import type { BrowserContext } from "patchright";
16 | import { chromium } from "patchright";
17 | import { CONFIG } from "../config.js";
18 | import { log } from "../utils/logger.js";
19 | import { AuthManager } from "../auth/auth-manager.js";
20 | import fs from "fs";
21 | import path from "path";
22 |
23 | /**
24 | * Shared Context Manager
25 | *
26 | * Benefits:
27 | * 1. ONE consistent browser fingerprint for all sessions
28 | * 2. Fingerprint persists across app restarts (user_data_dir)
29 | * 3. Mimics real user behavior (one browser, multiple tabs)
30 | * 4. Google sees: "Same browser since day 1"
31 | */
32 | export class SharedContextManager {
33 | private authManager: AuthManager;
34 | private globalContext: BrowserContext | null = null;
35 | private contextCreatedAt: number | null = null;
36 | private currentProfileDir: string | null = null;
37 | private isIsolatedProfile: boolean = false;
38 | private currentHeadlessMode: boolean | null = null;
39 |
40 | constructor(authManager: AuthManager) {
41 | this.authManager = authManager;
42 |
43 | log.info("🌐 SharedContextManager initialized (PERSISTENT MODE)");
44 | log.info(` Chrome Profile: ${CONFIG.chromeProfileDir}`);
45 | log.success(" Fingerprint: PERSISTENT across restarts! 🎯");
46 |
47 | // Cleanup old isolated profiles at startup (best-effort)
48 | if (CONFIG.cleanupInstancesOnStartup) {
49 | void this.pruneIsolatedProfiles("startup");
50 | }
51 | }
52 |
53 | /**
54 | * Get the global shared persistent context, or create new if needed
55 | *
56 | * Context is recreated only when:
57 | * - First time (no context exists in this app instance)
58 | * - Context was closed/invalid
59 | *
60 | * Note: Auth expiry does NOT recreate context - we reuse the SAME
61 | * fingerprint and just re-login!
62 | *
63 | * @param overrideHeadless Optional override for headless mode (true = show browser)
64 | */
65 | async getOrCreateContext(overrideHeadless?: boolean): Promise<BrowserContext> {
66 | // Check if headless mode needs to be changed (e.g., show_browser=true)
67 | // If yes, close the browser so it gets recreated with the new mode
68 | if (this.needsHeadlessModeChange(overrideHeadless)) {
69 | log.warning("🔄 Headless mode change detected - recreating browser context...");
70 | await this.closeContext();
71 | }
72 |
73 | if (await this.needsRecreation()) {
74 | log.warning("🔄 Creating/Loading persistent context...");
75 | await this.recreateContext(overrideHeadless);
76 | } else {
77 | log.success("♻️ Reusing existing persistent context");
78 | }
79 |
80 | return this.globalContext!;
81 | }
82 |
83 | /**
84 | * Check if global context needs to be recreated
85 | */
86 | private async needsRecreation(): Promise<boolean> {
87 | // No context exists yet (first time or after manual close)
88 | if (!this.globalContext) {
89 | log.info(" ℹ️ No context exists yet");
90 | return true;
91 | }
92 |
93 | // Async validity check (will throw if closed)
94 | try {
95 | await this.globalContext.cookies();
96 | log.dim(" ✅ Context still valid (browser open)");
97 | return false;
98 | } catch (error) {
99 | log.warning(" ⚠️ Context appears closed - will recreate");
100 | this.globalContext = null;
101 | this.contextCreatedAt = null;
102 | this.currentHeadlessMode = null;
103 | return true;
104 | }
105 | }
106 |
107 | /**
108 | * Create/Load the global PERSISTENT context with Chrome user_data_dir
109 | *
110 | * This is THE KEY to fingerprint persistence!
111 | *
112 | * First time:
113 | * - Chrome creates new profile in user_data_dir
114 | * - Generates fingerprint (Canvas, WebGL, Audio, etc.)
115 | * - Saves everything to disk
116 | *
117 | * Subsequent starts:
118 | * - Chrome loads profile from user_data_dir
119 | * - SAME fingerprint as before! ✅
120 | * - Google sees: "Same browser since day 1"
121 | *
122 | * @param overrideHeadless Optional override for headless mode (true = show browser)
123 | */
124 | private async recreateContext(overrideHeadless?: boolean): Promise<void> {
125 | // Close old context if exists
126 | if (this.globalContext) {
127 | try {
128 | log.info(" 🗑️ Closing old context...");
129 | await this.globalContext.close();
130 | } catch (error) {
131 | log.warning(` ⚠️ Error closing old context: ${error}`);
132 | }
133 | }
134 |
135 | // Check for saved auth
136 | const statePath = await this.authManager.getValidStatePath();
137 |
138 | if (statePath) {
139 | log.success(` 📂 Found auth state: ${statePath}`);
140 | log.info(" 💡 Will load cookies into persistent profile");
141 | } else {
142 | log.warning(" 🆕 No saved auth - fresh persistent profile");
143 | log.info(" 💡 First login will save auth to persistent profile");
144 | }
145 |
146 | // Determine headless mode: use override if provided, otherwise use CONFIG
147 | const shouldBeHeadless = overrideHeadless !== undefined ? !overrideHeadless : CONFIG.headless;
148 |
149 | if (overrideHeadless !== undefined) {
150 | log.info(` Browser visibility override: ${overrideHeadless ? 'VISIBLE' : 'HEADLESS'}`);
151 | }
152 |
153 | // Build launch options for persistent context
154 | // NOTE: userDataDir is passed as first parameter, NOT in options!
155 | const launchOptions = {
156 | headless: shouldBeHeadless,
157 | channel: "chrome" as const,
158 | viewport: CONFIG.viewport,
159 | locale: "en-US",
160 | timezoneId: "Europe/Berlin",
161 | // ✅ CRITICAL FIX: Pass storageState directly at launch!
162 | // This is the PROPER way to handle session cookies (Playwright bug workaround)
163 | // Benefits:
164 | // - Session cookies persist correctly
165 | // - No need for addCookies() workarounds
166 | // - Chrome loads everything automatically
167 | ...(statePath && { storageState: statePath }),
168 | args: [
169 | "--disable-blink-features=AutomationControlled",
170 | "--disable-dev-shm-usage",
171 | "--no-first-run",
172 | "--no-default-browser-check",
173 | ],
174 | };
175 |
176 | // 🔥 CRITICAL: launchPersistentContext creates/loads Chrome profile
177 | // Strategy handling for concurrent instances
178 | const baseProfile = CONFIG.chromeProfileDir;
179 | const strategy = CONFIG.profileStrategy;
180 | const tryLaunch = async (userDataDir: string) => {
181 | log.info(" 🚀 Launching persistent Chrome context...");
182 | log.dim(` 📍 Profile location: ${userDataDir}`);
183 | if (statePath) {
184 | log.info(` 📄 Loading auth state: ${statePath}`);
185 | }
186 | return chromium.launchPersistentContext(userDataDir, launchOptions);
187 | };
188 |
189 | try {
190 | if (strategy === "isolated") {
191 | const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
192 | this.globalContext = await tryLaunch(isolatedDir);
193 | this.currentProfileDir = isolatedDir;
194 | this.isIsolatedProfile = true;
195 | } else {
196 | // single or auto → first try base
197 | this.globalContext = await tryLaunch(baseProfile);
198 | this.currentProfileDir = baseProfile;
199 | this.isIsolatedProfile = false;
200 | }
201 | } catch (e: any) {
202 | const msg = String(e?.message || e);
203 | const isSingleton = /ProcessSingleton|SingletonLock|profile is already in use/i.test(msg);
204 | if (strategy === "single" || !isSingleton) {
205 | // hard fail
206 | if (isSingleton && strategy === "single") {
207 | log.error("❌ Chrome profile already in use and strategy=single. Close other instance or set NOTEBOOK_PROFILE_STRATEGY=auto/isolated.");
208 | }
209 | throw e;
210 | }
211 |
212 | // auto strategy with lock → fall back to isolated instance dir
213 | log.warning("⚠️ Base Chrome profile in use by another process. Falling back to isolated per-instance profile...");
214 | const isolatedDir = await this.prepareIsolatedProfileDir(baseProfile);
215 | this.globalContext = await tryLaunch(isolatedDir);
216 | this.currentProfileDir = isolatedDir;
217 | this.isIsolatedProfile = true;
218 | }
219 | this.contextCreatedAt = Date.now();
220 | this.currentHeadlessMode = shouldBeHeadless;
221 | // Track close event to force recreation next time
222 | try {
223 | this.globalContext.on("close", () => {
224 | log.warning(" 🛑 Persistent context was closed externally");
225 | this.globalContext = null;
226 | this.contextCreatedAt = null;
227 | this.currentHeadlessMode = null;
228 | });
229 | } catch {}
230 |
231 | // Validate cookies if we loaded state
232 | if (statePath) {
233 | try {
234 | if (await this.authManager.validateCookiesExpiry(this.globalContext)) {
235 | log.success(" ✅ Authentication state loaded successfully");
236 | log.success(" 🎯 Session cookies persisted correctly!");
237 | } else {
238 | log.warning(" ⚠️ Cookies expired - will need re-login");
239 | }
240 | } catch (error) {
241 | log.warning(` ⚠️ Could not validate auth state: ${error}`);
242 | }
243 | }
244 |
245 | log.success("✅ Persistent context ready!");
246 | log.dim(` Context ID: ${this.getContextId()}`);
247 | log.dim(` Chrome Profile: ${CONFIG.chromeProfileDir}`);
248 | log.success(" 🎯 Fingerprint: PERSISTENT (same across restarts!)");
249 | }
250 |
251 | /**
252 | * Manually close the global context (e.g., on shutdown)
253 | *
254 | * Note: This closes the context for ALL sessions!
255 | * Chrome will save everything to user_data_dir automatically.
256 | */
257 | async closeContext(): Promise<void> {
258 | if (this.globalContext) {
259 | log.warning("🛑 Closing persistent context...");
260 | log.info(" 💾 Chrome is saving profile to disk...");
261 | try {
262 | await this.globalContext.close();
263 | this.globalContext = null;
264 | this.contextCreatedAt = null;
265 | this.currentHeadlessMode = null;
266 | log.success("✅ Persistent context closed");
267 | log.success(` 💾 Profile saved: ${this.currentProfileDir || CONFIG.chromeProfileDir}`);
268 | } catch (error) {
269 | log.error(`❌ Error closing context: ${error}`);
270 | }
271 | }
272 |
273 | // Best-effort cleanup on shutdown
274 | if (CONFIG.cleanupInstancesOnShutdown) {
275 | try {
276 | // If this process used an isolated profile, remove it now
277 | if (this.isIsolatedProfile && this.currentProfileDir) {
278 | await this.safeRemoveIsolatedProfile(this.currentProfileDir);
279 | }
280 | } catch (err) {
281 | log.warning(` ⚠️ Cleanup (self) failed: ${err}`);
282 | }
283 | try {
284 | await this.pruneIsolatedProfiles("shutdown");
285 | } catch (err) {
286 | log.warning(` ⚠️ Cleanup (prune) failed: ${err}`);
287 | }
288 | }
289 | }
290 |
291 | private async prepareIsolatedProfileDir(baseProfile: string): Promise<string> {
292 | const stamp = `${process.pid}-${Date.now()}`;
293 | const dir = path.join(CONFIG.chromeInstancesDir, `instance-${stamp}`);
294 | try {
295 | fs.mkdirSync(dir, { recursive: true });
296 | if (CONFIG.cloneProfileOnIsolated && fs.existsSync(baseProfile)) {
297 | log.info(" 🧬 Cloning base Chrome profile into isolated instance (may take time)...");
298 | // Best-effort clone without locks
299 | await (fs.promises as any).cp(baseProfile, dir, {
300 | recursive: true,
301 | errorOnExist: false,
302 | force: true,
303 | filter: (src: string) => {
304 | const bn = path.basename(src);
305 | return !/^Singleton/i.test(bn) && !bn.endsWith(".lock") && !bn.endsWith(".tmp");
306 | },
307 | } as any);
308 | log.success(" ✅ Clone complete");
309 | } else {
310 | log.info(" 🧪 Using fresh isolated Chrome profile (no clone)");
311 | }
312 | } catch (err) {
313 | log.warning(` ⚠️ Could not prepare isolated profile: ${err}`);
314 | }
315 | return dir;
316 | }
317 |
318 | private async pruneIsolatedProfiles(phase: "startup" | "shutdown"): Promise<void> {
319 | const root = CONFIG.chromeInstancesDir;
320 | let entries: Array<{ path: string; mtimeMs: number }>;
321 | try {
322 | const names = await fs.promises.readdir(root, { withFileTypes: true });
323 | entries = [];
324 | for (const d of names) {
325 | if (!d.isDirectory()) continue;
326 | const p = path.join(root, d.name);
327 | try {
328 | const st = await fs.promises.stat(p);
329 | entries.push({ path: p, mtimeMs: st.mtimeMs });
330 | } catch {}
331 | }
332 | } catch {
333 | return; // directory absent is fine
334 | }
335 |
336 | if (entries.length === 0) return;
337 |
338 | const now = Date.now();
339 | const ttlMs = CONFIG.instanceProfileTtlHours * 3600 * 1000;
340 | const maxCount = Math.max(0, CONFIG.instanceProfileMaxCount);
341 |
342 | // Sort newest first
343 | entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
344 |
345 | const keep: Set<string> = new Set();
346 | const toDelete: Set<string> = new Set();
347 |
348 | // Keep newest up to maxCount
349 | for (let i = 0; i < entries.length; i++) {
350 | const e = entries[i];
351 | const ageMs = now - e.mtimeMs;
352 | const overTtl = ttlMs > 0 && ageMs > ttlMs;
353 | const overCount = i >= maxCount;
354 | const isCurrent = this.currentProfileDir && path.resolve(e.path) === path.resolve(this.currentProfileDir);
355 | if (!isCurrent && (overTtl || overCount)) {
356 | toDelete.add(e.path);
357 | } else {
358 | keep.add(e.path);
359 | }
360 | }
361 |
362 | if (toDelete.size === 0) return;
363 | log.info(`🧹 Pruning isolated profiles (${phase})...`);
364 | for (const p of toDelete) {
365 | try {
366 | await this.safeRemoveIsolatedProfile(p);
367 | log.dim(` 🗑️ removed ${p}`);
368 | } catch (err) {
369 | log.warning(` ⚠️ Failed to remove ${p}: ${err}`);
370 | }
371 | }
372 | }
373 |
374 | private async safeRemoveIsolatedProfile(dir: string): Promise<void> {
375 | // Never remove the base profile
376 | if (path.resolve(dir) === path.resolve(CONFIG.chromeProfileDir)) return;
377 | // Only remove within instances root
378 | if (!path.resolve(dir).startsWith(path.resolve(CONFIG.chromeInstancesDir))) return;
379 | // Best-effort: try removing typical lock files first, then the directory
380 | try {
381 | await fs.promises.rm(dir, { recursive: true, force: true } as any);
382 | } catch (err) {
383 | // If rm is not available in older node, fallback to rmdir
384 | try {
385 | await (fs.promises as any).rmdir(dir, { recursive: true });
386 | } catch {}
387 | }
388 | }
389 |
390 | /**
391 | * Get information about the global persistent context
392 | */
393 | getContextInfo(): {
394 | exists: boolean;
395 | age_seconds?: number;
396 | age_hours?: number;
397 | fingerprint_id?: string;
398 | user_data_dir: string;
399 | persistent: boolean;
400 | } {
401 | if (!this.globalContext) {
402 | return {
403 | exists: false,
404 | user_data_dir: CONFIG.chromeProfileDir,
405 | persistent: true,
406 | };
407 | }
408 |
409 | const ageSeconds = this.contextCreatedAt
410 | ? (Date.now() - this.contextCreatedAt) / 1000
411 | : undefined;
412 | const ageHours = ageSeconds ? ageSeconds / 3600 : undefined;
413 |
414 | return {
415 | exists: true,
416 | age_seconds: ageSeconds,
417 | age_hours: ageHours,
418 | fingerprint_id: this.getContextId(),
419 | user_data_dir: CONFIG.chromeProfileDir,
420 | persistent: true,
421 | };
422 | }
423 |
424 | /**
425 | * Get the current headless mode of the browser context
426 | *
427 | * @returns boolean | null - true if headless, false if visible, null if no context exists
428 | */
429 | getCurrentHeadlessMode(): boolean | null {
430 | return this.currentHeadlessMode;
431 | }
432 |
433 | /**
434 | * Check if the browser context needs to be recreated due to headless mode change
435 | *
436 | * @param overrideHeadless - Optional override for headless mode (true = show browser)
437 | * @returns boolean - true if context needs to be recreated with new mode
438 | */
439 | needsHeadlessModeChange(overrideHeadless?: boolean): boolean {
440 | // No context exists yet = will be created with correct mode anyway
441 | if (this.currentHeadlessMode === null) {
442 | return false;
443 | }
444 |
445 | // Calculate target headless mode
446 | // If override is specified, use it (!overrideHeadless because true = show browser = headless false)
447 | // Otherwise, use CONFIG.headless (which may have been temporarily modified by browser_options)
448 | const targetHeadless = overrideHeadless !== undefined
449 | ? !overrideHeadless
450 | : CONFIG.headless;
451 |
452 | // Compare with current mode
453 | const needsChange = this.currentHeadlessMode !== targetHeadless;
454 |
455 | if (needsChange) {
456 | log.info(` Browser mode change detected: ${this.currentHeadlessMode ? 'HEADLESS' : 'VISIBLE'} → ${targetHeadless ? 'HEADLESS' : 'VISIBLE'}`);
457 | }
458 |
459 | return needsChange;
460 | }
461 |
462 | /**
463 | * Get context ID for logging
464 | */
465 | private getContextId(): string {
466 | if (!this.globalContext) {
467 | return "none";
468 | }
469 | // Use object hash as ID
470 | return `ctx-${(this.globalContext as any)._guid || "unknown"}`;
471 | }
472 | }
473 |
```
--------------------------------------------------------------------------------
/src/session/browser-session.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Browser Session
3 | *
4 | * Represents a single browser session for NotebookLM interactions.
5 | *
6 | * Features:
7 | * - Human-like question typing
8 | * - Streaming response detection
9 | * - Auto-login on session expiry
10 | * - Session activity tracking
11 | * - Chat history reset
12 | *
13 | * Based on the Python implementation from browser_session.py
14 | */
15 |
16 | import type { BrowserContext, Page } from "patchright";
17 | import { SharedContextManager } from "./shared-context-manager.js";
18 | import { AuthManager } from "../auth/auth-manager.js";
19 | import { humanType, randomDelay } from "../utils/stealth-utils.js";
20 | import {
21 | waitForLatestAnswer,
22 | snapshotAllResponses,
23 | } from "../utils/page-utils.js";
24 | import { CONFIG } from "../config.js";
25 | import { log } from "../utils/logger.js";
26 | import type { SessionInfo, ProgressCallback } from "../types.js";
27 | import { RateLimitError } from "../errors.js";
28 |
29 | export class BrowserSession {
30 | public readonly sessionId: string;
31 | public readonly notebookUrl: string;
32 | public readonly createdAt: number;
33 | public lastActivity: number;
34 | public messageCount: number;
35 |
36 | private context!: BrowserContext;
37 | private sharedContextManager: SharedContextManager;
38 | private authManager: AuthManager;
39 | private page: Page | null = null;
40 | private initialized: boolean = false;
41 |
42 | constructor(
43 | sessionId: string,
44 | sharedContextManager: SharedContextManager,
45 | authManager: AuthManager,
46 | notebookUrl: string
47 | ) {
48 | this.sessionId = sessionId;
49 | this.sharedContextManager = sharedContextManager;
50 | this.authManager = authManager;
51 | this.notebookUrl = notebookUrl;
52 | this.createdAt = Date.now();
53 | this.lastActivity = Date.now();
54 | this.messageCount = 0;
55 |
56 | log.info(`🆕 BrowserSession ${sessionId} created`);
57 | }
58 |
59 | /**
60 | * Initialize the session by creating a page and navigating to the notebook
61 | */
62 | async init(): Promise<void> {
63 | if (this.initialized) {
64 | log.warning(`⚠️ Session ${this.sessionId} already initialized`);
65 | return;
66 | }
67 |
68 | log.info(`🚀 Initializing session ${this.sessionId}...`);
69 |
70 | try {
71 | // Ensure a valid shared context
72 | this.context = await this.sharedContextManager.getOrCreateContext();
73 |
74 | // Create new page (tab) in the shared context (with auto-recovery)
75 | try {
76 | this.page = await this.context.newPage();
77 | } catch (e: any) {
78 | const msg = String(e?.message || e);
79 | if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
80 | log.warning(" ♻️ Context was closed. Recreating and retrying newPage...");
81 | this.context = await this.sharedContextManager.getOrCreateContext();
82 | this.page = await this.context.newPage();
83 | } else {
84 | throw e;
85 | }
86 | }
87 | log.success(` ✅ Created new page`);
88 |
89 | // Navigate to notebook
90 | log.info(` 🌐 Navigating to: ${this.notebookUrl}`);
91 | await this.page.goto(this.notebookUrl, {
92 | waitUntil: "domcontentloaded",
93 | timeout: CONFIG.browserTimeout,
94 | });
95 |
96 | // Wait for page to stabilize
97 | await randomDelay(2000, 3000);
98 |
99 | // Check if we need to login
100 | const isAuthenticated = await this.authManager.validateCookiesExpiry(
101 | this.context
102 | );
103 |
104 | if (!isAuthenticated) {
105 | log.warning(` 🔑 Session ${this.sessionId} needs authentication`);
106 | const loginSuccess = await this.ensureAuthenticated();
107 | if (!loginSuccess) {
108 | throw new Error("Failed to authenticate session");
109 | }
110 | } else {
111 | log.success(` ✅ Session already authenticated`);
112 | }
113 |
114 | // CRITICAL: Restore sessionStorage from saved state
115 | // This is essential for maintaining Google session state!
116 | log.info(` 🔄 Restoring sessionStorage...`);
117 | const sessionData = await this.authManager.loadSessionStorage();
118 | if (sessionData) {
119 | const entryCount = Object.keys(sessionData).length;
120 | if (entryCount > 0) {
121 | await this.restoreSessionStorage(sessionData, entryCount);
122 | } else {
123 | log.info(` ℹ️ SessionStorage empty (fresh session)`);
124 | }
125 | } else {
126 | log.info(` ℹ️ No saved sessionStorage found (fresh session)`);
127 | }
128 |
129 | // Wait for NotebookLM interface to load
130 | log.info(` ⏳ Waiting for NotebookLM interface...`);
131 | await this.waitForNotebookLMReady();
132 |
133 | this.initialized = true;
134 | this.updateActivity();
135 | log.success(`✅ Session ${this.sessionId} initialized successfully`);
136 | } catch (error) {
137 | log.error(`❌ Failed to initialize session ${this.sessionId}: ${error}`);
138 | if (this.page) {
139 | await this.page.close();
140 | this.page = null;
141 | }
142 | throw error;
143 | }
144 | }
145 |
146 | /**
147 | * Wait for NotebookLM interface to be ready
148 | *
149 | * IMPORTANT: Matches Python implementation EXACTLY!
150 | * - Uses SPECIFIC selectors (textarea.query-box-input)
151 | * - Checks ONLY for "visible" state (NOT disabled!)
152 | * - NO placeholder checks (let NotebookLM handle that!)
153 | *
154 | * Based on Python _wait_for_ready() from browser_session.py:104-113
155 | */
156 | private async waitForNotebookLMReady(): Promise<void> {
157 | if (!this.page) {
158 | throw new Error("Page not initialized");
159 | }
160 |
161 | try {
162 | // PRIMARY: Exact Python selector - textarea.query-box-input
163 | log.info(" ⏳ Waiting for chat input (textarea.query-box-input)...");
164 | await this.page.waitForSelector("textarea.query-box-input", {
165 | timeout: 10000, // Python uses 10s timeout
166 | state: "visible", // ONLY check visibility (NO disabled check!)
167 | });
168 | log.success(" ✅ Chat input ready!");
169 | } catch {
170 | // FALLBACK: Python alternative selector
171 | try {
172 | log.info(" ⏳ Trying fallback selector (aria-label)...");
173 | await this.page.waitForSelector('textarea[aria-label="Feld für Anfragen"]', {
174 | timeout: 5000, // Python uses 5s for fallback
175 | state: "visible",
176 | });
177 | log.success(" ✅ Chat input ready (fallback)!");
178 | } catch (error) {
179 | log.error(` ❌ NotebookLM interface not ready: ${error}`);
180 | throw new Error(
181 | "Could not find NotebookLM chat input. " +
182 | "Please ensure the notebook page has loaded correctly."
183 | );
184 | }
185 | }
186 | }
187 |
188 | private isPageClosedSafe(): boolean {
189 | if (!this.page) return true;
190 | const p: any = this.page as any;
191 | try {
192 | if (typeof p.isClosed === 'function') {
193 | if (p.isClosed()) return true;
194 | }
195 | // Accessing URL should be safe; if page is gone, this may throw
196 | void this.page.url();
197 | return false;
198 | } catch {
199 | return true;
200 | }
201 | }
202 |
203 | /**
204 | * Ensure the session is authenticated, perform auto-login if needed
205 | */
206 | private async ensureAuthenticated(): Promise<boolean> {
207 | if (!this.page) {
208 | throw new Error("Page not initialized");
209 | }
210 |
211 | log.info(`🔑 Checking authentication for session ${this.sessionId}...`);
212 |
213 | // Check cookie validity
214 | const isValid = await this.authManager.validateCookiesExpiry(this.context);
215 |
216 | if (isValid) {
217 | log.success(` ✅ Cookies valid`);
218 | return true;
219 | }
220 |
221 | log.warning(` ⚠️ Cookies expired or invalid`);
222 |
223 | // Try to get valid auth state
224 | const statePath = await this.authManager.getValidStatePath();
225 |
226 | if (statePath) {
227 | // Load saved state
228 | log.info(` 📂 Loading auth state from: ${statePath}`);
229 | await this.authManager.loadAuthState(this.context, statePath);
230 |
231 | // Reload page to apply new auth
232 | log.info(` 🔄 Reloading page...`);
233 | await (this.page as Page).reload({ waitUntil: "domcontentloaded" });
234 | await randomDelay(2000, 3000);
235 |
236 | // Check if it worked
237 | const nowValid = await this.authManager.validateCookiesExpiry(
238 | this.context
239 | );
240 | if (nowValid) {
241 | log.success(` ✅ Auth state loaded successfully`);
242 | return true;
243 | }
244 | }
245 |
246 | // Need fresh login
247 | log.warning(` 🔑 Fresh login required`);
248 |
249 | if (CONFIG.autoLoginEnabled) {
250 | log.info(` 🤖 Attempting auto-login...`);
251 | const loginSuccess = await this.authManager.loginWithCredentials(
252 | this.context,
253 | this.page,
254 | CONFIG.loginEmail,
255 | CONFIG.loginPassword
256 | );
257 |
258 | if (loginSuccess) {
259 | log.success(` ✅ Auto-login successful`);
260 | // Navigate back to notebook
261 | await this.page.goto(this.notebookUrl, {
262 | waitUntil: "domcontentloaded",
263 | });
264 | await randomDelay(2000, 3000);
265 | return true;
266 | } else {
267 | log.error(` ❌ Auto-login failed`);
268 | return false;
269 | }
270 | } else {
271 | log.error(
272 | ` ❌ Auto-login disabled and no valid auth state - manual login required`
273 | );
274 | return false;
275 | }
276 | }
277 |
278 | private getOriginFromUrl(url: string): string | null {
279 | try {
280 | return new URL(url).origin;
281 | } catch {
282 | return null;
283 | }
284 | }
285 |
286 | /**
287 | * Safely restore sessionStorage when the page is on the expected origin
288 | */
289 | private async restoreSessionStorage(
290 | sessionData: Record<string, string>,
291 | entryCount: number
292 | ): Promise<void> {
293 | if (!this.page) {
294 | log.warning(` ⚠️ Cannot restore sessionStorage without an active page`);
295 | return;
296 | }
297 |
298 | const targetOrigin = this.getOriginFromUrl(this.notebookUrl);
299 | if (!targetOrigin) {
300 | log.warning(` ⚠️ Unable to determine target origin for sessionStorage restore`);
301 | return;
302 | }
303 |
304 | let restored = false;
305 |
306 | const applyToPage = async (): Promise<boolean> => {
307 | if (!this.page) {
308 | return false;
309 | }
310 |
311 | const currentOrigin = this.getOriginFromUrl(this.page.url());
312 | if (currentOrigin !== targetOrigin) {
313 | return false;
314 | }
315 |
316 | try {
317 | await this.page.evaluate((data) => {
318 | for (const [key, value] of Object.entries(data)) {
319 | // @ts-expect-error - sessionStorage exists in browser context
320 | sessionStorage.setItem(key, value);
321 | }
322 | }, sessionData);
323 | restored = true;
324 | log.success(` ✅ SessionStorage restored: ${entryCount} entries`);
325 | return true;
326 | } catch (error) {
327 | log.warning(` ⚠️ Failed to restore sessionStorage: ${error}`);
328 | return false;
329 | }
330 | };
331 |
332 | if (await applyToPage()) {
333 | return;
334 | }
335 |
336 | log.info(` ⏳ Waiting for NotebookLM origin before restoring sessionStorage...`);
337 |
338 | const handleNavigation = async () => {
339 | if (restored) {
340 | return;
341 | }
342 |
343 | if (await applyToPage()) {
344 | this.page?.off("framenavigated", handleNavigation);
345 | }
346 | };
347 |
348 | this.page.on("framenavigated", handleNavigation);
349 | }
350 |
351 | /**
352 | * Ask a question to NotebookLM
353 | */
354 | async ask(question: string, sendProgress?: ProgressCallback): Promise<string> {
355 | const askOnce = async (): Promise<string> => {
356 | if (!this.initialized || !this.page || this.isPageClosedSafe()) {
357 | log.warning(` ℹ️ Session not initialized or page missing → re-initializing...`);
358 | await this.init();
359 | }
360 |
361 | log.info(`💬 [${this.sessionId}] Asking: "${question.substring(0, 100)}..."`);
362 | const page = this.page!;
363 | // Ensure we're still authenticated
364 | await sendProgress?.("Verifying authentication...", 2, 5);
365 | const isAuth = await this.authManager.validateCookiesExpiry(this.context);
366 | if (!isAuth) {
367 | log.warning(` 🔑 Session expired, re-authenticating...`);
368 | await sendProgress?.("Re-authenticating session...", 2, 5);
369 | const reAuthSuccess = await this.ensureAuthenticated();
370 | if (!reAuthSuccess) {
371 | throw new Error("Failed to re-authenticate session");
372 | }
373 | }
374 |
375 | // Snapshot existing responses BEFORE asking
376 | log.info(` 📸 Snapshotting existing responses...`);
377 | const existingResponses = await snapshotAllResponses(page);
378 | log.success(` ✅ Captured ${existingResponses.length} existing responses`);
379 |
380 | // Find the chat input
381 | const inputSelector = await this.findChatInput();
382 | if (!inputSelector) {
383 | throw new Error(
384 | "Could not find visible chat input element. " +
385 | "Please check if the notebook page has loaded correctly."
386 | );
387 | }
388 |
389 | log.info(` ⌨️ Typing question with human-like behavior...`);
390 | await sendProgress?.("Typing question with human-like behavior...", 2, 5);
391 | await humanType(page, inputSelector, question, {
392 | withTypos: true,
393 | wpm: Math.max(CONFIG.typingWpmMin, CONFIG.typingWpmMax),
394 | });
395 |
396 | // Small pause before submitting
397 | await randomDelay(500, 1000);
398 |
399 | // Submit the question (Enter key)
400 | log.info(` 📤 Submitting question...`);
401 | await sendProgress?.("Submitting question...", 3, 5);
402 | await page.keyboard.press("Enter");
403 |
404 | // Small pause after submit
405 | await randomDelay(1000, 1500);
406 |
407 | // Wait for the response with streaming detection
408 | log.info(` ⏳ Waiting for response (with streaming detection)...`);
409 | await sendProgress?.("Waiting for NotebookLM response (streaming detection active)...", 3, 5);
410 | const answer = await waitForLatestAnswer(page, {
411 | question,
412 | timeoutMs: 120000, // 2 minutes
413 | pollIntervalMs: 1000,
414 | ignoreTexts: existingResponses,
415 | debug: false,
416 | });
417 |
418 | if (!answer) {
419 | throw new Error("Timeout waiting for response from NotebookLM");
420 | }
421 |
422 | // Check for rate limit errors AFTER receiving answer
423 | log.info(` 🔍 Checking for rate limit errors...`);
424 | if (await this.detectRateLimitError()) {
425 | throw new RateLimitError(
426 | "NotebookLM rate limit reached (50 queries/day for free accounts)"
427 | );
428 | }
429 |
430 | // Update session stats
431 | this.messageCount++;
432 | this.updateActivity();
433 |
434 | log.success(
435 | `✅ [${this.sessionId}] Received answer (${answer.length} chars, ${this.messageCount} total messages)`
436 | );
437 |
438 | return answer;
439 | };
440 |
441 | try {
442 | return await askOnce();
443 | } catch (error: any) {
444 | const msg = String(error?.message || error);
445 | if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
446 | log.warning(` ♻️ Detected closed page/context. Recovering session and retrying ask...`);
447 | try {
448 | this.initialized = false;
449 | if (this.page) { try { await this.page.close(); } catch {} }
450 | this.page = null;
451 | await this.init();
452 | return await askOnce();
453 | } catch (e2) {
454 | log.error(`❌ Recovery failed: ${e2}`);
455 | throw e2;
456 | }
457 | }
458 | log.error(`❌ [${this.sessionId}] Failed to ask question: ${msg}`);
459 | throw error;
460 | }
461 | }
462 |
463 | /**
464 | * Find the chat input element
465 | *
466 | * IMPORTANT: Matches Python implementation EXACTLY!
467 | * - Uses SPECIFIC selectors from Python
468 | * - Checks ONLY visibility (NOT disabled state!)
469 | *
470 | * Based on Python ask() method from browser_session.py:166-171
471 | */
472 | private async findChatInput(): Promise<string | null> {
473 | if (!this.page) {
474 | return null;
475 | }
476 |
477 | // Use EXACT Python selectors (in order of preference)
478 | const selectors = [
479 | "textarea.query-box-input", // ← PRIMARY Python selector
480 | 'textarea[aria-label="Feld für Anfragen"]', // ← Python fallback
481 | ];
482 |
483 | for (const selector of selectors) {
484 | try {
485 | const element = await this.page.$(selector);
486 | if (element) {
487 | const isVisible = await element.isVisible();
488 | if (isVisible) {
489 | // NO disabled check! Just like Python!
490 | log.success(` ✅ Found chat input: ${selector}`);
491 | return selector;
492 | }
493 | }
494 | } catch {
495 | continue;
496 | }
497 | }
498 |
499 | log.error(` ❌ Could not find visible chat input`);
500 | return null;
501 | }
502 |
503 | /**
504 | * Detect if a rate limit error occurred
505 | *
506 | * Searches the page for error messages indicating rate limit/quota exhaustion.
507 | * Free NotebookLM accounts have 50 queries/day limit.
508 | *
509 | * @returns true if rate limit error detected, false otherwise
510 | */
511 | private async detectRateLimitError(): Promise<boolean> {
512 | if (!this.page) {
513 | return false;
514 | }
515 |
516 | // Error message selectors (common patterns for error containers)
517 | const errorSelectors = [
518 | ".error-message",
519 | ".error-container",
520 | "[role='alert']",
521 | ".rate-limit-message",
522 | "[data-error]",
523 | ".notification-error",
524 | ".alert-error",
525 | ".toast-error",
526 | ];
527 |
528 | // Keywords that indicate rate limiting
529 | const keywords = [
530 | "rate limit",
531 | "limit exceeded",
532 | "quota exhausted",
533 | "daily limit",
534 | "limit reached",
535 | "too many requests",
536 | "ratenlimit",
537 | "quota",
538 | "query limit",
539 | "request limit",
540 | ];
541 |
542 | // Check error containers for rate limit messages
543 | for (const selector of errorSelectors) {
544 | try {
545 | const elements = await this.page.$$(selector);
546 | for (const el of elements) {
547 | try {
548 | const text = await el.innerText();
549 | const lower = text.toLowerCase();
550 |
551 | if (keywords.some((k) => lower.includes(k))) {
552 | log.error(`🚫 Rate limit detected: ${text.slice(0, 100)}`);
553 | return true;
554 | }
555 | } catch {
556 | continue;
557 | }
558 | }
559 | } catch {
560 | continue;
561 | }
562 | }
563 |
564 | // Also check if chat input is disabled (sometimes NotebookLM disables input when rate limited)
565 | try {
566 | const inputSelector = "textarea.query-box-input";
567 | const input = await this.page.$(inputSelector);
568 | if (input) {
569 | const isDisabled = await input.evaluate((el: any) => {
570 | return el.disabled || el.hasAttribute("disabled");
571 | });
572 |
573 | if (isDisabled) {
574 | // Check if there's an error message near the input
575 | const parent = await input.evaluateHandle((el) => el.parentElement);
576 | const parentEl = parent.asElement();
577 | if (parentEl) {
578 | try {
579 | const parentText = await parentEl.innerText();
580 | const lower = parentText.toLowerCase();
581 | if (keywords.some((k) => lower.includes(k))) {
582 | log.error(`🚫 Rate limit detected: Chat input disabled with error message`);
583 | return true;
584 | }
585 | } catch {
586 | // Ignore
587 | }
588 | }
589 | }
590 | }
591 | } catch {
592 | // Ignore errors checking input state
593 | }
594 |
595 | return false;
596 | }
597 |
598 | /**
599 | * Reset the chat history (start a new conversation)
600 | */
601 | async reset(): Promise<void> {
602 | const resetOnce = async (): Promise<void> => {
603 | if (!this.initialized || !this.page || this.isPageClosedSafe()) {
604 | await this.init();
605 | }
606 | log.info(`🔄 [${this.sessionId}] Resetting chat history...`);
607 | // Reload the page to clear chat history
608 | await (this.page as Page).reload({ waitUntil: "domcontentloaded" });
609 | await randomDelay(2000, 3000);
610 |
611 | // Wait for interface to be ready again
612 | await this.waitForNotebookLMReady();
613 |
614 | // Reset message count
615 | this.messageCount = 0;
616 | this.updateActivity();
617 |
618 | log.success(`✅ [${this.sessionId}] Chat history reset`);
619 | };
620 |
621 | try {
622 | await resetOnce();
623 | } catch (error: any) {
624 | const msg = String(error?.message || error);
625 | if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
626 | log.warning(` ♻️ Detected closed page/context during reset. Recovering and retrying...`);
627 | this.initialized = false;
628 | if (this.page) { try { await this.page.close(); } catch {} }
629 | this.page = null;
630 | await this.init();
631 | await resetOnce();
632 | return;
633 | }
634 | log.error(`❌ [${this.sessionId}] Failed to reset: ${msg}`);
635 | throw error;
636 | }
637 | }
638 |
639 | /**
640 | * Close the session
641 | */
642 | async close(): Promise<void> {
643 | log.info(`🛑 Closing session ${this.sessionId}...`);
644 |
645 | if (this.page) {
646 | try {
647 | await this.page.close();
648 | this.page = null;
649 | log.success(` ✅ Page closed`);
650 | } catch (error) {
651 | log.warning(` ⚠️ Error closing page: ${error}`);
652 | }
653 | }
654 |
655 | this.initialized = false;
656 | log.success(`✅ Session ${this.sessionId} closed`);
657 | }
658 |
659 | /**
660 | * Update last activity timestamp
661 | */
662 | updateActivity(): void {
663 | this.lastActivity = Date.now();
664 | }
665 |
666 | /**
667 | * Check if session has expired (inactive for too long)
668 | */
669 | isExpired(timeoutSeconds: number): boolean {
670 | const inactiveSeconds = (Date.now() - this.lastActivity) / 1000;
671 | return inactiveSeconds > timeoutSeconds;
672 | }
673 |
674 | /**
675 | * Get session information
676 | */
677 | getInfo(): SessionInfo {
678 | const now = Date.now();
679 | return {
680 | id: this.sessionId,
681 | created_at: this.createdAt,
682 | last_activity: this.lastActivity,
683 | age_seconds: (now - this.createdAt) / 1000,
684 | inactive_seconds: (now - this.lastActivity) / 1000,
685 | message_count: this.messageCount,
686 | notebook_url: this.notebookUrl,
687 | };
688 | }
689 |
690 | /**
691 | * Get the underlying page (for advanced operations)
692 | */
693 | getPage(): Page | null {
694 | return this.page;
695 | }
696 |
697 | /**
698 | * Check if session is initialized
699 | */
700 | isInitialized(): boolean {
701 | return this.initialized && this.page !== null;
702 | }
703 | }
704 |
```
--------------------------------------------------------------------------------
/src/utils/cleanup-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Cleanup Manager for NotebookLM MCP Server
3 | *
4 | * ULTRATHINK EDITION - Complete cleanup across all platforms!
5 | *
6 | * Handles safe removal of:
7 | * - Legacy data from notebooklm-mcp-nodejs
8 | * - Current installation data
9 | * - Browser profiles and session data
10 | * - NPM/NPX cache
11 | * - Claude CLI MCP logs
12 | * - Claude Projects cache
13 | * - Temporary backups
14 | * - Editor logs (Cursor, VSCode)
15 | * - Trash files (optional)
16 | *
17 | * Platform support: Linux, Windows, macOS
18 | */
19 |
20 | import fs from "fs/promises";
21 | import path from "path";
22 | import { globby } from "globby";
23 | import envPaths from "env-paths";
24 | import os from "os";
25 | import { log } from "./logger.js";
26 |
27 | export type CleanupMode = "legacy" | "all" | "deep";
28 |
29 | export interface CleanupResult {
30 | success: boolean;
31 | mode: CleanupMode;
32 | deletedPaths: string[];
33 | failedPaths: string[];
34 | totalSizeBytes: number;
35 | categorySummary: Record<string, { count: number; bytes: number }>;
36 | }
37 |
38 | export interface CleanupCategory {
39 | name: string;
40 | description: string;
41 | paths: string[];
42 | totalBytes: number;
43 | optional: boolean;
44 | }
45 |
46 | interface Paths {
47 | data: string;
48 | config: string;
49 | cache: string;
50 | log: string;
51 | temp: string;
52 | }
53 |
54 | export class CleanupManager {
55 | private legacyPaths: Paths;
56 | private currentPaths: Paths;
57 | private homeDir: string;
58 | private tempDir: string;
59 |
60 | constructor() {
61 | // envPaths() does NOT create directories - it just returns path strings
62 | // IMPORTANT: envPaths() has a default suffix 'nodejs', so we must explicitly disable it!
63 |
64 | // Legacy paths with -nodejs suffix (using default suffix behavior)
65 | this.legacyPaths = envPaths("notebooklm-mcp"); // This becomes notebooklm-mcp-nodejs by default
66 | // Current paths without suffix (disable the default suffix with empty string)
67 | this.currentPaths = envPaths("notebooklm-mcp", {suffix: ""});
68 | // Platform-agnostic paths
69 | this.homeDir = os.homedir();
70 | this.tempDir = os.tmpdir();
71 | }
72 |
73 | // ============================================================================
74 | // Platform-Specific Path Resolution
75 | // ============================================================================
76 |
77 | /**
78 | * Get NPM cache directory (platform-specific)
79 | */
80 | private getNpmCachePath(): string {
81 | return path.join(this.homeDir, ".npm");
82 | }
83 |
84 | /**
85 | * Get Claude CLI cache directory (platform-specific)
86 | */
87 | private getClaudeCliCachePath(): string {
88 | const platform = process.platform;
89 |
90 | if (platform === "win32") {
91 | const localAppData = process.env.LOCALAPPDATA || path.join(this.homeDir, "AppData", "Local");
92 | return path.join(localAppData, "claude-cli-nodejs");
93 | } else if (platform === "darwin") {
94 | return path.join(this.homeDir, "Library", "Caches", "claude-cli-nodejs");
95 | } else {
96 | // Linux and others
97 | return path.join(this.homeDir, ".cache", "claude-cli-nodejs");
98 | }
99 | }
100 |
101 | /**
102 | * Get Claude projects directory (platform-specific)
103 | */
104 | private getClaudeProjectsPath(): string {
105 | const platform = process.platform;
106 |
107 | if (platform === "win32") {
108 | const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
109 | return path.join(appData, ".claude", "projects");
110 | } else if (platform === "darwin") {
111 | return path.join(this.homeDir, "Library", "Application Support", "claude", "projects");
112 | } else {
113 | // Linux and others
114 | return path.join(this.homeDir, ".claude", "projects");
115 | }
116 | }
117 |
118 | /**
119 | * Get editor config paths (Cursor, VSCode)
120 | */
121 | private getEditorConfigPaths(): string[] {
122 | const platform = process.platform;
123 | const paths: string[] = [];
124 |
125 | if (platform === "win32") {
126 | const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
127 | paths.push(
128 | path.join(appData, "Cursor", "logs"),
129 | path.join(appData, "Code", "logs")
130 | );
131 | } else if (platform === "darwin") {
132 | paths.push(
133 | path.join(this.homeDir, "Library", "Application Support", "Cursor", "logs"),
134 | path.join(this.homeDir, "Library", "Application Support", "Code", "logs")
135 | );
136 | } else {
137 | // Linux
138 | paths.push(
139 | path.join(this.homeDir, ".config", "Cursor", "logs"),
140 | path.join(this.homeDir, ".config", "Code", "logs")
141 | );
142 | }
143 |
144 | return paths;
145 | }
146 |
147 | /**
148 | * Get trash directory (platform-specific)
149 | */
150 | private getTrashPath(): string | null {
151 | const platform = process.platform;
152 |
153 | if (platform === "darwin") {
154 | return path.join(this.homeDir, ".Trash");
155 | } else if (platform === "linux") {
156 | return path.join(this.homeDir, ".local", "share", "Trash");
157 | } else {
158 | // Windows Recycle Bin is complex, skip for now
159 | return null;
160 | }
161 | }
162 |
163 | /**
164 | * Get manual legacy config paths that might not be caught by envPaths
165 | * This ensures we catch ALL legacy installations including old config.json files
166 | */
167 | private getManualLegacyPaths(): string[] {
168 | const paths: string[] = [];
169 | const platform = process.platform;
170 |
171 | if (platform === "linux") {
172 | // Linux-specific paths
173 | paths.push(
174 | path.join(this.homeDir, ".config", "notebooklm-mcp"),
175 | path.join(this.homeDir, ".config", "notebooklm-mcp-nodejs"),
176 | path.join(this.homeDir, ".local", "share", "notebooklm-mcp"),
177 | path.join(this.homeDir, ".local", "share", "notebooklm-mcp-nodejs"),
178 | path.join(this.homeDir, ".cache", "notebooklm-mcp"),
179 | path.join(this.homeDir, ".cache", "notebooklm-mcp-nodejs"),
180 | path.join(this.homeDir, ".local", "state", "notebooklm-mcp"),
181 | path.join(this.homeDir, ".local", "state", "notebooklm-mcp-nodejs")
182 | );
183 | } else if (platform === "darwin") {
184 | // macOS-specific paths
185 | paths.push(
186 | path.join(this.homeDir, "Library", "Application Support", "notebooklm-mcp"),
187 | path.join(this.homeDir, "Library", "Application Support", "notebooklm-mcp-nodejs"),
188 | path.join(this.homeDir, "Library", "Preferences", "notebooklm-mcp"),
189 | path.join(this.homeDir, "Library", "Preferences", "notebooklm-mcp-nodejs"),
190 | path.join(this.homeDir, "Library", "Caches", "notebooklm-mcp"),
191 | path.join(this.homeDir, "Library", "Caches", "notebooklm-mcp-nodejs"),
192 | path.join(this.homeDir, "Library", "Logs", "notebooklm-mcp"),
193 | path.join(this.homeDir, "Library", "Logs", "notebooklm-mcp-nodejs")
194 | );
195 | } else if (platform === "win32") {
196 | // Windows-specific paths
197 | const localAppData = process.env.LOCALAPPDATA || path.join(this.homeDir, "AppData", "Local");
198 | const appData = process.env.APPDATA || path.join(this.homeDir, "AppData", "Roaming");
199 | paths.push(
200 | path.join(localAppData, "notebooklm-mcp"),
201 | path.join(localAppData, "notebooklm-mcp-nodejs"),
202 | path.join(appData, "notebooklm-mcp"),
203 | path.join(appData, "notebooklm-mcp-nodejs")
204 | );
205 | }
206 |
207 | return paths;
208 | }
209 |
210 | // ============================================================================
211 | // Search Methods for Different File Types
212 | // ============================================================================
213 |
214 | /**
215 | * Find NPM/NPX cache files
216 | */
217 | private async findNpmCache(): Promise<string[]> {
218 | const found: string[] = [];
219 |
220 | try {
221 | const npmCachePath = this.getNpmCachePath();
222 | const npxPath = path.join(npmCachePath, "_npx");
223 |
224 | if (!(await this.pathExists(npxPath))) {
225 | return found;
226 | }
227 |
228 | // Search for notebooklm-mcp in npx cache
229 | const pattern = path.join(npxPath, "*/node_modules/notebooklm-mcp");
230 | const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
231 | found.push(...matches);
232 | } catch (error) {
233 | log.warning(`⚠️ Error searching NPM cache: ${error}`);
234 | }
235 |
236 | return found;
237 | }
238 |
239 | /**
240 | * Find Claude CLI MCP logs
241 | */
242 | private async findClaudeCliLogs(): Promise<string[]> {
243 | const found: string[] = [];
244 |
245 | try {
246 | const claudeCliPath = this.getClaudeCliCachePath();
247 |
248 | if (!(await this.pathExists(claudeCliPath))) {
249 | return found;
250 | }
251 |
252 | // Search for notebooklm MCP logs
253 | const patterns = [
254 | path.join(claudeCliPath, "*/mcp-logs-notebooklm"),
255 | path.join(claudeCliPath, "*notebooklm-mcp*"),
256 | ];
257 |
258 | for (const pattern of patterns) {
259 | const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
260 | found.push(...matches);
261 | }
262 | } catch (error) {
263 | log.warning(`⚠️ Error searching Claude CLI cache: ${error}`);
264 | }
265 |
266 | return found;
267 | }
268 |
269 | /**
270 | * Find Claude projects cache
271 | */
272 | private async findClaudeProjects(): Promise<string[]> {
273 | const found: string[] = [];
274 |
275 | try {
276 | const projectsPath = this.getClaudeProjectsPath();
277 |
278 | if (!(await this.pathExists(projectsPath))) {
279 | return found;
280 | }
281 |
282 | // Search for notebooklm-mcp projects
283 | const pattern = path.join(projectsPath, "*notebooklm-mcp*");
284 | const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
285 | found.push(...matches);
286 | } catch (error) {
287 | log.warning(`⚠️ Error searching Claude projects: ${error}`);
288 | }
289 |
290 | return found;
291 | }
292 |
293 | /**
294 | * Find temporary backups
295 | */
296 | private async findTemporaryBackups(): Promise<string[]> {
297 | const found: string[] = [];
298 |
299 | try {
300 | // Search for notebooklm backup directories in temp
301 | const pattern = path.join(this.tempDir, "notebooklm-backup-*");
302 | const matches = await globby(pattern, { onlyDirectories: true, absolute: true });
303 | found.push(...matches);
304 | } catch (error) {
305 | log.warning(`⚠️ Error searching temp backups: ${error}`);
306 | }
307 |
308 | return found;
309 | }
310 |
311 | /**
312 | * Find editor logs (Cursor, VSCode)
313 | */
314 | private async findEditorLogs(): Promise<string[]> {
315 | const found: string[] = [];
316 |
317 | try {
318 | const editorPaths = this.getEditorConfigPaths();
319 |
320 | for (const editorPath of editorPaths) {
321 | if (!(await this.pathExists(editorPath))) {
322 | continue;
323 | }
324 |
325 | // Search for MCP notebooklm logs
326 | const pattern = path.join(editorPath, "**/exthost/**/*notebooklm*.log");
327 | const matches = await globby(pattern, { onlyFiles: true, absolute: true });
328 | found.push(...matches);
329 | }
330 | } catch (error) {
331 | log.warning(`⚠️ Error searching editor logs: ${error}`);
332 | }
333 |
334 | return found;
335 | }
336 |
337 | /**
338 | * Find trash files
339 | */
340 | private async findTrashFiles(): Promise<string[]> {
341 | const found: string[] = [];
342 |
343 | try {
344 | const trashPath = this.getTrashPath();
345 | if (!trashPath || !(await this.pathExists(trashPath))) {
346 | return found;
347 | }
348 |
349 | // Search for notebooklm files in trash
350 | const patterns = [
351 | path.join(trashPath, "**/*notebooklm*"),
352 | ];
353 |
354 | for (const pattern of patterns) {
355 | const matches = await globby(pattern, { absolute: true });
356 | found.push(...matches);
357 | }
358 | } catch (error) {
359 | log.warning(`⚠️ Error searching trash: ${error}`);
360 | }
361 |
362 | return found;
363 | }
364 |
365 | // ============================================================================
366 | // Main Cleanup Methods
367 | // ============================================================================
368 |
369 | /**
370 | * Get all paths that would be deleted for a given mode (with categorization)
371 | */
372 | async getCleanupPaths(
373 | mode: CleanupMode,
374 | preserveLibrary: boolean = false
375 | ): Promise<{
376 | categories: CleanupCategory[];
377 | totalPaths: string[];
378 | totalSizeBytes: number;
379 | }> {
380 | const categories: CleanupCategory[] = [];
381 | const allPaths: Set<string> = new Set();
382 | let totalSizeBytes = 0;
383 |
384 | // Category 1: Legacy Paths (notebooklm-mcp-nodejs & manual legacy paths)
385 | if (mode === "legacy" || mode === "all" || mode === "deep") {
386 | const legacyPaths: string[] = [];
387 | let legacyBytes = 0;
388 |
389 | // Check envPaths-based legacy directories
390 | const legacyDirs = [
391 | this.legacyPaths.data,
392 | this.legacyPaths.config,
393 | this.legacyPaths.cache,
394 | this.legacyPaths.log,
395 | this.legacyPaths.temp,
396 | ];
397 |
398 | for (const dir of legacyDirs) {
399 | if (await this.pathExists(dir)) {
400 | const size = await this.getDirectorySize(dir);
401 | legacyPaths.push(dir);
402 | legacyBytes += size;
403 | allPaths.add(dir);
404 | }
405 | }
406 |
407 | // CRITICAL: Also check manual legacy paths to catch old config.json files
408 | // and any paths that envPaths might miss
409 | const manualLegacyPaths = this.getManualLegacyPaths();
410 | for (const dir of manualLegacyPaths) {
411 | if (await this.pathExists(dir) && !allPaths.has(dir)) {
412 | const size = await this.getDirectorySize(dir);
413 | legacyPaths.push(dir);
414 | legacyBytes += size;
415 | allPaths.add(dir);
416 | }
417 | }
418 |
419 | if (legacyPaths.length > 0) {
420 | categories.push({
421 | name: "Legacy Installation (notebooklm-mcp-nodejs)",
422 | description: "Old installation data with -nodejs suffix and legacy config files",
423 | paths: legacyPaths,
424 | totalBytes: legacyBytes,
425 | optional: false,
426 | });
427 | totalSizeBytes += legacyBytes;
428 | }
429 | }
430 |
431 | // Category 2: Current Installation
432 | if (mode === "all" || mode === "deep") {
433 | const currentPaths: string[] = [];
434 | let currentBytes = 0;
435 |
436 | // If preserveLibrary is true, don't delete the data directory itself
437 | // Instead, only delete subdirectories
438 | const currentDirs = preserveLibrary
439 | ? [
440 | // Don't include data directory to preserve library.json
441 | this.currentPaths.config,
442 | this.currentPaths.cache,
443 | this.currentPaths.log,
444 | this.currentPaths.temp,
445 | // Only delete subdirectories, not the parent
446 | path.join(this.currentPaths.data, "browser_state"),
447 | path.join(this.currentPaths.data, "chrome_profile"),
448 | path.join(this.currentPaths.data, "chrome_profile_instances"),
449 | ]
450 | : [
451 | // Delete everything including data directory
452 | this.currentPaths.data,
453 | this.currentPaths.config,
454 | this.currentPaths.cache,
455 | this.currentPaths.log,
456 | this.currentPaths.temp,
457 | // Specific subdirectories (only if parent doesn't exist)
458 | path.join(this.currentPaths.data, "browser_state"),
459 | path.join(this.currentPaths.data, "chrome_profile"),
460 | path.join(this.currentPaths.data, "chrome_profile_instances"),
461 | ];
462 |
463 | for (const dir of currentDirs) {
464 | if (await this.pathExists(dir) && !allPaths.has(dir)) {
465 | const size = await this.getDirectorySize(dir);
466 | currentPaths.push(dir);
467 | currentBytes += size;
468 | allPaths.add(dir);
469 | }
470 | }
471 |
472 | if (currentPaths.length > 0) {
473 | const description = preserveLibrary
474 | ? "Active installation data and browser profiles (library.json will be preserved)"
475 | : "Active installation data and browser profiles";
476 |
477 | categories.push({
478 | name: "Current Installation (notebooklm-mcp)",
479 | description,
480 | paths: currentPaths,
481 | totalBytes: currentBytes,
482 | optional: false,
483 | });
484 | totalSizeBytes += currentBytes;
485 | }
486 | }
487 |
488 | // Category 3: NPM Cache
489 | if (mode === "all" || mode === "deep") {
490 | const npmPaths = await this.findNpmCache();
491 | if (npmPaths.length > 0) {
492 | let npmBytes = 0;
493 | for (const p of npmPaths) {
494 | if (!allPaths.has(p)) {
495 | npmBytes += await this.getDirectorySize(p);
496 | allPaths.add(p);
497 | }
498 | }
499 |
500 | if (npmBytes > 0) {
501 | categories.push({
502 | name: "NPM/NPX Cache",
503 | description: "NPX cached installations of notebooklm-mcp",
504 | paths: npmPaths,
505 | totalBytes: npmBytes,
506 | optional: false,
507 | });
508 | totalSizeBytes += npmBytes;
509 | }
510 | }
511 | }
512 |
513 | // Category 4: Claude CLI Logs
514 | if (mode === "all" || mode === "deep") {
515 | const claudeCliPaths = await this.findClaudeCliLogs();
516 | if (claudeCliPaths.length > 0) {
517 | let claudeCliBytes = 0;
518 | for (const p of claudeCliPaths) {
519 | if (!allPaths.has(p)) {
520 | claudeCliBytes += await this.getDirectorySize(p);
521 | allPaths.add(p);
522 | }
523 | }
524 |
525 | if (claudeCliBytes > 0) {
526 | categories.push({
527 | name: "Claude CLI MCP Logs",
528 | description: "MCP server logs from Claude CLI",
529 | paths: claudeCliPaths,
530 | totalBytes: claudeCliBytes,
531 | optional: false,
532 | });
533 | totalSizeBytes += claudeCliBytes;
534 | }
535 | }
536 | }
537 |
538 | // Category 5: Temporary Backups
539 | if (mode === "all" || mode === "deep") {
540 | const backupPaths = await this.findTemporaryBackups();
541 | if (backupPaths.length > 0) {
542 | let backupBytes = 0;
543 | for (const p of backupPaths) {
544 | if (!allPaths.has(p)) {
545 | backupBytes += await this.getDirectorySize(p);
546 | allPaths.add(p);
547 | }
548 | }
549 |
550 | if (backupBytes > 0) {
551 | categories.push({
552 | name: "Temporary Backups",
553 | description: "Temporary backup directories in system temp",
554 | paths: backupPaths,
555 | totalBytes: backupBytes,
556 | optional: false,
557 | });
558 | totalSizeBytes += backupBytes;
559 | }
560 | }
561 | }
562 |
563 | // Category 6: Claude Projects (deep mode only)
564 | if (mode === "deep") {
565 | const projectPaths = await this.findClaudeProjects();
566 | if (projectPaths.length > 0) {
567 | let projectBytes = 0;
568 | for (const p of projectPaths) {
569 | if (!allPaths.has(p)) {
570 | projectBytes += await this.getDirectorySize(p);
571 | allPaths.add(p);
572 | }
573 | }
574 |
575 | if (projectBytes > 0) {
576 | categories.push({
577 | name: "Claude Projects Cache",
578 | description: "Project-specific cache in Claude config",
579 | paths: projectPaths,
580 | totalBytes: projectBytes,
581 | optional: true,
582 | });
583 | totalSizeBytes += projectBytes;
584 | }
585 | }
586 | }
587 |
588 | // Category 7: Editor Logs (deep mode only)
589 | if (mode === "deep") {
590 | const editorPaths = await this.findEditorLogs();
591 | if (editorPaths.length > 0) {
592 | let editorBytes = 0;
593 | for (const p of editorPaths) {
594 | if (!allPaths.has(p)) {
595 | editorBytes += await this.getFileSize(p);
596 | allPaths.add(p);
597 | }
598 | }
599 |
600 | if (editorBytes > 0) {
601 | categories.push({
602 | name: "Editor Logs (Cursor/VSCode)",
603 | description: "MCP logs from code editors",
604 | paths: editorPaths,
605 | totalBytes: editorBytes,
606 | optional: true,
607 | });
608 | totalSizeBytes += editorBytes;
609 | }
610 | }
611 | }
612 |
613 | // Category 8: Trash Files (deep mode only)
614 | if (mode === "deep") {
615 | const trashPaths = await this.findTrashFiles();
616 | if (trashPaths.length > 0) {
617 | let trashBytes = 0;
618 | for (const p of trashPaths) {
619 | if (!allPaths.has(p)) {
620 | trashBytes += await this.getFileSize(p);
621 | allPaths.add(p);
622 | }
623 | }
624 |
625 | if (trashBytes > 0) {
626 | categories.push({
627 | name: "Trash Files",
628 | description: "Deleted notebooklm files in system trash",
629 | paths: trashPaths,
630 | totalBytes: trashBytes,
631 | optional: true,
632 | });
633 | totalSizeBytes += trashBytes;
634 | }
635 | }
636 | }
637 |
638 | return {
639 | categories,
640 | totalPaths: Array.from(allPaths),
641 | totalSizeBytes,
642 | };
643 | }
644 |
645 | /**
646 | * Perform cleanup with safety checks and detailed reporting
647 | */
648 | async performCleanup(
649 | mode: CleanupMode,
650 | preserveLibrary: boolean = false
651 | ): Promise<CleanupResult> {
652 | log.info(`🧹 Starting cleanup in "${mode}" mode...`);
653 | if (preserveLibrary) {
654 | log.info(`📚 Library preservation enabled - library.json will be kept!`);
655 | }
656 |
657 | const { categories, totalSizeBytes } = await this.getCleanupPaths(mode, preserveLibrary);
658 | const deletedPaths: string[] = [];
659 | const failedPaths: string[] = [];
660 | const categorySummary: Record<string, { count: number; bytes: number }> = {};
661 |
662 | // Delete by category
663 | for (const category of categories) {
664 | log.info(`\n📦 ${category.name} (${category.paths.length} items, ${this.formatBytes(category.totalBytes)})`);
665 |
666 | if (category.optional) {
667 | log.warning(` ⚠️ Optional category - ${category.description}`);
668 | }
669 |
670 | let categoryDeleted = 0;
671 | let categoryBytes = 0;
672 |
673 | for (const itemPath of category.paths) {
674 | try {
675 | if (await this.pathExists(itemPath)) {
676 | const size = await this.getDirectorySize(itemPath);
677 | log.info(` 🗑️ Deleting: ${itemPath}`);
678 | await fs.rm(itemPath, { recursive: true, force: true });
679 | deletedPaths.push(itemPath);
680 | categoryDeleted++;
681 | categoryBytes += size;
682 | log.success(` ✅ Deleted: ${itemPath} (${this.formatBytes(size)})`);
683 | }
684 | } catch (error) {
685 | log.error(` ❌ Failed to delete: ${itemPath} - ${error}`);
686 | failedPaths.push(itemPath);
687 | }
688 | }
689 |
690 | categorySummary[category.name] = {
691 | count: categoryDeleted,
692 | bytes: categoryBytes,
693 | };
694 | }
695 |
696 | const success = failedPaths.length === 0;
697 |
698 | if (success) {
699 | log.success(`\n✅ Cleanup complete! Deleted ${deletedPaths.length} items (${this.formatBytes(totalSizeBytes)})`);
700 | } else {
701 | log.warning(`\n⚠️ Cleanup completed with ${failedPaths.length} errors`);
702 | log.success(` Deleted: ${deletedPaths.length} items`);
703 | log.error(` Failed: ${failedPaths.length} items`);
704 | }
705 |
706 | return {
707 | success,
708 | mode,
709 | deletedPaths,
710 | failedPaths,
711 | totalSizeBytes,
712 | categorySummary,
713 | };
714 | }
715 |
716 | // ============================================================================
717 | // Helper Methods
718 | // ============================================================================
719 |
720 | /**
721 | * Check if a path exists
722 | */
723 | private async pathExists(dirPath: string): Promise<boolean> {
724 | try {
725 | await fs.access(dirPath);
726 | return true;
727 | } catch {
728 | return false;
729 | }
730 | }
731 |
732 | /**
733 | * Get the size of a single file
734 | */
735 | private async getFileSize(filePath: string): Promise<number> {
736 | try {
737 | const stats = await fs.stat(filePath);
738 | return stats.size;
739 | } catch {
740 | return 0;
741 | }
742 | }
743 |
744 | /**
745 | * Get the total size of a directory (recursive)
746 | */
747 | private async getDirectorySize(dirPath: string): Promise<number> {
748 | try {
749 | const stats = await fs.stat(dirPath);
750 | if (!stats.isDirectory()) {
751 | return stats.size;
752 | }
753 |
754 | let totalSize = 0;
755 | const files = await fs.readdir(dirPath);
756 |
757 | for (const file of files) {
758 | const filePath = path.join(dirPath, file);
759 | const fileStats = await fs.stat(filePath);
760 |
761 | if (fileStats.isDirectory()) {
762 | totalSize += await this.getDirectorySize(filePath);
763 | } else {
764 | totalSize += fileStats.size;
765 | }
766 | }
767 |
768 | return totalSize;
769 | } catch {
770 | return 0;
771 | }
772 | }
773 |
774 | /**
775 | * Format bytes to human-readable string
776 | */
777 | formatBytes(bytes: number): string {
778 | if (bytes === 0) return "0 Bytes";
779 | const k = 1024;
780 | const sizes = ["Bytes", "KB", "MB", "GB"];
781 | const i = Math.floor(Math.log(bytes) / Math.log(k));
782 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
783 | }
784 |
785 | /**
786 | * Get platform-specific path info
787 | */
788 | getPlatformInfo(): {
789 | platform: string;
790 | legacyBasePath: string;
791 | currentBasePath: string;
792 | npmCachePath: string;
793 | claudeCliCachePath: string;
794 | claudeProjectsPath: string;
795 | } {
796 | const platform = process.platform;
797 | let platformName = "Unknown";
798 |
799 | switch (platform) {
800 | case "win32":
801 | platformName = "Windows";
802 | break;
803 | case "darwin":
804 | platformName = "macOS";
805 | break;
806 | case "linux":
807 | platformName = "Linux";
808 | break;
809 | }
810 |
811 | return {
812 | platform: platformName,
813 | legacyBasePath: this.legacyPaths.data,
814 | currentBasePath: this.currentPaths.data,
815 | npmCachePath: this.getNpmCachePath(),
816 | claudeCliCachePath: this.getClaudeCliCachePath(),
817 | claudeProjectsPath: this.getClaudeProjectsPath(),
818 | };
819 | }
820 | }
821 |
```
--------------------------------------------------------------------------------
/src/tools/handlers.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP Tool Handlers
3 | *
4 | * Implements the logic for all MCP tools.
5 | */
6 |
7 | import { SessionManager } from "../session/session-manager.js";
8 | import { AuthManager } from "../auth/auth-manager.js";
9 | import { NotebookLibrary } from "../library/notebook-library.js";
10 | import type { AddNotebookInput, UpdateNotebookInput } from "../library/types.js";
11 | import { CONFIG, applyBrowserOptions, type BrowserOptions } from "../config.js";
12 | import { log } from "../utils/logger.js";
13 | import type {
14 | AskQuestionResult,
15 | ToolResult,
16 | ProgressCallback,
17 | } from "../types.js";
18 | import { RateLimitError } from "../errors.js";
19 | import { CleanupManager } from "../utils/cleanup-manager.js";
20 |
21 | const FOLLOW_UP_REMINDER =
22 | "\n\nEXTREMELY IMPORTANT: Is that ALL you need to know? You can always ask another question using the same session ID! Think about it carefully: before you reply to the user, review their original request and this answer. If anything is still unclear or missing, ask me another question first.";
23 |
24 | /**
25 | * MCP Tool Handlers
26 | */
27 | export class ToolHandlers {
28 | private sessionManager: SessionManager;
29 | private authManager: AuthManager;
30 | private library: NotebookLibrary;
31 |
32 | constructor(sessionManager: SessionManager, authManager: AuthManager, library: NotebookLibrary) {
33 | this.sessionManager = sessionManager;
34 | this.authManager = authManager;
35 | this.library = library;
36 | }
37 |
38 | /**
39 | * Handle ask_question tool
40 | */
41 | async handleAskQuestion(
42 | args: {
43 | question: string;
44 | session_id?: string;
45 | notebook_id?: string;
46 | notebook_url?: string;
47 | show_browser?: boolean;
48 | browser_options?: BrowserOptions;
49 | },
50 | sendProgress?: ProgressCallback
51 | ): Promise<ToolResult<AskQuestionResult>> {
52 | const { question, session_id, notebook_id, notebook_url, show_browser, browser_options } = args;
53 |
54 | log.info(`🔧 [TOOL] ask_question called`);
55 | log.info(` Question: "${question.substring(0, 100)}"...`);
56 | if (session_id) {
57 | log.info(` Session ID: ${session_id}`);
58 | }
59 | if (notebook_id) {
60 | log.info(` Notebook ID: ${notebook_id}`);
61 | }
62 | if (notebook_url) {
63 | log.info(` Notebook URL: ${notebook_url}`);
64 | }
65 |
66 | try {
67 | // Resolve notebook URL
68 | let resolvedNotebookUrl = notebook_url;
69 |
70 | if (!resolvedNotebookUrl && notebook_id) {
71 | const notebook = this.library.incrementUseCount(notebook_id);
72 | if (!notebook) {
73 | throw new Error(`Notebook not found in library: ${notebook_id}`);
74 | }
75 |
76 | resolvedNotebookUrl = notebook.url;
77 | log.info(` Resolved notebook: ${notebook.name}`);
78 | } else if (!resolvedNotebookUrl) {
79 | const active = this.library.getActiveNotebook();
80 | if (active) {
81 | const notebook = this.library.incrementUseCount(active.id);
82 | if (!notebook) {
83 | throw new Error(`Active notebook not found: ${active.id}`);
84 | }
85 | resolvedNotebookUrl = notebook.url;
86 | log.info(` Using active notebook: ${notebook.name}`);
87 | }
88 | }
89 |
90 | // Progress: Getting or creating session
91 | await sendProgress?.("Getting or creating browser session...", 1, 5);
92 |
93 | // Apply browser options temporarily
94 | const originalConfig = { ...CONFIG };
95 | const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
96 | Object.assign(CONFIG, effectiveConfig);
97 |
98 | // Calculate overrideHeadless parameter for session manager
99 | // show_browser takes precedence over browser_options.headless
100 | let overrideHeadless: boolean | undefined = undefined;
101 | if (show_browser !== undefined) {
102 | overrideHeadless = show_browser;
103 | } else if (browser_options?.show !== undefined) {
104 | overrideHeadless = browser_options.show;
105 | } else if (browser_options?.headless !== undefined) {
106 | overrideHeadless = !browser_options.headless;
107 | }
108 |
109 | try {
110 | // Get or create session (with headless override to handle mode changes)
111 | const session = await this.sessionManager.getOrCreateSession(
112 | session_id,
113 | resolvedNotebookUrl,
114 | overrideHeadless
115 | );
116 |
117 | // Progress: Asking question
118 | await sendProgress?.("Asking question to NotebookLM...", 2, 5);
119 |
120 | // Ask the question (pass progress callback)
121 | const rawAnswer = await session.ask(question, sendProgress);
122 | const answer = `${rawAnswer.trimEnd()}${FOLLOW_UP_REMINDER}`;
123 |
124 | // Get session info
125 | const sessionInfo = session.getInfo();
126 |
127 | const result: AskQuestionResult = {
128 | status: "success",
129 | question,
130 | answer,
131 | session_id: session.sessionId,
132 | notebook_url: session.notebookUrl,
133 | session_info: {
134 | age_seconds: sessionInfo.age_seconds,
135 | message_count: sessionInfo.message_count,
136 | last_activity: sessionInfo.last_activity,
137 | },
138 | };
139 |
140 | // Progress: Complete
141 | await sendProgress?.("Question answered successfully!", 5, 5);
142 |
143 | log.success(`✅ [TOOL] ask_question completed successfully`);
144 | return {
145 | success: true,
146 | data: result,
147 | };
148 | } finally {
149 | // Restore original CONFIG
150 | Object.assign(CONFIG, originalConfig);
151 | }
152 | } catch (error) {
153 | const errorMessage =
154 | error instanceof Error ? error.message : String(error);
155 |
156 | // Special handling for rate limit errors
157 | if (error instanceof RateLimitError || errorMessage.toLowerCase().includes("rate limit")) {
158 | log.error(`🚫 [TOOL] Rate limit detected`);
159 | return {
160 | success: false,
161 | error:
162 | "NotebookLM rate limit reached (50 queries/day for free accounts).\n\n" +
163 | "You can:\n" +
164 | "1. Use the 're_auth' tool to login with a different Google account\n" +
165 | "2. Wait until tomorrow for the quota to reset\n" +
166 | "3. Upgrade to Google AI Pro/Ultra for 5x higher limits\n\n" +
167 | `Original error: ${errorMessage}`,
168 | };
169 | }
170 |
171 | log.error(`❌ [TOOL] ask_question failed: ${errorMessage}`);
172 | return {
173 | success: false,
174 | error: errorMessage,
175 | };
176 | }
177 | }
178 |
179 | /**
180 | * Handle list_sessions tool
181 | */
182 | async handleListSessions(): Promise<
183 | ToolResult<{
184 | active_sessions: number;
185 | max_sessions: number;
186 | session_timeout: number;
187 | oldest_session_seconds: number;
188 | total_messages: number;
189 | sessions: Array<{
190 | id: string;
191 | created_at: number;
192 | last_activity: number;
193 | age_seconds: number;
194 | inactive_seconds: number;
195 | message_count: number;
196 | notebook_url: string;
197 | }>;
198 | }>
199 | > {
200 | log.info(`🔧 [TOOL] list_sessions called`);
201 |
202 | try {
203 | const stats = this.sessionManager.getStats();
204 | const sessions = this.sessionManager.getAllSessionsInfo();
205 |
206 | const result = {
207 | active_sessions: stats.active_sessions,
208 | max_sessions: stats.max_sessions,
209 | session_timeout: stats.session_timeout,
210 | oldest_session_seconds: stats.oldest_session_seconds,
211 | total_messages: stats.total_messages,
212 | sessions: sessions.map((info) => ({
213 | id: info.id,
214 | created_at: info.created_at,
215 | last_activity: info.last_activity,
216 | age_seconds: info.age_seconds,
217 | inactive_seconds: info.inactive_seconds,
218 | message_count: info.message_count,
219 | notebook_url: info.notebook_url,
220 | })),
221 | };
222 |
223 | log.success(
224 | `✅ [TOOL] list_sessions completed (${result.active_sessions} sessions)`
225 | );
226 | return {
227 | success: true,
228 | data: result,
229 | };
230 | } catch (error) {
231 | const errorMessage =
232 | error instanceof Error ? error.message : String(error);
233 | log.error(`❌ [TOOL] list_sessions failed: ${errorMessage}`);
234 | return {
235 | success: false,
236 | error: errorMessage,
237 | };
238 | }
239 | }
240 |
241 | /**
242 | * Handle close_session tool
243 | */
244 | async handleCloseSession(args: { session_id: string }): Promise<
245 | ToolResult<{ status: string; message: string; session_id: string }>
246 | > {
247 | const { session_id } = args;
248 |
249 | log.info(`🔧 [TOOL] close_session called`);
250 | log.info(` Session ID: ${session_id}`);
251 |
252 | try {
253 | const closed = await this.sessionManager.closeSession(session_id);
254 |
255 | if (closed) {
256 | log.success(`✅ [TOOL] close_session completed`);
257 | return {
258 | success: true,
259 | data: {
260 | status: "success",
261 | message: `Session ${session_id} closed successfully`,
262 | session_id,
263 | },
264 | };
265 | } else {
266 | log.warning(`⚠️ [TOOL] Session ${session_id} not found`);
267 | return {
268 | success: false,
269 | error: `Session ${session_id} not found`,
270 | };
271 | }
272 | } catch (error) {
273 | const errorMessage =
274 | error instanceof Error ? error.message : String(error);
275 | log.error(`❌ [TOOL] close_session failed: ${errorMessage}`);
276 | return {
277 | success: false,
278 | error: errorMessage,
279 | };
280 | }
281 | }
282 |
283 | /**
284 | * Handle reset_session tool
285 | */
286 | async handleResetSession(args: { session_id: string }): Promise<
287 | ToolResult<{ status: string; message: string; session_id: string }>
288 | > {
289 | const { session_id } = args;
290 |
291 | log.info(`🔧 [TOOL] reset_session called`);
292 | log.info(` Session ID: ${session_id}`);
293 |
294 | try {
295 | const session = this.sessionManager.getSession(session_id);
296 |
297 | if (!session) {
298 | log.warning(`⚠️ [TOOL] Session ${session_id} not found`);
299 | return {
300 | success: false,
301 | error: `Session ${session_id} not found`,
302 | };
303 | }
304 |
305 | await session.reset();
306 |
307 | log.success(`✅ [TOOL] reset_session completed`);
308 | return {
309 | success: true,
310 | data: {
311 | status: "success",
312 | message: `Session ${session_id} reset successfully`,
313 | session_id,
314 | },
315 | };
316 | } catch (error) {
317 | const errorMessage =
318 | error instanceof Error ? error.message : String(error);
319 | log.error(`❌ [TOOL] reset_session failed: ${errorMessage}`);
320 | return {
321 | success: false,
322 | error: errorMessage,
323 | };
324 | }
325 | }
326 |
327 | /**
328 | * Handle get_health tool
329 | */
330 | async handleGetHealth(): Promise<
331 | ToolResult<{
332 | status: string;
333 | authenticated: boolean;
334 | notebook_url: string;
335 | active_sessions: number;
336 | max_sessions: number;
337 | session_timeout: number;
338 | total_messages: number;
339 | headless: boolean;
340 | auto_login_enabled: boolean;
341 | stealth_enabled: boolean;
342 | troubleshooting_tip?: string;
343 | }>
344 | > {
345 | log.info(`🔧 [TOOL] get_health called`);
346 |
347 | try {
348 | // Check authentication status
349 | const statePath = await this.authManager.getValidStatePath();
350 | const authenticated = statePath !== null;
351 |
352 | // Get session stats
353 | const stats = this.sessionManager.getStats();
354 |
355 | const result = {
356 | status: "ok",
357 | authenticated,
358 | notebook_url: CONFIG.notebookUrl || "not configured",
359 | active_sessions: stats.active_sessions,
360 | max_sessions: stats.max_sessions,
361 | session_timeout: stats.session_timeout,
362 | total_messages: stats.total_messages,
363 | headless: CONFIG.headless,
364 | auto_login_enabled: CONFIG.autoLoginEnabled,
365 | stealth_enabled: CONFIG.stealthEnabled,
366 | // Add troubleshooting tip if not authenticated
367 | ...((!authenticated) && {
368 | troubleshooting_tip:
369 | "For fresh start with clean browser session: Close all Chrome instances → " +
370 | "cleanup_data(confirm=true, preserve_library=true) → setup_auth"
371 | }),
372 | };
373 |
374 | log.success(`✅ [TOOL] get_health completed`);
375 | return {
376 | success: true,
377 | data: result,
378 | };
379 | } catch (error) {
380 | const errorMessage =
381 | error instanceof Error ? error.message : String(error);
382 | log.error(`❌ [TOOL] get_health failed: ${errorMessage}`);
383 | return {
384 | success: false,
385 | error: errorMessage,
386 | };
387 | }
388 | }
389 |
390 | /**
391 | * Handle setup_auth tool
392 | *
393 | * Opens a browser window for manual login with live progress updates.
394 | * The operation waits synchronously for login completion (up to 10 minutes).
395 | */
396 | async handleSetupAuth(
397 | args: {
398 | show_browser?: boolean;
399 | browser_options?: BrowserOptions;
400 | },
401 | sendProgress?: ProgressCallback
402 | ): Promise<
403 | ToolResult<{
404 | status: string;
405 | message: string;
406 | authenticated: boolean;
407 | duration_seconds?: number;
408 | }>
409 | > {
410 | const { show_browser, browser_options } = args;
411 |
412 | // CRITICAL: Send immediate progress to reset timeout from the very start
413 | await sendProgress?.("Initializing authentication setup...", 0, 10);
414 |
415 | log.info(`🔧 [TOOL] setup_auth called`);
416 | if (show_browser !== undefined) {
417 | log.info(` Show browser: ${show_browser}`);
418 | }
419 |
420 | const startTime = Date.now();
421 |
422 | // Apply browser options temporarily
423 | const originalConfig = { ...CONFIG };
424 | const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
425 | Object.assign(CONFIG, effectiveConfig);
426 |
427 | try {
428 | // Progress: Starting
429 | await sendProgress?.("Preparing authentication browser...", 1, 10);
430 |
431 | log.info(` 🌐 Opening browser for interactive login...`);
432 |
433 | // Progress: Opening browser
434 | await sendProgress?.("Opening browser window...", 2, 10);
435 |
436 | // Perform setup with progress updates (uses CONFIG internally)
437 | const success = await this.authManager.performSetup(sendProgress);
438 |
439 | const durationSeconds = (Date.now() - startTime) / 1000;
440 |
441 | if (success) {
442 | // Progress: Complete
443 | await sendProgress?.("Authentication saved successfully!", 10, 10);
444 |
445 | log.success(`✅ [TOOL] setup_auth completed (${durationSeconds.toFixed(1)}s)`);
446 | return {
447 | success: true,
448 | data: {
449 | status: "authenticated",
450 | message: "Successfully authenticated and saved browser state",
451 | authenticated: true,
452 | duration_seconds: durationSeconds,
453 | },
454 | };
455 | } else {
456 | log.error(`❌ [TOOL] setup_auth failed (${durationSeconds.toFixed(1)}s)`);
457 | return {
458 | success: false,
459 | error: "Authentication failed or was cancelled",
460 | };
461 | }
462 | } catch (error) {
463 | const errorMessage =
464 | error instanceof Error ? error.message : String(error);
465 | const durationSeconds = (Date.now() - startTime) / 1000;
466 | log.error(`❌ [TOOL] setup_auth failed: ${errorMessage} (${durationSeconds.toFixed(1)}s)`);
467 | return {
468 | success: false,
469 | error: errorMessage,
470 | };
471 | } finally {
472 | // Restore original CONFIG
473 | Object.assign(CONFIG, originalConfig);
474 | }
475 | }
476 |
477 | /**
478 | * Handle re_auth tool
479 | *
480 | * Performs a complete re-authentication:
481 | * 1. Closes all active browser sessions
482 | * 2. Deletes all saved authentication data (cookies, Chrome profile)
483 | * 3. Opens browser for fresh Google login
484 | *
485 | * Use for switching Google accounts or recovering from rate limits.
486 | */
487 | async handleReAuth(
488 | args: {
489 | show_browser?: boolean;
490 | browser_options?: BrowserOptions;
491 | },
492 | sendProgress?: ProgressCallback
493 | ): Promise<
494 | ToolResult<{
495 | status: string;
496 | message: string;
497 | authenticated: boolean;
498 | duration_seconds?: number;
499 | }>
500 | > {
501 | const { show_browser, browser_options } = args;
502 |
503 | await sendProgress?.("Preparing re-authentication...", 0, 12);
504 | log.info(`🔧 [TOOL] re_auth called`);
505 | if (show_browser !== undefined) {
506 | log.info(` Show browser: ${show_browser}`);
507 | }
508 |
509 | const startTime = Date.now();
510 |
511 | // Apply browser options temporarily
512 | const originalConfig = { ...CONFIG };
513 | const effectiveConfig = applyBrowserOptions(browser_options, show_browser);
514 | Object.assign(CONFIG, effectiveConfig);
515 |
516 | try {
517 | // 1. Close all active sessions
518 | await sendProgress?.("Closing all active sessions...", 1, 12);
519 | log.info(" 🛑 Closing all sessions...");
520 | await this.sessionManager.closeAllSessions();
521 | log.success(" ✅ All sessions closed");
522 |
523 | // 2. Clear all auth data
524 | await sendProgress?.("Clearing authentication data...", 2, 12);
525 | log.info(" 🗑️ Clearing all auth data...");
526 | await this.authManager.clearAllAuthData();
527 | log.success(" ✅ Auth data cleared");
528 |
529 | // 3. Perform fresh setup
530 | await sendProgress?.("Starting fresh authentication...", 3, 12);
531 | log.info(" 🌐 Starting fresh authentication setup...");
532 | const success = await this.authManager.performSetup(sendProgress);
533 |
534 | const durationSeconds = (Date.now() - startTime) / 1000;
535 |
536 | if (success) {
537 | await sendProgress?.("Re-authentication complete!", 12, 12);
538 | log.success(`✅ [TOOL] re_auth completed (${durationSeconds.toFixed(1)}s)`);
539 | return {
540 | success: true,
541 | data: {
542 | status: "authenticated",
543 | message:
544 | "Successfully re-authenticated with new account. All previous sessions have been closed.",
545 | authenticated: true,
546 | duration_seconds: durationSeconds,
547 | },
548 | };
549 | } else {
550 | log.error(`❌ [TOOL] re_auth failed (${durationSeconds.toFixed(1)}s)`);
551 | return {
552 | success: false,
553 | error: "Re-authentication failed or was cancelled",
554 | };
555 | }
556 | } catch (error) {
557 | const errorMessage = error instanceof Error ? error.message : String(error);
558 | const durationSeconds = (Date.now() - startTime) / 1000;
559 | log.error(
560 | `❌ [TOOL] re_auth failed: ${errorMessage} (${durationSeconds.toFixed(1)}s)`
561 | );
562 | return {
563 | success: false,
564 | error: errorMessage,
565 | };
566 | } finally {
567 | // Restore original CONFIG
568 | Object.assign(CONFIG, originalConfig);
569 | }
570 | }
571 |
572 | /**
573 | * Handle add_notebook tool
574 | */
575 | async handleAddNotebook(args: AddNotebookInput): Promise<ToolResult<{ notebook: any }>> {
576 | log.info(`🔧 [TOOL] add_notebook called`);
577 | log.info(` Name: ${args.name}`);
578 |
579 | try {
580 | const notebook = this.library.addNotebook(args);
581 | log.success(`✅ [TOOL] add_notebook completed: ${notebook.id}`);
582 | return {
583 | success: true,
584 | data: { notebook },
585 | };
586 | } catch (error) {
587 | const errorMessage = error instanceof Error ? error.message : String(error);
588 | log.error(`❌ [TOOL] add_notebook failed: ${errorMessage}`);
589 | return {
590 | success: false,
591 | error: errorMessage,
592 | };
593 | }
594 | }
595 |
596 | /**
597 | * Handle list_notebooks tool
598 | */
599 | async handleListNotebooks(): Promise<ToolResult<{ notebooks: any[] }>> {
600 | log.info(`🔧 [TOOL] list_notebooks called`);
601 |
602 | try {
603 | const notebooks = this.library.listNotebooks();
604 | log.success(`✅ [TOOL] list_notebooks completed (${notebooks.length} notebooks)`);
605 | return {
606 | success: true,
607 | data: { notebooks },
608 | };
609 | } catch (error) {
610 | const errorMessage = error instanceof Error ? error.message : String(error);
611 | log.error(`❌ [TOOL] list_notebooks failed: ${errorMessage}`);
612 | return {
613 | success: false,
614 | error: errorMessage,
615 | };
616 | }
617 | }
618 |
619 | /**
620 | * Handle get_notebook tool
621 | */
622 | async handleGetNotebook(args: { id: string }): Promise<ToolResult<{ notebook: any }>> {
623 | log.info(`🔧 [TOOL] get_notebook called`);
624 | log.info(` ID: ${args.id}`);
625 |
626 | try {
627 | const notebook = this.library.getNotebook(args.id);
628 | if (!notebook) {
629 | log.warning(`⚠️ [TOOL] Notebook not found: ${args.id}`);
630 | return {
631 | success: false,
632 | error: `Notebook not found: ${args.id}`,
633 | };
634 | }
635 |
636 | log.success(`✅ [TOOL] get_notebook completed: ${notebook.name}`);
637 | return {
638 | success: true,
639 | data: { notebook },
640 | };
641 | } catch (error) {
642 | const errorMessage = error instanceof Error ? error.message : String(error);
643 | log.error(`❌ [TOOL] get_notebook failed: ${errorMessage}`);
644 | return {
645 | success: false,
646 | error: errorMessage,
647 | };
648 | }
649 | }
650 |
651 | /**
652 | * Handle select_notebook tool
653 | */
654 | async handleSelectNotebook(args: { id: string }): Promise<ToolResult<{ notebook: any }>> {
655 | log.info(`🔧 [TOOL] select_notebook called`);
656 | log.info(` ID: ${args.id}`);
657 |
658 | try {
659 | const notebook = this.library.selectNotebook(args.id);
660 | log.success(`✅ [TOOL] select_notebook completed: ${notebook.name}`);
661 | return {
662 | success: true,
663 | data: { notebook },
664 | };
665 | } catch (error) {
666 | const errorMessage = error instanceof Error ? error.message : String(error);
667 | log.error(`❌ [TOOL] select_notebook failed: ${errorMessage}`);
668 | return {
669 | success: false,
670 | error: errorMessage,
671 | };
672 | }
673 | }
674 |
675 | /**
676 | * Handle update_notebook tool
677 | */
678 | async handleUpdateNotebook(args: UpdateNotebookInput): Promise<ToolResult<{ notebook: any }>> {
679 | log.info(`🔧 [TOOL] update_notebook called`);
680 | log.info(` ID: ${args.id}`);
681 |
682 | try {
683 | const notebook = this.library.updateNotebook(args);
684 | log.success(`✅ [TOOL] update_notebook completed: ${notebook.name}`);
685 | return {
686 | success: true,
687 | data: { notebook },
688 | };
689 | } catch (error) {
690 | const errorMessage = error instanceof Error ? error.message : String(error);
691 | log.error(`❌ [TOOL] update_notebook failed: ${errorMessage}`);
692 | return {
693 | success: false,
694 | error: errorMessage,
695 | };
696 | }
697 | }
698 |
699 | /**
700 | * Handle remove_notebook tool
701 | */
702 | async handleRemoveNotebook(args: { id: string }): Promise<ToolResult<{ removed: boolean; closed_sessions: number }>> {
703 | log.info(`🔧 [TOOL] remove_notebook called`);
704 | log.info(` ID: ${args.id}`);
705 |
706 | try {
707 | const notebook = this.library.getNotebook(args.id);
708 | if (!notebook) {
709 | log.warning(`⚠️ [TOOL] Notebook not found: ${args.id}`);
710 | return {
711 | success: false,
712 | error: `Notebook not found: ${args.id}`,
713 | };
714 | }
715 |
716 | const removed = this.library.removeNotebook(args.id);
717 | if (removed) {
718 | const closedSessions = await this.sessionManager.closeSessionsForNotebook(
719 | notebook.url
720 | );
721 | log.success(`✅ [TOOL] remove_notebook completed`);
722 | return {
723 | success: true,
724 | data: { removed: true, closed_sessions: closedSessions },
725 | };
726 | } else {
727 | log.warning(`⚠️ [TOOL] Notebook not found: ${args.id}`);
728 | return {
729 | success: false,
730 | error: `Notebook not found: ${args.id}`,
731 | };
732 | }
733 | } catch (error) {
734 | const errorMessage = error instanceof Error ? error.message : String(error);
735 | log.error(`❌ [TOOL] remove_notebook failed: ${errorMessage}`);
736 | return {
737 | success: false,
738 | error: errorMessage,
739 | };
740 | }
741 | }
742 |
743 | /**
744 | * Handle search_notebooks tool
745 | */
746 | async handleSearchNotebooks(args: { query: string }): Promise<ToolResult<{ notebooks: any[] }>> {
747 | log.info(`🔧 [TOOL] search_notebooks called`);
748 | log.info(` Query: "${args.query}"`);
749 |
750 | try {
751 | const notebooks = this.library.searchNotebooks(args.query);
752 | log.success(`✅ [TOOL] search_notebooks completed (${notebooks.length} results)`);
753 | return {
754 | success: true,
755 | data: { notebooks },
756 | };
757 | } catch (error) {
758 | const errorMessage = error instanceof Error ? error.message : String(error);
759 | log.error(`❌ [TOOL] search_notebooks failed: ${errorMessage}`);
760 | return {
761 | success: false,
762 | error: errorMessage,
763 | };
764 | }
765 | }
766 |
767 | /**
768 | * Handle get_library_stats tool
769 | */
770 | async handleGetLibraryStats(): Promise<ToolResult<any>> {
771 | log.info(`🔧 [TOOL] get_library_stats called`);
772 |
773 | try {
774 | const stats = this.library.getStats();
775 | log.success(`✅ [TOOL] get_library_stats completed`);
776 | return {
777 | success: true,
778 | data: stats,
779 | };
780 | } catch (error) {
781 | const errorMessage = error instanceof Error ? error.message : String(error);
782 | log.error(`❌ [TOOL] get_library_stats failed: ${errorMessage}`);
783 | return {
784 | success: false,
785 | error: errorMessage,
786 | };
787 | }
788 | }
789 |
790 | /**
791 | * Handle cleanup_data tool
792 | *
793 | * ULTRATHINK Deep Cleanup - scans entire system for ALL NotebookLM MCP files
794 | */
795 | async handleCleanupData(
796 | args: { confirm: boolean; preserve_library?: boolean }
797 | ): Promise<
798 | ToolResult<{
799 | status: string;
800 | mode: string;
801 | preview?: {
802 | categories: Array<{ name: string; description: string; paths: string[]; totalBytes: number; optional: boolean }>;
803 | totalPaths: number;
804 | totalSizeBytes: number;
805 | };
806 | result?: {
807 | deletedPaths: string[];
808 | failedPaths: string[];
809 | totalSizeBytes: number;
810 | categorySummary: Record<string, { count: number; bytes: number }>;
811 | };
812 | }>
813 | > {
814 | const { confirm, preserve_library = false } = args;
815 |
816 | log.info(`🔧 [TOOL] cleanup_data called`);
817 | log.info(` Confirm: ${confirm}`);
818 | log.info(` Preserve Library: ${preserve_library}`);
819 |
820 | const cleanupManager = new CleanupManager();
821 |
822 | try {
823 | // Always run in deep mode
824 | const mode = "deep";
825 |
826 | if (!confirm) {
827 | // Preview mode - show what would be deleted
828 | log.info(` 📋 Generating cleanup preview (mode: ${mode})...`);
829 |
830 | const preview = await cleanupManager.getCleanupPaths(mode, preserve_library);
831 | const platformInfo = cleanupManager.getPlatformInfo();
832 |
833 | log.info(` Found ${preview.totalPaths.length} items (${cleanupManager.formatBytes(preview.totalSizeBytes)})`);
834 | log.info(` Platform: ${platformInfo.platform}`);
835 |
836 | return {
837 | success: true,
838 | data: {
839 | status: "preview",
840 | mode,
841 | preview: {
842 | categories: preview.categories,
843 | totalPaths: preview.totalPaths.length,
844 | totalSizeBytes: preview.totalSizeBytes,
845 | },
846 | },
847 | };
848 | } else {
849 | // Cleanup mode - actually delete files
850 | log.info(` 🗑️ Performing cleanup (mode: ${mode})...`);
851 |
852 | const result = await cleanupManager.performCleanup(mode, preserve_library);
853 |
854 | if (result.success) {
855 | log.success(`✅ [TOOL] cleanup_data completed - deleted ${result.deletedPaths.length} items`);
856 | } else {
857 | log.warning(`⚠️ [TOOL] cleanup_data completed with ${result.failedPaths.length} errors`);
858 | }
859 |
860 | return {
861 | success: result.success,
862 | data: {
863 | status: result.success ? "completed" : "partial",
864 | mode,
865 | result: {
866 | deletedPaths: result.deletedPaths,
867 | failedPaths: result.failedPaths,
868 | totalSizeBytes: result.totalSizeBytes,
869 | categorySummary: result.categorySummary,
870 | },
871 | },
872 | };
873 | }
874 | } catch (error) {
875 | const errorMessage = error instanceof Error ? error.message : String(error);
876 | log.error(`❌ [TOOL] cleanup_data failed: ${errorMessage}`);
877 | return {
878 | success: false,
879 | error: errorMessage,
880 | };
881 | }
882 | }
883 |
884 | /**
885 | * Cleanup all resources (called on server shutdown)
886 | */
887 | async cleanup(): Promise<void> {
888 | log.info(`🧹 Cleaning up tool handlers...`);
889 | await this.sessionManager.closeAllSessions();
890 | log.success(`✅ Tool handlers cleanup complete`);
891 | }
892 | }
893 |
```
--------------------------------------------------------------------------------
/src/auth/auth-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Authentication Manager for NotebookLM
3 | *
4 | * Handles:
5 | * - Interactive login (headful browser for setup)
6 | * - Auto-login with credentials (email/password from ENV)
7 | * - Browser state persistence (cookies + localStorage + sessionStorage)
8 | * - Cookie expiry validation
9 | * - State expiry checks (24h file age)
10 | * - Hard reset for clean start
11 | *
12 | * Based on the Python implementation from auth.py
13 | */
14 |
15 | import type { BrowserContext, Page } from "patchright";
16 | import fs from "fs/promises";
17 | import { existsSync } from "fs";
18 | import path from "path";
19 | import { CONFIG, NOTEBOOKLM_AUTH_URL } from "../config.js";
20 | import { log } from "../utils/logger.js";
21 | import {
22 | humanType,
23 | randomDelay,
24 | realisticClick,
25 | randomMouseMovement,
26 | } from "../utils/stealth-utils.js";
27 | import type { ProgressCallback } from "../types.js";
28 |
29 | /**
30 | * Critical cookie names for Google authentication
31 | */
32 | const CRITICAL_COOKIE_NAMES = [
33 | "SID",
34 | "HSID",
35 | "SSID", // Google session
36 | "APISID",
37 | "SAPISID", // API auth
38 | "OSID",
39 | "__Secure-OSID", // NotebookLM-specific
40 | "__Secure-1PSID",
41 | "__Secure-3PSID", // Secure variants
42 | ];
43 |
44 | export class AuthManager {
45 | private stateFilePath: string;
46 | private sessionFilePath: string;
47 |
48 | constructor() {
49 | this.stateFilePath = path.join(CONFIG.browserStateDir, "state.json");
50 | this.sessionFilePath = path.join(CONFIG.browserStateDir, "session.json");
51 | }
52 |
53 | // ============================================================================
54 | // Browser State Management
55 | // ============================================================================
56 |
57 | /**
58 | * Save entire browser state (cookies + localStorage)
59 | */
60 | async saveBrowserState(context: BrowserContext, page?: Page): Promise<boolean> {
61 | try {
62 | // Save storage state (cookies + localStorage + IndexedDB)
63 | await context.storageState({ path: this.stateFilePath });
64 |
65 | // Also save sessionStorage if page is provided
66 | if (page) {
67 | try {
68 | const sessionStorageData: string = await page.evaluate((): string => {
69 | // Properly extract sessionStorage as a plain object
70 | const storage: Record<string, string> = {};
71 | // @ts-expect-error - sessionStorage exists in browser context
72 | for (let i = 0; i < sessionStorage.length; i++) {
73 | // @ts-expect-error - sessionStorage exists in browser context
74 | const key = sessionStorage.key(i);
75 | if (key) {
76 | // @ts-expect-error - sessionStorage exists in browser context
77 | storage[key] = sessionStorage.getItem(key) || '';
78 | }
79 | }
80 | return JSON.stringify(storage);
81 | });
82 |
83 | await fs.writeFile(this.sessionFilePath, sessionStorageData, {
84 | encoding: "utf-8",
85 | });
86 |
87 | const entries = Object.keys(JSON.parse(sessionStorageData)).length;
88 | log.success(`✅ Browser state saved (incl. sessionStorage: ${entries} entries)`);
89 | } catch (error) {
90 | log.warning(`⚠️ State saved, but sessionStorage failed: ${error}`);
91 | }
92 | } else {
93 | log.success("✅ Browser state saved");
94 | }
95 |
96 | return true;
97 | } catch (error) {
98 | log.error(`❌ Failed to save browser state: ${error}`);
99 | return false;
100 | }
101 | }
102 |
103 | /**
104 | * Check if saved browser state exists
105 | */
106 | async hasSavedState(): Promise<boolean> {
107 | try {
108 | await fs.access(this.stateFilePath);
109 | return true;
110 | } catch {
111 | return false;
112 | }
113 | }
114 |
115 | /**
116 | * Get path to saved browser state
117 | */
118 | getStatePath(): string | null {
119 | // Synchronous check using imported existsSync
120 | if (existsSync(this.stateFilePath)) {
121 | return this.stateFilePath;
122 | }
123 | return null;
124 | }
125 |
126 | /**
127 | * Get valid state path (checks expiry)
128 | */
129 | async getValidStatePath(): Promise<string | null> {
130 | const statePath = this.getStatePath();
131 | if (!statePath) {
132 | return null;
133 | }
134 |
135 | if (await this.isStateExpired()) {
136 | log.warning("⚠️ Saved state is expired (>24h old)");
137 | log.info("💡 Run setup_auth tool to re-authenticate");
138 | return null;
139 | }
140 |
141 | return statePath;
142 | }
143 |
144 | /**
145 | * Load sessionStorage from file
146 | */
147 | async loadSessionStorage(): Promise<Record<string, string> | null> {
148 | try {
149 | const data = await fs.readFile(this.sessionFilePath, { encoding: "utf-8" });
150 | const sessionData = JSON.parse(data);
151 | log.success(`✅ Loaded sessionStorage (${Object.keys(sessionData).length} entries)`);
152 | return sessionData;
153 | } catch (error) {
154 | log.warning(`⚠️ Failed to load sessionStorage: ${error}`);
155 | return null;
156 | }
157 | }
158 |
159 | // ============================================================================
160 | // Cookie Validation
161 | // ============================================================================
162 |
163 | /**
164 | * Validate if saved state is still valid
165 | */
166 | async validateState(context: BrowserContext): Promise<boolean> {
167 | try {
168 | const cookies = await context.cookies();
169 | if (cookies.length === 0) {
170 | log.warning("⚠️ No cookies found in state");
171 | return false;
172 | }
173 |
174 | // Check for Google auth cookies
175 | const googleCookies = cookies.filter((c) =>
176 | c.domain.includes("google.com")
177 | );
178 | if (googleCookies.length === 0) {
179 | log.warning("⚠️ No Google cookies found");
180 | return false;
181 | }
182 |
183 | // Check if important cookies are expired
184 | const currentTime = Date.now() / 1000;
185 |
186 | for (const cookie of googleCookies) {
187 | const expires = cookie.expires ?? -1;
188 | if (expires !== -1 && expires < currentTime) {
189 | log.warning(`⚠️ Cookie '${cookie.name}' has expired`);
190 | return false;
191 | }
192 | }
193 |
194 | log.success("✅ State validation passed");
195 | return true;
196 | } catch (error) {
197 | log.warning(`⚠️ State validation failed: ${error}`);
198 | return false;
199 | }
200 | }
201 |
202 | /**
203 | * Validate if critical authentication cookies are still valid
204 | */
205 | async validateCookiesExpiry(context: BrowserContext): Promise<boolean> {
206 | try {
207 | const cookies = await context.cookies();
208 | if (cookies.length === 0) {
209 | log.warning("⚠️ No cookies found");
210 | return false;
211 | }
212 |
213 | // Find critical cookies
214 | const criticalCookies = cookies.filter((c) =>
215 | CRITICAL_COOKIE_NAMES.includes(c.name)
216 | );
217 |
218 | if (criticalCookies.length === 0) {
219 | log.warning("⚠️ No critical auth cookies found");
220 | return false;
221 | }
222 |
223 | // Check expiration for each critical cookie
224 | const currentTime = Date.now() / 1000;
225 | const expiredCookies: string[] = [];
226 |
227 | for (const cookie of criticalCookies) {
228 | const expires = cookie.expires ?? -1;
229 |
230 | // -1 means session cookie (valid until browser closes)
231 | if (expires === -1) {
232 | continue;
233 | }
234 |
235 | // Check if cookie is expired
236 | if (expires < currentTime) {
237 | expiredCookies.push(cookie.name);
238 | }
239 | }
240 |
241 | if (expiredCookies.length > 0) {
242 | log.warning(`⚠️ Expired cookies: ${expiredCookies.join(", ")}`);
243 | return false;
244 | }
245 |
246 | log.success(`✅ All ${criticalCookies.length} critical cookies are valid`);
247 | return true;
248 | } catch (error) {
249 | log.warning(`⚠️ Cookie validation failed: ${error}`);
250 | return false;
251 | }
252 | }
253 |
254 | /**
255 | * Check if the saved state file is too old (>24 hours)
256 | */
257 | async isStateExpired(): Promise<boolean> {
258 | try {
259 | const stats = await fs.stat(this.stateFilePath);
260 | const fileAgeSeconds = (Date.now() - stats.mtimeMs) / 1000;
261 | const maxAgeSeconds = 24 * 60 * 60; // 24 hours
262 |
263 | if (fileAgeSeconds > maxAgeSeconds) {
264 | const hoursOld = fileAgeSeconds / 3600;
265 | log.warning(`⚠️ Saved state is ${hoursOld.toFixed(1)}h old (max: 24h)`);
266 | return true;
267 | }
268 |
269 | return false;
270 | } catch {
271 | return true; // File doesn't exist = expired
272 | }
273 | }
274 |
275 | // ============================================================================
276 | // Interactive Login
277 | // ============================================================================
278 |
279 | /**
280 | * Perform interactive login
281 | * User will see a browser window and login manually
282 | *
283 | * SIMPLE & RELIABLE: Just wait for URL to change to notebooklm.google.com
284 | */
285 | async performLogin(page: Page, sendProgress?: ProgressCallback): Promise<boolean> {
286 | try {
287 | log.info("🌐 Opening Google login page...");
288 | log.warning("📝 Please login to your Google account");
289 | log.warning("⏳ Browser will close automatically once you reach NotebookLM");
290 | log.info("");
291 |
292 | // Progress: Navigating
293 | await sendProgress?.("Navigating to Google login...", 3, 10);
294 |
295 | // Navigate to Google login (redirects to NotebookLM after auth)
296 | await page.goto(NOTEBOOKLM_AUTH_URL, { timeout: 60000 });
297 |
298 | // Progress: Waiting for login
299 | await sendProgress?.("Waiting for manual login (up to 10 minutes)...", 4, 10);
300 |
301 | // Wait for user to complete login
302 | log.warning("⏳ Waiting for login (up to 10 minutes)...");
303 |
304 | const checkIntervalMs = 1000; // Check every 1 second
305 | const maxAttempts = 600; // 10 minutes total
306 | let lastProgressUpdate = 0;
307 |
308 | for (let attempt = 0; attempt < maxAttempts; attempt++) {
309 | try {
310 | const currentUrl = page.url();
311 | const elapsedSeconds = Math.floor(attempt * (checkIntervalMs / 1000));
312 |
313 | // Send progress every 10 seconds
314 | if (elapsedSeconds - lastProgressUpdate >= 10) {
315 | lastProgressUpdate = elapsedSeconds;
316 | const progressStep = Math.min(8, 4 + Math.floor(elapsedSeconds / 60));
317 | await sendProgress?.(
318 | `Waiting for login... (${elapsedSeconds}s elapsed)`,
319 | progressStep,
320 | 10
321 | );
322 | }
323 |
324 | // ✅ SIMPLE: Check if we're on NotebookLM (any path!)
325 | if (currentUrl.startsWith("https://notebooklm.google.com/")) {
326 | await sendProgress?.("Login successful! NotebookLM detected!", 9, 10);
327 | log.success("✅ Login successful! NotebookLM URL detected.");
328 | log.success(`✅ Current URL: ${currentUrl}`);
329 |
330 | // Short wait to ensure page is loaded
331 | await page.waitForTimeout(2000);
332 | return true;
333 | }
334 |
335 | // Still on accounts.google.com - log periodically
336 | if (currentUrl.includes("accounts.google.com") && attempt % 30 === 0 && attempt > 0) {
337 | log.warning(`⏳ Still waiting... (${elapsedSeconds}s elapsed)`);
338 | }
339 |
340 | await page.waitForTimeout(checkIntervalMs);
341 | } catch {
342 | await page.waitForTimeout(checkIntervalMs);
343 | continue;
344 | }
345 | }
346 |
347 | // Timeout reached - final check
348 | const currentUrl = page.url();
349 | if (currentUrl.startsWith("https://notebooklm.google.com/")) {
350 | await sendProgress?.("Login successful (detected on timeout check)!", 9, 10);
351 | log.success("✅ Login successful (detected on timeout check)");
352 | return true;
353 | }
354 |
355 | log.error("❌ Login verification failed - timeout reached");
356 | log.warning(`Current URL: ${currentUrl}`);
357 | return false;
358 | } catch (error) {
359 | log.error(`❌ Login failed: ${error}`);
360 | return false;
361 | }
362 | }
363 |
364 | // ============================================================================
365 | // Auto-Login with Credentials
366 | // ============================================================================
367 |
368 | /**
369 | * Attempt to authenticate using configured credentials
370 | */
371 | async loginWithCredentials(
372 | context: BrowserContext,
373 | page: Page,
374 | email: string,
375 | password: string
376 | ): Promise<boolean> {
377 | const maskedEmail = this.maskEmail(email);
378 | log.warning(`🔁 Attempting automatic login for ${maskedEmail}...`);
379 |
380 | // Log browser visibility
381 | if (!CONFIG.headless) {
382 | log.info(" 👁️ Browser is VISIBLE for debugging");
383 | } else {
384 | log.info(" 🙈 Browser is HEADLESS (invisible)");
385 | }
386 |
387 | log.info(` 🌐 Navigating to Google login...`);
388 |
389 | try {
390 | await page.goto(NOTEBOOKLM_AUTH_URL, {
391 | waitUntil: "domcontentloaded",
392 | timeout: CONFIG.browserTimeout,
393 | });
394 | log.success(` ✅ Page loaded: ${page.url().slice(0, 80)}...`);
395 | } catch (error) {
396 | log.warning(` ⚠️ Page load timeout (continuing anyway)`);
397 | }
398 |
399 | const deadline = Date.now() + CONFIG.autoLoginTimeoutMs;
400 | log.info(` ⏰ Auto-login timeout: ${CONFIG.autoLoginTimeoutMs / 1000}s`);
401 |
402 | // Already on NotebookLM?
403 | log.info(" 🔍 Checking if already authenticated...");
404 | if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
405 | log.success("✅ Already authenticated");
406 | await this.saveBrowserState(context, page);
407 | return true;
408 | }
409 |
410 | log.warning(" ❌ Not authenticated yet, proceeding with login...");
411 |
412 | // Handle possible account chooser
413 | log.info(" 🔍 Checking for account chooser...");
414 | if (await this.handleAccountChooser(page, email)) {
415 | log.success(" ✅ Account selected from chooser");
416 | if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
417 | log.success("✅ Automatic login successful");
418 | await this.saveBrowserState(context, page);
419 | return true;
420 | }
421 | }
422 |
423 | // Email step
424 | log.info(" 📧 Entering email address...");
425 | if (!(await this.fillIdentifier(page, email))) {
426 | if (await this.waitForNotebook(page, CONFIG.autoLoginTimeoutMs)) {
427 | log.success("✅ Automatic login successful");
428 | await this.saveBrowserState(context, page);
429 | return true;
430 | }
431 | log.warning("⚠️ Email input not detected");
432 | }
433 |
434 | // Password step (wait until visible)
435 | let waitAttempts = 0;
436 | log.warning(" ⏳ Waiting for password page to load...");
437 |
438 | while (Date.now() < deadline && !(await this.fillPassword(page, password))) {
439 | waitAttempts++;
440 |
441 | // Log every 10 seconds (20 attempts * 0.5s)
442 | if (waitAttempts % 20 === 0) {
443 | const secondsWaited = waitAttempts * 0.5;
444 | const secondsRemaining = (deadline - Date.now()) / 1000;
445 | log.warning(
446 | ` ⏳ Still waiting for password field... (${secondsWaited}s elapsed, ${secondsRemaining.toFixed(0)}s remaining)`
447 | );
448 | log.info(` 📍 Current URL: ${page.url().slice(0, 100)}`);
449 | }
450 |
451 | if (page.url().includes("challenge")) {
452 | log.warning("⚠️ Additional verification required (Google challenge page).");
453 | return false;
454 | }
455 | await page.waitForTimeout(500);
456 | }
457 |
458 | // Wait for Google redirect after login
459 | log.info(" 🔄 Waiting for Google redirect to NotebookLM...");
460 |
461 | if (await this.waitForRedirectAfterLogin(page, deadline)) {
462 | log.success("✅ Automatic login successful");
463 | await this.saveBrowserState(context, page);
464 | return true;
465 | }
466 |
467 | // Login failed - diagnose
468 | log.error("❌ Automatic login timed out");
469 |
470 | // Take screenshot for debugging
471 | try {
472 | const screenshotPath = path.join(
473 | CONFIG.dataDir,
474 | `login_failed_${Date.now()}.png`
475 | );
476 | await page.screenshot({ path: screenshotPath });
477 | log.info(` 📸 Screenshot saved: ${screenshotPath}`);
478 | } catch (error) {
479 | log.warning(` ⚠️ Could not save screenshot: ${error}`);
480 | }
481 |
482 | // Diagnose specific failure reason
483 | const currentUrl = page.url();
484 | log.warning(" 🔍 Diagnosing failure...");
485 |
486 | if (currentUrl.includes("accounts.google.com")) {
487 | if (currentUrl.includes("/signin/identifier")) {
488 | log.error(" ❌ Still on email page - email input might have failed");
489 | log.info(" 💡 Check if email is correct in .env");
490 | } else if (currentUrl.includes("/challenge")) {
491 | log.error(
492 | " ❌ Google requires additional verification (2FA, CAPTCHA, suspicious login)"
493 | );
494 | log.info(" 💡 Try logging in manually first: use setup_auth tool");
495 | } else if (currentUrl.includes("/pwd") || currentUrl.includes("/password")) {
496 | log.error(" ❌ Still on password page - password input might have failed");
497 | log.info(" 💡 Check if password is correct in .env");
498 | } else {
499 | log.error(` ❌ Stuck on Google accounts page: ${currentUrl.slice(0, 80)}...`);
500 | }
501 | } else if (currentUrl.includes("notebooklm.google.com")) {
502 | log.warning(" ⚠️ Reached NotebookLM but couldn't detect successful login");
503 | log.info(" 💡 This might be a timing issue - try again");
504 | } else {
505 | log.error(` ❌ Unexpected page: ${currentUrl.slice(0, 80)}...`);
506 | }
507 |
508 | return false;
509 | }
510 |
511 | // ============================================================================
512 | // Helper Methods
513 | // ============================================================================
514 |
515 | /**
516 | * Wait for Google to redirect to NotebookLM after successful login (SIMPLE & RELIABLE)
517 | *
518 | * Just checks if URL changes to notebooklm.google.com - no complex UI element searching!
519 | * Matches the simplified approach used in performLogin().
520 | */
521 | private async waitForRedirectAfterLogin(
522 | page: Page,
523 | deadline: number
524 | ): Promise<boolean> {
525 | log.info(" ⏳ Waiting for redirect to NotebookLM...");
526 |
527 | while (Date.now() < deadline) {
528 | try {
529 | const currentUrl = page.url();
530 |
531 | // Simple check: Are we on NotebookLM?
532 | if (currentUrl.startsWith("https://notebooklm.google.com/")) {
533 | log.success(" ✅ NotebookLM URL detected!");
534 | // Short wait to ensure page is loaded
535 | await page.waitForTimeout(2000);
536 | return true;
537 | }
538 | } catch {
539 | // Ignore errors
540 | }
541 |
542 | await page.waitForTimeout(500);
543 | }
544 |
545 | log.error(" ❌ Redirect timeout - NotebookLM URL not reached");
546 | return false;
547 | }
548 |
549 | /**
550 | * Wait for NotebookLM to load (SIMPLE & RELIABLE)
551 | *
552 | * Just checks if URL starts with notebooklm.google.com - no complex UI element searching!
553 | * Matches the simplified approach used in performLogin().
554 | */
555 | private async waitForNotebook(page: Page, timeoutMs: number): Promise<boolean> {
556 | const endTime = Date.now() + timeoutMs;
557 |
558 | while (Date.now() < endTime) {
559 | try {
560 | const currentUrl = page.url();
561 |
562 | // Simple check: Are we on NotebookLM?
563 | if (currentUrl.startsWith("https://notebooklm.google.com/")) {
564 | log.success(" ✅ NotebookLM URL detected");
565 | return true;
566 | }
567 | } catch {
568 | // Ignore errors
569 | }
570 |
571 | await page.waitForTimeout(1000);
572 | }
573 |
574 | return false;
575 | }
576 |
577 | /**
578 | * Handle possible account chooser
579 | */
580 | private async handleAccountChooser(page: Page, email: string): Promise<boolean> {
581 | try {
582 | const chooser = await page.$$("div[data-identifier], li[data-identifier]");
583 |
584 | if (chooser.length > 0) {
585 | for (const item of chooser) {
586 | const identifier = (await item.getAttribute("data-identifier"))?.toLowerCase() || "";
587 | if (identifier === email.toLowerCase()) {
588 | await item.click();
589 | await randomDelay(150, 320);
590 | await page.waitForTimeout(500);
591 | return true;
592 | }
593 | }
594 |
595 | // Click "Use another account"
596 | await this.clickText(page, [
597 | "Use another account",
598 | "Weiteres Konto hinzufügen",
599 | "Anderes Konto verwenden",
600 | ]);
601 | await randomDelay(150, 320);
602 | return false;
603 | }
604 |
605 | return false;
606 | } catch {
607 | return false;
608 | }
609 | }
610 |
611 | /**
612 | * Fill email identifier field with human-like typing
613 | */
614 | private async fillIdentifier(page: Page, email: string): Promise<boolean> {
615 | log.info(" 📧 Looking for email field...");
616 |
617 | const emailSelectors = [
618 | "input#identifierId",
619 | "input[name='identifier']",
620 | "input[type='email']",
621 | ];
622 |
623 | let emailSelector: string | null = null;
624 | let emailField: any = null;
625 |
626 | for (const selector of emailSelectors) {
627 | try {
628 | const candidate = await page.waitForSelector(selector, {
629 | state: "attached",
630 | timeout: 3000,
631 | });
632 | if (!candidate) continue;
633 |
634 | try {
635 | if (!(await candidate.isVisible())) {
636 | continue; // Hidden field
637 | }
638 | } catch {
639 | continue;
640 | }
641 |
642 | emailField = candidate;
643 | emailSelector = selector;
644 | log.success(` ✅ Email field visible: ${selector}`);
645 | break;
646 | } catch {
647 | continue;
648 | }
649 | }
650 |
651 | if (!emailField || !emailSelector) {
652 | log.warning(" ℹ️ No visible email field found (likely pre-filled)");
653 | log.info(` 📍 Current URL: ${page.url().slice(0, 100)}`);
654 | return false;
655 | }
656 |
657 | // Human-like mouse movement to field
658 | try {
659 | const box = await emailField.boundingBox();
660 | if (box) {
661 | const targetX = box.x + box.width / 2;
662 | const targetY = box.y + box.height / 2;
663 | await randomMouseMovement(page, targetX, targetY);
664 | await randomDelay(200, 500);
665 | }
666 | } catch {
667 | // Ignore errors
668 | }
669 |
670 | // Click to focus
671 | try {
672 | await realisticClick(page, emailSelector, false);
673 | } catch (error) {
674 | log.warning(` ⚠️ Could not click email field (${error}); trying direct focus`);
675 | try {
676 | await emailField.focus();
677 | } catch {
678 | log.error(" ❌ Failed to focus email field");
679 | return false;
680 | }
681 | }
682 |
683 | // ✅ FASTER: Programmer typing speed (90-120 WPM from config)
684 | log.info(` ⌨️ Typing email: ${this.maskEmail(email)}`);
685 | try {
686 | const wpm = CONFIG.typingWpmMin + Math.floor(Math.random() * (CONFIG.typingWpmMax - CONFIG.typingWpmMin + 1));
687 | await humanType(page, emailSelector, email, { wpm, withTypos: false });
688 | log.success(" ✅ Email typed successfully");
689 | } catch (error) {
690 | log.error(` ❌ Typing failed: ${error}`);
691 | try {
692 | await page.fill(emailSelector, email);
693 | log.success(" ✅ Filled email using fallback");
694 | } catch {
695 | return false;
696 | }
697 | }
698 |
699 | // Human "thinking" pause before clicking Next
700 | await randomDelay(400, 1200);
701 |
702 | // Click Next button
703 | log.info(" 🔘 Looking for Next button...");
704 |
705 | const nextSelectors = [
706 | "button:has-text('Next')",
707 | "button:has-text('Weiter')",
708 | "#identifierNext",
709 | ];
710 |
711 | let nextClicked = false;
712 | for (const selector of nextSelectors) {
713 | try {
714 | const button = await page.locator(selector);
715 | if ((await button.count()) > 0) {
716 | await realisticClick(page, selector, true);
717 | log.success(` ✅ Next button clicked: ${selector}`);
718 | nextClicked = true;
719 | break;
720 | }
721 | } catch {
722 | continue;
723 | }
724 | }
725 |
726 | if (!nextClicked) {
727 | log.warning(" ⚠️ Button not found, pressing Enter");
728 | await emailField.press("Enter");
729 | }
730 |
731 | // Variable delay
732 | await randomDelay(800, 1500);
733 | log.success(" ✅ Email step complete");
734 | return true;
735 | }
736 |
737 | /**
738 | * Fill password field with human-like typing
739 | */
740 | private async fillPassword(page: Page, password: string): Promise<boolean> {
741 | log.info(" 🔐 Looking for password field...");
742 |
743 | const passwordSelectors = ["input[name='Passwd']", "input[type='password']"];
744 |
745 | let passwordSelector: string | null = null;
746 | let passwordField: any = null;
747 |
748 | for (const selector of passwordSelectors) {
749 | try {
750 | passwordField = await page.$(selector);
751 | if (passwordField) {
752 | passwordSelector = selector;
753 | log.success(` ✅ Password field found: ${selector}`);
754 | break;
755 | }
756 | } catch {
757 | continue;
758 | }
759 | }
760 |
761 | if (!passwordField) {
762 | // Not found yet, but don't fail - this is called in a loop
763 | return false;
764 | }
765 |
766 | // Human-like mouse movement to field
767 | try {
768 | const box = await passwordField.boundingBox();
769 | if (box) {
770 | const targetX = box.x + box.width / 2;
771 | const targetY = box.y + box.height / 2;
772 | await randomMouseMovement(page, targetX, targetY);
773 | await randomDelay(300, 700);
774 | }
775 | } catch {
776 | // Ignore errors
777 | }
778 |
779 | // Click to focus
780 | if (passwordSelector) {
781 | await realisticClick(page, passwordSelector, false);
782 | }
783 |
784 | // ✅ FASTER: Programmer typing speed (90-120 WPM from config)
785 | log.info(" ⌨️ Typing password...");
786 | try {
787 | const wpm = CONFIG.typingWpmMin + Math.floor(Math.random() * (CONFIG.typingWpmMax - CONFIG.typingWpmMin + 1));
788 | if (passwordSelector) {
789 | await humanType(page, passwordSelector, password, { wpm, withTypos: false });
790 | }
791 | log.success(" ✅ Password typed successfully");
792 | } catch (error) {
793 | log.error(` ❌ Typing failed: ${error}`);
794 | return false;
795 | }
796 |
797 | // Human "review" pause before submitting password
798 | await randomDelay(300, 1000);
799 |
800 | // Click Next button
801 | log.info(" 🔘 Looking for Next button...");
802 |
803 | const pwdNextSelectors = [
804 | "button:has-text('Next')",
805 | "button:has-text('Weiter')",
806 | "#passwordNext",
807 | ];
808 |
809 | let pwdNextClicked = false;
810 | for (const selector of pwdNextSelectors) {
811 | try {
812 | const button = await page.locator(selector);
813 | if ((await button.count()) > 0) {
814 | await realisticClick(page, selector, true);
815 | log.success(` ✅ Next button clicked: ${selector}`);
816 | pwdNextClicked = true;
817 | break;
818 | }
819 | } catch {
820 | continue;
821 | }
822 | }
823 |
824 | if (!pwdNextClicked) {
825 | log.warning(" ⚠️ Button not found, pressing Enter");
826 | await passwordField.press("Enter");
827 | }
828 |
829 | // Variable delay
830 | await randomDelay(800, 1500);
831 | log.success(" ✅ Password step complete");
832 | return true;
833 | }
834 |
835 | /**
836 | * Click text element
837 | */
838 | private async clickText(page: Page, texts: string[]): Promise<boolean> {
839 | for (const text of texts) {
840 | const selector = `text="${text}"`;
841 | try {
842 | const locator = page.locator(selector);
843 | if ((await locator.count()) > 0) {
844 | await realisticClick(page, selector, true);
845 | await randomDelay(120, 260);
846 | return true;
847 | }
848 | } catch {
849 | continue;
850 | }
851 | }
852 | return false;
853 | }
854 |
855 | /**
856 | * Mask email for logging
857 | */
858 | private maskEmail(email: string): string {
859 | if (!email.includes("@")) {
860 | return "***";
861 | }
862 | const [name, domain] = email.split("@");
863 | if (name.length <= 2) {
864 | return `${"*".repeat(name.length)}@${domain}`;
865 | }
866 | return `${name[0]}${"*".repeat(name.length - 2)}${name[name.length - 1]}@${domain}`;
867 | }
868 |
869 | // ============================================================================
870 | // Additional Helper Methods
871 | // ============================================================================
872 |
873 | /**
874 | * Load authentication state from a specific file path
875 | */
876 | async loadAuthState(context: BrowserContext, statePath: string): Promise<boolean> {
877 | try {
878 | // Read state.json
879 | const stateData = await fs.readFile(statePath, { encoding: "utf-8" });
880 | const state = JSON.parse(stateData);
881 |
882 | // Add cookies to context
883 | if (state.cookies) {
884 | await context.addCookies(state.cookies);
885 | log.success(`✅ Loaded ${state.cookies.length} cookies from ${statePath}`);
886 | return true;
887 | }
888 |
889 | log.warning(`⚠️ No cookies found in state file`);
890 | return false;
891 | } catch (error) {
892 | log.error(`❌ Failed to load auth state: ${error}`);
893 | return false;
894 | }
895 | }
896 |
897 | /**
898 | * Perform interactive setup (for setup_auth tool)
899 | * Opens a PERSISTENT browser for manual login
900 | *
901 | * CRITICAL: Uses the SAME persistent context as runtime!
902 | * This ensures cookies are automatically saved to the Chrome profile.
903 | *
904 | * Benefits over temporary browser:
905 | * - Session cookies persist correctly (Playwright bug workaround)
906 | * - Same fingerprint as runtime
907 | * - No need for addCookies() workarounds
908 | * - Automatic cookie persistence via Chrome profile
909 | *
910 | * @param sendProgress Optional progress callback
911 | * @param overrideHeadless Optional override for headless mode (true = visible, false = headless)
912 | * If not provided, defaults to true (visible) for setup
913 | */
914 | async performSetup(sendProgress?: ProgressCallback, overrideHeadless?: boolean): Promise<boolean> {
915 | const { chromium } = await import("patchright");
916 |
917 | // Determine headless mode: override or default to true (visible for setup)
918 | // overrideHeadless contains show_browser value (true = show, false = hide)
919 | const shouldShowBrowser = overrideHeadless !== undefined ? overrideHeadless : true;
920 |
921 | try {
922 | // CRITICAL: Clear ALL old auth data FIRST (for account switching)
923 | log.info("🔄 Preparing for new account authentication...");
924 | await sendProgress?.("Clearing old authentication data...", 1, 10);
925 | await this.clearAllAuthData();
926 |
927 | log.info("🚀 Launching persistent browser for interactive setup...");
928 | log.info(` 📍 Profile: ${CONFIG.chromeProfileDir}`);
929 | await sendProgress?.("Launching persistent browser...", 2, 10);
930 |
931 | // ✅ CRITICAL FIX: Use launchPersistentContext (same as runtime!)
932 | // This ensures session cookies persist correctly
933 | const context = await chromium.launchPersistentContext(
934 | CONFIG.chromeProfileDir,
935 | {
936 | headless: !shouldShowBrowser, // Use override or default to visible for setup
937 | channel: "chrome" as const,
938 | viewport: CONFIG.viewport,
939 | locale: "en-US",
940 | timezoneId: "Europe/Berlin",
941 | args: [
942 | "--disable-blink-features=AutomationControlled",
943 | "--disable-dev-shm-usage",
944 | "--no-first-run",
945 | "--no-default-browser-check",
946 | ],
947 | }
948 | );
949 |
950 | // Get or create a page
951 | const pages = context.pages();
952 | const page = pages.length > 0 ? pages[0] : await context.newPage();
953 |
954 | // Perform login with progress updates
955 | const loginSuccess = await this.performLogin(page, sendProgress);
956 |
957 | if (loginSuccess) {
958 | // ✅ Save browser state to state.json (for validation & backup)
959 | // Chrome ALSO saves everything to the persistent profile automatically!
960 | await sendProgress?.("Saving authentication state...", 9, 10);
961 | await this.saveBrowserState(context, page);
962 | log.success("✅ Setup complete - authentication saved to:");
963 | log.success(` 📄 State file: ${this.stateFilePath}`);
964 | log.success(` 📁 Chrome profile: ${CONFIG.chromeProfileDir}`);
965 | log.info("💡 Session cookies will now persist across restarts!");
966 | }
967 |
968 | // Close persistent context
969 | await context.close();
970 |
971 | return loginSuccess;
972 | } catch (error) {
973 | log.error(`❌ Setup failed: ${error}`);
974 | return false;
975 | }
976 | }
977 |
978 | // ============================================================================
979 | // Cleanup
980 | // ============================================================================
981 |
982 | /**
983 | * Clear ALL authentication data for account switching
984 | *
985 | * CRITICAL: This deletes EVERYTHING to ensure only ONE account is active:
986 | * - All state.json files (cookies, localStorage)
987 | * - sessionStorage files
988 | * - Chrome profile directory (browser fingerprint, cache, etc.)
989 | *
990 | * Use this BEFORE authenticating a new account!
991 | */
992 | async clearAllAuthData(): Promise<void> {
993 | log.warning("🗑️ Clearing ALL authentication data for account switch...");
994 |
995 | let deletedCount = 0;
996 |
997 | // 1. Delete all state files in browser_state_dir
998 | try {
999 | const files = await fs.readdir(CONFIG.browserStateDir);
1000 | for (const file of files) {
1001 | if (file.endsWith(".json")) {
1002 | await fs.unlink(path.join(CONFIG.browserStateDir, file));
1003 | log.info(` ✅ Deleted: ${file}`);
1004 | deletedCount++;
1005 | }
1006 | }
1007 | } catch (error) {
1008 | log.warning(` ⚠️ Could not delete state files: ${error}`);
1009 | }
1010 |
1011 | // 2. Delete Chrome profile (THE KEY for account switching!)
1012 | // This removes ALL browser data: cookies, cache, fingerprint, etc.
1013 | try {
1014 | const chromeProfileDir = CONFIG.chromeProfileDir;
1015 | if (existsSync(chromeProfileDir)) {
1016 | await fs.rm(chromeProfileDir, { recursive: true, force: true });
1017 | log.success(` ✅ Deleted Chrome profile: ${chromeProfileDir}`);
1018 | deletedCount++;
1019 | }
1020 | } catch (error) {
1021 | log.warning(` ⚠️ Could not delete Chrome profile: ${error}`);
1022 | }
1023 |
1024 | if (deletedCount === 0) {
1025 | log.info(" ℹ️ No old auth data found (already clean)");
1026 | } else {
1027 | log.success(`✅ All auth data cleared (${deletedCount} items) - ready for new account!`);
1028 | }
1029 | }
1030 |
1031 | /**
1032 | * Clear all saved authentication state
1033 | */
1034 | async clearState(): Promise<boolean> {
1035 | try {
1036 | try {
1037 | await fs.unlink(this.stateFilePath);
1038 | } catch {
1039 | // File doesn't exist
1040 | }
1041 |
1042 | try {
1043 | await fs.unlink(this.sessionFilePath);
1044 | } catch {
1045 | // File doesn't exist
1046 | }
1047 |
1048 | log.success("✅ Authentication state cleared");
1049 | return true;
1050 | } catch (error) {
1051 | log.error(`❌ Failed to clear state: ${error}`);
1052 | return false;
1053 | }
1054 | }
1055 |
1056 | /**
1057 | * HARD RESET: Completely delete ALL authentication state
1058 | */
1059 | async hardResetState(): Promise<boolean> {
1060 | try {
1061 | log.warning("🧹 Performing HARD RESET of all authentication state...");
1062 |
1063 | let deletedCount = 0;
1064 |
1065 | // Delete state file
1066 | try {
1067 | await fs.unlink(this.stateFilePath);
1068 | log.info(` 🗑️ Deleted: ${this.stateFilePath}`);
1069 | deletedCount++;
1070 | } catch {
1071 | // File doesn't exist
1072 | }
1073 |
1074 | // Delete session file
1075 | try {
1076 | await fs.unlink(this.sessionFilePath);
1077 | log.info(` 🗑️ Deleted: ${this.sessionFilePath}`);
1078 | deletedCount++;
1079 | } catch {
1080 | // File doesn't exist
1081 | }
1082 |
1083 | // Delete entire browser_state_dir
1084 | try {
1085 | const files = await fs.readdir(CONFIG.browserStateDir);
1086 | for (const file of files) {
1087 | await fs.unlink(path.join(CONFIG.browserStateDir, file));
1088 | deletedCount++;
1089 | }
1090 | log.info(` 🗑️ Deleted: ${CONFIG.browserStateDir}/ (${files.length} files)`);
1091 | } catch {
1092 | // Directory doesn't exist or empty
1093 | }
1094 |
1095 | if (deletedCount === 0) {
1096 | log.info(" ℹ️ No state to delete (already clean)");
1097 | } else {
1098 | log.success(`✅ Hard reset complete: ${deletedCount} items deleted`);
1099 | }
1100 |
1101 | return true;
1102 | } catch (error) {
1103 | log.error(`❌ Hard reset failed: ${error}`);
1104 | return false;
1105 | }
1106 | }
1107 | }
1108 |
```