Skip to content

Commit 3feb7a6

Browse files
authored
Merge pull request #245 from teng-lin/worktree-issue-233
feat: add rookiepy browser cookie login (--browser-cookies)
2 parents e275f8f + ca7e39f commit 3feb7a6

5 files changed

Lines changed: 518 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Issues = "https://github.com/teng-lin/notebooklm-py/issues"
3535

3636
[project.optional-dependencies]
3737
browser = ["playwright>=1.40.0"]
38+
cookies = ["rookiepy>=0.1.0"]
3839
dev = [
3940
"pytest>=8.0.0",
4041
"pytest-asyncio>=0.23.0",

src/notebooklm/auth.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,57 @@ def _is_allowed_auth_domain(domain: str) -> bool:
254254
return domain in ALLOWED_COOKIE_DOMAINS or _is_google_domain(domain)
255255

256256

257+
def convert_rookiepy_cookies_to_storage_state(
258+
rookiepy_cookies: list[dict],
259+
) -> dict[str, Any]:
260+
"""Convert rookiepy cookie dicts to Playwright storage_state.json format.
261+
262+
Key mappings:
263+
- ``http_only`` → ``httpOnly`` (snake_case to camelCase)
264+
- ``expires=None`` → ``expires=-1`` (Playwright convention for session cookies)
265+
- ``sameSite`` always ``"None"`` for cross-site Google cookies
266+
267+
Args:
268+
rookiepy_cookies: List of cookie dicts from any ``rookiepy.*()`` call.
269+
Required keys: ``domain``, ``name``, ``value``.
270+
271+
Returns:
272+
Dict matching storage_state.json schema: ``{"cookies": [...], "origins": []}``.
273+
Cookies missing required fields or from non-Google domains are silently skipped.
274+
"""
275+
converted = []
276+
for cookie in rookiepy_cookies:
277+
domain = cookie.get("domain", "")
278+
name = cookie.get("name", "")
279+
value = cookie.get("value", "")
280+
281+
# Validate required fields
282+
if not name or not value or not domain:
283+
continue
284+
285+
if not _is_allowed_auth_domain(domain):
286+
continue
287+
288+
path = cookie.get("path", "/")
289+
http_only = cookie.get("http_only", False)
290+
secure = cookie.get("secure", False)
291+
expires = cookie.get("expires")
292+
293+
converted.append(
294+
{
295+
"name": name,
296+
"value": value,
297+
"domain": domain,
298+
"path": path,
299+
"expires": expires if expires is not None else -1,
300+
"httpOnly": http_only,
301+
"secure": secure,
302+
"sameSite": "None",
303+
}
304+
)
305+
return {"cookies": converted, "origins": []}
306+
307+
257308
def extract_cookies_from_storage(storage_state: dict[str, Any]) -> dict[str, str]:
258309
"""Extract Google cookies from Playwright storage state for NotebookLM auth.
259310

src/notebooklm/cli/session.py

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,17 @@
2020
from typing import Any
2121

2222
import click
23+
import httpx
2324
from rich.table import Table
2425

25-
from ..auth import AuthTokens
26+
from ..auth import (
27+
ALLOWED_COOKIE_DOMAINS,
28+
GOOGLE_REGIONAL_CCTLDS,
29+
AuthTokens,
30+
convert_rookiepy_cookies_to_storage_state,
31+
extract_cookies_from_storage,
32+
fetch_tokens,
33+
)
2634
from ..client import NotebookLMClient
2735
from ..paths import (
2836
get_browser_profile_dir,
@@ -65,6 +73,170 @@
6573
" 4. Check if notebooklm.google.com is accessible in your browser"
6674
)
6775

76+
# Maps user-facing browser names to rookiepy function names.
77+
_ROOKIEPY_BROWSER_ALIASES: dict[str, str] = {
78+
"arc": "arc",
79+
"brave": "brave",
80+
"chrome": "chrome",
81+
"chromium": "chromium",
82+
"edge": "edge",
83+
"firefox": "firefox",
84+
"ie": "ie",
85+
"librewolf": "librewolf",
86+
"octo": "octo",
87+
"opera": "opera",
88+
"opera-gx": "opera_gx",
89+
"opera_gx": "opera_gx",
90+
"safari": "safari",
91+
"vivaldi": "vivaldi",
92+
"zen": "zen",
93+
}
94+
95+
96+
def _handle_rookiepy_error(e: Exception, browser_name: str) -> None:
97+
"""Print a user-friendly error for rookiepy exceptions."""
98+
msg = str(e).lower()
99+
if "lock" in msg or "database" in msg:
100+
console.print(
101+
f"[red]Could not read {browser_name} cookies: browser database is locked.[/red]\n"
102+
"Close your browser and try again."
103+
)
104+
elif "permission" in msg or "access" in msg:
105+
console.print(
106+
f"[red]Permission denied reading {browser_name} cookies.[/red]\n"
107+
"You may need to grant Terminal/Python access to your browser profile directory."
108+
)
109+
elif "keychain" in msg or "decrypt" in msg:
110+
console.print(
111+
f"[red]Could not decrypt {browser_name} cookies.[/red]\n"
112+
"On macOS, allow Keychain access when prompted, or try a different browser."
113+
)
114+
else:
115+
console.print(f"[red]Failed to read cookies from {browser_name}:[/red] {e}")
116+
117+
118+
def _login_with_browser_cookies(storage_path: Path, browser_name: str) -> None:
119+
"""Extract Google cookies from an installed browser via rookiepy.
120+
121+
Args:
122+
storage_path: Where to write storage_state.json.
123+
browser_name: "auto" to use rookiepy.load(), or a specific browser name.
124+
"""
125+
try:
126+
import rookiepy
127+
except ImportError:
128+
console.print(
129+
"[red]rookiepy is not installed.[/red]\n"
130+
"Install it with:\n"
131+
" pip install 'notebooklm-py[cookies]'\n"
132+
"or directly:\n"
133+
" pip install rookiepy"
134+
)
135+
raise SystemExit(1) from None
136+
137+
# Build domains list including base and regional Google domains for rookiepy
138+
domains = list(ALLOWED_COOKIE_DOMAINS)
139+
# Add regional Google auth domains (e.g., .google.co.uk, .google.com.sg)
140+
for cctld in GOOGLE_REGIONAL_CCTLDS:
141+
domain = f".google.{cctld}"
142+
if domain not in domains:
143+
domains.append(domain)
144+
145+
if browser_name == "auto":
146+
console.print("[yellow]Reading cookies from installed browser (auto-detect)...[/yellow]")
147+
try:
148+
raw_cookies = rookiepy.load(domains=domains)
149+
except (OSError, RuntimeError) as e:
150+
# OSError: file access issues (locked DB, permission denied)
151+
# RuntimeError: decryption/keychain errors
152+
_handle_rookiepy_error(e, "auto-detect")
153+
raise SystemExit(1) from None
154+
else:
155+
canonical = _ROOKIEPY_BROWSER_ALIASES.get(browser_name.lower())
156+
if canonical is None:
157+
console.print(
158+
f"[red]Unknown browser: '{browser_name}'[/red]\n"
159+
f"Supported: {', '.join(sorted(_ROOKIEPY_BROWSER_ALIASES))}"
160+
)
161+
raise SystemExit(1)
162+
console.print(f"[yellow]Reading cookies from {browser_name}...[/yellow]")
163+
browser_fn = getattr(rookiepy, canonical, None)
164+
if browser_fn is None or not callable(browser_fn):
165+
console.print(
166+
f"[red]rookiepy does not support '{canonical}' on this platform.[/red]\n"
167+
"Check that rookiepy is properly installed: pip install rookiepy"
168+
)
169+
raise SystemExit(1)
170+
try:
171+
raw_cookies = browser_fn(domains=domains)
172+
except (OSError, RuntimeError) as e:
173+
# OSError: file access issues (locked DB, permission denied)
174+
# RuntimeError: decryption/keychain errors
175+
_handle_rookiepy_error(e, browser_name)
176+
raise SystemExit(1) from None
177+
178+
storage_state = convert_rookiepy_cookies_to_storage_state(raw_cookies)
179+
try:
180+
cookies = extract_cookies_from_storage(storage_state) # validates SID is present
181+
except ValueError as e:
182+
console.print(
183+
"[red]No valid Google authentication cookies found.[/red]\n"
184+
f"{e}\n\n"
185+
"Make sure you are logged into Google in your browser."
186+
)
187+
raise SystemExit(1) from None
188+
189+
# Create parent directory (avoid mode= on Windows to prevent ACL issues)
190+
try:
191+
storage_path.parent.mkdir(parents=True, exist_ok=True)
192+
storage_path.write_text(
193+
json.dumps(storage_state, indent=2, ensure_ascii=False), encoding="utf-8"
194+
)
195+
if sys.platform != "win32":
196+
# On Unix: ensure both directory and file have restrictive permissions
197+
storage_path.parent.chmod(0o700)
198+
storage_path.chmod(0o600)
199+
except OSError as e:
200+
logger.error("Failed to save authentication to %s: %s", storage_path, e)
201+
console.print(
202+
f"[red]Failed to save authentication to {storage_path}.[/red]\n" f"Details: {e}"
203+
)
204+
raise SystemExit(1) from None
205+
206+
console.print(f"\n[green]Authentication saved to:[/green] {storage_path}")
207+
208+
# Verify that cookies work — reuse cookies extracted above (no redundant disk read)
209+
try:
210+
run_async(fetch_tokens(cookies))
211+
logger.info("Cookies verified successfully")
212+
console.print("[green]Cookies verified successfully.[/green]")
213+
except ValueError as e:
214+
# Cookie validation failed - the extracted cookies are invalid
215+
logger.error("Extracted cookies are invalid: %s", e)
216+
console.print(
217+
"[red]Warning: Extracted cookies failed validation.[/red]\n"
218+
"The cookies may be expired or malformed.\n"
219+
f"Error: {e}\n\n"
220+
"Saved anyway, but you may need to re-run login if these are invalid."
221+
)
222+
except httpx.RequestError as e:
223+
# Network error - can't verify but cookies might be OK
224+
logger.warning("Could not verify cookies due to network error: %s", e)
225+
console.print(
226+
"[yellow]Warning: Could not verify cookies (network issue).[/yellow]\n"
227+
"Cookies saved but may not be working.\n"
228+
"Try running 'notebooklm ask' to test authentication."
229+
)
230+
except Exception as e:
231+
# Unexpected error - log it fully
232+
logger.warning("Unexpected error verifying cookies: %s: %s", type(e).__name__, e)
233+
console.print(
234+
f"[yellow]Warning: Unexpected error during verification: {e}[/yellow]\n"
235+
"Cookies saved but please verify with 'notebooklm auth check --test'"
236+
)
237+
238+
_sync_server_language_to_config()
239+
68240

69241
def _sync_server_language_to_config() -> None:
70242
"""Fetch server language setting and persist to local config.
@@ -184,7 +356,19 @@ def register_session_commands(cli):
184356
default="chromium",
185357
help="Browser to use for login (default: chromium). Use 'msedge' for Microsoft Edge.",
186358
)
187-
def login(storage, browser):
359+
@click.option(
360+
"--browser-cookies",
361+
"browser_cookies",
362+
default=None,
363+
is_flag=False,
364+
flag_value="auto",
365+
help=(
366+
"Read cookies from an installed browser instead of launching Playwright. "
367+
"Optionally specify browser: chrome, firefox, brave, edge, safari, arc, ... "
368+
"Requires: pip install 'notebooklm[cookies]'"
369+
),
370+
)
371+
def login(storage, browser, browser_cookies):
188372
"""Log in to NotebookLM via browser.
189373
190374
Opens a browser window for Google login. After logging in,
@@ -207,6 +391,12 @@ def login(storage, browser):
207391
)
208392
raise SystemExit(1)
209393

394+
# rookiepy fast-path: skip Playwright entirely
395+
if browser_cookies is not None:
396+
resolved_storage = Path(storage) if storage else get_storage_path()
397+
_login_with_browser_cookies(resolved_storage, browser_cookies)
398+
return
399+
210400
storage_path = Path(storage) if storage else get_storage_path()
211401
browser_profile = get_browser_profile_dir()
212402
if sys.platform == "win32":

0 commit comments

Comments
 (0)