
Notebooklm
- 3 installs
- 3 repo stars
- Updated March 4, 2026
- sanjay3290/notebooklm-skill
This is a copy of notebooklm by sanjay3290 - installs and ranking accrue to the original listing.
Helps with productivity & planning tasks.
About
notebooklm is a Claude Code skill for productivity & planning. It helps solo builders move faster with AI-assisted development.
- notebooklm
- Productivity & Planning
- AI-coding skill
Notebooklm by the numbers
- 3 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/notebooklm-skill --skill notebooklmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 3 |
| Last updated | March 4, 2026 |
| Repository | sanjay3290/notebooklm-skill ↗ |
What it does
Helps with productivity & planning tasks.
Files
NotebookLM Skill
Query NotebookLM notebooks and manage notebooks/sources via Playwright browser automation.
All commands run from the skill directory. All scripts output JSON to stdout and exit 1 on error. Use --help on any script for full flag reference.
Workflow
1. Authenticate: python scripts/auth_manager.py setup --profile <name> 2. Register notebook: python scripts/notebook_manager.py add --url <url> --name <name> --description <desc> --topics <topics> 3. Ask questions: python scripts/ask_question.py --question "..." --notebook-id <id> 4. Manage sources: python scripts/remote_manager.py add-source|sync-sources ...
Key Behaviors
- Runs headless by default; use
--show-browserfor debugging only. - Persistent Chrome profiles stored at
~/.config/claude/notebooklm-skill/(override withNOTEBOOKLM_DATA_DIR). - Hash-based dedupe: file uploads skip unchanged sources automatically.
--dry-runavailable on all destructive/bulk operations (create, add-source, delete-source, sync-sources).--retries Nretries transient browser failures with screenshot/HTML artifact capture.- Batch mode (
--questions "q1||q2||q3") and multi-notebook comparison (--compare-notebook-ids) supported. - Exports to JSON or Markdown via
--export-format markdown --save-notes. - Answers include a follow-up reminder prompting Claude to ask clarifying questions before replying.
Quick Reference
# Auth
python scripts/auth_manager.py setup --profile work
python scripts/auth_manager.py status --profile work
# Library
python scripts/notebook_manager.py add --url "..." --name "..." --description "..." --topics "..."
python scripts/notebook_manager.py list
# Ask
python scripts/ask_question.py --question "..." --notebook-id <id>
python scripts/ask_question.py --questions "q1||q2" --notebook-id <id>
# Sources
python scripts/remote_manager.py add-source --notebook-id <id> --dir ./docs --recursive
python scripts/remote_manager.py sync-sources --notebook-id <id> --dir ./docs --recursive --delete-missing --dry-runFor full command reference with all flags and examples, see references/commands.md.
{
"name": "notebooklm-skill",
"owner": { "name": "Sanjay" },
"metadata": {
"description": "NotebookLM skill for AI coding assistants — query, manage, and sync notebooks",
"version": "1.0.0",
"license": "Apache-2.0"
},
"plugins": [
{
"name": "notebooklm",
"description": "Query and manage Google NotebookLM notebooks with persistent profile auth, source sync, batch/multi queries, and structured exports.",
"source": "./",
"skills": ["./"]
}
]
}
__pycache__/
.pytest_cache/
*.pyc
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
appropriate open-source license header file from
https://www.apache.org/legal/apply-license.html
Copyright 2026 Sanjay
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
NotebookLM Skill
Query and manage Google NotebookLM notebooks from your AI coding assistant.
Supported AI Clients
<p align="center"> <a href="#claude-code"><img src="https://img.shields.io/badge/Claude_Code-D97757?style=for-the-badge&logo=anthropic&logoColor=white" alt="Claude Code" /></a> <a href="#gemini-cli"><img src="https://img.shields.io/badge/Gemini_CLI-8E75B2?style=for-the-badge&logo=google&logoColor=white" alt="Gemini CLI" /></a> <a href="#google-antigravity"><img src="https://img.shields.io/badge/Antigravity-4285F4?style=for-the-badge&logo=google&logoColor=white" alt="Google Antigravity" /></a> <a href="#cursor"><img src="https://img.shields.io/badge/Cursor-000000?style=for-the-badge&logo=cursor&logoColor=white" alt="Cursor" /></a> <a href="#openai-codex-cli"><img src="https://img.shields.io/badge/OpenAI_Codex-412991?style=for-the-badge&logo=openai&logoColor=white" alt="OpenAI Codex" /></a> <a href="#goose"><img src="https://img.shields.io/badge/Goose-FF6B35?style=for-the-badge&logo=go&logoColor=white" alt="Goose" /></a> </p>
Features
- Query notebooks with natural language questions
- Batch questions (
q1||q2||q3) and question files - Multi-notebook comparison across notebooks
- Manage notebook library (add, list, search, activate)
- Remote operations: create notebooks, add/delete/sync sources
- Source sync with hash-based deduplication
- Directory uploads with filters (extensions, size, modified date)
- Dry-run mode for all destructive operations
- Multiple auth profiles (work, personal, etc.)
- Exports to JSON or Markdown
- Headless Playwright automation (
--show-browserfor debug)
Quick Install
Claude Code
/plugin marketplace add sanjay3290/notebooklm-skillGemini CLI / Cursor / Codex / Goose
npx skills add sanjay3290/notebooklm-skillSetup
1. Install dependencies
pip install -r requirements.txt
python -m playwright install chromium2. Authenticate with a profile
python scripts/auth_manager.py setup --profile workThis opens a browser window for Google sign-in. Your session is stored persistently at ~/.config/claude/notebooklm-skill/ (override with NOTEBOOKLM_DATA_DIR).
3. Register a notebook
python scripts/notebook_manager.py add \
--url "https://notebooklm.google.com/notebook/..." \
--name "My Notebook" \
--description "What this notebook contains" \
--topics "topic1,topic2"Usage Examples
# Ask a single question against the active notebook
python scripts/ask_question.py --question "What are the key implementation details?"
# Ask by notebook ID or URL
python scripts/ask_question.py --question "Summarize auth flow" --notebook-id notebook-id
python scripts/ask_question.py --question "What is covered here?" --notebook-url "https://notebooklm.google.com/notebook/..."
# Batch questions (pipe-separated)
python scripts/ask_question.py --questions "q1||q2||q3" --notebook-id notebook-id
# Questions from a file (one per line)
python scripts/ask_question.py --questions-file ./questions.txt --notebook-id notebook-id
# Compare one question across multiple notebooks
python scripts/ask_question.py --question "What changed this week?" --compare-notebook-ids "notebook-a,notebook-b"
# Export answer as Markdown
python scripts/ask_question.py --question "Summarize" --notebook-id notebook-id --export-format markdown --save-notes
# Upload a directory of sources with filters
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir ./docs --recursive --include-ext "md,txt" --max-size "10MB"
# Sync local files to notebook (add new, optionally delete removed)
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --dir ./docs --recursive --delete-missing --dry-run
# List all notebooks in your library
python scripts/notebook_manager.py listFull Command Reference
Authentication
# Check auth status
python scripts/auth_manager.py status --profile default
# Initial setup (opens browser for Google sign-in)
python scripts/auth_manager.py setup --profile work
# Re-authenticate an expired session
python scripts/auth_manager.py reauth --profile work
# Clear a specific profile
python scripts/auth_manager.py clear --profile work
# Clear all profiles
python scripts/auth_manager.py clear --all-profiles
# List all profiles
python scripts/auth_manager.py profilesNotebook Library
# List all registered notebooks
python scripts/notebook_manager.py list
# Add a notebook to the library
python scripts/notebook_manager.py add \
--url "https://notebooklm.google.com/notebook/..." \
--name "Notebook Name" \
--description "What this notebook contains" \
--topics "topic1,topic2"
# Set active notebook
python scripts/notebook_manager.py activate --id notebook-id
# Search notebooks by keyword
python scripts/notebook_manager.py search --query "keyword"
# Remove a notebook from the library
python scripts/notebook_manager.py remove --id notebook-id
# Show library statistics
python scripts/notebook_manager.py statsAsk NotebookLM
# Single question (active notebook)
python scripts/ask_question.py --question "What are the key implementation details?"
# Ask by notebook ID or URL
python scripts/ask_question.py --question "Summarize auth flow" --notebook-id notebook-id
python scripts/ask_question.py --question "What is covered here?" --notebook-url "https://notebooklm.google.com/notebook/..."
# Batch questions (pipe-separated)
python scripts/ask_question.py --questions "q1||q2||q3" --notebook-id notebook-id
# Questions from a file
python scripts/ask_question.py --questions-file ./questions.txt --notebook-id notebook-id
# Compare one question across multiple notebooks
python scripts/ask_question.py --question "What changed this week?" --compare-notebook-ids "notebook-a,notebook-b"
# Save structured export
python scripts/ask_question.py --question "Summarize" --notebook-id notebook-id --export-format markdown --save-notesRemote NotebookLM Operations
# List all notebooks in your Google account
python scripts/remote_manager.py list-remote --profile work
# Create a new notebook remotely
python scripts/remote_manager.py create-remote --name "My Notebook" --description "Description" --topics "topic1,topic2"
python scripts/remote_manager.py create-remote --name "My Notebook" --skip-library
python scripts/remote_manager.py create-remote --name "My Notebook" --dry-run
# List sources in a notebook
python scripts/remote_manager.py list-sources --notebook-id notebook-id
# Add sources (text, URL, file, or directory)
python scripts/remote_manager.py add-source --notebook-id notebook-id --text "Some source text"
python scripts/remote_manager.py add-source --notebook-id notebook-id --url "https://example.com"
python scripts/remote_manager.py add-source --notebook-id notebook-id --file "/path/to/file.pdf"
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/folder" --recursive
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/folder" --include-ext "md,txt" --exclude "*.tmp" --max-size "10MB" --modified-since "7d"
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/folder" --copy-to-temp
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/folder" --dry-run
# Delete sources
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "source title"
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "temp" --contains --all-matches
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "temp" --contains --all-matches --dry-run
# Sync local directory to notebook (add new, optionally remove deleted)
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --dir "/path/to/folder" --recursive
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --dir "/path/to/folder" --recursive --delete-missing
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --manifest ./source-manifest.json --dry-runCommon Options
These flags work with ask_question.py and remote_manager.py:
--profile work # Named auth profile
--retries 3 # Retry transient browser failures
--artifacts-dir /tmp/x # Screenshot/HTML dump directory
--show-browser # Show browser window for debuggingPart of AI Skills Collection
This skill is also available in the ai-skills collection with 20+ other skills for databases, research, image generation, Google Workspace, and more.
License
Apache-2.0
NotebookLM Command Reference
Authentication
python scripts/auth_manager.py status --profile default
python scripts/auth_manager.py setup --profile work
python scripts/auth_manager.py reauth --profile work
python scripts/auth_manager.py clear --profile work
python scripts/auth_manager.py clear --all-profiles
python scripts/auth_manager.py profilesNotebook Library
python scripts/notebook_manager.py list
python scripts/notebook_manager.py add --url "https://notebooklm.google.com/notebook/..." --name "Notebook Name" --description "What this notebook contains" --topics "topic1,topic2"
python scripts/notebook_manager.py activate --id notebook-id
python scripts/notebook_manager.py search --query "keyword"
python scripts/notebook_manager.py remove --id notebook-id
python scripts/notebook_manager.py statsAsk NotebookLM
# Single ask (active notebook)
python scripts/ask_question.py --question "What are the key implementation details?"
# Ask by notebook ID / URL
python scripts/ask_question.py --question "Summarize auth flow" --notebook-id notebook-id
python scripts/ask_question.py --question "What is covered here?" --notebook-url "https://notebooklm.google.com/notebook/..."
# Batch ask
python scripts/ask_question.py --questions "q1||q2||q3" --notebook-id notebook-id
python scripts/ask_question.py --questions-file ./questions.txt --notebook-id notebook-id
# Compare one question across notebooks
python scripts/ask_question.py --question "What changed this week?" --compare-notebook-ids "notebook-a,notebook-b"
# Save structured export
python scripts/ask_question.py --question "Summarize" --notebook-id notebook-id --export-format markdown --save-notesRemote NotebookLM Operations
# List account notebooks
python scripts/remote_manager.py list-remote --profile work
# Create notebook remotely
python scripts/remote_manager.py create-remote --name "My Notebook" --description "What this notebook contains" --topics "topic1,topic2"
python scripts/remote_manager.py create-remote --name "My Notebook" --skip-library
python scripts/remote_manager.py create-remote --name "My Notebook" --dry-run
# List sources
python scripts/remote_manager.py list-sources --notebook-id notebook-id
# Add sources
python scripts/remote_manager.py add-source --notebook-id notebook-id --text "Some source text"
python scripts/remote_manager.py add-source --notebook-id notebook-id --url "https://example.com"
python scripts/remote_manager.py add-source --notebook-id notebook-id --file "/path/to/file.pdf"
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/source-folder" --recursive
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/source-folder" --include-ext "md,txt" --exclude "*.tmp" --max-size "10MB" --modified-since "7d"
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/source-folder" --copy-to-temp
python scripts/remote_manager.py add-source --notebook-id notebook-id --dir "/path/to/source-folder" --dry-run
# Delete sources
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "source title"
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "temp" --contains --all-matches
python scripts/remote_manager.py delete-source --notebook-id notebook-id --source-title "temp" --contains --all-matches --dry-run
# Sync local desired state to notebook
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --dir "/path/to/source-folder" --recursive
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --dir "/path/to/source-folder" --recursive --delete-missing
python scripts/remote_manager.py sync-sources --notebook-id notebook-id --manifest ./source-manifest.json --dry-runCommon Options
Use with ask_question.py and remote_manager.py:
--profile work # Named auth profile
--retries 3 # Retry transient browser failures
--artifacts-dir /tmp/x # Screenshot/HTML dump directory
--show-browser # Show browser window for debuggingplaywright>=1.49.0
#!/usr/bin/env python3
"""
Ask questions to NotebookLM notebooks via browser automation.
Supports single ask, batch ask, and multi-notebook comparison queries.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import Page, sync_playwright
from common import (
get_active_notebook,
get_artifacts_dir,
get_notes_dir,
get_notebook_by_id,
get_notebook_by_url,
is_valid_notebook_url,
launch_persistent_context,
load_library,
now_iso,
parse_csv_values,
record_notebook_use,
sanitize_profile_name,
)
FOLLOW_UP_REMINDER = (
"\n\nEXTREMELY IMPORTANT: Is that ALL you need to know? You can always ask another question. "
"Before replying to the user, check if anything is still unclear, and ask follow-up questions if needed."
)
CHAT_INPUT_SELECTORS = [
"textarea.query-box-input",
"textarea[aria-label*='Ask']",
"textarea[aria-label*='Anfrage']",
]
RATE_LIMIT_KEYWORDS = [
"rate limit",
"limit exceeded",
"quota exhausted",
"daily limit",
"too many requests",
]
PLACEHOLDER_PHRASES = [
"analyzing your files",
"analyzing your sources",
"thinking",
"loading",
"just a moment",
"working on it",
]
CITATION_SELECTORS = [
".citation-chip",
".source-chip",
".grounding-chip",
".source-link",
"a[href*='source']",
"a[href*='notebooklm']",
]
RETRYABLE_ERROR_KEYWORDS = [
"timeout",
"timed out",
"connection",
"target closed",
"context closed",
"execution context was destroyed",
"protocol error",
"page crashed",
"browser has been closed",
]
def _safe_name(value: str) -> str:
return re.sub(r"[^a-zA-Z0-9._-]+", "-", value).strip("-") or "item"
def _artifact_dir(args) -> Path:
raw = args.artifacts_dir
if raw:
return Path(raw).expanduser().resolve()
return get_artifacts_dir()
def _capture_debug_artifacts(page: Page, args, mode: str, attempt: int, error_message: str) -> Optional[Dict]:
try:
directory = _artifact_dir(args)
directory.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
prefix = f"{stamp}-{_safe_name(mode)}-attempt{attempt}"
screenshot_path = directory / f"{prefix}.png"
html_path = directory / f"{prefix}.html"
page.screenshot(path=str(screenshot_path), full_page=True)
html_path.write_text(page.content(), encoding="utf-8")
return {
"attempt": attempt,
"error": error_message,
"url": page.url,
"screenshot": str(screenshot_path),
"html": str(html_path),
}
except Exception: # noqa: BLE001
return None
def _is_retryable_error(message: str) -> bool:
lower = message.lower().strip()
if any(keyword in lower for keyword in RATE_LIMIT_KEYWORDS):
return False
return any(keyword in lower for keyword in RETRYABLE_ERROR_KEYWORDS)
def _is_transient_placeholder(text: str) -> bool:
lower = text.strip().lower()
if not lower:
return True
return any(phrase in lower for phrase in PLACEHOLDER_PHRASES)
def _is_still_thinking(page: Page) -> bool:
try:
thinking = page.query_selector("div.thinking-message")
if not thinking:
return False
return thinking.is_visible()
except PlaywrightError:
return False
def _find_chat_input(page: Page, timeout_ms: int = 30000) -> Optional[str]:
for selector in CHAT_INPUT_SELECTORS:
try:
page.wait_for_selector(selector, state="visible", timeout=timeout_ms)
return selector
except PlaywrightError:
continue
return None
def _collect_response_texts(page: Page) -> List[str]:
texts: List[str] = []
try:
containers = page.query_selector_all(".to-user-container")
for container in containers:
text_el = container.query_selector(".message-text-content")
if not text_el:
continue
text = text_el.inner_text().strip()
if text:
texts.append(text)
except PlaywrightError:
pass
if texts:
return texts
fallback_selectors = [
"[data-message-author='bot']",
"[data-message-author='assistant']",
"[data-testid*='response']",
]
for selector in fallback_selectors:
try:
for element in page.query_selector_all(selector):
text = element.inner_text().strip()
if text:
texts.append(text)
except PlaywrightError:
continue
if texts:
break
return texts
def _collect_citations(page: Page) -> List[str]:
seen: set[str] = set()
citations: List[str] = []
for selector in CITATION_SELECTORS:
try:
elements = page.query_selector_all(selector)
except PlaywrightError:
continue
for element in elements:
try:
text = element.inner_text().strip()
except PlaywrightError:
continue
if not text:
continue
if text in seen:
continue
seen.add(text)
citations.append(text)
return citations
def _detect_rate_limit(page: Page) -> bool:
try:
body = page.inner_text("body").lower()
return any(keyword in body for keyword in RATE_LIMIT_KEYWORDS)
except PlaywrightError:
return False
def _wait_for_new_answer(
page: Page,
question: str,
existing_texts: List[str],
timeout_sec: int,
) -> Optional[str]:
seen = {text.strip() for text in existing_texts if text.strip()}
normalized_question = question.strip().lower()
deadline = time.time() + timeout_sec
stable_value = None
stable_count = 0
required_stable_polls = 3
while time.time() < deadline:
if _is_still_thinking(page):
page.wait_for_timeout(1000)
continue
if _detect_rate_limit(page):
raise RuntimeError("NotebookLM rate limit reached. Try again later or re-authenticate.")
candidate = None
for text in _collect_response_texts(page):
clean = text.strip()
if not clean:
continue
if clean in seen:
continue
if clean.lower() == normalized_question:
continue
if _is_transient_placeholder(clean):
continue
candidate = clean
break
if candidate:
if candidate == stable_value:
stable_count += 1
else:
stable_value = candidate
stable_count = 1
if stable_count >= required_stable_polls:
return candidate
page.wait_for_timeout(1000)
return None
def _resolve_notebook(args, library: Dict) -> Dict:
notebook_id = None
notebook_url = args.notebook_url
if notebook_url:
if not is_valid_notebook_url(notebook_url):
return {"error": "Invalid --notebook-url format"}
notebook = get_notebook_by_url(library, notebook_url)
if notebook:
notebook_id = notebook.get("id")
elif args.notebook_id:
notebook = get_notebook_by_id(library, args.notebook_id)
if not notebook:
return {"error": f"Notebook not found in library: {args.notebook_id}"}
notebook_id = notebook.get("id")
notebook_url = notebook.get("url")
else:
active = get_active_notebook(library)
if not active:
return {
"error": (
"No notebook specified and no active notebook configured. "
"Use --notebook-url, --notebook-id, or activate a notebook first."
)
}
notebook_id = active.get("id")
notebook_url = active.get("url")
if not notebook_url:
return {"error": "Failed to resolve notebook URL"}
return {
"notebook_id": notebook_id,
"notebook_url": notebook_url,
}
def _resolve_compare_notebooks(args, library: Dict) -> Dict:
ids = parse_csv_values(args.compare_notebook_ids)
urls = parse_csv_values(args.compare_notebook_urls)
if not ids and not urls:
return {"targets": []}
targets: List[Dict] = []
seen_urls: set[str] = set()
for notebook_id in ids:
notebook = get_notebook_by_id(library, notebook_id)
if not notebook:
return {"error": f"Notebook not found in library: {notebook_id}"}
notebook_url = str(notebook.get("url", "")).strip()
if notebook_url in seen_urls:
continue
seen_urls.add(notebook_url)
targets.append({"notebook_id": notebook_id, "notebook_url": notebook_url})
for notebook_url in urls:
if not is_valid_notebook_url(notebook_url):
return {"error": f"Invalid notebook URL in --compare-notebook-urls: {notebook_url}"}
notebook = get_notebook_by_url(library, notebook_url)
notebook_id = notebook.get("id") if notebook else None
if notebook_url in seen_urls:
continue
seen_urls.add(notebook_url)
targets.append({"notebook_id": notebook_id, "notebook_url": notebook_url})
return {"targets": targets}
def _extract_questions(args) -> Tuple[List[str], Optional[str]]:
questions: List[str] = []
if args.question:
questions.append(args.question.strip())
if args.questions:
if "||" in args.questions:
questions.extend([part.strip() for part in args.questions.split("||") if part.strip()])
else:
questions.extend(parse_csv_values(args.questions))
if args.questions_file:
path = Path(args.questions_file).expanduser().resolve()
if not path.exists():
return [], f"Questions file not found: {path}"
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
questions.append(stripped)
cleaned = [q for q in (q.strip() for q in questions) if q]
if not cleaned:
return [], "Provide at least one question via --question, --questions, or --questions-file"
return cleaned, None
def _ask_once_on_page(
page: Page,
notebook_url: str,
question: str,
timeout: int,
input_timeout: int,
) -> Dict:
page.goto(notebook_url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(2000)
if "accounts.google.com" in page.url:
return {
"error": (
"NotebookLM redirected to Google login. "
"Run: python scripts/auth_manager.py setup"
)
}
input_selector = _find_chat_input(page, timeout_ms=input_timeout * 1000)
if not input_selector:
return {
"error": (
"Could not find NotebookLM chat input. "
"Notebook may still be loading or login may be required."
)
}
existing_texts = _collect_response_texts(page)
before_citations = set(_collect_citations(page))
page.click(input_selector)
page.fill(input_selector, question)
page.press(input_selector, "Enter")
answer = _wait_for_new_answer(
page=page,
question=question,
existing_texts=existing_texts,
timeout_sec=timeout,
)
if not answer:
return {"error": f"Timed out waiting for answer after {timeout}s"}
after_citations = _collect_citations(page)
new_citations = [citation for citation in after_citations if citation not in before_citations]
return {
"status": "success",
"question": question,
"answer": f"{answer.rstrip()}{FOLLOW_UP_REMINDER}",
"citations": new_citations,
"timestamp_utc": now_iso(),
}
def _ask_with_retries(
args,
notebook_url: str,
question: str,
mode: str,
) -> Dict:
attempts = max(1, int(args.retries or 1))
profile = sanitize_profile_name(args.profile)
errors: List[str] = []
artifacts: List[Dict] = []
for attempt in range(1, attempts + 1):
with sync_playwright() as p:
context = launch_persistent_context(
p,
headless=not args.show_browser,
profile=profile,
)
page = context.new_page()
try:
result = _ask_once_on_page(
page=page,
notebook_url=notebook_url,
question=question,
timeout=args.timeout,
input_timeout=args.input_timeout,
)
if result.get("error"):
error_message = str(result["error"])
if attempt < attempts and _is_retryable_error(error_message):
errors.append(error_message)
artifact = _capture_debug_artifacts(page, args, mode, attempt, error_message)
if artifact:
artifacts.append(artifact)
time.sleep(min(attempt * 1.5, 5.0))
continue
if errors:
result.setdefault("previous_errors", errors)
if artifacts:
result.setdefault("artifacts", artifacts)
if attempt > 1:
result.setdefault("attempts", attempt)
return result
if errors:
result["previous_errors"] = errors
if artifacts:
result["artifacts"] = artifacts
if attempt > 1:
result["attempts"] = attempt
return result
except Exception as exc: # noqa: BLE001
error_message = str(exc)
errors.append(error_message)
artifact = _capture_debug_artifacts(page, args, mode, attempt, error_message)
if artifact:
artifacts.append(artifact)
if attempt >= attempts:
result: Dict = {
"error": f"ask failed after {attempts} attempts: {error_message}",
"errors": errors,
"attempts": attempts,
}
if artifacts:
result["artifacts"] = artifacts
return result
time.sleep(min(attempt * 1.5, 5.0))
finally:
context.close()
return {"error": "ask failed unexpectedly"}
def _build_markdown_export(result: Dict) -> str:
mode = result.get("mode", "single")
lines = [
f"# NotebookLM Export ({mode})",
"",
f"Generated: {now_iso()}",
"",
]
if mode == "single":
lines.extend(
[
f"Notebook: {result.get('notebook_url')}",
f"Question: {result.get('question')}",
"",
"## Answer",
"",
result.get("answer", ""),
"",
]
)
citations = result.get("citations") or []
lines.append("## Citations")
if citations:
lines.extend([f"- {citation}" for citation in citations])
else:
lines.append("- (none detected)")
lines.append("")
return "\n".join(lines)
if mode == "batch":
lines.append(f"Notebook: {result.get('notebook_url')}")
lines.append("")
for idx, item in enumerate(result.get("results", []), start=1):
lines.append(f"## Q{idx}: {item.get('question')}")
lines.append("")
if item.get("error"):
lines.append(f"Error: {item['error']}")
lines.append("")
continue
lines.append(item.get("answer", ""))
lines.append("")
citations = item.get("citations") or []
lines.append("Citations:")
if citations:
lines.extend([f"- {citation}" for citation in citations])
else:
lines.append("- (none detected)")
lines.append("")
return "\n".join(lines)
lines.append(f"Question: {result.get('question')}")
lines.append("")
for item in result.get("responses", []):
lines.append(f"## Notebook: {item.get('notebook_url')}")
lines.append("")
if item.get("error"):
lines.append(f"Error: {item['error']}")
lines.append("")
continue
lines.append(item.get("answer", ""))
lines.append("")
citations = item.get("citations") or []
lines.append("Citations:")
if citations:
lines.extend([f"- {citation}" for citation in citations])
else:
lines.append("- (none detected)")
lines.append("")
return "\n".join(lines)
def _write_export_if_requested(result: Dict, args, mode: str) -> Optional[str]:
should_write = bool(args.export_file or args.save_notes)
if not should_write:
return None
export_format = args.export_format.lower()
if export_format not in {"json", "markdown"}:
raise ValueError("--export-format must be json or markdown")
if args.export_file:
out_path = Path(args.export_file).expanduser().resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
else:
extension = "json" if export_format == "json" else "md"
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out_path = get_notes_dir() / f"notebooklm-{mode}-{stamp}.{extension}"
if export_format == "json":
out_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
else:
out_path.write_text(_build_markdown_export(result), encoding="utf-8")
return str(out_path)
def _run_single_mode(args, library: Dict, questions: List[str]) -> Dict:
if len(questions) != 1:
return {"error": "Single mode requires exactly one question"}
resolved = _resolve_notebook(args, library)
if resolved.get("error"):
return {"error": resolved["error"]}
question = questions[0]
ask_result = _ask_with_retries(
args,
notebook_url=resolved["notebook_url"],
question=question,
mode="single",
)
if ask_result.get("error"):
return {
"mode": "single",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
**ask_result,
}
if resolved.get("notebook_id"):
record_notebook_use(library, resolved["notebook_id"])
return {
"status": "success",
"mode": "single",
"question": question,
"answer": ask_result["answer"],
"citations": ask_result.get("citations", []),
"timestamp_utc": ask_result.get("timestamp_utc", now_iso()),
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"profile": sanitize_profile_name(args.profile),
**({"attempts": ask_result["attempts"]} if ask_result.get("attempts") else {}),
**({"artifacts": ask_result["artifacts"]} if ask_result.get("artifacts") else {}),
**({"previous_errors": ask_result["previous_errors"]} if ask_result.get("previous_errors") else {}),
}
def _run_batch_mode(args, library: Dict, questions: List[str]) -> Dict:
resolved = _resolve_notebook(args, library)
if resolved.get("error"):
return {"error": resolved["error"]}
results: List[Dict] = []
for question in questions:
ask_result = _ask_with_retries(
args,
notebook_url=resolved["notebook_url"],
question=question,
mode="batch",
)
if ask_result.get("error"):
item = {
"question": question,
"error": ask_result["error"],
"timestamp_utc": now_iso(),
}
if ask_result.get("artifacts"):
item["artifacts"] = ask_result["artifacts"]
results.append(item)
if args.fail_fast:
break
continue
if resolved.get("notebook_id"):
record_notebook_use(library, resolved["notebook_id"])
item = {
"question": question,
"answer": ask_result["answer"],
"citations": ask_result.get("citations", []),
"timestamp_utc": ask_result.get("timestamp_utc", now_iso()),
}
if ask_result.get("attempts"):
item["attempts"] = ask_result["attempts"]
if ask_result.get("previous_errors"):
item["previous_errors"] = ask_result["previous_errors"]
if ask_result.get("artifacts"):
item["artifacts"] = ask_result["artifacts"]
results.append(item)
success_count = len([item for item in results if not item.get("error")])
error_count = len(results) - success_count
return {
"status": "success" if error_count == 0 else "partial_success",
"mode": "batch",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"count": len(results),
"success_count": success_count,
"error_count": error_count,
"results": results,
"profile": sanitize_profile_name(args.profile),
}
def _run_multi_mode(args, library: Dict, questions: List[str]) -> Dict:
if len(questions) != 1:
return {"error": "Multi-notebook mode supports exactly one question"}
resolved = _resolve_compare_notebooks(args, library)
if resolved.get("error"):
return {"error": resolved["error"]}
targets = resolved.get("targets", [])
if not targets:
return {
"error": (
"No comparison notebooks resolved. "
"Use --compare-notebook-ids and/or --compare-notebook-urls"
)
}
question = questions[0]
responses: List[Dict] = []
for target in targets:
ask_result = _ask_with_retries(
args,
notebook_url=target["notebook_url"],
question=question,
mode="multi",
)
if ask_result.get("error"):
item = {
"notebook_url": target["notebook_url"],
"notebook_id": target.get("notebook_id"),
"error": ask_result["error"],
"timestamp_utc": now_iso(),
}
if ask_result.get("artifacts"):
item["artifacts"] = ask_result["artifacts"]
responses.append(item)
if args.fail_fast:
break
continue
if target.get("notebook_id"):
record_notebook_use(library, target["notebook_id"])
item = {
"notebook_url": target["notebook_url"],
"notebook_id": target.get("notebook_id"),
"answer": ask_result["answer"],
"citations": ask_result.get("citations", []),
"timestamp_utc": ask_result.get("timestamp_utc", now_iso()),
}
if ask_result.get("attempts"):
item["attempts"] = ask_result["attempts"]
if ask_result.get("previous_errors"):
item["previous_errors"] = ask_result["previous_errors"]
if ask_result.get("artifacts"):
item["artifacts"] = ask_result["artifacts"]
responses.append(item)
success_count = len([item for item in responses if not item.get("error")])
error_count = len(responses) - success_count
return {
"status": "success" if error_count == 0 else "partial_success",
"mode": "multi",
"question": question,
"count": len(responses),
"success_count": success_count,
"error_count": error_count,
"responses": responses,
"profile": sanitize_profile_name(args.profile),
}
def _run(args) -> Dict:
library = load_library()
questions, question_error = _extract_questions(args)
if question_error:
return {"error": question_error}
compare_targets = _resolve_compare_notebooks(args, library)
if compare_targets.get("error"):
return {"error": compare_targets["error"]}
multi_mode = len(compare_targets.get("targets", [])) > 0
if multi_mode:
result = _run_multi_mode(args, library, questions)
elif len(questions) > 1:
result = _run_batch_mode(args, library, questions)
else:
result = _run_single_mode(args, library, questions)
if result.get("error"):
return result
mode = result.get("mode", "single")
export_file = _write_export_if_requested(result, args, mode)
if export_file:
result["export_file"] = export_file
return result
def main() -> None:
parser = argparse.ArgumentParser(description="Ask question(s) to NotebookLM")
parser.add_argument("--question", help="Single question to ask")
parser.add_argument(
"--questions",
help="Multiple questions (comma-separated or '||'-separated)",
)
parser.add_argument(
"--questions-file",
help="Path to file with one question per line",
)
parser.add_argument("--notebook-id", help="Notebook ID from local library")
parser.add_argument("--notebook-url", help="NotebookLM URL")
parser.add_argument(
"--compare-notebook-ids",
help="Comma-separated notebook IDs for multi-notebook comparison mode",
)
parser.add_argument(
"--compare-notebook-urls",
help="Comma-separated notebook URLs for multi-notebook comparison mode",
)
parser.add_argument("--show-browser", action="store_true", help="Show browser window while asking")
parser.add_argument("--profile", default="default", help="Auth/browser profile name (default: default)")
parser.add_argument("--timeout", type=int, default=120, help="Answer wait timeout in seconds (default: 120)")
parser.add_argument(
"--input-timeout",
type=int,
default=30,
help="Timeout to wait for chat input visibility in seconds (default: 30)",
)
parser.add_argument("--retries", type=int, default=2, help="Retry attempts for transient browser failures")
parser.add_argument(
"--artifacts-dir",
help="Directory for failure screenshots/HTML dumps (default: NOTEBOOKLM data dir artifacts)",
)
parser.add_argument("--fail-fast", action="store_true", help="Stop batch/multi mode on first error")
parser.add_argument(
"--export-format",
default="json",
help="Export format when saving results: json or markdown (default: json)",
)
parser.add_argument("--export-file", help="Write full result to this file path")
parser.add_argument(
"--save-notes",
action="store_true",
help="Save export under the skill notes directory when no --export-file is provided",
)
args = parser.parse_args()
try:
result = _run(args)
except Exception as exc: # noqa: BLE001
result = {"error": str(exc)}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and result.get("error"):
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
NotebookLM authentication manager using a persistent Playwright profile.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from pathlib import Path
from typing import Dict, List
from playwright.sync_api import sync_playwright
from common import (
NOTEBOOKLM_AUTH_URL,
NOTEBOOKLM_HOME_URL,
ensure_data_dirs,
get_data_dir,
get_profile_dir,
launch_persistent_context,
sanitize_profile_name,
)
CRITICAL_COOKIE_NAMES = {
"SID",
"HSID",
"SSID",
"APISID",
"SAPISID",
"__Secure-1PSID",
"__Secure-3PSID",
}
DEFAULT_AUTH_TIMEOUT_SEC = 600
def _auth_status(profile: str) -> Dict:
ensure_data_dirs()
with sync_playwright() as p:
context = launch_persistent_context(p, headless=True, profile=profile)
page = context.new_page()
try:
page.goto(NOTEBOOKLM_HOME_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(1500)
cookies = context.cookies()
critical = [c for c in cookies if c.get("name") in CRITICAL_COOKIE_NAMES]
current_url = page.url
authenticated = len(critical) > 0 and "accounts.google.com" not in current_url
return {
"authenticated": authenticated,
"profile": profile,
"profileDir": str(get_profile_dir(profile)),
"criticalCookieCount": len(critical),
"currentUrl": current_url,
}
finally:
context.close()
def _setup_auth(timeout_seconds: int, profile: str) -> Dict:
ensure_data_dirs()
with sync_playwright() as p:
context = launch_persistent_context(p, headless=False, profile=profile)
page = context.new_page()
try:
page.goto(NOTEBOOKLM_AUTH_URL, wait_until="domcontentloaded", timeout=60000)
start = time.time()
while time.time() - start < timeout_seconds:
url = page.url
if url.startswith("https://notebooklm.google.com/") and "accounts.google.com" not in url:
page.wait_for_timeout(1500)
cookies = context.cookies()
critical = [c for c in cookies if c.get("name") in CRITICAL_COOKIE_NAMES]
return {
"authenticated": len(critical) > 0,
"profile": profile,
"profileDir": str(get_profile_dir(profile)),
"criticalCookieCount": len(critical),
"currentUrl": url,
"message": "Authentication appears complete.",
}
page.wait_for_timeout(1000)
return {
"authenticated": False,
"message": (
f"Timed out waiting for login after {timeout_seconds}s. "
"Run setup again and complete login in the opened browser."
),
"profile": profile,
"profileDir": str(get_profile_dir(profile)),
}
finally:
context.close()
def _discover_profiles() -> List[Dict]:
ensure_data_dirs()
profiles: List[Dict] = []
default_dir = get_profile_dir("default")
profiles.append(
{
"profile": "default",
"profileDir": str(default_dir),
"exists": default_dir.exists(),
}
)
profiles_dir = get_data_dir() / "profiles"
if profiles_dir.exists():
for child in sorted(profiles_dir.iterdir()):
if not child.is_dir():
continue
profile_name = sanitize_profile_name(child.name)
profiles.append(
{
"profile": profile_name,
"profileDir": str(child),
"exists": True,
}
)
return profiles
def _clear_auth(profile: str, all_profiles: bool = False) -> Dict:
ensure_data_dirs()
cleared_profiles: List[str] = []
if all_profiles:
profiles_to_clear = _discover_profiles()
else:
profiles_to_clear = [
{
"profile": profile,
"profileDir": str(get_profile_dir(profile)),
"exists": get_profile_dir(profile).exists(),
}
]
for entry in profiles_to_clear:
profile_dir = Path(entry["profileDir"])
if profile_dir.exists():
shutil.rmtree(profile_dir, ignore_errors=True)
cleared_profiles.append(entry["profile"])
ensure_data_dirs()
return {
"success": True,
"clearedProfiles": sorted(set(cleared_profiles)),
"allProfiles": all_profiles,
}
def _list_profiles() -> Dict:
return {"profiles": _discover_profiles(), "count": len(_discover_profiles())}
def _resolve_profile(raw_profile: str) -> str:
return sanitize_profile_name(raw_profile)
def _clear_auth_legacy_compat() -> Dict:
# Kept for safety if any external callers rely on legacy behavior.
return _clear_auth(profile="default", all_profiles=False)
def main() -> None:
parser = argparse.ArgumentParser(description="NotebookLM auth management")
subparsers = parser.add_subparsers(dest="command", required=True)
setup_parser = subparsers.add_parser("setup", help="Open browser and perform manual Google login")
setup_parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_AUTH_TIMEOUT_SEC,
help=f"Max seconds to wait for login (default: {DEFAULT_AUTH_TIMEOUT_SEC})",
)
setup_parser.add_argument(
"--profile",
default="default",
help="Profile name for auth session (default: default)",
)
status_parser = subparsers.add_parser("status", help="Check if auth profile appears valid")
status_parser.add_argument(
"--profile",
default="default",
help="Profile name for auth session (default: default)",
)
clear_parser = subparsers.add_parser("clear", help="Clear local browser auth profile")
clear_parser.add_argument(
"--profile",
default="default",
help="Profile name for auth session (default: default)",
)
clear_parser.add_argument(
"--all-profiles",
action="store_true",
help="Clear all known profiles",
)
subparsers.add_parser("profiles", help="List auth profiles")
reauth_parser = subparsers.add_parser("reauth", help="Clear auth profile and run setup")
reauth_parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_AUTH_TIMEOUT_SEC,
help=f"Max seconds to wait for login (default: {DEFAULT_AUTH_TIMEOUT_SEC})",
)
reauth_parser.add_argument(
"--profile",
default="default",
help="Profile name for auth session (default: default)",
)
args = parser.parse_args()
try:
if args.command == "setup":
profile = _resolve_profile(args.profile)
result = _setup_auth(args.timeout, profile)
elif args.command == "status":
profile = _resolve_profile(args.profile)
result = _auth_status(profile)
elif args.command == "clear":
profile = _resolve_profile(args.profile)
result = _clear_auth(profile=profile, all_profiles=bool(args.all_profiles))
elif args.command == "profiles":
result = _list_profiles()
elif args.command == "reauth":
profile = _resolve_profile(args.profile)
_clear_auth(profile=profile, all_profiles=False)
result = _setup_auth(args.timeout, profile)
else:
result = {"error": f"Unknown command: {args.command}"}
except Exception as exc: # noqa: BLE001
result = {"error": str(exc)}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and result.get("error"):
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Shared utilities for the NotebookLM skill scripts.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from playwright.sync_api import Error as PlaywrightError
NOTEBOOKLM_HOME_URL = "https://notebooklm.google.com/"
NOTEBOOKLM_AUTH_URL = (
"https://accounts.google.com/v3/signin/identifier?"
"continue=https%3A%2F%2Fnotebooklm.google.com%2F&"
"flowName=GlifWebSignIn&flowEntry=ServiceLogin"
)
_NOTEBOOK_URL_PATTERN = re.compile(
r"^https://notebooklm\.google\.com/notebook/[A-Za-z0-9_-]+(?:[/?#].*)?$"
)
DEFAULT_DATA_DIR = Path.home() / ".config" / "claude" / "notebooklm-skill"
PROFILE_DIR_NAME = "chrome_profile"
LIBRARY_FILE_NAME = "library.json"
SOURCE_STATE_FILE_NAME = "source_state.json"
ARTIFACTS_DIR_NAME = "artifacts"
NOTES_DIR_NAME = "notes"
def now_iso() -> str:
"""Return current UTC timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def get_data_dir() -> Path:
"""Return configured data directory."""
override = os.environ.get("NOTEBOOKLM_DATA_DIR")
if override:
return Path(override).expanduser().resolve()
return DEFAULT_DATA_DIR
def sanitize_profile_name(profile: Optional[str]) -> str:
"""Normalize profile names to safe path fragments."""
raw = (profile or "").strip().lower()
if not raw or raw == "default":
return "default"
safe = re.sub(r"[^a-z0-9_-]+", "-", raw).strip("-")
return safe or "default"
def get_profile_dir(profile: Optional[str] = None) -> Path:
"""Return persistent browser profile directory for the selected profile."""
normalized = sanitize_profile_name(profile)
if normalized == "default":
return get_data_dir() / PROFILE_DIR_NAME
return get_data_dir() / "profiles" / normalized
def get_library_path() -> Path:
"""Return notebook library JSON path."""
return get_data_dir() / LIBRARY_FILE_NAME
def get_source_state_path() -> Path:
"""Return source state JSON path."""
return get_data_dir() / SOURCE_STATE_FILE_NAME
def get_artifacts_dir() -> Path:
"""Return folder where debug artifacts are written."""
return get_data_dir() / ARTIFACTS_DIR_NAME
def get_notes_dir() -> Path:
"""Return folder for exported answers/notes."""
return get_data_dir() / NOTES_DIR_NAME
def ensure_data_dirs() -> None:
"""Ensure data and profile directories exist."""
data_dir = get_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
get_profile_dir("default").mkdir(parents=True, exist_ok=True)
get_artifacts_dir().mkdir(parents=True, exist_ok=True)
get_notes_dir().mkdir(parents=True, exist_ok=True)
def is_valid_notebook_url(url: str) -> bool:
"""Validate NotebookLM notebook URL format."""
return bool(_NOTEBOOK_URL_PATTERN.match(url.strip()))
def parse_csv_values(raw: Optional[str]) -> List[str]:
"""Parse comma-separated values into a cleaned list."""
if not raw:
return []
return [item.strip() for item in raw.split(",") if item.strip()]
def _default_library() -> Dict[str, Any]:
return {
"version": "1.0.0",
"active_notebook_id": None,
"notebooks": [],
"last_modified": now_iso(),
}
def _default_source_state() -> Dict[str, Any]:
return {
"version": "1.0.0",
"notebooks": {},
"last_modified": now_iso(),
}
def load_library() -> Dict[str, Any]:
"""Load library from disk, creating it if needed."""
ensure_data_dirs()
path = get_library_path()
if not path.exists():
library = _default_library()
save_library(library)
return library
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
data.setdefault("version", "1.0.0")
data.setdefault("active_notebook_id", None)
data.setdefault("notebooks", [])
data.setdefault("last_modified", now_iso())
return data
except (json.JSONDecodeError, OSError):
pass
library = _default_library()
save_library(library)
return library
def save_library(library: Dict[str, Any]) -> None:
"""Persist library to disk."""
ensure_data_dirs()
library["last_modified"] = now_iso()
with get_library_path().open("w", encoding="utf-8") as f:
json.dump(library, f, indent=2)
def load_source_state() -> Dict[str, Any]:
"""Load source sync/hash state from disk, creating it if needed."""
ensure_data_dirs()
path = get_source_state_path()
if not path.exists():
state = _default_source_state()
save_source_state(state)
return state
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
data.setdefault("version", "1.0.0")
data.setdefault("notebooks", {})
data.setdefault("last_modified", now_iso())
return data
except (json.JSONDecodeError, OSError):
pass
state = _default_source_state()
save_source_state(state)
return state
def save_source_state(state: Dict[str, Any]) -> None:
"""Persist source sync/hash state to disk."""
ensure_data_dirs()
state["last_modified"] = now_iso()
with get_source_state_path().open("w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
def slugify(value: str) -> str:
"""Create a stable slug ID from a string."""
base = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
if not base:
base = "notebook"
return base[:40]
def generate_notebook_id(name: str, existing_ids: List[str]) -> str:
"""Generate a unique notebook ID from notebook name."""
root = slugify(name)
candidate = root
index = 1
existing = set(existing_ids)
while candidate in existing:
candidate = f"{root}-{index}"
index += 1
return candidate
def get_notebook_by_id(library: Dict[str, Any], notebook_id: str) -> Optional[Dict[str, Any]]:
"""Find notebook by ID."""
for notebook in library.get("notebooks", []):
if notebook.get("id") == notebook_id:
return notebook
return None
def get_notebook_by_url(library: Dict[str, Any], notebook_url: str) -> Optional[Dict[str, Any]]:
"""Find notebook by URL."""
target = notebook_url.strip()
for notebook in library.get("notebooks", []):
if str(notebook.get("url", "")).strip() == target:
return notebook
return None
def get_active_notebook(library: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Return active notebook entry if configured."""
active_id = library.get("active_notebook_id")
if not active_id:
return None
return get_notebook_by_id(library, active_id)
def launch_persistent_context(
playwright,
headless: bool,
profile: Optional[str] = None,
viewport: Optional[Tuple[int, int]] = None,
):
"""Launch a persistent Chromium context with reusable profile directory."""
vw, vh = viewport or (1280, 900)
profile_dir = str(get_profile_dir(profile))
common_args = {
"user_data_dir": profile_dir,
"headless": headless,
"viewport": {"width": vw, "height": vh},
"args": [
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
"--no-first-run",
"--no-default-browser-check",
],
}
try:
return playwright.chromium.launch_persistent_context(channel="chrome", **common_args)
except PlaywrightError:
return playwright.chromium.launch_persistent_context(**common_args)
def record_notebook_use(library: Dict[str, Any], notebook_id: str) -> Optional[Dict[str, Any]]:
"""Increment usage counters for a notebook and save the library."""
notebook = get_notebook_by_id(library, notebook_id)
if not notebook:
return None
notebook["use_count"] = int(notebook.get("use_count", 0)) + 1
notebook["last_used"] = now_iso()
save_library(library)
return notebook
#!/usr/bin/env python3
"""
Notebook library manager for NotebookLM skill.
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Dict, List
from common import (
generate_notebook_id,
get_active_notebook,
get_notebook_by_id,
is_valid_notebook_url,
load_library,
now_iso,
parse_csv_values,
save_library,
)
def cmd_add(args) -> Dict:
library = load_library()
notebooks: List[Dict] = library.get("notebooks", [])
if not is_valid_notebook_url(args.url):
return {"error": "Invalid NotebookLM URL. Expected https://notebooklm.google.com/notebook/<id>"}
existing_ids = [n.get("id", "") for n in notebooks]
notebook_id = generate_notebook_id(args.name, existing_ids)
topics = parse_csv_values(args.topics)
tags = parse_csv_values(args.tags)
if not topics:
return {"error": "--topics is required and must include at least one topic"}
notebook = {
"id": notebook_id,
"url": args.url.strip(),
"name": args.name.strip(),
"description": args.description.strip(),
"topics": topics,
"tags": tags,
"added_at": now_iso(),
"last_used": now_iso(),
"use_count": 0,
}
notebooks.append(notebook)
if not library.get("active_notebook_id"):
library["active_notebook_id"] = notebook_id
save_library(library)
return {"status": "success", "notebook": notebook}
def cmd_list(_args) -> Dict:
library = load_library()
return {
"active_notebook_id": library.get("active_notebook_id"),
"count": len(library.get("notebooks", [])),
"notebooks": library.get("notebooks", []),
}
def cmd_get(args) -> Dict:
library = load_library()
notebook = get_notebook_by_id(library, args.id)
if not notebook:
return {"error": f"Notebook not found: {args.id}"}
return {"notebook": notebook, "active": library.get("active_notebook_id") == args.id}
def cmd_activate(args) -> Dict:
library = load_library()
notebook = get_notebook_by_id(library, args.id)
if not notebook:
return {"error": f"Notebook not found: {args.id}"}
library["active_notebook_id"] = args.id
notebook["last_used"] = now_iso()
save_library(library)
return {"status": "success", "active_notebook_id": args.id, "notebook": notebook}
def cmd_remove(args) -> Dict:
library = load_library()
notebooks = library.get("notebooks", [])
before = len(notebooks)
notebooks = [n for n in notebooks if n.get("id") != args.id]
if len(notebooks) == before:
return {"error": f"Notebook not found: {args.id}"}
library["notebooks"] = notebooks
if library.get("active_notebook_id") == args.id:
library["active_notebook_id"] = notebooks[0]["id"] if notebooks else None
save_library(library)
return {"status": "success", "removed_id": args.id, "remaining": len(notebooks)}
def cmd_search(args) -> Dict:
library = load_library()
query = args.query.lower().strip()
results = []
for notebook in library.get("notebooks", []):
haystack = " ".join(
[
str(notebook.get("name", "")),
str(notebook.get("description", "")),
" ".join(notebook.get("topics", [])),
" ".join(notebook.get("tags", [])),
]
).lower()
if query in haystack:
results.append(notebook)
return {"query": args.query, "count": len(results), "results": results}
def cmd_update(args) -> Dict:
library = load_library()
notebook = get_notebook_by_id(library, args.id)
if not notebook:
return {"error": f"Notebook not found: {args.id}"}
if args.name:
notebook["name"] = args.name.strip()
if args.description:
notebook["description"] = args.description.strip()
if args.topics is not None:
notebook["topics"] = parse_csv_values(args.topics)
if args.tags is not None:
notebook["tags"] = parse_csv_values(args.tags)
if args.url:
if not is_valid_notebook_url(args.url):
return {"error": "Invalid NotebookLM URL. Expected https://notebooklm.google.com/notebook/<id>"}
notebook["url"] = args.url.strip()
save_library(library)
return {"status": "success", "notebook": notebook}
def cmd_stats(_args) -> Dict:
library = load_library()
notebooks = library.get("notebooks", [])
total_queries = sum(int(n.get("use_count", 0)) for n in notebooks)
most_used = None
if notebooks:
most_used = max(notebooks, key=lambda n: int(n.get("use_count", 0))).get("id")
active = get_active_notebook(library)
return {
"total_notebooks": len(notebooks),
"active_notebook_id": library.get("active_notebook_id"),
"active_notebook_name": active.get("name") if active else None,
"most_used_notebook_id": most_used,
"total_queries": total_queries,
}
def main() -> None:
parser = argparse.ArgumentParser(description="NotebookLM notebook library manager")
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="Add notebook to local library")
add_parser.add_argument("--url", required=True, help="NotebookLM notebook URL")
add_parser.add_argument("--name", required=True, help="Notebook display name")
add_parser.add_argument("--description", required=True, help="Notebook description")
add_parser.add_argument("--topics", required=True, help="Comma-separated topics")
add_parser.add_argument("--tags", default="", help="Comma-separated tags")
subparsers.add_parser("list", help="List notebooks")
get_parser = subparsers.add_parser("get", help="Get notebook by ID")
get_parser.add_argument("--id", required=True, help="Notebook ID")
activate_parser = subparsers.add_parser("activate", help="Set active notebook")
activate_parser.add_argument("--id", required=True, help="Notebook ID")
remove_parser = subparsers.add_parser("remove", help="Remove notebook")
remove_parser.add_argument("--id", required=True, help="Notebook ID")
search_parser = subparsers.add_parser("search", help="Search notebooks")
search_parser.add_argument("--query", required=True, help="Search query")
update_parser = subparsers.add_parser("update", help="Update notebook metadata")
update_parser.add_argument("--id", required=True, help="Notebook ID")
update_parser.add_argument("--name", help="Notebook name")
update_parser.add_argument("--description", help="Notebook description")
update_parser.add_argument("--topics", help="Comma-separated topics")
update_parser.add_argument("--tags", help="Comma-separated tags")
update_parser.add_argument("--url", help="Notebook URL")
subparsers.add_parser("stats", help="Show library stats")
args = parser.parse_args()
handlers = {
"add": cmd_add,
"list": cmd_list,
"get": cmd_get,
"activate": cmd_activate,
"remove": cmd_remove,
"search": cmd_search,
"update": cmd_update,
"stats": cmd_stats,
}
try:
result = handlers[args.command](args)
except Exception as exc: # noqa: BLE001
result = {"error": str(exc)}
print(json.dumps(result, indent=2))
if isinstance(result, dict) and result.get("error"):
sys.exit(1)
if __name__ == "__main__":
main()
def pytest_configure(config):
config.addinivalue_line("markers", "smoke: live Playwright smoke tests against authenticated NotebookLM profile")
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import ask_question as aq # noqa: E402
def test_extract_questions_single() -> None:
args = SimpleNamespace(question="What is this?", questions=None, questions_file=None)
questions, error = aq._extract_questions(args)
assert error is None
assert questions == ["What is this?"]
def test_extract_questions_mixed(tmp_path: Path) -> None:
questions_file = tmp_path / "questions.txt"
questions_file.write_text("first\n#ignore\nsecond\n", encoding="utf-8")
args = SimpleNamespace(
question="zero",
questions="one||two",
questions_file=str(questions_file),
)
questions, error = aq._extract_questions(args)
assert error is None
assert questions == ["zero", "one", "two", "first", "second"]
def test_resolve_compare_notebooks_from_library() -> None:
library = {
"notebooks": [
{
"id": "docs",
"url": "https://notebooklm.google.com/notebook/11111111-1111-1111-1111-111111111111",
}
]
}
args = SimpleNamespace(
compare_notebook_ids="docs",
compare_notebook_urls="https://notebooklm.google.com/notebook/22222222-2222-2222-2222-222222222222",
)
result = aq._resolve_compare_notebooks(args, library)
assert "error" not in result
targets = result["targets"]
assert len(targets) == 2
assert targets[0]["notebook_id"] == "docs"
def test_build_markdown_export_single() -> None:
result = {
"mode": "single",
"notebook_url": "https://notebooklm.google.com/notebook/abc",
"question": "Q?",
"answer": "A",
"citations": ["source 1"],
}
output = aq._build_markdown_export(result)
assert "NotebookLM Export" in output
assert "## Answer" in output
assert "source 1" in output
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import remote_manager as rm # noqa: E402
def test_parse_max_size_bytes() -> None:
assert rm._parse_max_size_bytes("1024") == 1024
assert rm._parse_max_size_bytes("1kb") == 1024
assert rm._parse_max_size_bytes("2MB") == 2 * 1024 * 1024
assert rm._parse_max_size_bytes(None) is None
@pytest.mark.parametrize("raw", ["abc", "10tb", "1.2.3mb"])
def test_parse_max_size_bytes_invalid(raw: str) -> None:
with pytest.raises(ValueError):
rm._parse_max_size_bytes(raw)
def test_parse_modified_since_epoch_relative() -> None:
cutoff = rm._parse_modified_since_epoch("1d")
assert cutoff is not None
assert cutoff <= time.time()
def test_collect_source_files_filters(tmp_path: Path) -> None:
keep = tmp_path / "keep.md"
skip_ext = tmp_path / "skip.txt"
skip_size = tmp_path / "big.md"
keep.write_text("hello", encoding="utf-8")
skip_ext.write_text("skip", encoding="utf-8")
skip_size.write_text("x" * 4096, encoding="utf-8")
files, filtered = rm._collect_source_files(
files=None,
dirs=[str(tmp_path)],
recursive=False,
include_ext_raw="md",
exclude_patterns_raw=["skip*"],
max_size_raw="2KB",
modified_since_raw=None,
)
assert [p.name for p in files] == ["keep.md"]
reasons = {item["reason"] for item in filtered}
assert "extension-filtered" in reasons
assert "size-filtered" in reasons
def test_ensure_unique_titles_detects_duplicates(tmp_path: Path) -> None:
a_dir = tmp_path / "a"
b_dir = tmp_path / "b"
a_dir.mkdir()
b_dir.mkdir()
file_a = a_dir / "same.md"
file_b = b_dir / "same.md"
file_a.write_text("one", encoding="utf-8")
file_b.write_text("two", encoding="utf-8")
infos = [
{
"title": "same.md",
"source_path": file_a,
"upload_path": file_a,
"size_bytes": os.path.getsize(file_a),
"mtime_epoch": file_a.stat().st_mtime,
"hash": "h1",
},
{
"title": "same.md",
"source_path": file_b,
"upload_path": file_b,
"size_bytes": os.path.getsize(file_b),
"mtime_epoch": file_b.stat().st_mtime,
"hash": "h2",
},
]
with pytest.raises(ValueError):
rm._ensure_unique_titles(infos)
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
import pytest
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = SKILL_DIR / "scripts"
REMOTE_MANAGER = SCRIPTS_DIR / "remote_manager.py"
AUTH_MANAGER = SCRIPTS_DIR / "auth_manager.py"
def _run_json(command: list[str]) -> dict:
proc = subprocess.run(
command,
cwd=str(SKILL_DIR),
check=False,
text=True,
capture_output=True,
)
if proc.returncode != 0:
raise AssertionError(f"Command failed ({proc.returncode}): {' '.join(command)}\n{proc.stdout}\n{proc.stderr}")
try:
return json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise AssertionError(f"Invalid JSON output from command: {' '.join(command)}\n{proc.stdout}") from exc
@pytest.mark.smoke
def test_remote_manager_create_list_add_delete_smoke() -> None:
if os.environ.get("NOTEBOOKLM_E2E") != "1":
pytest.skip("Set NOTEBOOKLM_E2E=1 to run live smoke tests")
profile = os.environ.get("NOTEBOOKLM_SMOKE_PROFILE", "default")
status = _run_json([
"python3",
str(AUTH_MANAGER),
"status",
"--profile",
profile,
])
if not status.get("authenticated"):
pytest.skip(f"NotebookLM profile '{profile}' is not authenticated")
notebook_name = f"Codex Smoke {int(time.time())}"
created = _run_json(
[
"python3",
str(REMOTE_MANAGER),
"create-remote",
"--name",
notebook_name,
"--profile",
profile,
]
)
notebook_id = created.get("library_notebook", {}).get("id")
assert notebook_id, created
added = _run_json(
[
"python3",
str(REMOTE_MANAGER),
"add-source",
"--notebook-id",
notebook_id,
"--text",
f"smoke source {int(time.time())}",
"--profile",
profile,
]
)
assert added.get("status") == "success", added
listed = _run_json(
[
"python3",
str(REMOTE_MANAGER),
"list-sources",
"--notebook-id",
notebook_id,
"--profile",
profile,
]
)
assert listed.get("status") == "success", listed
assert listed.get("count", 0) >= 1, listed
source_to_delete = listed["sources"][0]["title"]
deleted = _run_json(
[
"python3",
str(REMOTE_MANAGER),
"delete-source",
"--notebook-id",
notebook_id,
"--source-title",
source_to_delete,
"--all-matches",
"--profile",
profile,
]
)
assert deleted.get("status") == "success", deleted