/**
* 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 { CONFIG } from "../config.js";
import {
CHAT_INPUT_SELECTORS,
CHAT_INPUT_PRIMARY,
ERROR_SELECTORS,
RATE_LIMIT_KEYWORDS,
} from "../selectors.js";
import { log } from "../utils/logger.js";
import type { SessionInfo, ProgressCallback } from "../types.js";
import { RateLimitError, isPageClosedError } from "../errors.js";
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) {
if (isPageClosedError(e)) {
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(CONFIG.navigationDelayMinMs, CONFIG.navigationDelayMaxMs);
// 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: Main chat input selector
log.info(` âŗ Waiting for chat input (${CHAT_INPUT_SELECTORS[0]})...`);
await this.page.waitForSelector(CHAT_INPUT_SELECTORS[0], {
timeout: CONFIG.chatInputTimeoutMs,
state: "visible", // ONLY check visibility (NO disabled check!)
});
log.success(" â
Chat input ready!");
} catch {
// FALLBACK: Alternative selector
if (CHAT_INPUT_SELECTORS.length > 1) {
try {
log.info(" âŗ Trying fallback selector...");
await this.page.waitForSelector(CHAT_INPUT_SELECTORS[1], {
timeout: CONFIG.chatInputFallbackTimeoutMs,
state: "visible",
});
log.success(" â
Chat input ready (fallback)!");
} catch (error) {
log.error(` â NotebookLM interface not ready: ${error}`);
throw new Error(
"Could not find NotebookLM chat input. " +
"Please ensure the notebook page has loaded correctly."
);
}
} else {
throw new Error(
"Could not find NotebookLM chat input. " +
"Please ensure the notebook page has loaded correctly."
);
}
}
}
private isPageClosedSafe(): boolean {
if (!this.page) return true;
const p: any = this.page as any;
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(CONFIG.navigationDelayMinMs, CONFIG.navigationDelayMaxMs);
// 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(CONFIG.navigationDelayMinMs, CONFIG.navigationDelayMaxMs);
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: Record<string, string>) => {
for (const [key, value] of Object.entries(data)) {
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
*/
async ask(question: string, sendProgress?: ProgressCallback): Promise<string> {
const askOnce = async (): Promise<string> => {
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(CONFIG.preSubmitDelayMinMs, CONFIG.preSubmitDelayMaxMs);
// 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(CONFIG.postSubmitDelayMinMs, CONFIG.postSubmitDelayMaxMs);
// 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: CONFIG.responseTimeoutMs,
pollIntervalMs: 1000,
ignoreTexts: existingResponses,
debug: false,
});
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)`
);
return answer;
};
try {
return await askOnce();
} catch (error: unknown) {
if (isPageClosedError(error)) {
log.warning(` âģī¸ Detected closed page/context. Recovering session and retrying ask...`);
try {
this.initialized = false;
if (this.page) { try { await this.page.close(); } catch {} }
this.page = null;
await this.init();
return await askOnce();
} catch (e2) {
log.error(`â Recovery failed: ${e2}`);
throw e2;
}
}
const msg = String((error as Error)?.message || error);
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;
}
for (const selector of CHAT_INPUT_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;
}
// Check error containers for rate limit messages
for (const selector of ERROR_SELECTORS) {
try {
const elements = await this.page.$$(selector);
for (const el of elements) {
try {
const text = await el.innerText();
const lower = text.toLowerCase();
if (RATE_LIMIT_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 input = await this.page.$(CHAT_INPUT_PRIMARY);
if (input) {
const isDisabled = await input.evaluate((el: any) => {
return el.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 (RATE_LIMIT_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(CONFIG.navigationDelayMinMs, CONFIG.navigationDelayMaxMs);
// 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) {
if (isPageClosedError(error)) {
log.warning(` âģī¸ Detected closed page/context during reset. Recovering and retrying...`);
this.initialized = false;
if (this.page) { try { await this.page.close(); } catch {} }
this.page = null;
await this.init();
await resetOnce();
return;
}
const msg = String((error as Error)?.message || error);
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;
}
}