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