Skip to main content
Glama
browser-session.tsβ€’24.1 kB
/** * Browser Session * * Represents a single browser session for NotebookLM interactions. * * Features: * - Human-like question typing * - Streaming response detection * - Auto-login on session expiry * - Session activity tracking * - Chat history reset * * Based on the Python implementation from browser_session.py */ import type { BrowserContext, Page } from 'patchright'; import { SharedContextManager } from './shared-context-manager.js'; import { AuthManager } from '../auth/auth-manager.js'; import { humanType, randomDelay } from '../utils/stealth-utils.js'; import { waitForLatestAnswer, snapshotAllResponses } from '../utils/page-utils.js'; import { extractCitations, type SourceFormat, type CitationExtractionResult, } from '../utils/citation-extractor.js'; import { CONFIG } from '../config.js'; import { log } from '../utils/logger.js'; import type { SessionInfo, ProgressCallback } from '../types.js'; import { RateLimitError } from '../errors.js'; /** * Result from asking a question (internal) */ export interface AskResult { /** The answer text (formatted if source_format specified) */ answer: string; /** Original unformatted answer */ originalAnswer: string; /** Citation extraction result (if source_format is not 'none') */ citationResult?: CitationExtractionResult; } export class BrowserSession { public readonly sessionId: string; public readonly notebookUrl: string; public readonly createdAt: number; public lastActivity: number; public messageCount: number; private context!: BrowserContext; private sharedContextManager: SharedContextManager; private authManager: AuthManager; private page: Page | null = null; private initialized: boolean = false; constructor( sessionId: string, sharedContextManager: SharedContextManager, authManager: AuthManager, notebookUrl: string ) { this.sessionId = sessionId; this.sharedContextManager = sharedContextManager; this.authManager = authManager; this.notebookUrl = notebookUrl; this.createdAt = Date.now(); this.lastActivity = Date.now(); this.messageCount = 0; log.info(`πŸ†• BrowserSession ${sessionId} created`); } /** * Initialize the session by creating a page and navigating to the notebook */ async init(): Promise<void> { if (this.initialized) { log.warning(`⚠️ Session ${this.sessionId} already initialized`); return; } log.info(`πŸš€ Initializing session ${this.sessionId}...`); try { // Ensure a valid shared context this.context = await this.sharedContextManager.getOrCreateContext(); // Create new page (tab) in the shared context (with auto-recovery) try { this.page = await this.context.newPage(); } catch (e: unknown) { const msg = String(e instanceof Error ? e.message : e); if ( /has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg) ) { log.warning(' ♻️ Context was closed. Recreating and retrying newPage...'); this.context = await this.sharedContextManager.getOrCreateContext(); this.page = await this.context.newPage(); } else { throw e; } } log.success(` βœ… Created new page`); // Navigate to notebook log.info(` 🌐 Navigating to: ${this.notebookUrl}`); await this.page.goto(this.notebookUrl, { waitUntil: 'domcontentloaded', timeout: CONFIG.browserTimeout, }); // Wait for page to stabilize await randomDelay(2000, 3000); // Check if we need to login const isAuthenticated = await this.authManager.validateCookiesExpiry(this.context); if (!isAuthenticated) { log.warning(` πŸ”‘ Session ${this.sessionId} needs authentication`); const loginSuccess = await this.ensureAuthenticated(); if (!loginSuccess) { throw new Error('Failed to authenticate session'); } } else { log.success(` βœ… Session already authenticated`); } // CRITICAL: Restore sessionStorage from saved state // This is essential for maintaining Google session state! log.info(` πŸ”„ Restoring sessionStorage...`); const sessionData = await this.authManager.loadSessionStorage(); if (sessionData) { const entryCount = Object.keys(sessionData).length; if (entryCount > 0) { await this.restoreSessionStorage(sessionData, entryCount); } else { log.info(` ℹ️ SessionStorage empty (fresh session)`); } } else { log.info(` ℹ️ No saved sessionStorage found (fresh session)`); } // Wait for NotebookLM interface to load log.info(` ⏳ Waiting for NotebookLM interface...`); await this.waitForNotebookLMReady(); this.initialized = true; this.updateActivity(); log.success(`βœ… Session ${this.sessionId} initialized successfully`); } catch (error) { log.error(`❌ Failed to initialize session ${this.sessionId}: ${error}`); if (this.page) { await this.page.close(); this.page = null; } throw error; } } /** * Wait for NotebookLM interface to be ready * * IMPORTANT: Matches Python implementation EXACTLY! * - Uses SPECIFIC selectors (textarea.query-box-input) * - Checks ONLY for "visible" state (NOT disabled!) * - NO placeholder checks (let NotebookLM handle that!) * * Based on Python _wait_for_ready() from browser_session.py:104-113 */ private async waitForNotebookLMReady(): Promise<void> { if (!this.page) { throw new Error('Page not initialized'); } try { // PRIMARY: Exact Python selector - textarea.query-box-input log.info(' ⏳ Waiting for chat input (textarea.query-box-input)...'); await this.page.waitForSelector('textarea.query-box-input', { timeout: 10000, // Python uses 10s timeout state: 'visible', // ONLY check visibility (NO disabled check!) }); log.success(' βœ… Chat input ready!'); } catch { // FALLBACK: Python alternative selector try { log.info(' ⏳ Trying fallback selector (aria-label)...'); await this.page.waitForSelector('textarea[aria-label="Feld fΓΌr Anfragen"]', { timeout: 5000, // Python uses 5s for fallback state: 'visible', }); log.success(' βœ… Chat input ready (fallback)!'); } catch (error) { log.error(` ❌ NotebookLM interface not ready: ${error}`); const currentUrl = this.page?.url() || 'unknown'; throw new Error( `Could not find NotebookLM chat input.\n\n` + `Current URL: ${currentUrl}\n\n` + `Possible causes:\n` + `1. Invalid notebook URL - the notebook may not exist or you don't have access\n` + `2. NotebookLM page structure changed (rare)\n` + `3. Page took too long to load (timeout after 15 seconds)\n\n` + `Please verify:\n` + `- The notebook URL is correct\n` + `- You have access to this notebook\n` + `- The URL format: https://notebooklm.google.com/notebook/[id]` ); } } } private isPageClosedSafe(): boolean { if (!this.page) return true; const p = this.page as { isClosed?: () => boolean }; try { if (typeof p.isClosed === 'function') { if (p.isClosed()) return true; } // Accessing URL should be safe; if page is gone, this may throw void this.page.url(); return false; } catch { return true; } } /** * Ensure the session is authenticated, perform auto-login if needed */ private async ensureAuthenticated(): Promise<boolean> { if (!this.page) { throw new Error('Page not initialized'); } log.info(`πŸ”‘ Checking authentication for session ${this.sessionId}...`); // Check cookie validity const isValid = await this.authManager.validateCookiesExpiry(this.context); if (isValid) { log.success(` βœ… Cookies valid`); return true; } log.warning(` ⚠️ Cookies expired or invalid`); // Try to get valid auth state const statePath = await this.authManager.getValidStatePath(); if (statePath) { // Load saved state log.info(` πŸ“‚ Loading auth state from: ${statePath}`); await this.authManager.loadAuthState(this.context, statePath); // Reload page to apply new auth log.info(` πŸ”„ Reloading page...`); await (this.page as Page).reload({ waitUntil: 'domcontentloaded' }); await randomDelay(2000, 3000); // Check if it worked const nowValid = await this.authManager.validateCookiesExpiry(this.context); if (nowValid) { log.success(` βœ… Auth state loaded successfully`); return true; } } // Need fresh login log.warning(` πŸ”‘ Fresh login required`); if (CONFIG.autoLoginEnabled) { log.info(` πŸ€– Attempting auto-login...`); const loginSuccess = await this.authManager.loginWithCredentials( this.context, this.page, CONFIG.loginEmail, CONFIG.loginPassword ); if (loginSuccess) { log.success(` βœ… Auto-login successful`); // Navigate back to notebook await this.page.goto(this.notebookUrl, { waitUntil: 'domcontentloaded', }); await randomDelay(2000, 3000); return true; } else { log.error(` ❌ Auto-login failed`); return false; } } else { log.error(` ❌ Auto-login disabled and no valid auth state - manual login required`); return false; } } private getOriginFromUrl(url: string): string | null { try { return new URL(url).origin; } catch { return null; } } /** * Safely restore sessionStorage when the page is on the expected origin */ private async restoreSessionStorage( sessionData: Record<string, string>, entryCount: number ): Promise<void> { if (!this.page) { log.warning(` ⚠️ Cannot restore sessionStorage without an active page`); return; } const targetOrigin = this.getOriginFromUrl(this.notebookUrl); if (!targetOrigin) { log.warning(` ⚠️ Unable to determine target origin for sessionStorage restore`); return; } let restored = false; const applyToPage = async (): Promise<boolean> => { if (!this.page) { return false; } const currentOrigin = this.getOriginFromUrl(this.page.url()); if (currentOrigin !== targetOrigin) { return false; } try { await this.page.evaluate((data) => { for (const [key, value] of Object.entries(data)) { // @ts-expect-error - sessionStorage exists in browser context sessionStorage.setItem(key, value); } }, sessionData); restored = true; log.success(` βœ… SessionStorage restored: ${entryCount} entries`); return true; } catch (error) { log.warning(` ⚠️ Failed to restore sessionStorage: ${error}`); return false; } }; if (await applyToPage()) { return; } log.info(` ⏳ Waiting for NotebookLM origin before restoring sessionStorage...`); const handleNavigation = async () => { if (restored) { return; } if (await applyToPage()) { this.page?.off('framenavigated', handleNavigation); } }; this.page.on('framenavigated', handleNavigation); } /** * Ask a question to NotebookLM * * @param question The question to ask * @param sendProgress Progress callback for status updates * @param sourceFormat Optional format for source citation extraction * @returns AskResult with answer and optional citation data */ async ask( question: string, sendProgress?: ProgressCallback, sourceFormat: SourceFormat = 'none' ): Promise<AskResult> { const askOnce = async (): Promise<AskResult> => { if (!this.initialized || !this.page || this.isPageClosedSafe()) { log.warning(` ℹ️ Session not initialized or page missing β†’ re-initializing...`); await this.init(); } log.info(`πŸ’¬ [${this.sessionId}] Asking: "${question.substring(0, 100)}..."`); const page = this.page!; // Ensure we're still authenticated await sendProgress?.('Verifying authentication...', 2, 5); const isAuth = await this.authManager.validateCookiesExpiry(this.context); if (!isAuth) { log.warning(` πŸ”‘ Session expired, re-authenticating...`); await sendProgress?.('Re-authenticating session...', 2, 5); const reAuthSuccess = await this.ensureAuthenticated(); if (!reAuthSuccess) { throw new Error('Failed to re-authenticate session'); } } // Snapshot existing responses BEFORE asking log.info(` πŸ“Έ Snapshotting existing responses...`); const existingResponses = await snapshotAllResponses(page); log.success(` βœ… Captured ${existingResponses.length} existing responses`); // Find the chat input const inputSelector = await this.findChatInput(); if (!inputSelector) { throw new Error( 'Could not find visible chat input element. ' + 'Please check if the notebook page has loaded correctly.' ); } log.info(` ⌨️ Typing question with human-like behavior...`); await sendProgress?.('Typing question with human-like behavior...', 2, 5); await humanType(page, inputSelector, question, { withTypos: true, wpm: Math.max(CONFIG.typingWpmMin, CONFIG.typingWpmMax), }); // Small pause before submitting await randomDelay(500, 1000); // Submit the question (Enter key) log.info(` πŸ“€ Submitting question...`); await sendProgress?.('Submitting question...', 3, 5); await page.keyboard.press('Enter'); // Small pause after submit await randomDelay(1000, 1500); // Wait for the response with streaming detection log.info(` ⏳ Waiting for response (with streaming detection)...`); await sendProgress?.('Waiting for NotebookLM response (streaming detection active)...', 3, 5); const answer = await waitForLatestAnswer(page, { question, timeoutMs: 120000, // 2 minutes pollIntervalMs: 1000, ignoreTexts: existingResponses, debug: true, // Enable debug to see exact text }); if (!answer) { throw new Error('Timeout waiting for response from NotebookLM'); } // Check for rate limit errors AFTER receiving answer log.info(` πŸ” Checking for rate limit errors...`); if (await this.detectRateLimitError()) { throw new RateLimitError( 'NotebookLM rate limit reached (50 queries/day for free accounts)' ); } // Update session stats this.messageCount++; this.updateActivity(); log.success( `βœ… [${this.sessionId}] Received answer (${answer.length} chars, ${this.messageCount} total messages)` ); // Extract citations if requested (no additional API calls - just DOM interaction) let citationResult: CitationExtractionResult | undefined; if (sourceFormat !== 'none') { await sendProgress?.('Extracting source citations...', 4, 5); // Find the response container for citation extraction const responseContainer = await page.$( '.to-user-container:last-child .message-text-content' ); citationResult = await extractCitations(page, answer, responseContainer, sourceFormat); if (citationResult.success && citationResult.citations.length > 0) { log.success(` πŸ“š Extracted ${citationResult.citations.length} source citations`); } } // Return result with optional citation data const result: AskResult = { answer: citationResult?.formattedAnswer || answer, originalAnswer: answer, citationResult: sourceFormat !== 'none' ? citationResult : undefined, }; return result; }; try { return await askOnce(); } catch (error: unknown) { const msg = String(error instanceof Error ? error.message : error); if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) { log.warning(` ♻️ Detected closed page/context. Recovering session and retrying ask...`); try { this.initialized = false; if (this.page) { try { await this.page.close(); } catch { /* Ignore errors during cleanup */ } } this.page = null; await this.init(); return await askOnce(); } catch (e2) { log.error(`❌ Recovery failed: ${e2}`); throw e2; } } log.error(`❌ [${this.sessionId}] Failed to ask question: ${msg}`); throw error; } } /** * Find the chat input element * * IMPORTANT: Matches Python implementation EXACTLY! * - Uses SPECIFIC selectors from Python * - Checks ONLY visibility (NOT disabled state!) * * Based on Python ask() method from browser_session.py:166-171 */ private async findChatInput(): Promise<string | null> { if (!this.page) { return null; } // Use EXACT Python selectors (in order of preference) const selectors = [ 'textarea.query-box-input', // ← PRIMARY Python selector 'textarea[aria-label="Feld fΓΌr Anfragen"]', // ← Python fallback ]; for (const selector of selectors) { try { const element = await this.page.$(selector); if (element) { const isVisible = await element.isVisible(); if (isVisible) { // NO disabled check! Just like Python! log.success(` βœ… Found chat input: ${selector}`); return selector; } } } catch { continue; } } log.error(` ❌ Could not find visible chat input`); return null; } /** * Detect if a rate limit error occurred * * Searches the page for error messages indicating rate limit/quota exhaustion. * Free NotebookLM accounts have 50 queries/day limit. * * @returns true if rate limit error detected, false otherwise */ private async detectRateLimitError(): Promise<boolean> { if (!this.page) { return false; } // Error message selectors (common patterns for error containers) const errorSelectors = [ '.error-message', '.error-container', "[role='alert']", '.rate-limit-message', '[data-error]', '.notification-error', '.alert-error', '.toast-error', ]; // Keywords that indicate rate limiting const keywords = [ 'rate limit', 'limit exceeded', 'quota exhausted', 'daily limit', 'limit reached', 'too many requests', 'ratenlimit', 'quota', 'query limit', 'request limit', ]; // Check error containers for rate limit messages for (const selector of errorSelectors) { try { const elements = await this.page.$$(selector); for (const el of elements) { try { const text = await el.innerText(); const lower = text.toLowerCase(); if (keywords.some((k) => lower.includes(k))) { log.error(`🚫 Rate limit detected: ${text.slice(0, 100)}`); return true; } } catch { continue; } } } catch { continue; } } // Also check if chat input is disabled (sometimes NotebookLM disables input when rate limited) try { const inputSelector = 'textarea.query-box-input'; const input = await this.page.$(inputSelector); if (input) { const isDisabled = await input.evaluate((el) => { return (el as { disabled?: boolean }).disabled || el.hasAttribute('disabled'); }); if (isDisabled) { // Check if there's an error message near the input const parent = await input.evaluateHandle((el) => el.parentElement); const parentEl = parent.asElement(); if (parentEl) { try { const parentText = await parentEl.innerText(); const lower = parentText.toLowerCase(); if (keywords.some((k) => lower.includes(k))) { log.error(`🚫 Rate limit detected: Chat input disabled with error message`); return true; } } catch { // Ignore } } } } } catch { // Ignore errors checking input state } return false; } /** * Reset the chat history (start a new conversation) */ async reset(): Promise<void> { const resetOnce = async (): Promise<void> => { if (!this.initialized || !this.page || this.isPageClosedSafe()) { await this.init(); } log.info(`πŸ”„ [${this.sessionId}] Resetting chat history...`); // Reload the page to clear chat history await (this.page as Page).reload({ waitUntil: 'domcontentloaded' }); await randomDelay(2000, 3000); // Wait for interface to be ready again await this.waitForNotebookLMReady(); // Reset message count this.messageCount = 0; this.updateActivity(); log.success(`βœ… [${this.sessionId}] Chat history reset`); }; try { await resetOnce(); } catch (error: unknown) { const msg = String(error instanceof Error ? error.message : error); if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) { log.warning(` ♻️ Detected closed page/context during reset. Recovering and retrying...`); this.initialized = false; if (this.page) { try { await this.page.close(); } catch { /* Ignore errors during cleanup */ } } this.page = null; await this.init(); await resetOnce(); return; } log.error(`❌ [${this.sessionId}] Failed to reset: ${msg}`); throw error; } } /** * Close the session */ async close(): Promise<void> { log.info(`πŸ›‘ Closing session ${this.sessionId}...`); if (this.page) { try { await this.page.close(); this.page = null; log.success(` βœ… Page closed`); } catch (error) { log.warning(` ⚠️ Error closing page: ${error}`); } } this.initialized = false; log.success(`βœ… Session ${this.sessionId} closed`); } /** * Update last activity timestamp */ updateActivity(): void { this.lastActivity = Date.now(); } /** * Check if session has expired (inactive for too long) */ isExpired(timeoutSeconds: number): boolean { const inactiveSeconds = (Date.now() - this.lastActivity) / 1000; return inactiveSeconds > timeoutSeconds; } /** * Get session information */ getInfo(): SessionInfo { const now = Date.now(); return { id: this.sessionId, created_at: this.createdAt, last_activity: this.lastActivity, age_seconds: (now - this.createdAt) / 1000, inactive_seconds: (now - this.lastActivity) / 1000, message_count: this.messageCount, notebook_url: this.notebookUrl, }; } /** * Get the underlying page (for advanced operations) */ getPage(): Page | null { return this.page; } /** * Check if session is initialized */ isInitialized(): boolean { return this.initialized && this.page !== null; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/roomi-fields/notebooklm-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server