
Notebooklm
- 10 installs
- 364 repo stars
- Updated July 9, 2026
- sanjay3290/postgres-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
- 10 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanjay3290/postgres-skill --skill notebooklmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 364 |
| Last updated | July 9, 2026 |
| Repository | sanjay3290/postgres-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.
__pycache__/
.pytest_cache/
*.pyc
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()
#!/usr/bin/env python3
"""
Remote NotebookLM operations via browser automation.
Supports:
- list all notebooks visible in account
- create new notebook
- list/add/delete/sync sources in a notebook
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
import re
import shutil
import sys
import tempfile
import time
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import Page, sync_playwright
from common import (
NOTEBOOKLM_HOME_URL,
generate_notebook_id,
get_active_notebook,
get_artifacts_dir,
get_notebook_by_id,
get_notebook_by_url,
is_valid_notebook_url,
launch_persistent_context,
load_library,
load_source_state,
now_iso,
parse_csv_values,
sanitize_profile_name,
save_library,
save_source_state,
)
NOTEBOOK_ID_PATTERN = re.compile(r"project-([0-9a-fA-F-]{36})-title")
RETRYABLE_ERROR_KEYWORDS = [
"timeout",
"timed out",
"net::",
"connection",
"target closed",
"page crashed",
"execution context was destroyed",
"context closed",
"protocol error",
"browser has been closed",
"closed",
]
def _safe_name(value: str) -> str:
return re.sub(r"[^a-zA-Z0-9._-]+", "-", value).strip("-") or "item"
def _command_attempts(args) -> int:
retries = int(getattr(args, "retries", 1) or 1)
return max(1, retries)
def _is_retryable_error(message: str) -> bool:
lower = message.lower().strip()
return any(keyword in lower for keyword in RETRYABLE_ERROR_KEYWORDS)
def _artifact_dir_for_args(args) -> Path:
raw = getattr(args, "artifacts_dir", None)
if raw:
return Path(raw).expanduser().resolve()
return get_artifacts_dir()
def _capture_debug_artifacts(page: Page, args, command_name: str, attempt: int, error_message: str) -> Optional[Dict]:
try:
artifact_dir = _artifact_dir_for_args(args)
artifact_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
prefix = f"{stamp}-{_safe_name(command_name)}-attempt{attempt}"
screenshot_path = artifact_dir / f"{prefix}.png"
html_path = artifact_dir / 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 _run_browser_command(args, command_name: str, action: Callable[[Page], Dict]) -> Dict:
attempts = _command_attempts(args)
profile = sanitize_profile_name(getattr(args, "profile", "default"))
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 getattr(args, "show_browser", False),
profile=profile,
viewport=(1600, 1100),
)
page = context.new_page()
try:
result = action(page)
if isinstance(result, dict) and 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, command_name, attempt, error_message)
if artifact:
artifacts.append(artifact)
time.sleep(min(attempt * 1.5, 5.0))
continue
if errors:
result = dict(result)
result.setdefault("previous_errors", errors)
if artifacts:
result = dict(result)
result.setdefault("artifacts", artifacts)
if attempt > 1:
result = dict(result)
result.setdefault("attempts", attempt)
return result
if isinstance(result, dict):
if errors:
result = dict(result)
result.setdefault("previous_errors", errors)
if artifacts:
result = dict(result)
result.setdefault("artifacts", artifacts)
if attempt > 1:
result = dict(result)
result.setdefault("attempts", attempt)
return result
except Exception as exc: # noqa: BLE001
error_message = str(exc)
errors.append(error_message)
artifact = _capture_debug_artifacts(page, args, command_name, attempt, error_message)
if artifact:
artifacts.append(artifact)
if attempt >= attempts:
result = {
"error": f"{command_name} 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()
# Unreachable, keeps mypy/linters happy.
return {"error": f"{command_name} failed unexpectedly"}
def _wait_for(condition_fn, timeout_sec: int = 30, poll_ms: int = 400) -> bool:
deadline = time.time() + timeout_sec
while time.time() < deadline:
if condition_fn():
return True
time.sleep(poll_ms / 1000.0)
return False
def _ensure_logged_in(page: Page) -> Optional[Dict]:
if "accounts.google.com" in page.url:
return {
"error": (
"NotebookLM redirected to Google login. "
"Run: python scripts/auth_manager.py setup"
)
}
return None
def _go_to_home(page: Page) -> Optional[Dict]:
page.goto(NOTEBOOKLM_HOME_URL, wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(2500)
return _ensure_logged_in(page)
def _scroll_notebook_home(page: Page) -> None:
stable_rounds = 0
last_count = -1
for _ in range(14):
count = page.locator(
"mat-card.project-button-card:not(.featured-project-card):not(.create-new-action-button)"
).count()
if count == last_count:
stable_rounds += 1
else:
stable_rounds = 0
last_count = count
if stable_rounds >= 3:
break
try:
page.mouse.wheel(0, 3200)
except PlaywrightError:
pass
page.wait_for_timeout(450)
def _parse_notebook_card(card) -> Optional[Dict]:
try:
title_el = card.query_selector(".project-button-title")
subtitle_el = card.query_selector(".project-button-subtitle")
button_el = card.query_selector("button.primary-action-button")
title = title_el.inner_text().strip() if title_el else card.inner_text().split("\n")[0].strip()
subtitle = subtitle_el.inner_text().strip() if subtitle_el else ""
button_aria = button_el.get_attribute("aria-labelledby") if button_el else ""
card_html = card.evaluate("e => e.outerHTML")
notebook_id = None
for haystack in [button_aria or "", card_html or ""]:
match = NOTEBOOK_ID_PATTERN.search(haystack)
if match:
notebook_id = match.group(1)
break
source_count = None
source_match = re.search(r"(\d+)\s+sources?", subtitle.lower())
if source_match:
source_count = int(source_match.group(1))
is_public = "public" in card.inner_text().lower().split()
return {
"id": notebook_id,
"url": f"https://notebooklm.google.com/notebook/{notebook_id}" if notebook_id else None,
"name": title,
"subtitle": subtitle,
"source_count": source_count,
"is_public": is_public,
}
except PlaywrightError:
return None
def _list_remote_notebooks(page: Page) -> List[Dict]:
# Ensure personal notebooks are visible.
try:
my_notebooks_toggle = page.get_by_role("button", name="My notebooks")
if my_notebooks_toggle.count() > 0:
my_notebooks_toggle.first.click()
page.wait_for_timeout(1200)
except PlaywrightError:
pass
_scroll_notebook_home(page)
cards = page.query_selector_all(
"mat-card.project-button-card:not(.featured-project-card):not(.create-new-action-button)"
)
notebooks: List[Dict] = []
seen: set[str] = set()
for card in cards:
data = _parse_notebook_card(card)
if not data:
continue
key = str(data.get("id") or data.get("name") or "").strip()
if not key or key in seen:
continue
seen.add(key)
notebooks.append(data)
return notebooks
def _resolve_notebook_for_ops(args) -> Dict:
library = load_library()
notebook_url = args.notebook_url
notebook_id = None
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 {"library": library, "notebook_id": notebook_id, "notebook_url": notebook_url}
def _ensure_source_panel_open(page: Page) -> None:
try:
expand = page.locator("button[aria-label='Expand source panel']")
if expand.count() > 0 and expand.first.is_visible():
expand.first.click()
page.wait_for_timeout(700)
except PlaywrightError:
pass
def _dismiss_blocking_overlays(page: Page) -> None:
# Handles transient NotebookLM modals/backdrops that can block pointer events.
for _ in range(4):
try:
backdrop = page.locator(".cdk-overlay-backdrop.cdk-overlay-backdrop-showing")
if backdrop.count() == 0:
break
except PlaywrightError:
break
for label in ["Close", "Cancel", "Done", "Not now", "Got it"]:
try:
btn = page.get_by_role("button", name=label)
if btn.count() > 0 and btn.first.is_visible():
btn.first.click(timeout=1200)
page.wait_for_timeout(350)
break
except PlaywrightError:
continue
try:
page.keyboard.press("Escape")
except PlaywrightError:
pass
page.wait_for_timeout(400)
def _read_sources(page: Page) -> List[Dict]:
rows = page.query_selector_all(".single-source-container")
results: List[Dict] = []
for row in rows:
try:
title_el = row.query_selector(".source-title")
if not title_el:
continue
title = title_el.inner_text().strip()
if not title:
continue
icon_el = row.query_selector(".source-item-source-icon")
source_type = icon_el.inner_text().strip() if icon_el else None
menu_btn = row.query_selector("button.source-item-more-button")
menu_btn_id = menu_btn.get_attribute("id") if menu_btn else None
source_id = None
if menu_btn_id:
match = re.search(r"source-item-more-button-(.+)$", menu_btn_id)
if match:
source_id = match.group(1)
results.append(
{
"title": title,
"type": source_type,
"source_id": source_id,
}
)
except PlaywrightError:
continue
return results
def _wait_for_source_diff(page: Page, before_titles: List[str], timeout_sec: int = 120) -> List[str]:
before_counter = Counter(before_titles)
deadline = time.time() + timeout_sec
while time.time() < deadline:
current = _read_sources(page)
current_titles = [src["title"] for src in current]
cur_counter = Counter(current_titles)
diff_counter = cur_counter - before_counter
added = list(diff_counter.elements())
if added:
return added
page.wait_for_timeout(800)
return []
def _open_add_sources_dialog(page: Page) -> None:
_dismiss_blocking_overlays(page)
dialog = page.locator("add-sources-dialog")
if dialog.count() == 0:
add_btn = page.locator("button[aria-label='Add source']")
if add_btn.count() == 0:
add_btn = page.get_by_role("button", name="Add source")
if add_btn.count() == 0:
raise RuntimeError("Could not find 'Add source' button in notebook")
add_btn.first.click()
page.wait_for_timeout(900)
dialog = page.locator("add-sources-dialog")
if dialog.count() == 0:
_dismiss_blocking_overlays(page)
dialog = page.locator("add-sources-dialog")
if dialog.count() == 0:
raise RuntimeError("Add sources dialog did not open")
def _insert_text_source(page: Page, text: str) -> None:
_open_add_sources_dialog(page)
page.get_by_role("button", name="Copied text").click()
page.wait_for_timeout(500)
textarea = page.locator("textarea[placeholder='Paste text here']").first
if textarea.count() == 0:
raise RuntimeError("Could not find copied text textarea")
textarea.fill(text)
insert_btn = page.locator("mat-dialog-container button", has_text="Insert").first
ready = _wait_for(
lambda: insert_btn.count() > 0 and not insert_btn.is_disabled(),
timeout_sec=25,
poll_ms=350,
)
if not ready:
raise RuntimeError("Insert button did not become ready. Check provided source content.")
insert_btn.click()
def _insert_url_source(page: Page, url: str) -> None:
_open_add_sources_dialog(page)
page.get_by_role("button", name="Websites").click()
page.wait_for_timeout(500)
textarea = page.locator("textarea[placeholder='Paste any links']").first
if textarea.count() == 0:
raise RuntimeError("Could not find website URL textarea")
textarea.fill(url)
insert_btn = page.locator("mat-dialog-container button", has_text="Insert").first
ready = _wait_for(
lambda: insert_btn.count() > 0 and not insert_btn.is_disabled(),
timeout_sec=25,
poll_ms=350,
)
if not ready:
raise RuntimeError("Insert button did not become ready. Check provided URL source.")
insert_btn.click()
def _upload_file_sources(page: Page, files: List[Path]) -> None:
if not files:
return
_open_add_sources_dialog(page)
upload_btn = page.get_by_role("button", name="Upload files")
if upload_btn.count() == 0:
raise RuntimeError("Could not find 'Upload files' button in add sources dialog")
with page.expect_file_chooser(timeout=25000) as chooser_info:
upload_btn.first.click()
chooser = chooser_info.value
chooser.set_files([str(path) for path in files])
def _find_matching_source_indexes(sources: List[Dict], title: str, contains: bool) -> List[int]:
matches: List[int] = []
wanted = title.lower().strip()
for idx, src in enumerate(sources):
current = str(src.get("title", "")).lower().strip()
if contains:
if wanted in current:
matches.append(idx)
else:
if wanted == current:
matches.append(idx)
return matches
def _delete_source_once(page: Page, row_index: int) -> None:
row = page.locator(".single-source-container").nth(row_index)
row.scroll_into_view_if_needed()
row.locator("button.source-item-more-button").click(timeout=7000)
page.wait_for_timeout(400)
delete_menu = page.locator("button.more-menu-delete-source-button")
if delete_menu.count() == 0:
page.get_by_role("menuitem", name="Remove source").first.click(timeout=7000)
else:
delete_menu.first.click(timeout=7000)
page.wait_for_timeout(500)
# Confirm modal.
if page.locator("mat-dialog-container").count() > 0:
confirm = page.locator("button[aria-label='Confirm deletion']")
if confirm.count() > 0:
confirm.first.click(timeout=7000)
else:
submit = page.locator("button.submit")
if submit.count() > 0:
submit.first.click(timeout=7000)
else:
page.get_by_role("button", name="Delete").first.click(timeout=7000)
page.wait_for_timeout(1300)
def _delete_all_exact_title(page: Page, title: str, max_delete: int = 40) -> int:
removed = 0
for _ in range(max_delete):
current_sources = _read_sources(page)
current_indexes = _find_matching_source_indexes(current_sources, title, contains=False)
if not current_indexes:
break
_delete_source_once(page, current_indexes[0])
_wait_for(
lambda: len(_find_matching_source_indexes(_read_sources(page), title, contains=False))
< len(current_indexes),
timeout_sec=35,
poll_ms=500,
)
removed += 1
return removed
def _upsert_library_notebook(url: str, name: str, description: str, topics: List[str]) -> Dict:
library = load_library()
existing = get_notebook_by_url(library, url)
if existing:
existing["name"] = name
existing["description"] = description
existing["topics"] = topics
existing["last_used"] = now_iso()
save_library(library)
return existing
notebooks = library.get("notebooks", [])
existing_ids = [n.get("id", "") for n in notebooks]
notebook_id = generate_notebook_id(name, existing_ids)
notebook = {
"id": notebook_id,
"url": url,
"name": name,
"description": description,
"topics": topics,
"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 notebook
def _parse_max_size_bytes(raw: Optional[str]) -> Optional[int]:
if not raw:
return None
text = raw.strip().lower()
match = re.match(r"^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$", text)
if not match:
raise ValueError(f"Invalid --max-size value: {raw}")
number = float(match.group(1))
unit = (match.group(2) or "b").lower()
multiplier = {
"b": 1,
"kb": 1024,
"mb": 1024**2,
"gb": 1024**3,
}[unit]
return int(number * multiplier)
def _parse_modified_since_epoch(raw: Optional[str]) -> Optional[float]:
if not raw:
return None
value = raw.strip()
rel_match = re.match(r"^(\d+)\s*([dhm])$", value.lower())
if rel_match:
amount = int(rel_match.group(1))
unit = rel_match.group(2)
now = datetime.now(timezone.utc)
if unit == "d":
cutoff = now - timedelta(days=amount)
elif unit == "h":
cutoff = now - timedelta(hours=amount)
else:
cutoff = now - timedelta(minutes=amount)
return cutoff.timestamp()
normalized = value.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise ValueError(
"Invalid --modified-since format. Use ISO datetime/date or relative values like 7d, 24h, 90m"
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
def _parse_exclude_patterns(raw_values: Optional[List[str]]) -> List[str]:
patterns: List[str] = []
for raw in raw_values or []:
parts = parse_csv_values(raw)
if parts:
patterns.extend(parts)
else:
stripped = raw.strip()
if stripped:
patterns.append(stripped)
return patterns
def _parse_include_extensions(raw: Optional[str]) -> set[str]:
exts: set[str] = set()
for value in parse_csv_values(raw):
ext = value.lower().strip()
if not ext:
continue
if not ext.startswith("."):
ext = f".{ext}"
exts.add(ext)
return exts
def _collect_source_files(
files: Optional[List[str]],
dirs: Optional[List[str]],
recursive: bool,
include_ext_raw: Optional[str],
exclude_patterns_raw: Optional[List[str]],
max_size_raw: Optional[str],
modified_since_raw: Optional[str],
) -> Tuple[List[Path], List[Dict]]:
include_ext = _parse_include_extensions(include_ext_raw)
exclude_patterns = _parse_exclude_patterns(exclude_patterns_raw)
max_size = _parse_max_size_bytes(max_size_raw)
modified_since = _parse_modified_since_epoch(modified_since_raw)
candidates: List[Path] = []
for raw in files or []:
path = Path(raw).expanduser().resolve()
if not path.exists():
raise ValueError(f"File not found: {path}")
if not path.is_file():
raise ValueError(f"Path is not a file: {path}")
candidates.append(path)
for raw in dirs or []:
dir_path = Path(raw).expanduser().resolve()
if not dir_path.exists():
raise ValueError(f"Directory not found: {dir_path}")
if not dir_path.is_dir():
raise ValueError(f"Path is not a directory: {dir_path}")
iterator = dir_path.rglob("*") if recursive else dir_path.iterdir()
for candidate in iterator:
try:
if candidate.is_file():
candidates.append(candidate.resolve())
except OSError:
continue
deduped: List[Path] = []
seen_paths: set[str] = set()
filtered_out: List[Dict] = []
for path in candidates:
key = str(path)
if key in seen_paths:
filtered_out.append({"path": key, "reason": "duplicate-path"})
continue
seen_paths.add(key)
try:
stat = path.stat()
except OSError:
filtered_out.append({"path": key, "reason": "stat-failed"})
continue
if include_ext and path.suffix.lower() not in include_ext:
filtered_out.append(
{
"path": key,
"reason": "extension-filtered",
"allowed_extensions": sorted(include_ext),
}
)
continue
excluded = False
for pattern in exclude_patterns:
if fnmatch.fnmatch(path.name, pattern) or fnmatch.fnmatch(key, pattern):
filtered_out.append({"path": key, "reason": f"excluded:{pattern}"})
excluded = True
break
if excluded:
continue
if max_size is not None and stat.st_size > max_size:
filtered_out.append(
{
"path": key,
"reason": "size-filtered",
"size_bytes": stat.st_size,
"max_size_bytes": max_size,
}
)
continue
if modified_since is not None and stat.st_mtime < modified_since:
filtered_out.append(
{
"path": key,
"reason": "modified-since-filtered",
"mtime_epoch": stat.st_mtime,
}
)
continue
deduped.append(path)
deduped.sort(key=lambda p: str(p).lower())
return deduped, filtered_out
def _hash_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _make_file_infos(paths: List[Path]) -> List[Dict]:
infos: List[Dict] = []
for path in paths:
stat = path.stat()
infos.append(
{
"title": path.name,
"source_path": path,
"upload_path": path,
"size_bytes": stat.st_size,
"mtime_epoch": stat.st_mtime,
"hash": _hash_file(path),
}
)
return infos
def _ensure_unique_titles(file_infos: List[Dict]) -> None:
title_to_paths: Dict[str, List[str]] = {}
for info in file_infos:
title_to_paths.setdefault(info["title"], []).append(str(info["source_path"]))
duplicates = {title: paths for title, paths in title_to_paths.items() if len(paths) > 1}
if duplicates:
details = "; ".join(f"{title}: {', '.join(paths)}" for title, paths in duplicates.items())
raise ValueError(f"Duplicate filenames detected. Rename files to unique names before upload: {details}")
def _copy_infos_to_temp(file_infos: List[Dict]) -> Tuple[List[Dict], Optional[Path]]:
if not file_infos:
return file_infos, None
temp_dir = Path(tempfile.mkdtemp(prefix="notebooklm-upload-"))
copied_infos: List[Dict] = []
for info in file_infos:
src = info["source_path"]
dst = temp_dir / src.name
shutil.copy2(src, dst)
cloned = dict(info)
cloned["upload_path"] = dst
copied_infos.append(cloned)
return copied_infos, temp_dir
def _notebook_state_key(notebook_url: str) -> str:
return notebook_url.strip()
def _get_notebook_state_sources(state: Dict, notebook_url: str) -> Dict[str, Dict]:
notebooks = state.setdefault("notebooks", {})
key = _notebook_state_key(notebook_url)
notebook_entry = notebooks.setdefault(key, {"sources": {}, "updated_at": now_iso()})
notebook_entry.setdefault("sources", {})
return notebook_entry["sources"]
def _update_notebook_state_hashes(state: Dict, notebook_url: str, file_infos: List[Dict]) -> None:
sources = _get_notebook_state_sources(state, notebook_url)
for info in file_infos:
sources[info["title"]] = {
"hash": info["hash"],
"size_bytes": info["size_bytes"],
"mtime_epoch": info["mtime_epoch"],
"source_path": str(info["source_path"]),
"updated_at": now_iso(),
}
key = _notebook_state_key(notebook_url)
state.setdefault("notebooks", {}).setdefault(key, {}).update({"updated_at": now_iso()})
def _remove_notebook_state_titles(state: Dict, notebook_url: str, titles: List[str]) -> None:
sources = _get_notebook_state_sources(state, notebook_url)
for title in titles:
sources.pop(title, None)
key = _notebook_state_key(notebook_url)
state.setdefault("notebooks", {}).setdefault(key, {}).update({"updated_at": now_iso()})
def _read_manifest_paths(manifest_path: Path) -> List[str]:
if not manifest_path.exists():
raise ValueError(f"Manifest file not found: {manifest_path}")
text = manifest_path.read_text(encoding="utf-8")
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = None
if isinstance(payload, list):
return [str(item).strip() for item in payload if str(item).strip()]
if isinstance(payload, dict) and isinstance(payload.get("files"), list):
return [str(item).strip() for item in payload["files"] if str(item).strip()]
paths: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
paths.append(stripped)
return paths
def cmd_list_remote_notebooks(args) -> Dict:
def action(page: Page) -> Dict:
auth_err = _go_to_home(page)
if auth_err:
return auth_err
notebooks = _list_remote_notebooks(page)
return {
"status": "success",
"count": len(notebooks),
"notebooks": notebooks,
}
return _run_browser_command(args, "list-remote", action)
def cmd_create_remote_notebook(args) -> Dict:
if args.dry_run:
return {
"status": "dry-run",
"operation": "create-remote",
"name": args.name,
"skip_library": bool(args.skip_library),
"description": args.description,
"topics": parse_csv_values(args.topics),
"profile": sanitize_profile_name(args.profile),
}
def action(page: Page) -> Dict:
auth_err = _go_to_home(page)
if auth_err:
return auth_err
clicked = False
selectors = [
"button[aria-label='Create new notebook']",
"mat-card.create-new-action-button button.primary-action-button",
"mat-card.create-new-action-button",
"button[aria-label='Create notebook']",
]
for selector in selectors:
try:
loc = page.locator(selector)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
clicked = True
break
except PlaywrightError:
continue
if not clicked:
return {"error": "Could not find create notebook button on NotebookLM home page"}
created = _wait_for(lambda: "/notebook/" in page.url and "accounts.google.com" not in page.url, timeout_sec=45)
if not created:
return {"error": "Timed out waiting for new notebook creation"}
page.wait_for_timeout(2000)
notebook_url = page.url.split("?")[0]
id_match = re.search(r"/notebook/([0-9a-fA-F-]+)", notebook_url)
notebook_remote_id = id_match.group(1) if id_match else None
# Set title if requested.
try:
title_input = page.locator("input.title-input").first
if title_input.is_visible():
title_input.click()
try:
page.keyboard.press("Meta+A")
except PlaywrightError:
page.keyboard.press("Control+A")
title_input.fill(args.name)
page.keyboard.press("Enter")
page.wait_for_timeout(1000)
except PlaywrightError:
pass
name = args.name
try:
title_input = page.locator("input.title-input").first
if title_input.count() > 0:
name = title_input.input_value().strip() or args.name
except PlaywrightError:
pass
result: Dict = {
"status": "success",
"notebook": {
"remote_id": notebook_remote_id,
"url": notebook_url,
"name": name,
},
}
if not args.skip_library:
topics = parse_csv_values(args.topics) or ["notebooklm"]
description = (
args.description.strip()
if args.description
else "Notebook created through NotebookLM remote manager"
)
library_entry = _upsert_library_notebook(
url=notebook_url,
name=name,
description=description,
topics=topics,
)
result["library_notebook"] = library_entry
return result
return _run_browser_command(args, "create-remote", action)
def cmd_list_sources(args) -> Dict:
resolved = _resolve_notebook_for_ops(args)
if resolved.get("error"):
return {"error": resolved["error"]}
def action(page: Page) -> Dict:
page.goto(resolved["notebook_url"], wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(2500)
auth_err = _ensure_logged_in(page)
if auth_err:
return auth_err
_ensure_source_panel_open(page)
sources = _read_sources(page)
return {
"status": "success",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"count": len(sources),
"sources": sources,
}
return _run_browser_command(args, "list-sources", action)
def cmd_add_source(args) -> Dict:
resolved = _resolve_notebook_for_ops(args)
if resolved.get("error"):
return {"error": resolved["error"]}
source_modes = [bool(args.text), bool(args.url), bool(args.file), bool(args.dir)]
if sum(1 for mode in source_modes if mode) != 1:
return {"error": "Provide exactly one source type: --text, --url, --file, or --dir"}
if args.text and args.dry_run:
return {
"status": "dry-run",
"operation": "add-source",
"source_type": "text",
"notebook_url": resolved["notebook_url"],
"preview_chars": len(args.text),
}
if args.url and args.dry_run:
return {
"status": "dry-run",
"operation": "add-source",
"source_type": "url",
"notebook_url": resolved["notebook_url"],
"url": args.url,
}
file_infos: List[Dict] = []
filtered_out: List[Dict] = []
temp_dir_path: Optional[Path] = None
if args.file or args.dir:
try:
resolved_files, filtered_out = _collect_source_files(
files=args.file,
dirs=args.dir,
recursive=bool(args.recursive),
include_ext_raw=args.include_ext,
exclude_patterns_raw=args.exclude,
max_size_raw=args.max_size,
modified_since_raw=args.modified_since,
)
except ValueError as exc:
return {"error": str(exc)}
if not resolved_files:
return {
"error": "No files found to upload from provided --file/--dir input",
"filtered_out": filtered_out,
}
file_infos = _make_file_infos(resolved_files)
try:
_ensure_unique_titles(file_infos)
except ValueError as exc:
return {"error": str(exc), "filtered_out": filtered_out}
if args.copy_to_temp:
file_infos, temp_dir_path = _copy_infos_to_temp(file_infos)
if args.dry_run:
return {
"status": "dry-run",
"operation": "add-source",
"source_type": "files",
"notebook_url": resolved["notebook_url"],
"candidate_count": len(file_infos),
"filtered_out": filtered_out,
"files": [
{
"title": info["title"],
"source_path": str(info["source_path"]),
"upload_path": str(info["upload_path"]),
"size_bytes": info["size_bytes"],
}
for info in file_infos
],
}
state = load_source_state()
state_sources = _get_notebook_state_sources(state, resolved["notebook_url"])
try:
def action(page: Page) -> Dict:
page.goto(resolved["notebook_url"], wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(2500)
auth_err = _ensure_logged_in(page)
if auth_err:
return auth_err
_ensure_source_panel_open(page)
before = _read_sources(page)
before_titles = [src["title"] for src in before]
skipped_unchanged: List[Dict] = []
upload_infos = file_infos
if file_infos and args.dedupe_hash:
existing_titles = {src["title"] for src in before}
selected: List[Dict] = []
for info in file_infos:
previous = state_sources.get(info["title"], {})
previous_hash = previous.get("hash")
if previous_hash and previous_hash == info["hash"] and info["title"] in existing_titles:
skipped_unchanged.append(
{
"title": info["title"],
"source_path": str(info["source_path"]),
"hash": info["hash"],
}
)
continue
selected.append(info)
upload_infos = selected
if args.text:
_insert_text_source(page, args.text)
elif args.url:
_insert_url_source(page, args.url)
elif upload_infos:
_upload_file_sources(page, [info["upload_path"] for info in upload_infos])
added_titles: List[str] = []
if args.text or args.url or upload_infos:
page.wait_for_timeout(1200)
added_titles = _wait_for_source_diff(page, before_titles, timeout_sec=args.timeout)
after = _read_sources(page)
result: Dict = {
"status": "success",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"before_count": len(before),
"after_count": len(after),
"added_sources": added_titles,
"filtered_out": filtered_out,
"skipped_unchanged": skipped_unchanged,
}
if file_infos:
result["uploaded_files"] = [str(info["upload_path"]) for info in upload_infos]
result["source_files"] = [str(info["source_path"]) for info in upload_infos]
if args.dir:
result["uploaded_dirs"] = [str(Path(raw).expanduser().resolve()) for raw in args.dir]
if args.copy_to_temp and temp_dir_path:
result["temp_upload_dir"] = str(temp_dir_path)
result["uploaded_count"] = len(upload_infos)
return result
result = _run_browser_command(args, "add-source", action)
if result.get("status") == "success" and file_infos:
uploaded_sources = set(result.get("source_files", []))
uploaded_infos = [
info
for info in file_infos
if str(info["source_path"]) in uploaded_sources
]
if uploaded_infos:
_update_notebook_state_hashes(state, resolved["notebook_url"], uploaded_infos)
save_source_state(state)
return result
finally:
if temp_dir_path and temp_dir_path.exists():
shutil.rmtree(temp_dir_path, ignore_errors=True)
def cmd_delete_source(args) -> Dict:
resolved = _resolve_notebook_for_ops(args)
if resolved.get("error"):
return {"error": resolved["error"]}
def action(page: Page) -> Dict:
page.goto(resolved["notebook_url"], wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(2500)
auth_err = _ensure_logged_in(page)
if auth_err:
return auth_err
_ensure_source_panel_open(page)
sources = _read_sources(page)
if not sources:
return {"error": "No sources found in notebook"}
indexes = _find_matching_source_indexes(sources, args.source_title, args.contains)
if not indexes:
return {"error": f"No source matched: {args.source_title}"}
matched_titles = [sources[i]["title"] for i in indexes]
if len(indexes) > 1 and not args.all_matches:
return {
"error": (
f"Matched {len(indexes)} sources. Re-run with --all-matches "
"or use a more specific title."
),
"matches": matched_titles,
}
to_delete_titles = matched_titles if args.all_matches else [matched_titles[0]]
if args.dry_run:
return {
"status": "dry-run",
"operation": "delete-source",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"matched_count": len(to_delete_titles),
"matched_titles": to_delete_titles,
}
removed: List[str] = []
for title in to_delete_titles:
current_sources = _read_sources(page)
current_indexes = _find_matching_source_indexes(
current_sources, title, contains=False
)
if not current_indexes:
continue
_delete_source_once(page, current_indexes[0])
disappeared = _wait_for(
lambda: len(_find_matching_source_indexes(_read_sources(page), title, contains=False)) == 0,
timeout_sec=35,
poll_ms=500,
)
if disappeared:
removed.append(title)
if removed:
state = load_source_state()
_remove_notebook_state_titles(state, resolved["notebook_url"], removed)
save_source_state(state)
final_sources = _read_sources(page)
return {
"status": "success",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"removed_sources": removed,
"remaining_count": len(final_sources),
}
return _run_browser_command(args, "delete-source", action)
def cmd_sync_sources(args) -> Dict:
resolved = _resolve_notebook_for_ops(args)
if resolved.get("error"):
return {"error": resolved["error"]}
manifest_files: List[str] = []
if args.manifest:
try:
manifest_files = _read_manifest_paths(Path(args.manifest).expanduser().resolve())
except ValueError as exc:
return {"error": str(exc)}
combined_files = list(args.file or []) + manifest_files
try:
resolved_files, filtered_out = _collect_source_files(
files=combined_files,
dirs=args.dir,
recursive=bool(args.recursive),
include_ext_raw=args.include_ext,
exclude_patterns_raw=args.exclude,
max_size_raw=args.max_size,
modified_since_raw=args.modified_since,
)
except ValueError as exc:
return {"error": str(exc)}
if not resolved_files and not args.delete_missing:
return {
"error": "No local files resolved for sync. Provide --file/--dir/--manifest or use --delete-missing",
"filtered_out": filtered_out,
}
file_infos = _make_file_infos(resolved_files)
try:
_ensure_unique_titles(file_infos)
except ValueError as exc:
return {"error": str(exc), "filtered_out": filtered_out}
if args.copy_to_temp:
file_infos, temp_dir = _copy_infos_to_temp(file_infos)
else:
temp_dir = None
local_by_title = {info["title"]: info for info in file_infos}
state = load_source_state()
state_sources = _get_notebook_state_sources(state, resolved["notebook_url"])
try:
def action(page: Page) -> Dict:
page.goto(resolved["notebook_url"], wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(2500)
auth_err = _ensure_logged_in(page)
if auth_err:
return auth_err
_ensure_source_panel_open(page)
before_sources = _read_sources(page)
remote_titles = [src["title"] for src in before_sources]
remote_title_set = set(remote_titles)
add_infos: List[Dict] = []
update_infos: List[Dict] = []
unchanged_infos: List[Dict] = []
for title, info in local_by_title.items():
if title not in remote_title_set:
add_infos.append(info)
continue
previous_hash = state_sources.get(title, {}).get("hash")
if args.force_update:
update_infos.append(info)
elif previous_hash and previous_hash != info["hash"]:
update_infos.append(info)
else:
unchanged_infos.append(info)
delete_titles: List[str] = []
if args.delete_missing:
for title in sorted(remote_title_set):
if title not in local_by_title:
delete_titles.append(title)
plan = {
"to_add": [info["title"] for info in add_infos],
"to_update": [info["title"] for info in update_infos],
"to_delete": delete_titles,
"unchanged": [info["title"] for info in unchanged_infos],
}
if args.dry_run:
return {
"status": "dry-run",
"operation": "sync-sources",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"plan": plan,
"filtered_out": filtered_out,
"local_count": len(file_infos),
"remote_count": len(before_sources),
}
removed_titles: List[str] = []
for title in delete_titles + [info["title"] for info in update_infos]:
removed_count = _delete_all_exact_title(page, title)
if removed_count > 0:
removed_titles.append(title)
upload_infos = add_infos + update_infos
added_titles: List[str] = []
if upload_infos:
before_after_delete = _read_sources(page)
before_titles = [src["title"] for src in before_after_delete]
_upload_file_sources(page, [info["upload_path"] for info in upload_infos])
page.wait_for_timeout(1200)
added_titles = _wait_for_source_diff(page, before_titles, timeout_sec=args.timeout)
final_sources = _read_sources(page)
return {
"status": "success",
"operation": "sync-sources",
"notebook_url": resolved["notebook_url"],
"notebook_id": resolved.get("notebook_id"),
"plan": plan,
"filtered_out": filtered_out,
"uploaded_titles": [info["title"] for info in upload_infos],
"added_sources": added_titles,
"removed_titles": removed_titles,
"before_count": len(before_sources),
"after_count": len(final_sources),
"final_sources": [src["title"] for src in final_sources],
}
result = _run_browser_command(args, "sync-sources", action)
if result.get("status") == "success":
# Record current local desired state hashes to enable future update detection/deduping.
_update_notebook_state_hashes(state, resolved["notebook_url"], list(local_by_title.values()))
if args.delete_missing:
existing_titles = set(local_by_title.keys())
current_state_titles = list(_get_notebook_state_sources(state, resolved["notebook_url"]).keys())
to_remove = [title for title in current_state_titles if title not in existing_titles]
if to_remove:
_remove_notebook_state_titles(state, resolved["notebook_url"], to_remove)
save_source_state(state)
return result
finally:
if temp_dir and temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
def _add_common_browser_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--show-browser", action="store_true", help="Show browser instead of background mode")
parser.add_argument("--profile", default="default", help="Auth/browser profile name (default: default)")
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)",
)
def _add_filter_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--include-ext",
help="Comma-separated file extensions to include, e.g. md,txt,pdf",
)
parser.add_argument(
"--exclude",
action="append",
help="Exclude glob pattern(s), repeatable or comma-separated",
)
parser.add_argument("--max-size", help="Max file size (e.g. 5MB, 120KB)")
parser.add_argument(
"--modified-since",
help="Only include files modified since ISO date/time or relative value like 7d, 24h, 90m",
)
def main() -> None:
parser = argparse.ArgumentParser(description="NotebookLM remote notebook manager")
subparsers = parser.add_subparsers(dest="command", required=True)
list_remote_parser = subparsers.add_parser("list-remote", help="List all visible notebooks from NotebookLM account")
_add_common_browser_args(list_remote_parser)
create_remote_parser = subparsers.add_parser("create-remote", help="Create a new notebook in NotebookLM")
create_remote_parser.add_argument("--name", required=True, help="New notebook name")
create_remote_parser.add_argument(
"--skip-library",
action="store_true",
help="Do not add created notebook to local library",
)
create_remote_parser.add_argument("--description", help="Local library description")
create_remote_parser.add_argument("--topics", help="Local library topics (comma-separated)")
create_remote_parser.add_argument("--dry-run", action="store_true", help="Preview create action without making changes")
_add_common_browser_args(create_remote_parser)
list_sources_parser = subparsers.add_parser("list-sources", help="List sources in a notebook")
list_sources_parser.add_argument("--notebook-id", help="Notebook ID from local library")
list_sources_parser.add_argument("--notebook-url", help="NotebookLM URL")
_add_common_browser_args(list_sources_parser)
add_source_parser = subparsers.add_parser("add-source", help="Add source to a notebook")
add_source_parser.add_argument("--notebook-id", help="Notebook ID from local library")
add_source_parser.add_argument("--notebook-url", help="NotebookLM URL")
add_source_parser.add_argument("--text", help="Copied text source content")
add_source_parser.add_argument("--url", help="Website/YouTube URL source")
add_source_parser.add_argument(
"--file",
action="append",
help="Local file path to upload as source (repeat for multiple files)",
)
add_source_parser.add_argument(
"--dir",
action="append",
help="Directory containing files to upload as sources",
)
add_source_parser.add_argument(
"--recursive",
action="store_true",
help="When using --dir, include files from nested subdirectories",
)
add_source_parser.add_argument(
"--copy-to-temp",
action="store_true",
help="Copy files to a temporary directory before uploading",
)
add_source_parser.add_argument(
"--no-dedupe-hash",
dest="dedupe_hash",
action="store_false",
help="Disable hash-based dedupe for file uploads",
)
add_source_parser.set_defaults(dedupe_hash=True)
add_source_parser.add_argument("--timeout", type=int, default=120, help="Timeout in seconds for source to appear")
add_source_parser.add_argument("--dry-run", action="store_true", help="Preview source changes without mutating remote notebook")
_add_filter_args(add_source_parser)
_add_common_browser_args(add_source_parser)
delete_source_parser = subparsers.add_parser("delete-source", help="Delete source(s) from a notebook")
delete_source_parser.add_argument("--notebook-id", help="Notebook ID from local library")
delete_source_parser.add_argument("--notebook-url", help="NotebookLM URL")
delete_source_parser.add_argument("--source-title", required=True, help="Source title to delete")
delete_source_parser.add_argument(
"--contains",
action="store_true",
help="Match source title by substring instead of exact match",
)
delete_source_parser.add_argument(
"--all-matches",
action="store_true",
help="Delete all matched sources (otherwise deletes one)",
)
delete_source_parser.add_argument("--dry-run", action="store_true", help="Preview matched deletions without deleting")
_add_common_browser_args(delete_source_parser)
sync_sources_parser = subparsers.add_parser(
"sync-sources",
help="Sync local files to notebook sources with add/update/delete planning",
)
sync_sources_parser.add_argument("--notebook-id", help="Notebook ID from local library")
sync_sources_parser.add_argument("--notebook-url", help="NotebookLM URL")
sync_sources_parser.add_argument(
"--file",
action="append",
help="Local file path(s) to include in desired source state",
)
sync_sources_parser.add_argument(
"--dir",
action="append",
help="Directory containing desired source files",
)
sync_sources_parser.add_argument(
"--manifest",
help="Manifest file path containing file list (JSON list or newline-delimited)",
)
sync_sources_parser.add_argument(
"--recursive",
action="store_true",
help="When using --dir, include nested files",
)
sync_sources_parser.add_argument(
"--delete-missing",
action="store_true",
help="Delete remote sources that are missing from local desired state",
)
sync_sources_parser.add_argument(
"--force-update",
action="store_true",
help="Re-upload sources that already exist even when hash has not changed",
)
sync_sources_parser.add_argument(
"--copy-to-temp",
action="store_true",
help="Copy files to temporary folder before upload",
)
sync_sources_parser.add_argument("--timeout", type=int, default=180, help="Timeout in seconds for source updates")
sync_sources_parser.add_argument("--dry-run", action="store_true", help="Preview sync plan without mutating remote notebook")
_add_filter_args(sync_sources_parser)
_add_common_browser_args(sync_sources_parser)
args = parser.parse_args()
handlers = {
"list-remote": cmd_list_remote_notebooks,
"create-remote": cmd_create_remote_notebook,
"list-sources": cmd_list_sources,
"add-source": cmd_add_source,
"delete-source": cmd_delete_source,
"sync-sources": cmd_sync_sources,
}
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