docs: major documentation restructure and cleanup for v0.2.0

Documentation consolidation:
- Merge docs/contributing/*.md into docs/development.md
- Merge docs/reference/internals/*.md into docs/rpc-development.md
- Move rpc-ui-reference.md to docs/rpc-reference.md
- Consolidate examples/ into docs/examples/ (6 files total)
- Remove getting-started.md (content in README)
- Remove docs/README.md (navigation implicit)

Cleanup:
- Remove AGENTS.md (redundant with CLAUDE.md)
- Remove RELEASING.md (merged into docs/development.md)
- Remove .gemini/ and .github/copilot-instructions.md
- Remove investigation files and artifacts
- Add gitignore for auto-generated CLAUDE.md files

Version bump: 0.1.4 → 0.2.0 (new features per stability.md)

Final structure:
  docs/
  ├── cli-reference.md      # User docs
  ├── python-api.md
  ├── configuration.md
  ├── troubleshooting.md
  ├── stability.md
  ├── development.md        # Contributor docs (merged)
  ├── rpc-development.md    # RPC docs (merged)
  ├── rpc-reference.md
  ├── examples/             # Consolidated examples
  └── designs/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Teng Lin 2026-01-13 23:13:55 -05:00
parent 3de99fb928
commit fbc4fd5de7
59 changed files with 820 additions and 5790 deletions

View file

@ -1,44 +0,0 @@
# Code Review Style Guide
This project is an unofficial Python client for Google NotebookLM using undocumented RPC APIs.
## Key Review Focus Areas
### Async Patterns
- All API methods should be `async` and use `await` properly
- Use `async with` for context managers (e.g., `NotebookLMClient`)
- Avoid blocking calls in async code
### RPC Layer (`src/notebooklm/rpc/`)
- Method IDs in `types.py` are undocumented and may change
- Parameter positions are critical - verify against existing patterns
- Source ID nesting varies: `[id]`, `[[id]]`, `[[[id]]]`, `[[[[id]]]]`
### Error Handling
- Use specific exception types from `types.py` (e.g., `SourceProcessingError`, `SourceTimeoutError`)
- Don't catch generic `Exception` unless re-raising
### Type Annotations
- All public APIs should have type hints
- Use `dataclasses` for data structures (see `types.py`)
### Testing
- Unit tests: no network calls, mock at RPC layer
- Integration tests: mock HTTP responses
- E2E tests: marked with `@pytest.mark.e2e`, require auth
### CLI (`src/notebooklm/cli/`)
- Use Click decorators consistently
- Follow existing command patterns in `helpers.py`
## Code Style
- Formatter: `ruff format`
- Linter: `ruff check`
- Python: 3.10+
- Follow PEP-8 with 88-character line limit (Black-compatible)
## What NOT to Flag
- Undocumented RPC method IDs (these are intentionally opaque)
- `# type: ignore` comments on RPC responses (necessary for untyped Google APIs)

View file

@ -1,39 +0,0 @@
# Code Review Guidelines for notebooklm-py
## Project Context
This is an unofficial Python client for Google NotebookLM using undocumented RPC APIs. The codebase uses async/await patterns extensively with httpx for HTTP.
## Security
- Flag any hardcoded credentials, API keys, or tokens
- Check for proper CSRF token handling in RPC calls
- Ensure cookies and auth data are not logged or exposed
- Verify input validation for user-provided URLs and content
## Python Patterns
- Require type hints for public API methods in `client.py` and `_*.py` files
- Prefer `async with` context managers for HTTP client lifecycle
- Use `httpx.AsyncClient` consistently (not requests or aiohttp)
- Check for proper exception handling in async code
## Architecture
- RPC method IDs in `rpc/types.py` are the source of truth
- Verify parameter nesting matches existing patterns (some need `[id]`, others `[[id]]`, etc.)
- CLI commands should use Click decorators consistently
- Client methods should be in the appropriate namespace (`notebooks`, `sources`, `artifacts`, `chat`)
## Testing
- New features should have corresponding tests
- Mock HTTP responses in integration tests, don't call real APIs
- E2E tests are separate and require authentication
## Style
- Line length limit is 100 characters
- Use double quotes for strings
- Imports should be sorted (ruff handles this)
- No trailing whitespace

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

10
.gitignore vendored
View file

@ -19,6 +19,16 @@ captured_rpcs/
.sisyphus/
.claude/
# Auto-generated by claude-mem plugin
**/CLAUDE.md
!/CLAUDE.md
# Investigation artifacts
investigate*.py
INVESTIGATION*.md
investigation_output/
downloads/
# VCR cassettes - committed after security review
# Cassettes are scrubbed of sensitive data (cookies, tokens, user IDs, emails)
# See tests/vcr_config.py for scrubbing patterns

115
AGENTS.md
View file

@ -1,115 +0,0 @@
# AGENTS.md
Guidelines for AI agents working on `notebooklm-py`.
**IMPORTANT:** Follow documentation rules in [CONTRIBUTING.md](CONTRIBUTING.md) - especially the file creation and naming conventions.
## Quick Reference
See [CLAUDE.md](CLAUDE.md) for full project context. Essential commands:
```bash
source .venv/bin/activate # Always activate venv first
pytest # Run tests
pip install -e ".[all]" # Install in dev mode
```
## Code Style Guidelines
### Type Annotations (Python 3.10+)
```python
def process(items: list[str]) -> dict[str, Any]: ...
async def query(notebook_id: str, source_ids: Optional[list[str]] = None): ...
# Use TYPE_CHECKING for circular imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..api_client import NotebookLMClient
```
### Async Patterns
```python
# All client methods are async - use namespaced APIs
async with await NotebookLMClient.from_storage() as client:
notebooks = await client.notebooks.list()
await client.sources.add_url(nb_id, url)
result = await client.chat.ask(nb_id, question)
```
### Data Structures
```python
@dataclass
class Notebook:
id: str
title: str
created_at: Optional[datetime] = None
@classmethod
def from_api_response(cls, data: list[Any]) -> "Notebook": ...
```
### Enums for Constants
```python
class RPCMethod(str, Enum):
LIST_NOTEBOOKS = "wXbhsf"
class AudioFormat(int, Enum):
DEEP_DIVE = 1
```
### Error Handling
```python
class RPCError(Exception):
def __init__(self, message: str, rpc_id: Optional[str] = None, code: Optional[Any] = None):
self.rpc_id, self.code = rpc_id, code
super().__init__(message)
raise RPCError(f"No result found for RPC ID: {rpc_id}", rpc_id=rpc_id)
raise ValueError(f"Invalid YouTube URL: {url}") # For validation
```
### Docstrings
```python
def decode_response(raw_response: str, rpc_id: str, allow_null: bool = False) -> Any:
"""Complete decode pipeline: strip prefix -> parse chunks -> extract result.
Args:
raw_response: Raw response text from batchexecute
rpc_id: RPC method ID to extract result for
allow_null: If True, return None instead of raising when null
Returns:
Decoded result data
Raises:
RPCError: If RPC returned an error or result not found
"""
```
## Testing Patterns
```python
# Class-based for related tests
class TestDecodeResponse:
def test_full_decode_pipeline(self): ...
# Markers
@pytest.mark.e2e # End-to-end (requires auth)
@pytest.mark.slow # Long-running (audio/video)
@pytest.mark.asyncio # Async tests
# Async tests
@pytest.mark.asyncio
async def test_list_notebooks(self, client):
notebooks = await client.notebooks.list()
assert isinstance(notebooks, list)
```
## Do NOT
- Suppress type errors with `# type: ignore`
- Commit `.env` files or credentials
- Add dependencies without updating `pyproject.toml`
- Change RPC method IDs without verifying via network capture
- Delete or modify e2e tests without running them
- Create documentation files without following CONTRIBUTING.md rules

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.0] - 2026-01-13
### Added
- **Source fulltext extraction** - Retrieve the complete indexed text content of any source
- New `client.sources.get_fulltext(notebook_id, source_id)` Python API
@ -17,6 +19,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Each reference includes `source_id`, `cited_text`, `start_char`, `end_char`, `chunk_id`
- Use `notebooklm ask "question" --json` to see references in CLI output
- **Source status helper** - New `source_status_to_str()` function for consistent status display
- **Quiz and flashcard downloads** - Export interactive study materials in multiple formats
- New `download quiz` and `download flashcards` CLI commands
- Supports JSON, Markdown, and HTML output formats via `--format` flag
- Python API: `client.artifacts.download_quiz()` and `client.artifacts.download_flashcards()`
- **Extended artifact downloads** - Download additional artifact types
- New `download report` command (exports as Markdown)
- New `download mind-map` command (exports as JSON)
- New `download data-table` command (exports as CSV)
- All download commands support `--all`, `--latest`, `--name`, and `--artifact` selection options
### Fixed
- **Regional Google domain authentication** - SID cookie extraction now works with regional Google domains (e.g., google.co.uk, google.de) in addition to google.com
- **URL hostname validation** - Use proper URL parsing instead of string operations for security
### Changed
- **Pre-commit checks** - Added mypy type checking to required pre-commit workflow
@ -139,12 +154,13 @@ This is the initial public release of `notebooklm-py`. While core functionality
### Known Issues
- **RPC method IDs may change**: Google can update their internal APIs at any time, breaking this library. Check the [RPC Capture](docs/reference/internals/rpc-capture.md) for how to identify and update method IDs.
- **RPC method IDs may change**: Google can update their internal APIs at any time, breaking this library. Check the [RPC Development Guide](docs/rpc-development.md) for how to identify and update method IDs.
- **Rate limiting**: Heavy usage may trigger Google's rate limits. Add delays between bulk operations.
- **Authentication expiry**: CSRF tokens expire after some time. Re-run `notebooklm login` if you encounter auth errors.
- **Large file uploads**: Files over 50MB may fail or timeout. Split large documents if needed.
[Unreleased]: https://github.com/teng-lin/notebooklm-py/compare/v0.1.4...HEAD
[Unreleased]: https://github.com/teng-lin/notebooklm-py/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/teng-lin/notebooklm-py/compare/v0.1.4...v0.2.0
[0.1.4]: https://github.com/teng-lin/notebooklm-py/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/teng-lin/notebooklm-py/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/teng-lin/notebooklm-py/compare/v0.1.1...v0.1.2

View file

@ -178,12 +178,13 @@ Commands are organized as:
## Documentation
All docs use lowercase-kebab naming in `docs/`:
- `docs/getting-started.md` - Installation and first workflow
- `docs/cli-reference.md` - CLI commands
- `docs/python-api.md` - Python API reference
- `docs/configuration.md` - Storage and settings
- `docs/troubleshooting.md` - Known issues
- `docs/contributing/` - Architecture, debugging, testing guides
- `docs/development.md` - Architecture, testing, releasing
- `docs/rpc-development.md` - RPC capture and debugging
- `docs/rpc-reference.md` - RPC payload structures
## When to Suggest CLI vs API

View file

@ -111,8 +111,8 @@ Design decisions should be captured where they're most useful, not in separate d
|------|--------|---------|
| Root GitHub files | `UPPERCASE.md` | `README.md`, `CONTRIBUTING.md` |
| Agent files | `UPPERCASE.md` | `CLAUDE.md`, `AGENTS.md` |
| Folder README | `README.md` | `docs/README.md` (GitHub auto-renders) |
| All other docs/ files | `lowercase-kebab.md` | `getting-started.md`, `cli-reference.md` |
| Subfolder README | `README.md` | `docs/examples/README.md` |
| All other docs/ files | `lowercase-kebab.md` | `cli-reference.md`, `contributing.md` |
| Scratch files | `YYYY-MM-DD-context.md` | `2026-01-06-debug-auth.md` |
### Status Headers
@ -130,7 +130,7 @@ Agents should ignore files marked `Deprecated`.
1. **Link, Don't Copy** - Reference README.md sections instead of repeating commands. Prevents drift between docs.
2. **Scoped Instructions** - See `docs/README.md` for folder-specific documentation rules.
2. **Scoped Instructions** - Subfolders like `docs/examples/` may have their own README.md with folder-specific rules.
---
@ -138,19 +138,14 @@ Agents should ignore files marked `Deprecated`.
```
docs/
├── README.md # Folder-specific rules
├── getting-started.md # Installation and first workflow
├── cli-reference.md # CLI command reference
├── python-api.md # Python API reference
├── configuration.md # Storage and settings
├── troubleshooting.md # Common issues and solutions
├── stability.md # API versioning policy
├── development.md # Architecture, testing, releasing
├── rpc-development.md # RPC capture and debugging
├── rpc-reference.md # RPC payload structures
├── examples/ # Runnable example scripts
├── contributing/ # Contributor guides
│ ├── architecture.md # Code structure
│ ├── debugging.md # Network capture guide
│ └── testing.md # Running tests
├── reference/
│ └── internals/ # Reverse engineering notes
└── scratch/ # Temporary agent work (disposable)
└── YYYY-MM-DD-context.md
└── designs/ # Design decisions (ADRs)
```

View file

@ -1,189 +0,0 @@
# Flashcard & Quiz Download Investigation Findings
## Summary
**Successfully discovered how to download quiz and flashcard content from NotebookLM.**
The key discovery is a new RPC method `v9rmvd` (not previously documented) that returns the full quiz/flashcard content as a self-contained HTML document with embedded JSON data.
## Key Findings
### 1. New RPC Method: `v9rmvd`
- **RPC ID**: `v9rmvd`
- **Parameters**: `[artifact_id]`
- **Purpose**: Fetch quiz/flashcard content for display
- **Returns**: Full artifact data with HTML at position `[9][0]`
### 2. Response Structure
```
[
[0] artifact_id
[1] title
[2] type (4 = quiz/flashcard)
[3] [[source_ids]]
[4] status
[5-8] null
[9] [html_content, metadata] <-- The actual content!
[10] timestamps
...
]
```
Position `[9][0]` contains a complete HTML document with:
- Embedded CSS/fonts
- JavaScript for interactivity
- **JSON data with all questions/answers**
### 3. Quiz Data Structure
The HTML contains a `<script>` tag with quiz data in this format:
```json
{
"question": "What is the core philosophy of the project?",
"answerOptions": [
{
"text": "The model itself is the agent...",
"rationale": "This aligns with the project's philosophy...",
"isCorrect": true
},
{
"text": "The number and variety of tools...",
"rationale": "While tools are necessary...",
"isCorrect": false
}
],
"hint": "Consider the stated ratio of importance..."
}
```
### 4. Flashcard Data Structure
```json
{
"f": "What is the fundamental loop that every coding agent is based on?",
"b": "A loop where the model calls tools until it's done, and the results are appended to the message history."
}
```
- `f` = front (question)
- `b` = back (answer)
## Implementation Plan
### Step 1: Add RPC Method
Add to `src/notebooklm/rpc/types.py`:
```python
GET_ARTIFACT_CONTENT = "v9rmvd" # Fetch quiz/flashcard/interactive content
```
### Step 2: Create Content Fetcher
In `_artifacts.py`, add method to fetch content:
```python
async def _get_artifact_content(self, artifact_id: str) -> list | None:
"""Fetch full artifact content including HTML for interactive types."""
return await self._core.rpc_call(
RPCMethod.GET_ARTIFACT_CONTENT,
[artifact_id],
source_path=f"/notebook/{self._current_notebook_id}",
allow_null=True,
)
```
### Step 3: Parse Quiz/Flashcard Data
Extract JSON from HTML response:
```python
import re
import json
def _parse_quiz_from_html(html: str) -> list[dict]:
"""Extract quiz questions from embedded HTML."""
# Find the JSON data embedded in the HTML
match = re.search(r'"questions"\s*:\s*\[(.*?)\]\s*}', html, re.DOTALL)
if match:
return json.loads(f"[{match.group(1)}]")
return []
def _parse_flashcards_from_html(html: str) -> list[dict]:
"""Extract flashcard data from embedded HTML."""
# Similar pattern for flashcards
match = re.search(r'"cards"\s*:\s*\[(.*?)\]\s*}', html, re.DOTALL)
if match:
return json.loads(f"[{match.group(1)}]")
return []
```
### Step 4: Download Methods
```python
async def download_quiz(
self,
notebook_id: str,
output_path: Path,
artifact_id: str | None = None,
format: str = "json" # json, markdown, html
) -> Path:
"""Download quiz questions."""
async def download_flashcards(
self,
notebook_id: str,
output_path: Path,
artifact_id: str | None = None,
format: str = "json" # json, markdown, html
) -> Path:
"""Download flashcard deck."""
```
### Step 5: Export Formats
**JSON Export:**
```json
{
"title": "Agent Quiz",
"questions": [
{
"question": "...",
"options": [{"text": "...", "correct": true}, ...],
"hint": "..."
}
]
}
```
**Markdown Export:**
```markdown
# Agent Quiz
## Question 1
What is the core philosophy of the project?
- [ ] The agent's performance is primarily determined by clever engineering
- [x] The model itself is the agent, and the surrounding code's main job is to provide tools
**Hint:** Consider the stated ratio of importance...
```
**HTML Export:**
Return the original HTML for interactive use.
## Test Data
- Notebook: `167481cd-23a3-4331-9a45-c8948900bf91` (Claude Code High School)
- Quiz ID: `a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767` (Agent Quiz)
- Flashcard ID: `173255d8-12b3-4c67-b925-a76ce6c71735` (Agent Flashcards)
## Files Created During Investigation
- `investigate_v9rmvd_direct.py` - Working script to fetch quiz/flashcard content
- `investigation_output/quiz_content_v9rmvd.json` - Full quiz response
- `investigation_output/flashcard_content_v9rmvd.json` - Full flashcard response
## Next Steps
1. Add `GET_ARTIFACT_CONTENT = "v9rmvd"` to rpc/types.py
2. Implement `download_quiz()` and `download_flashcards()` in `_artifacts.py`
3. Add CLI commands for downloading
4. Write unit tests with mock data
5. Test with real API (e2e tests)

View file

@ -48,7 +48,6 @@ pip install notebooklm-py
pip install "notebooklm-py[browser]"
playwright install chromium
```
See [Installation](#installation) for options.
## Quick Start
@ -109,12 +108,12 @@ asyncio.run(main())
### Agent Skills (Claude Code)
```bash
# Install the skill
# Install via CLI or ask Claude Code to do it
notebooklm skill install
# Then use natural language in Claude Code:
# Then use natural language:
# "Create a podcast about quantum computing"
# "Summarize these URLs into a notebook"
# "Download the quiz as markdown"
# "/notebooklm generate video"
```
@ -127,12 +126,11 @@ notebooklm skill install
| **Chat** | Questions, conversation history, custom personas |
| **Generation** | Audio podcasts, video, slides, quizzes, flashcards, reports, infographics, mind maps |
| **Research** | Web and Drive research agents with auto-import |
| **Downloads** | Audio, video, slides, infographics |
| **Downloads** | Audio, video, slides, infographics, reports, mind maps, data tables, quizzes, flashcards |
| **Agent Skills** | Claude Code skill for LLM-driven automation |
## Documentation
- **[Getting Started](docs/getting-started.md)** - Installation and first workflow
- **[CLI Reference](docs/cli-reference.md)** - Complete command documentation
- **[Python API](docs/python-api.md)** - Full API reference
- **[Configuration](docs/configuration.md)** - Storage and settings
@ -141,10 +139,9 @@ notebooklm skill install
### For Contributors
- **[Architecture](docs/contributing/architecture.md)** - Code structure
- **[Testing](docs/contributing/testing.md)** - Running and writing tests
- **[RPC Capture](docs/reference/internals/rpc-capture.md)** - Protocol reference and capture guides
- **[Debugging](docs/contributing/debugging.md)** - Network capture guide
- **[Development Guide](docs/development.md)** - Architecture, testing, and releasing
- **[RPC Development](docs/rpc-development.md)** - Protocol capture and debugging
- **[RPC Reference](docs/rpc-reference.md)** - Payload structures
- **[Changelog](CHANGELOG.md)** - Version history and release notes
- **[Security](SECURITY.md)** - Security policy and credential handling

View file

@ -1,50 +0,0 @@
# Releasing notebooklm-py
## Pre-Release Checklist
### Code Quality
- [ ] `mypy src/notebooklm` - 0 errors
- [ ] `pytest tests/unit tests/integration -v` - 100% pass
- [ ] `coverage report` - >= 70% coverage (target: 80%)
- [ ] No TODO/FIXME in critical paths
### Packaging
- [ ] Version bumped in `pyproject.toml`
- [ ] Version bumped in `src/notebooklm/__init__.py`
- [ ] CHANGELOG.md updated with release date
- [ ] GitHub URLs correct (not "clinet")
- [ ] License file present
### Documentation
- [ ] README examples work copy-paste
- [ ] CLI help text accurate (`notebooklm --help`)
- [ ] Python API docs match actual signatures
### Manual Testing
- [ ] `pip install .` succeeds
- [ ] `notebooklm login` opens browser
- [ ] `notebooklm list` shows notebooks
- [ ] `notebooklm create "Test"` succeeds
- [ ] `notebooklm source add <url>` succeeds
- [ ] `notebooklm ask "question"` returns answer
### PyPI Publishing
1. Test on TestPyPI first:
```bash
hatch build
hatch publish -r test
```
2. Verify at https://test.pypi.org/project/notebooklm-py/
3. Publish to PyPI:
```bash
hatch publish
```
## Version Bumping
1. Update version in `pyproject.toml`
2. Update version in `src/notebooklm/__init__.py`
3. Update CHANGELOG.md
4. Commit: `git commit -m "chore: bump version to X.Y.Z"`
5. Tag: `git tag vX.Y.Z`
6. Push: `git push && git push --tags`

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -1,35 +0,0 @@
# Documentation Folder
**Status:** Active
**Last Updated:** 2026-01-08
This folder contains all project documentation. AI agents must follow the rules in `/CONTRIBUTING.md`.
## Folder Structure
| Folder | Purpose | File Format |
|--------|---------|-------------|
| `contributing/` | Contributor guides | `lowercase-kebab.md` |
| `examples/` | Runnable example scripts | `lowercase-kebab.py` |
| `reference/internals/` | Reverse engineering notes | `lowercase-kebab.md` |
| `designs/` | Approved design decisions | `lowercase-kebab.md` |
| `scratch/` | Temporary agent work | `YYYY-MM-DD-context.md` |
## Rules for This Folder
1. **Root files are defined** - Only the files listed in "Top-Level Files" belong at `docs/` root. All other docs go in subfolders.
2. **Reference docs are stable** - Only update `reference/` files when fixing errors or adding significant new information.
3. **Designs are living docs** - Files in `designs/` document architectural decisions. Delete when implementation is complete and design is captured elsewhere (code comments, PRs).
4. **Scratch is temporary** - Files in `scratch/` can be deleted after 30 days. Always use date prefix.
## Top-Level Files
- `getting-started.md` - Installation and first workflow
- `cli-reference.md` - Complete CLI command reference
- `python-api.md` - Python API reference with examples
- `configuration.md` - Storage, environment variables, settings
- `troubleshooting.md` - Common errors and known issues
- `releasing.md` - PyPI release checklist and process

View file

@ -1,379 +0,0 @@
# Adding New RPC Methods
**Status:** Active
**Last Updated:** 2026-01-07
Step-by-step guide for adding new RPC methods to `notebooklm-py`.
## When You Need This
- **New feature**: NotebookLM adds a feature you want to support
- **Method ID changed**: Google updated an RPC ID (causes `RPCError: No result found`)
- **Payload changed**: Existing method returns unexpected results
## Overview
```
1. Capture → 2. Decode → 3. Implement → 4. Test → 5. Document
```
| Step | Tools | Output |
|------|-------|--------|
| Capture | Chrome DevTools or Playwright | Raw request/response |
| Decode | Browser console or Python | RPC ID + params structure |
| Implement | Code editor | types.py + _*.py changes |
| Test | pytest | Unit + integration + E2E tests |
| Document | Markdown | Update rpc-ui-reference.md |
---
## Step 1: Capture the RPC Call
Use one of these approaches (see [rpc-capture.md](../reference/internals/rpc-capture.md) for details):
### Quick: Chrome DevTools
1. Open NotebookLM in Chrome
2. Open DevTools → Network tab
3. Filter by `batchexecute`
4. Perform the action in the UI
5. Click the request → copy from Headers and Payload tabs
### Systematic: Playwright
```python
# See rpc-capture.md for full setup
async def capture_action(page, action_name):
# Clear, perform action, capture request
...
```
### What to Capture
From the request:
- **URL `rpcids` parameter**: The 6-character RPC ID (e.g., `wXbhsf`)
- **`f.req` body**: URL-encoded payload
From the response:
- **Response body**: Starts with `)]}'\n`, contains result
---
## Step 2: Decode the Payload
### Decode f.req in Browser Console
```javascript
// Paste the f.req value
const encoded = "[[...encoded data...]]";
const decoded = decodeURIComponent(encoded);
const outer = JSON.parse(decoded);
console.log("RPC ID:", outer[0][0][0]);
console.log("Params:", JSON.parse(outer[0][0][1]));
```
### Decode in Python
```python
import json
from urllib.parse import unquote
def decode_f_req(encoded: str) -> dict:
decoded = unquote(encoded)
outer = json.loads(decoded)
inner = outer[0][0]
return {
"rpc_id": inner[0],
"params": json.loads(inner[1]) if inner[1] else None,
}
```
### Understand the Structure
RPC params are **position-sensitive arrays**. Document each position:
```python
# Example: ADD_SOURCE for URL
params = [
[[None, None, [url], None, None, None, None, None]], # 0: Source data, URL at [2]
notebook_id, # 1: Notebook ID
[2], # 2: Fixed flag
None, # 3: Optional settings
None, # 4: Optional settings
]
```
Key patterns to identify:
- **Nested source IDs**: `[id]`, `[[id]]`, `[[[id]]]` - check existing methods
- **Fixed flags**: Arrays like `[2]`, `[1]` that don't change
- **Optional positions**: Often `None`
---
## Step 3: Implement
### 3a. Add RPC Method ID
Edit `src/notebooklm/rpc/types.py`:
```python
class RPCMethod(str, Enum):
# ... existing methods ...
# Add new method with descriptive name
NEW_FEATURE = "abc123" # 6-char ID from capture
```
### 3b. Implement Client Method
Choose the appropriate API file:
- `_notebooks.py` - Notebook operations
- `_sources.py` - Source operations
- `_artifacts.py` - Artifact/generation operations
- `_chat.py` - Chat operations
- `_notes.py` - Note operations
- `_research.py` - Research operations
Add the method:
```python
async def new_feature(
self,
notebook_id: str,
some_param: str,
optional_param: Optional[str] = None,
) -> SomeResult:
"""Short description of what this does.
Args:
notebook_id: The notebook ID.
some_param: Description.
optional_param: Description.
Returns:
Description of return value.
Raises:
RPCError: If the RPC call fails.
"""
# Build params array matching captured structure
params = [
some_param, # Position 0
notebook_id, # Position 1
[2], # Position 2: Fixed flag
]
# Make RPC call
result = await self._core.rpc_call(
RPCMethod.NEW_FEATURE,
params,
source_path=f"/notebook/{notebook_id}",
)
# Parse response into dataclass
if result is None:
return None
return SomeResult.from_api_response(result)
```
### 3c. Add Dataclass (if needed)
Edit `src/notebooklm/types.py`:
```python
@dataclass
class SomeResult:
id: str
title: str
# ... other fields
@classmethod
def from_api_response(cls, data: list[Any]) -> "SomeResult":
"""Parse API response array into dataclass."""
return cls(
id=data[0],
title=data[1],
# Map positions to fields
)
```
### 3d. Add CLI Command (if needed)
Edit appropriate CLI file in `src/notebooklm/cli/`:
```python
@some_group.command("new-feature")
@click.argument("param")
@click.pass_context
@async_command
async def new_feature_cmd(ctx, param: str):
"""Short description."""
nb_id = get_notebook_id(ctx)
async with get_client(ctx) as client:
result = await client.some_api.new_feature(nb_id, param)
console.print(f"Result: {result}")
```
---
## Step 4: Test
### 4a. Unit Test (encoding)
`tests/unit/test_encoder.py`:
```python
def test_encode_new_feature():
params = ["value", "notebook_id", [2]]
result = encode_rpc_request(RPCMethod.NEW_FEATURE, params)
assert "abc123" in result # RPC ID
assert "value" in result
```
### 4b. Integration Test (mocked response)
`tests/integration/test_new_feature.py`:
```python
@pytest.mark.asyncio
async def test_new_feature(mock_client):
mock_response = ["result_id", "Result Title"]
with patch('notebooklm._core.ClientCore.rpc_call', new_callable=AsyncMock) as mock:
mock.return_value = mock_response
result = await mock_client.some_api.new_feature("nb_id", "param")
assert result.id == "result_id"
assert result.title == "Result Title"
```
### 4c. E2E Test (real API)
`tests/e2e/test_new_feature_e2e.py`:
```python
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_new_feature_e2e(client, read_only_notebook_id):
"""Test new feature against real API."""
# Use read_only_notebook_id for read-only tests
# Use temp_notebook for CRUD tests
# Use generation_notebook_id for artifact generation tests
# See docs/contributing/testing.md for fixture guidance
result = await client.some_api.new_feature(read_only_notebook_id, "param")
assert result is not None
```
### Run Tests
```bash
# Unit + integration
pytest tests/unit tests/integration -v
# E2E (requires auth)
pytest tests/e2e/test_new_feature_e2e.py -m e2e -v
```
---
## Step 5: Document
Update `docs/reference/internals/rpc-ui-reference.md`:
```markdown
### NEW_FEATURE (`abc123`)
**Purpose:** Short description
**Params:**
```python
params = [
some_value, # 0: Description
notebook_id, # 1: Notebook ID
[2], # 2: Fixed flag
]
```
**Response:** Description of response structure
**Source:** `_some_api.py:123`
```
---
## Common Pitfalls
### Wrong nesting level
Different methods need different source ID nesting:
```python
# Single: [source_id]
# Double: [[source_id]]
# Triple: [[[source_id]]]
```
Check similar methods for the pattern.
### Position sensitivity
Params are arrays, not dicts. Position matters:
```python
# WRONG - missing position 2
params = [value, notebook_id, settings]
# RIGHT - explicit None for unused positions
params = [value, notebook_id, None, settings]
```
### Forgetting source_path
Some methods require `source_path` for routing:
```python
# Without source_path - may fail
await self._core.rpc_call(RPCMethod.X, params)
# With source_path - correct
await self._core.rpc_call(
RPCMethod.X,
params,
source_path=f"/notebook/{notebook_id}",
)
```
### Response parsing
API returns nested arrays. Print raw response first:
```python
result = await self._core.rpc_call(...)
print(f"DEBUG: {result}") # See actual structure
```
---
## Checklist
- [ ] Captured RPC ID and params structure
- [ ] Added to `RPCMethod` enum in `rpc/types.py`
- [ ] Implemented method in appropriate `_*.py` file
- [ ] Added dataclass if needed in `types.py`
- [ ] Added CLI command if needed
- [ ] Unit test for encoding
- [ ] Integration test with mock
- [ ] E2E test (manual verification OK for rare operations)
- [ ] Updated `rpc-ui-reference.md`
---
## See Also
- [RPC Capture Guide](../reference/internals/rpc-capture.md) - Detailed capture methodology
- [RPC UI Reference](../reference/internals/rpc-ui-reference.md) - Existing payloads
- [Debugging Guide](debugging.md) - Troubleshooting RPC issues
- [Testing Guide](testing.md) - Test patterns and fixtures

View file

@ -1,249 +0,0 @@
# Architecture Guide
**Status:** Active
**Last Updated:** 2026-01-07
Overview of the `notebooklm-py` codebase structure and design decisions.
## Package Structure
```
src/notebooklm/
├── __init__.py # Public exports
├── client.py # NotebookLMClient main class
├── auth.py # Authentication handling
├── types.py # Dataclasses and type definitions
├── _core.py # Core HTTP/RPC infrastructure
├── _notebooks.py # NotebooksAPI implementation
├── _sources.py # SourcesAPI implementation
├── _artifacts.py # ArtifactsAPI implementation
├── _chat.py # ChatAPI implementation
├── _research.py # ResearchAPI implementation
├── _notes.py # NotesAPI implementation
├── rpc/ # RPC protocol layer
│ ├── __init__.py
│ ├── types.py # RPCMethod enum and constants
│ ├── encoder.py # Request encoding
│ └── decoder.py # Response parsing
└── cli/ # CLI implementation
├── __init__.py # CLI package exports
├── helpers.py # Shared utilities
├── options.py # Common Click options
├── grouped.py # Grouped command utilities
├── session.py # login, use, status, clear
├── notebook.py # list, create, delete, rename, etc.
├── source.py # source add, list, delete, etc.
├── artifact.py # artifact list, get, delete, etc.
├── generate.py # generate audio, video, etc.
├── download.py # download audio, video, etc.
├── download_helpers.py # Download utility functions
├── chat.py # ask, configure, history
├── note.py # note create, list, etc.
├── research.py # research status, wait
└── skill.py # skill install, status, uninstall
```
## Layered Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ CLI Layer │
│ cli/session.py, cli/notebook.py, cli/generate.py, etc. │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ Client Layer │
│ NotebookLMClient → NotebooksAPI, SourcesAPI, ArtifactsAPI │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ Core Layer │
│ ClientCore → _rpc_call(), HTTP client │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ RPC Layer │
│ encoder.py, decoder.py, types.py (RPCMethod) │
└─────────────────────────────────────────────────────────────┘
```
### Layer Responsibilities
**CLI Layer (`cli/`)**
- User-facing commands
- Input validation and formatting
- Output rendering with Rich
- Context management (active notebook)
**Client Layer (`client.py`, `_*.py`)**
- High-level Python API
- Domain-specific methods (`notebooks.create()`, `sources.add_url()`)
- Returns typed dataclasses (`Notebook`, `Source`, `Artifact`)
- Wraps Core layer RPC calls
**Core Layer (`_core.py`)**
- HTTP client management (`httpx.AsyncClient`)
- Request counter (`_reqid_counter`)
- RPC call abstraction
- Authentication handling
**RPC Layer (`rpc/`)**
- Protocol constants and enums
- Request encoding (`encode_rpc_request()`)
- Response parsing (`decode_response()`)
- No HTTP/networking logic
## Key Files
### `client.py`
The main public interface. Users import `NotebookLMClient` from here.
```python
class NotebookLMClient:
notebooks: NotebooksAPI
sources: SourcesAPI
artifacts: ArtifactsAPI
chat: ChatAPI
research: ResearchAPI
notes: NotesAPI
```
Design decisions:
- Namespaced APIs for organization
- Async context manager pattern
- `from_storage()` factory for easy initialization
### `_core.py`
Infrastructure shared by all API classes:
```python
class ClientCore:
auth: AuthTokens
_client: httpx.AsyncClient
_reqid_counter: int
async def rpc_call(method, params, ...) -> Any
async def open() / close()
```
All `_*.py` API classes receive a `ClientCore` instance.
### `_*.py` Files
Underscore prefix indicates internal modules (not for direct import by users).
Pattern:
```python
class SomeAPI:
def __init__(self, core: ClientCore):
self._core = core
async def some_method(self, ...):
params = [...] # Build RPC params
result = await self._core.rpc_call(RPCMethod.SOME, params)
return SomeType.from_api_response(result)
```
### `rpc/types.py`
**This is THE source of truth for RPC constants.**
```python
class RPCMethod(str, Enum):
LIST_NOTEBOOKS = "wXbhsf"
CREATE_NOTEBOOK = "CCqFvf"
# ... all method IDs
```
When Google changes method IDs, only this file needs updating.
### `types.py`
Domain dataclasses:
```python
@dataclass
class Notebook:
id: str
title: str
created_at: Optional[datetime]
sources_count: int
@classmethod
def from_api_response(cls, data: list) -> "Notebook":
# Parse nested list structure
```
## Design Decisions
### Why Underscore Prefixes?
Files like `_notebooks.py` use underscore prefixes to:
1. Signal they're internal implementation
2. Keep public API clean (`from notebooklm import NotebookLMClient`)
3. Allow refactoring without breaking imports
### Why Namespaced APIs?
Instead of `client.list_notebooks()`, we use `client.notebooks.list()`:
- Groups related methods
- Mirrors UI organization
- Scales better as API grows
- Tab completion friendly
### Why Async?
Google's API can be slow. Async allows:
- Concurrent operations
- Non-blocking downloads
- Integration with async frameworks (FastAPI, etc.)
### Why No Service Layer?
Originally had `NotebookService(client)` pattern. Removed because:
- Extra indirection without value
- Users had to instantiate both client and services
- Namespaced APIs (`client.notebooks`) achieve same organization
## Adding New Features
### New RPC Method
1. Capture network traffic (see `docs/contributing/debugging.md`)
2. Add to `rpc/types.py`:
```python
NEW_METHOD = "AbCdEf"
```
3. Add to appropriate `_*.py` API class
4. Add dataclass to `types.py` if needed
5. Add CLI command if user-facing
### New API Class
1. Create `_newfeature.py`:
```python
class NewFeatureAPI:
def __init__(self, core: ClientCore):
self._core = core
```
2. Add to `client.py`:
```python
self.newfeature = NewFeatureAPI(self._core)
```
3. Export types from `__init__.py`
### New CLI Command
1. Add to appropriate `cli/*.py` file
2. Register in `cli/__init__.py` or `notebooklm_cli.py`
3. Follow existing patterns (Click decorators, async handling)
## Testing Patterns
See `docs/contributing/testing.md` for details.
- Unit tests: Mock `ClientCore.rpc_call()`
- Integration tests: Mock HTTP responses
- E2E tests: Real API calls (require auth)

View file

@ -1,325 +0,0 @@
# Debugging Guide
**Status:** Active
**Last Updated:** 2026-01-07
How to debug issues and reverse engineer new NotebookLM features.
## Capturing Network Traffic
### Chrome DevTools Setup
1. Open Chrome DevTools (F12)
2. Go to **Network** tab
3. Check "Preserve log" to keep requests across navigations
4. Filter by: `batchexecute` (for RPC calls)
### Capturing an RPC Call
1. Clear network log
2. Perform action in NotebookLM UI
3. Find the `batchexecute` request
4. Examine:
- **URL params**: `rpcids=METHOD_ID`
- **Request body**: `f.req=...` (URL-encoded)
- **Response**: Starts with `)]}'\n`
### Decoding the Request
The `f.req` parameter contains URL-encoded JSON:
```python
import urllib.parse
import json
# Copy the f.req value from DevTools
encoded = "..."
# Decode
decoded = urllib.parse.unquote(encoded)
print(decoded)
# Parse JSON
data = json.loads(decoded)
# Structure: [[[rpc_id, params_json, null, "generic"]]]
# The params are themselves JSON-encoded
inner_params = json.loads(data[0][0][1])
print(json.dumps(inner_params, indent=2))
```
### Decoding the Response
Responses have an anti-XSSI prefix and chunked format:
```python
response_text = """)]}'
123
["wrb.fr","wXbhsf","[[\\"abc123\\",\\"My Notebook\\"]]"]
45
["di",42]
"""
# Step 1: Remove prefix
lines = response_text.split('\n')
lines = [l for l in lines if not l.startswith(')]}\'')]
# Step 2: Find wrb.fr chunk
for i, line in enumerate(lines):
if '"wrb.fr"' in line:
chunk = json.loads(line)
method_id = chunk[1]
result_json = chunk[2]
result = json.loads(result_json)
print(f"Method: {method_id}")
print(f"Result: {result}")
```
## Common Debugging Scenarios
### "Session Expired" Errors
**Symptoms:**
- `RPCError` mentioning unauthorized
- Redirects to login page
**Debug:**
```python
# Check if CSRF token is present
print(client.auth.csrf_token)
# Try refreshing
await client.refresh_auth()
print(client.auth.csrf_token) # Should be new value
```
**Solution:** Re-run `notebooklm login`
### RPC Method Returns None
**Symptoms:**
- Method completes but returns `None`
- No error raised
**Debug:**
```python
# Add logging to see raw response
from notebooklm.rpc import decode_response
# In your test code:
raw_response = await http_client.post(...)
print("Raw:", raw_response.text[:500])
result = decode_response(raw_response.text, "METHOD_ID")
print("Parsed:", result)
```
**Common causes:**
- Rate limiting (Google returns empty result)
- Wrong RPC method ID
- Incorrect parameter structure
### Parameter Order Issues
RPC parameters are position-sensitive:
```python
# Wrong - audio will fail
params = [
[2], notebook_id, [
None, None, 1,
source_ids,
# ... missing positional elements
]
]
# Correct - all positions filled
params = [
[2], notebook_id, [
None, None, 1,
source_ids,
None, None, # Required placeholders
[None, [instructions, length, None, sources, lang, None, format]]
]
]
```
**Debug:** Compare your params with captured traffic byte-by-byte.
### Nested List Depth
Source IDs have different nesting requirements:
```python
# Single nesting (some methods)
["source_id"]
# Double nesting
[["source_id"]]
# Triple nesting (artifact generation)
[[["source_id"]]]
# Quad nesting (get_source_guide)
[[[["source_id"]]]]
```
**Debug:** Capture working traffic and count brackets.
## RPC Tracing
### Using Debug Mode
The client has built-in RPC debugging via environment variable:
```bash
# Enable debug output for all RPC calls
NOTEBOOKLM_DEBUG_RPC=1 notebooklm <command>
```
This shows:
```
DEBUG: Looking for RPC ID: wXbhsf
DEBUG: Found RPC IDs in response: ['wXbhsf']
```
This is especially useful when:
- A method suddenly stops working (ID may have changed)
- You're implementing a new method and want to verify the ID
- Debugging parameter structure issues
### Adding Custom Logging
For more detailed debugging, add logging to `_core.py`:
```python
async def rpc_call(self, method, params, ...):
import json
print(f"=== RPC Call: {method} ===")
print(f"Params: {json.dumps(params, indent=2)}")
# ... existing code ...
print(f"Response status: {response.status_code}")
print(f"Response preview: {response.text[:500]}")
result = decode_response(response.text, method)
print(f"Decoded result: {result}")
return result
```
### Comparing with Browser
To verify your implementation matches the browser:
1. Capture browser request with DevTools
2. Save the decoded params
3. Run your code with logging
4. Compare params structure
5. Check for differences in:
- Nesting depth
- Null placeholder positions
- String vs integer types
## Testing RPC Changes
### Quick Test Script
```python
import asyncio
from notebooklm import NotebookLMClient
async def test_method():
async with await NotebookLMClient.from_storage() as client:
# Test the method
try:
result = await client.notebooks.list()
print(f"Success: {result}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
asyncio.run(test_method())
```
### Mocking for Unit Tests
```python
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_list_notebooks():
mock_response = [[["nb123", "Test Notebook", None, [[1234567890]], 3]]]
with patch.object(ClientCore, 'rpc_call', new_callable=AsyncMock) as mock:
mock.return_value = mock_response
client = NotebookLMClient(mock_auth)
await client.__aenter__()
notebooks = await client.notebooks.list()
assert len(notebooks) == 1
assert notebooks[0].title == "Test Notebook"
```
## Reverse Engineering New Features
### Process
1. **Identify the feature** in NotebookLM UI
2. **Capture traffic** while using it
3. **Document the RPC ID** from URL params
4. **Decode request payload**
5. **Decode response structure**
6. **Implement and test**
### Documentation Template
When discovering a new method, document:
```markdown
## NEW_METHOD (RPC ID: XyZ123)
**Purpose:** What it does
**Request:**
```python
params = [
# Document each position
position_0, # What this is
position_1, # What this is
...
]
```
**Response:**
```python
[
result_data, # Structure description
...
]
```
**Notes:**
- Any quirks or gotchas
- Related methods
```
## Getting Help
If you're stuck:
1. Check existing implementations in `_*.py` files
2. Look at test files for expected structures
3. Compare with RPC reference in `docs/reference/internals/rpc-ui-reference.md`
4. Follow the [Adding RPC Methods Guide](adding-rpc-methods.md) for implementation steps
5. Open an issue with:
- What you're trying to do
- Captured request/response (sanitized)
- Error messages

View file

@ -1,628 +0,0 @@
# Testing Guide
**Status:** Active
**Last Updated:** 2026-01-08
## Prerequisites
Before running ANY E2E tests, you must complete this setup:
### 1. Install Dependencies
```bash
# Using uv (recommended)
uv pip install -e ".[dev]"
# Or using pip
pip install -e ".[dev]"
```
### 2. Authenticate with NotebookLM
```bash
notebooklm login
```
This opens a browser, logs into your Google account, and stores cookies in `~/.notebooklm/storage_state.json`.
Verify with:
```bash
notebooklm status
```
### 3. Create Your Read-Only Test Notebook (REQUIRED)
**You MUST create a personal test notebook** for read-only tests. Tests will exit with an error if not configured.
1. Go to [NotebookLM](https://notebooklm.google.com)
2. Create a new notebook (e.g., "E2E Test Notebook")
3. Add multiple sources:
- At least one text/paste source
- At least one URL source
- Optionally: PDF, YouTube video
4. **Generate artifacts** (at least one of each type for full test coverage):
- Audio overview (try different formats: Deep Dive, Brief)
- Video overview
- Quiz
- Flashcards
- Infographic
- Slide deck
- Report (Briefing Doc, Study Guide, or Blog Post)
*Tip: Generate multiple artifacts of the same type with different customizations to test download selection.*
5. Copy the notebook ID from the URL: `notebooklm.google.com/notebook/YOUR_NOTEBOOK_ID`
6. Create your `.env` file:
```bash
cp .env.example .env
# Edit .env and set your notebook ID
```
Or set the environment variable directly:
```bash
export NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID="your-notebook-id-here"
```
**Note:** The generation notebook is auto-created on first run and stored in `NOTEBOOKLM_HOME/generation_notebook_id` (default: `~/.notebooklm/generation_notebook_id`). You don't need to configure it manually.
### 4. Verify Setup
```bash
# Should pass - unit tests don't need auth
pytest tests/unit/
# Should pass - uses your test notebook
pytest tests/e2e -m readonly -v
```
If tests skip with "no auth stored" or fail with permission errors, your setup is incomplete.
---
## ⚠️ Rate Limiting
NotebookLM has undocumented API rate limits. Running many tests in sequence (especially generation tests) can trigger rate limiting.
**How it works:**
- Generation tests use `assert_generation_started()` helper
- Rate-limited tests are **SKIPPED** (not failed) - you'll see `SKIPPED (Rate limited by API)`
- The API returns `USER_DISPLAYABLE_ERROR` when rate limited, detected via `result.is_rate_limited`
**Strategies:**
- Run `readonly` tests for quick validation (minimal API calls)
- Skip `variants` to reduce generation API calls
- Wait a few minutes between full test runs for rate limits to reset
- Check test output for skipped tests - many skips indicate you've hit rate limits
---
## VCR Testing (Recorded HTTP Cassettes)
VCR.py records HTTP interactions to YAML "cassettes" for deterministic replay. This helps with:
- **Rate limit protection**: Record once, replay infinitely without hitting API
- **Offline testing**: Run tests without network access
- **Faster tests**: Replay is instant, no network latency
- **Regression testing**: Detect API changes by comparing against recorded responses
### How It Works
1. **Recording mode**: Real HTTP requests are made and responses saved to cassettes
2. **Replay mode** (default): Requests are intercepted and recorded responses returned
### Recording Cassettes
Cassettes are **not committed** to the repository (gitignored). Each developer records their own:
```bash
# Set notebook IDs (same as e2e tests)
export NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID="your-id"
export NOTEBOOKLM_GENERATION_NOTEBOOK_ID="your-id"
# Record cassettes
NOTEBOOKLM_VCR_RECORD=1 pytest tests/integration/test_vcr_*.py -v
```
Cassettes are saved to `tests/cassettes/` (gitignored).
### Running VCR Tests
```bash
# With cassettes - tests run using recorded responses
pytest tests/integration/test_vcr_*.py
# Without cassettes - tests skip gracefully
pytest # VCR tests skipped, other tests pass
```
### Security
All sensitive data is automatically scrubbed before recording:
| Data Type | Example | Scrubbed To |
|-----------|---------|-------------|
| Session cookies | `SID=abc123` | `SID=SCRUBBED` |
| CSRF tokens | `SNlM0e: "token"` | `SNlM0e: "SCRUBBED_CSRF"` |
| User emails | `user@gmail.com` | `SCRUBBED_EMAIL@example.com` |
| User IDs | `105603816177942538178` | `SCRUBBED_USER_ID` |
| API keys | `AIzaSy...` | `SCRUBBED_API_KEY` |
**Never commit cassettes** - they may contain data specific to your account.
### Backing Up Cassettes
To preserve your recorded cassettes:
```bash
# Backup
cp -r tests/cassettes ~/.notebooklm-py/vcr-cassettes/
# Restore
cp ~/.notebooklm-py/vcr-cassettes/*.yaml tests/cassettes/
```
---
## Quick Reference
```bash
# Run unit + integration tests (default, no auth needed)
pytest
# Run E2E tests (requires setup above)
pytest tests/e2e -m readonly # Read-only tests only (minimal API calls)
pytest tests/e2e -m "not variants" # Skip generation parameter variants
pytest tests/e2e # All tests (variants skipped by default)
pytest tests/e2e --include-variants # ALL tests including parameter variants
```
## Test Structure
```
tests/
├── conftest.py # RPC response builders, VCR marker
├── vcr_config.py # VCR.py configuration and scrubbing
├── cassettes/ # Recorded HTTP responses (gitignored)
├── unit/ # No network, fast
│ ├── cli/ # CLI command tests
│ ├── test_decoder.py
│ ├── test_encoder.py
│ └── ...
├── integration/ # Mocked HTTP + VCR tests
│ ├── conftest.py # Mock auth tokens, VCR skip logic
│ ├── test_vcr_*.py # VCR recorded tests (skip if no cassettes)
│ └── test_*.py # pytest-httpx mocked tests
└── e2e/ # Real API
├── conftest.py # Auth, fixtures, cleanup
├── test_generation.py # All artifact generation tests
├── test_artifacts.py # Artifact CRUD/list operations
├── test_downloads.py # Download operations
├── test_sources.py # Source operations
└── ...
```
## E2E Fixtures
### Which Fixture Do I Need?
| I want to... | Use | Why |
|--------------|-----|-----|
| List/download existing artifacts | `read_only_notebook_id` | Your notebook with pre-made content |
| Add/delete sources or notes | `temp_notebook` | Isolated, auto-cleanup, has content |
| Generate audio/video/quiz | `generation_notebook_id` | Auto-created, has content, CI-aware cleanup |
### `read_only_notebook_id` (Your Test Notebook - REQUIRED)
Returns `NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID` env var. **Tests will exit with an error if not set.**
Your notebook must have:
- Multiple sources (text, URL, etc.)
- Pre-generated artifacts (audio, quiz, etc.)
```python
@pytest.mark.readonly
async def test_list_artifacts(self, client, read_only_notebook_id):
artifacts = await client.artifacts.list(read_only_notebook_id)
assert isinstance(artifacts, list)
```
### `temp_notebook`
Fresh notebook per test. Automatically deleted after test completes (even on failure).
```python
async def test_add_source(self, client, temp_notebook):
source = await client.sources.add_url(temp_notebook.id, "https://example.com")
assert source.id is not None
```
### `generation_notebook_id`
Notebook for generation tests. Auto-created on first run if not configured via env var.
```python
async def test_generate_quiz(self, client, generation_notebook_id):
result = await client.artifacts.generate_quiz(generation_notebook_id)
assert result is not None
assert result.task_id # Generation returns immediately with task_id
```
**Lifecycle:**
- Auto-created if `NOTEBOOKLM_GENERATION_NOTEBOOK_ID` not set
- Artifacts/notes cleaned BEFORE tests (clean starting state)
- **In CI (CI=true):** Notebook deleted after tests to avoid orphans
- **Locally:** Notebook persists, ID stored in `NOTEBOOKLM_HOME/generation_notebook_id`
## Test Markers
All markers defined in `pyproject.toml`:
| Marker | Purpose |
|--------|---------|
| `readonly` | Read-only tests against user's test notebook |
| `variants` | Generation parameter variants (skip to reduce API calls) |
### Understanding Generation Tests
Generation tests (audio, video, quiz, etc.) call the API and receive a `task_id` immediately - they do **not** wait for the artifact to complete. This means:
- Tests are fast (single API call each)
- The main concern is **rate limiting**, not execution time
- Running many generation tests in sequence triggers rate limits
### Variant Testing
Each artifact type has multiple parameter options (format, style, difficulty, etc.). To balance coverage and API quota:
- **Default test:** Tests with default parameters (always runs)
- **Variant tests:** Test other parameter combinations (skipped by default)
**Variants are skipped by default** to reduce API calls. Use `--include-variants` to run them:
```bash
pytest tests/e2e # Skips variants
pytest tests/e2e --include-variants # Includes variants
```
```python
# Runs by default - tests that generation works
async def test_generate_audio_default(self, client, generation_notebook_id):
result = await client.artifacts.generate_audio(generation_notebook_id)
assert result.task_id, "Expected non-empty task_id"
assert result.status in ("pending", "in_progress")
assert result.error is None
# Skipped by default - tests parameter encoding
@pytest.mark.variants
async def test_generate_audio_brief(self, client, generation_notebook_id):
result = await client.artifacts.generate_audio(
generation_notebook_id,
audio_format=AudioFormat.BRIEF,
)
assert result.task_id, "Expected non-empty task_id"
```
## E2E Fixtures Reference
From `tests/e2e/conftest.py`:
### Authentication
```python
@pytest.fixture(scope="session")
def auth_cookies() -> dict[str, str]:
"""Load cookies from ~/.notebooklm/storage_state.json"""
@pytest.fixture(scope="session")
def auth_tokens(auth_cookies) -> AuthTokens:
"""Fetch CSRF + session ID from NotebookLM homepage"""
@pytest.fixture
async def client(auth_tokens) -> NotebookLMClient:
"""Fresh client per test"""
```
### Notebooks
```python
@pytest.fixture
def read_only_notebook_id() -> str:
"""Returns NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID (required)"""
@pytest.fixture
async def temp_notebook(client) -> Notebook:
"""Create notebook with content, auto-delete after test"""
@pytest.fixture
async def generation_notebook_id(client) -> str:
"""Auto-created notebook for generation tests (deleted in CI)"""
```
### Decorators
```python
@requires_auth # Skip test if no auth stored
```
### Helpers
```python
from .conftest import assert_generation_started
# Use in generation tests - skips on rate limiting instead of failing
result = await client.artifacts.generate_audio(notebook_id)
assert_generation_started(result, "Audio") # Skips if rate limited
```
## Environment Variables
Set via `.env` file (recommended) or shell export:
```bash
# Required: Your read-only test notebook with sources and artifacts
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID=your-notebook-id-here
# Optional: Explicit generation notebook ID (auto-created if not set)
# NOTEBOOKLM_GENERATION_NOTEBOOK_ID=your-generation-notebook-id
```
See `.env.example` for the template.
## Writing New Tests
### Decision Tree
```
Need network?
├── No → tests/unit/
├── Mocked → tests/integration/
└── Real API → tests/e2e/
└── What notebook?
├── Read-only → read_only_notebook_id + @pytest.mark.readonly
├── CRUD → temp_notebook
└── Generation → generation_notebook_id
└── Parameter variant? → add @pytest.mark.variants
```
### Example: New Generation Test
Add generation tests to `tests/e2e/test_generation.py`:
```python
from .conftest import requires_auth, assert_generation_started
@requires_auth
class TestNewArtifact:
@pytest.mark.asyncio
async def test_generate_new_artifact_default(self, client, generation_notebook_id):
result = await client.artifacts.generate_new(generation_notebook_id)
# Use helper - skips test if rate limited, fails on other errors
assert_generation_started(result, "NewArtifact")
@pytest.mark.asyncio
@pytest.mark.variants
async def test_generate_new_artifact_with_options(self, client, generation_notebook_id):
result = await client.artifacts.generate_new(
generation_notebook_id,
option=SomeOption.VALUE,
)
assert_generation_started(result, "NewArtifact")
```
Note: Generation tests only need `client` and `generation_notebook_id`. Cleanup is automatic.
### Rate Limiting
NotebookLM has undocumented rate limits. Running many generation tests in sequence can trigger rate limiting:
- Rate-limited tests are **skipped** (not failed) via `assert_generation_started()` helper
- You'll see `SKIPPED (Rate limited by API)` in test output
- Many skipped tests = you've hit rate limits, wait and retry
**Strategies:**
- **Run `readonly` tests first:** `pytest tests/e2e -m readonly` (minimal API calls)
- **Skip variants:** `pytest tests/e2e -m "not variants"` (fewer generation calls)
- **Wait between runs:** Rate limits reset after a few minutes
---
## Rate Limit Optimization
### Understanding Rate Limits
NotebookLM appears to have multiple rate limit mechanisms:
| Limit Type | Scope | Notes |
|------------|-------|-------|
| **Per-type daily quota** | Per artifact type | e.g., limited audio generations per day |
| **Burst rate limit** | Per time window | e.g., generations per 5 minutes |
| **Account-wide** | All operations | Undocumented overall limits |
**Key insight:** Rate limits primarily affect **artifact generation**, not notebook/source creation.
### Current Optimizations
The test suite is designed to minimize rate limit issues:
1. **Spread across artifact types** - Default tests cover 9 different artifact types (audio, video, quiz, flashcards, infographic, slide_deck, data_table, mind_map, study_guide). This distributes load across per-type quotas.
2. **Variants skipped by default** - Only ~10 generation tests run by default. The 20+ variant tests (testing parameter combinations) require `--include-variants`.
3. **Graceful rate limit handling** - `assert_generation_started()` detects rate limiting and skips tests instead of failing.
4. **Combined mutation tests** - `test_artifacts.py` combines poll/rename/wait operations into one test, and spreads across artifact types (flashcards for mutations, quiz for delete).
### Default Generation Tests (10 calls)
```
audio_default, audio_brief → 2 audio calls
video_default → 1 video call
quiz_default → 1 quiz call
flashcards_default → 1 flashcards call
infographic_default → 1 infographic call
slide_deck_default → 1 slide_deck call
data_table_default → 1 data_table call
mind_map → 1 mind_map call (sync)
study_guide → 1 study_guide call
```
This spread is optimal for per-type rate limits.
### Generation Notebook Design
The `generation_notebook_id` fixture uses a hybrid approach to balance:
- **Local development**: Notebook persists for post-test verification
- **CI environments**: Auto-cleanup to avoid orphaned notebooks
**How it works:**
| Environment | Behavior |
|-------------|----------|
| `NOTEBOOKLM_GENERATION_NOTEBOOK_ID` set | Uses provided ID, never deleted |
| Local (no `CI` env var) | Auto-creates once, stores ID, persists across runs |
| CI (`CI=true`) | Auto-creates, deletes after tests complete |
Artifacts and notes are always cleaned BEFORE tests to ensure a clean starting state.
### Future Considerations
If rate limiting becomes more severe, consider:
1. **Add delays between generation tests:**
```python
@pytest.fixture(autouse=True)
async def rate_limit_delay():
yield
await asyncio.sleep(2) # Delay after each test
```
2. **CI scheduling** - Spread test runs over time rather than running all at once.
3. **Tiered test runs** - Run readonly tests in CI, full suite nightly.
## CI/CD Setup
### GitHub Actions Workflows
The project has two CI workflows:
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `test.yml` | Push/PR to main | Unit tests, linting, type checking |
| `nightly.yml` | Daily at 6 AM UTC | E2E tests with real API |
### Automatic CI (test.yml)
Runs automatically on every push and PR:
1. **Quality Job** - Ruff linting + mypy type checking (runs once)
2. **Test Job** - Unit/integration tests on Ubuntu and macOS across Python 3.10-3.14
No setup required - this works out of the box.
### Nightly E2E Tests (nightly.yml)
Runs E2E tests daily against the real NotebookLM API. Requires repository secrets.
#### Step 1: Get Your Storage State
```bash
# Make sure you're logged in
notebooklm login
# Copy the storage state content
cat ~/.notebooklm/storage_state.json
```
#### Step 2: Add the Secrets to GitHub
1. Go to your repository on GitHub
2. Click **Settings****Secrets and variables** → **Actions**
3. Add **two** repository secrets:
**Secret 1: Auth JSON**
- **Name:** `NOTEBOOKLM_AUTH_JSON`
- **Value:** Paste the entire JSON content from step 1
**Secret 2: Read-Only Notebook ID**
- **Name:** `NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID`
- **Value:** Your test notebook ID (from URL: `notebooklm.google.com/notebook/YOUR_ID`)
#### Step 3: Test the Workflow
```bash
# Trigger manually to verify it works
gh workflow run nightly.yml
# Check the run status
gh run list --workflow=nightly.yml
```
### Maintaining CI Secrets
| Task | Frequency | Action |
|------|-----------|--------|
| Refresh credentials | Every 1-2 weeks | Run `notebooklm login`, update secret |
| Check nightly results | Daily | Review Actions tab for failures |
| Update secret after expiry | When E2E tests fail with auth errors | Repeat steps 1-2 |
### Security Best Practices
- Use a **dedicated test Google account** (not personal)
- The secret is encrypted and never exposed in logs
- Only the main repository can access secrets (forks cannot)
- Session cookies expire naturally, requiring periodic refresh
---
## Troubleshooting
### Tests skip with "no auth stored"
Run `notebooklm login` and complete browser authentication.
### Tests fail with permission errors
Your `NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID` may be invalid or you don't own it. Verify:
```bash
echo $NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID
notebooklm list # Should show your notebooks
```
### Tests hang or timeout
- **CSRF token expired:** Run `notebooklm login` again.
- **Download tests:** May timeout if browser auth expired. Clear profile and re-login:
```bash
rm -rf ~/.notebooklm/browser_profile && notebooklm login
```
### Rate limiting / Many tests skipped
NotebookLM has undocumented rate limits. If you see many tests `SKIPPED (Rate limited by API)`:
1. **This is expected behavior** - tests skip instead of fail when rate limited
2. **Wait and retry:** Rate limits reset after a few minutes
3. **Reduce API calls:** Use `pytest tests/e2e -m "not variants"` (fewer generation calls)
4. **Run single tests:** `pytest tests/e2e/test_generation.py::TestAudioGeneration::test_generate_audio_default`
Note: The `assert_generation_started()` helper detects rate limiting via `USER_DISPLAYABLE_ERROR` from the API and skips the test gracefully.
### "CSRF token invalid" or 403 errors
Your session expired. Re-authenticate:
```bash
notebooklm login
```
### "NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID not set" error
This is expected - E2E tests require your own notebook. Follow Prerequisites step 3 to create one.
### Too many artifacts accumulating
- `temp_notebook`: Automatically deleted after each test
- `generation_notebook_id` in CI: Notebook deleted after tests
- `generation_notebook_id` locally: Artifacts cleaned before each run, notebook persists (ID stored in `NOTEBOOKLM_HOME/generation_notebook_id`)
- If tests are interrupted, orphaned notebooks may remain - clean up manually in the NotebookLM UI

276
docs/development.md Normal file
View file

@ -0,0 +1,276 @@
# Contributing Guide
**Status:** Active
**Last Updated:** 2026-01-13
This guide covers everything you need to contribute to `notebooklm-py`: architecture overview, testing, and releasing.
---
## Architecture
### Package Structure
```
src/notebooklm/
├── __init__.py # Public exports
├── client.py # NotebookLMClient main class
├── auth.py # Authentication handling
├── types.py # Dataclasses and type definitions
├── _core.py # Core HTTP/RPC infrastructure
├── _notebooks.py # NotebooksAPI implementation
├── _sources.py # SourcesAPI implementation
├── _artifacts.py # ArtifactsAPI implementation
├── _chat.py # ChatAPI implementation
├── _research.py # ResearchAPI implementation
├── _notes.py # NotesAPI implementation
├── rpc/ # RPC protocol layer
│ ├── __init__.py
│ ├── types.py # RPCMethod enum and constants
│ ├── encoder.py # Request encoding
│ └── decoder.py # Response parsing
└── cli/ # CLI implementation
├── __init__.py # CLI package exports
├── helpers.py # Shared utilities
├── session.py # login, use, status, clear
├── notebook.py # list, create, delete, rename
├── source.py # source add, list, delete
├── artifact.py # artifact list, get, delete
├── generate.py # generate audio, video, etc.
├── download.py # download audio, video, etc.
├── chat.py # ask, configure, history
└── ...
```
### Layered Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ CLI Layer │
│ cli/session.py, cli/notebook.py, cli/generate.py, etc. │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ Client Layer │
│ NotebookLMClient → NotebooksAPI, SourcesAPI, ArtifactsAPI │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ Core Layer │
│ ClientCore → _rpc_call(), HTTP client │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│ RPC Layer │
│ encoder.py, decoder.py, types.py (RPCMethod) │
└─────────────────────────────────────────────────────────────┘
```
### Layer Responsibilities
| Layer | Files | Responsibility |
|-------|-------|----------------|
| **CLI** | `cli/*.py` | User commands, input validation, Rich output |
| **Client** | `client.py`, `_*.py` | High-level Python API, returns typed dataclasses |
| **Core** | `_core.py` | HTTP client, request counter, RPC abstraction |
| **RPC** | `rpc/*.py` | Protocol encoding/decoding, method IDs |
### Key Design Decisions
**Why underscore prefixes?** Files like `_notebooks.py` are internal implementation. Public API stays clean (`from notebooklm import NotebookLMClient`).
**Why namespaced APIs?** `client.notebooks.list()` instead of `client.list_notebooks()` - better organization, scales well, tab-completion friendly.
**Why async?** Google's API can be slow. Async enables concurrent operations and non-blocking downloads.
### Adding New Features
**New RPC Method:**
1. Capture traffic (see [RPC Development Guide](rpc-development.md))
2. Add to `rpc/types.py`: `NEW_METHOD = "AbCdEf"`
3. Implement in appropriate `_*.py` API class
4. Add dataclass to `types.py` if needed
5. Add CLI command if user-facing
**New API Class:**
1. Create `_newfeature.py` with `NewFeatureAPI` class
2. Add to `client.py`: `self.newfeature = NewFeatureAPI(self._core)`
3. Export types from `__init__.py`
---
## Testing
### Prerequisites
1. **Install dependencies:**
```bash
uv pip install -e ".[dev]"
```
2. **Authenticate:**
```bash
notebooklm login
```
3. **Create read-only test notebook** (required for E2E tests):
- Create notebook at [NotebookLM](https://notebooklm.google.com)
- Add multiple sources (text, URL, etc.)
- Generate artifacts (audio, quiz, etc.)
- Set env var: `export NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID="your-id"`
### Quick Reference
```bash
# Unit + integration tests (no auth needed)
pytest
# E2E tests (requires auth + test notebook)
pytest tests/e2e -m readonly # Read-only tests only
pytest tests/e2e -m "not variants" # Skip parameter variants
pytest tests/e2e --include-variants # All tests including variants
```
### Test Structure
```
tests/
├── unit/ # No network, fast, mock everything
├── integration/ # Mocked HTTP responses + VCR cassettes
└── e2e/ # Real API calls (requires auth)
```
### E2E Fixtures
| Fixture | Use Case |
|---------|----------|
| `read_only_notebook_id` | List/download existing artifacts |
| `temp_notebook` | Add/delete sources (auto-cleanup) |
| `generation_notebook_id` | Generate artifacts (CI-aware cleanup) |
### Rate Limiting
NotebookLM has undocumented rate limits. Generation tests may be skipped when rate limited:
- Use `pytest tests/e2e -m readonly` for quick validation
- Wait a few minutes between full test runs
- `SKIPPED (Rate limited by API)` is expected behavior, not failure
### VCR Testing (Recorded HTTP)
Record HTTP interactions for offline/deterministic replay:
```bash
# Record cassettes (not committed to repo)
NOTEBOOKLM_VCR_RECORD=1 pytest tests/integration/test_vcr_*.py -v
# Run with recorded responses
pytest tests/integration/test_vcr_*.py
```
Sensitive data (cookies, tokens, emails) is automatically scrubbed.
### Writing New Tests
```
Need network?
├── No → tests/unit/
├── Mocked → tests/integration/
└── Real API → tests/e2e/
└── What notebook?
├── Read-only → read_only_notebook_id + @pytest.mark.readonly
├── CRUD → temp_notebook
└── Generation → generation_notebook_id
└── Parameter variant? → add @pytest.mark.variants
```
---
## Releasing
### Pre-release Checklist
- [ ] All tests pass: `pytest`
- [ ] E2E readonly tests pass: `pytest tests/e2e -m readonly`
- [ ] No uncommitted changes: `git status`
- [ ] On `main` branch with latest changes
- [ ] Version updated in `src/notebooklm/__init__.py`
- [ ] CHANGELOG.md updated
### Release Steps
1. **Update version** in `src/notebooklm/__init__.py`:
```python
__version__ = "X.Y.Z"
```
2. **Update CHANGELOG.md** with release notes
3. **Commit and push:**
```bash
git add -A
git commit -m "chore: release vX.Y.Z"
git push
```
4. **Test on TestPyPI:**
```bash
python -m build
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple notebooklm-py
notebooklm --version
```
5. **Create release tag:**
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
This triggers GitHub Actions to publish to PyPI automatically.
### Versioning Policy
| Change Type | Bump | Example |
|-------------|------|---------|
| RPC method ID fixes | PATCH | 0.1.0 → 0.1.1 |
| Bug fixes | PATCH | 0.1.1 → 0.1.2 |
| New features (backward compatible) | MINOR | 0.1.2 → 0.2.0 |
| Breaking API changes | MAJOR | 0.2.0 → 1.0.0 |
See [stability.md](stability.md) for full versioning policy.
---
## CI/CD
### Workflows
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `test.yml` | Push/PR | Unit tests, linting, type checking |
| `nightly.yml` | Daily 6 AM UTC | E2E tests with real API |
| `publish.yml` | Tag push | Publish to PyPI |
### Setting Up Nightly E2E Tests
1. Get storage state: `cat ~/.notebooklm/storage_state.json`
2. Add GitHub secrets:
- `NOTEBOOKLM_AUTH_JSON`: Storage state JSON
- `NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID`: Your test notebook ID
### Maintaining Secrets
| Task | Frequency |
|------|-----------|
| Refresh credentials | Every 1-2 weeks |
| Check nightly results | Daily |
---
## Getting Help
- Check existing implementations in `_*.py` files
- Look at test files for expected structures
- See [RPC Development Guide](rpc-development.md) for protocol details
- Open an issue with captured request/response (sanitized)

View file

@ -1,180 +0,0 @@
# Getting Started
**Status:** Active
**Last Updated:** 2026-01-09
This guide walks you through installing and using `notebooklm-py` for the first time.
## Prerequisites
- Python 3.10 or higher
- A Google account with access to [NotebookLM](https://notebooklm.google.com)
## Installation
### Basic Installation (CLI + Python API)
```bash
pip install notebooklm-py
```
### With Browser Login Support (Recommended for first-time setup)
```bash
pip install "notebooklm-py[browser]"
playwright install chromium
```
The browser extra installs Playwright, which is **only required for `notebooklm login`**. All other commands and the Python API use standard HTTP requests via `httpx`.
> **Headless/Remote Machines:** If you've already authenticated on another machine, copy `~/.notebooklm/storage_state.json` and use the basic installation without Playwright. See [Configuration - Headless Servers](configuration.md#headless-servers--containers).
### Development Installation
```bash
git clone https://github.com/teng-lin/notebooklm-py.git
cd notebooklm-py
pip install -e ".[all]"
playwright install chromium
```
> **See also:** [Configuration](configuration.md) for custom storage paths and environment settings.
## Authentication
NotebookLM uses Google's internal APIs, which require valid session cookies. The CLI provides a browser-based login flow:
```bash
notebooklm login
```
This will:
1. Open a Chromium browser window
2. Navigate to NotebookLM
3. Wait for you to log in with your Google account
4. Save your session to `~/.notebooklm/storage_state.json`
**Note:** The browser uses a persistent profile at `~/.notebooklm/browser_profile/` to avoid Google's bot detection. This makes it appear as a regular browser installation.
**Custom Locations:** Set `NOTEBOOKLM_HOME` to store all configuration files in a different directory:
```bash
export NOTEBOOKLM_HOME=/custom/path
notebooklm login
```
See [Configuration](configuration.md) for multiple accounts, CI/CD setup, and more options.
### Verifying Authentication
After login, verify your session works:
```bash
notebooklm list
```
This should show your existing notebooks (or an empty list if you're new to NotebookLM).
## Your First Workflow
Let's create a notebook, add a source, and generate a podcast.
### Step 1: Create a Notebook
```bash
notebooklm create "My First Notebook"
```
Output:
```
Created notebook: abc123def456
Title: My First Notebook
```
### Step 2: Set the Active Notebook
```bash
notebooklm use abc123def456
```
You can use partial IDs - `notebooklm use abc` will match `abc123def456`.
### Step 3: Add a Source
```bash
notebooklm source add "https://en.wikipedia.org/wiki/Artificial_intelligence"
```
The CLI auto-detects URLs, YouTube links, and local files:
- URLs: `source add "https://example.com/article"`
- YouTube: `source add "https://youtube.com/watch?v=..."`
- Files: `source add "./document.pdf"`
### Step 4: Chat with Your Sources
```bash
notebooklm ask "What are the key themes in this article?"
```
### Step 5: Generate a Podcast
```bash
notebooklm generate audio "Focus on the history and future predictions" --wait
```
This generates a podcast and waits for completion (takes 2-5 minutes).
### Step 6: Download the Result
```bash
notebooklm download audio ./my-podcast.mp3
```
## Using the Python API
```python
import asyncio
from notebooklm import NotebookLMClient
async def main():
async with await NotebookLMClient.from_storage() as client:
# List notebooks
notebooks = await client.notebooks.list()
print(f"Found {len(notebooks)} notebooks")
# Create a notebook
nb = await client.notebooks.create("API Test")
print(f"Created: {nb.id}")
# Add a source
await client.sources.add_url(nb.id, "https://example.com")
# Ask a question
result = await client.chat.ask(nb.id, "Summarize this content")
print(result.answer)
asyncio.run(main())
```
## Claude Code Integration
If you use [Claude Code](https://claude.ai/code), you can install a skill for natural language automation:
```bash
notebooklm skill install
```
After installation, Claude recognizes NotebookLM commands via:
- Explicit: `/notebooklm list`, `/notebooklm generate audio`
- Natural language: "Create a podcast about quantum computing", "Summarize these URLs"
Check installation status:
```bash
notebooklm skill status
```
## Next Steps
- [CLI Reference](cli-reference.md) - Complete command documentation
- [Python API](python-api.md) - Full API reference
- [Configuration](configuration.md) - Storage and environment settings
- [Troubleshooting](troubleshooting.md) - Common issues and solutions

View file

@ -1,380 +0,0 @@
# RPC Capture Guide
**Status:** Active
**Last Updated:** 2026-01-07
**Purpose:** How to capture and reverse-engineer NotebookLM RPC calls
---
## Overview
NotebookLM uses Google's `batchexecute` RPC protocol. This guide explains how to capture and decode RPC calls for different use cases.
### Key Concepts
| Term | Description |
|------|-------------|
| **batchexecute** | Google's internal RPC protocol endpoint |
| **RPC ID** | 6-character identifier (e.g., `wXbhsf`, `s0tc2d`) |
| **f.req** | URL-encoded JSON payload |
| **at** | CSRF token (SNlM0e value) |
| **Anti-XSSI** | `)]}'` prefix on responses |
### Protocol Flow
```
1. Build request: [[[rpc_id, json_params, null, "generic"]]]
2. Encode to f.req parameter
3. POST to /_/LabsTailwindUi/data/batchexecute
4. Strip )]}' prefix from response
5. Parse chunked JSON, extract result
```
---
## Choose Your Approach
| If you are... | Use this approach |
|---------------|-------------------|
| **Reporting a bug** or doing quick investigation | [Manual Capture](#manual-capture-for-bug-reports) |
| **Building library features** or systematic capture | [Playwright Automation](#playwright-automation-for-developers) |
| **An LLM agent** discovering new methods | [LLM Discovery Workflow](#llm-discovery-workflow) |
---
## Manual Capture (For Bug Reports)
**Best for:** Quick investigation, bug reports, one-off captures
### Step 1: Setup DevTools
1. Open Chrome → Navigate to `https://notebooklm.google.com/`
2. Open DevTools (`F12` or `Cmd+Option+I`)
3. Go to **Network** tab
4. Configure:
- [x] **Preserve log** (prevents clearing on navigation)
- [x] **Disable cache** (ensures fresh requests)
5. Filter by: `batchexecute`
### Step 2: Capture One Action
**CRITICAL**: Perform ONE action at a time to isolate the exact RPC call.
```
1. Clear network log immediately before action
2. Perform the UI action (e.g., rename notebook)
3. Wait for request to complete (check status code 200)
4. DO NOT perform any other actions
```
### Step 3: Document the Request
From the batchexecute request:
**Headers Tab:**
- `rpcids` query parameter = RPC method ID
**Payload Tab:**
- `f.req` = URL-encoded payload (decode this)
- `at` = CSRF token
**Response Tab:**
- Starts with `)]}'\n` (anti-XSSI prefix)
- Followed by chunked JSON
### Step 4: Decode Payload
**Manual decode in browser console:**
```javascript
const encoded = "..."; // Paste f.req value
const decoded = decodeURIComponent(encoded);
const outer = JSON.parse(decoded);
console.log("RPC ID:", outer[0][0][0]);
console.log("Params:", JSON.parse(outer[0][0][1]));
```
**Include in bug report:**
- RPC ID (e.g., `wXbhsf`)
- Decoded params (JSON format)
- Error message or unexpected behavior
- Response if relevant
---
## Playwright Automation (For Developers)
**Best for:** Systematic RPC capture, building new features, CI/CD integration
### Setup
```python
from playwright.async_api import async_playwright
import json
import time
from urllib.parse import unquote
async def setup_capture_session():
"""Initialize Playwright with network interception."""
playwright = await async_playwright().start()
browser = await playwright.chromium.launch_persistent_context(
user_data_dir="./browser_state",
headless=False,
)
page = browser.pages[0] if browser.pages else await browser.new_page()
# Storage for captured RPCs
captured_rpcs = []
# Intercept batchexecute requests
async def handle_request(request):
if "batchexecute" in request.url:
post_data = request.post_data
if post_data and "f.req" in post_data:
# Use proper URL parsing (handles encoded & and = in values)
from urllib.parse import parse_qs
params = parse_qs(post_data)
f_req = params.get("f.req", [None])[0]
if f_req:
decoded = decode_f_req(f_req)
captured_rpcs.append({
"timestamp": time.time(),
"url": request.url,
"rpc_id": decoded["rpc_id"],
"params": decoded["params"],
})
page.on("request", handle_request)
return page, captured_rpcs
def decode_f_req(encoded: str) -> dict:
"""Decode f.req parameter to extract RPC details."""
decoded = unquote(encoded)
outer = json.loads(decoded)
inner = outer[0][0]
return {
"rpc_id": inner[0],
"params": json.loads(inner[1]),
"raw": inner,
}
```
### Triggering Actions
```python
async def trigger_action(page, captured_rpcs, action_type: str, **kwargs):
"""Trigger a specific UI action and return captured RPC."""
captured_rpcs.clear()
if action_type == "list_notebooks":
await page.goto("https://notebooklm.google.com/")
await page.wait_for_selector("mat-card", timeout=10000)
elif action_type == "create_notebook":
await page.click("button:has-text('Create new')")
await page.wait_for_url("**/notebook/**", timeout=10000)
elif action_type == "add_source_url":
url = kwargs.get("url")
await page.click("button:has-text('Add source')")
await page.wait_for_selector("[role='dialog']", timeout=5000)
await page.click("button:has-text('Website')")
await page.fill("textarea[placeholder*='links']", url)
await page.click("button:has-text('Insert')")
await page.wait_for_timeout(5000)
return list(captured_rpcs)
```
### Example Capture Session
```python
async def capture_new_method():
"""Example: Discover the RPC for a new action."""
page, captured = await setup_capture_session()
# Authenticate if needed
await page.goto("https://notebooklm.google.com/")
# Capture the action
rpcs = await trigger_action(page, captured, "create_notebook")
# Print results
for rpc in rpcs:
print(f"RPC ID: {rpc['rpc_id']}")
print(f"Params: {json.dumps(rpc['params'], indent=2)}")
await browser.close()
```
---
## LLM Discovery Workflow
**Best for:** AI agents discovering new RPC methods, adaptive exploration
### Context for LLM
When investigating NotebookLM RPC calls, use this context:
```
NotebookLM Protocol Facts:
- Endpoint: /_/LabsTailwindUi/data/batchexecute
- RPC IDs are 6-character strings (e.g., "wXbhsf")
- Payload is triple-nested: [[[rpc_id, json_params, null, "generic"]]]
- Response has )]}' anti-XSSI prefix
- Parameters are position-sensitive arrays
Source of Truth:
- Canonical RPC IDs: src/notebooklm/rpc/types.py
- Payload structures: docs/reference/internals/rpc-ui-reference.md
```
### Discovery Prompt Template
Use this when discovering a new RPC method:
```
Task: Discover the RPC call for [ACTION_NAME]
Steps:
1. Identify the UI element that triggers this action
2. Set up network interception for batchexecute
3. Trigger the UI action
4. Capture the RPC request
Document:
- RPC ID (6-character string)
- Payload structure with parameter positions
- Any source ID nesting patterns (single/double/triple)
- Response structure
Reference: See rpc-ui-reference.md for existing patterns to follow.
```
### Validation Workflow
After discovering a new RPC:
```python
async def validate_rpc_call(rpc_id: str, params: list, expected_action: str):
"""Validate an RPC call works correctly."""
from notebooklm import NotebookLMClient
async with await NotebookLMClient.from_storage() as client:
result = await client._rpc_call(RPCMethod(rpc_id), params)
assert result is not None, f"RPC {rpc_id} returned None"
return {
"rpc_id": rpc_id,
"action": expected_action,
"status": "verified",
"timestamp": datetime.now().isoformat(),
}
```
---
## Common Patterns
### Parameter Position Sensitivity
NotebookLM RPC calls are **position-sensitive** - parameters must be at exact array indices.
**Example: ADD_SOURCE (URL)**
```python
params = [
[[None, None, None, None, None, None, None, [url], None, None, 1]], # Position 0
notebook_id, # Position 1
[2], # Position 2
[1, None, None, None, None, None, None, None, None, None, [1]], # Position 3
]
```
### Source ID Nesting
Different methods require different nesting levels:
| Nesting | Example | Used By |
|---------|---------|---------|
| Single | `source_id` | Simple lookups |
| Double | `[[source_id]]` | DELETE_SOURCE, UPDATE_SOURCE |
| Triple | `[[[source_id]]]` | CREATE_ARTIFACT source lists |
### Response Parsing
```python
import json
import re
def parse_response(text: str, rpc_id: str):
"""Parse batchexecute response."""
# Strip anti-XSSI prefix
if text.startswith(")]}'"):
text = re.sub(r"^\)\]\}'\r?\n", "", text)
# Find wrb.fr chunk for our RPC ID
for line in text.split("\n"):
try:
chunk = json.loads(line)
if chunk[0] == "wrb.fr" and chunk[1] == rpc_id:
result = chunk[2]
return json.loads(result) if isinstance(result, str) else result
except (json.JSONDecodeError, IndexError):
continue
return None
```
---
## Troubleshooting
### Request Returns Null (No Error)
**Cause:** Payload format is close but not exact.
**Fix:** Capture exact browser request and compare byte-by-byte:
```python
import json
your_params = [...]
captured_params = [...]
print(json.dumps(your_params, separators=(",", ":")))
print(json.dumps(captured_params, separators=(",", ":")))
```
### RPC ID Not Found in Response
**Cause:** Wrong RPC ID or different chunk format.
**Fix:** Log all chunks to find the result:
```python
for line in response.split("\n"):
try:
chunk = json.loads(line)
print(f"Type: {chunk[0]}, ID: {chunk[1] if len(chunk) > 1 else 'N/A'}")
except:
continue
```
### CSRF Token Expired
**Symptoms:** 403 errors or authentication failures.
**Fix:** Re-fetch tokens via browser or refresh storage state:
```python
await client.refresh_auth()
```
---
## Adding New RPC Methods
See **[Adding RPC Methods Guide](../../contributing/adding-rpc-methods.md)** for the complete step-by-step workflow.
Quick summary:
1. Capture traffic using methodology above
2. Decode payload and identify parameter positions
3. Add to `rpc/types.py` and implement in `_*.py`
4. Test with unit, integration, and E2E tests
5. Document in `rpc-ui-reference.md`

View file

@ -1,130 +0,0 @@
# Releasing to PyPI
This document describes how to release a new version of `notebooklm-py` to PyPI.
## Pre-release Checklist
- [ ] All tests pass: `pytest`
- [ ] E2E readonly tests pass: `pytest tests/e2e -m readonly`
- [ ] No uncommitted changes: `git status`
- [ ] On `main` branch with latest changes
## Release Steps
### 1. Update Version
Edit `pyproject.toml` and update the version number:
```toml
[project]
name = "notebooklm-py"
version = "X.Y.Z" # Update this
```
Follow [semantic versioning](https://semver.org/):
- **MAJOR** (X): Breaking API changes
- **MINOR** (Y): New features, backward compatible
- **PATCH** (Z): Bug fixes, backward compatible
### 2. Commit Version Bump
```bash
git add pyproject.toml
git commit -m "chore: bump version to X.Y.Z"
git push
```
### 3. Test on TestPyPI
Build and upload to TestPyPI first:
```bash
# Install build tools
pip install build twine
# Build the package
python -m build
# Upload to TestPyPI
twine upload --repository testpypi dist/*
```
Test the installation (uses TestPyPI for the package, PyPI for dependencies):
```bash
uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple notebooklm-py
```
Or with pip:
```bash
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple notebooklm-py
```
Verify it works:
```bash
notebooklm --version
notebooklm --help
```
### 4. Create Release Tag
Once TestPyPI verification passes:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
This triggers the GitHub Actions workflow (`.github/workflows/publish.yml`) which automatically publishes to PyPI using trusted publishing.
### 5. Verify PyPI Release
After the workflow completes:
```bash
# Install from PyPI
pip install --upgrade notebooklm-py
# Verify
notebooklm --version
```
## Troubleshooting
### TestPyPI upload fails
Ensure you have a TestPyPI account and API token:
1. Create account at https://test.pypi.org/account/register/
2. Create API token at https://test.pypi.org/manage/account/token/
3. Configure in `~/.pypirc` or use `twine upload --username __token__ --password <token>`
### GitHub Actions publish fails
Ensure trusted publishing is configured on PyPI:
1. Go to https://pypi.org/manage/project/notebooklm-py/settings/publishing/
2. Add publisher with:
- Owner: `teng-lin`
- Repository: `notebooklm-py`
- Workflow: `publish.yml`
### Version already exists
PyPI versions are immutable. If you need to fix something:
1. Yank the bad version (optional): `twine yank notebooklm-py X.Y.Z`
2. Bump to next patch version and release again
## Quick Reference
```bash
# Full release flow
pytest # Run tests
vim pyproject.toml # Bump version
git add pyproject.toml && git commit -m "chore: bump version to X.Y.Z"
git push
python -m build # Build
twine upload --repository testpypi dist/* # Upload to TestPyPI
uv pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple notebooklm-py # Test
git tag vX.Y.Z && git push origin vX.Y.Z # Release to PyPI
```

478
docs/rpc-development.md Normal file
View file

@ -0,0 +1,478 @@
# RPC Development Guide
**Status:** Active
**Last Updated:** 2026-01-13
This guide covers everything about NotebookLM's RPC protocol: capturing calls, debugging issues, and implementing new methods.
---
## Protocol Overview
NotebookLM uses Google's `batchexecute` RPC protocol.
### Key Concepts
| Term | Description |
|------|-------------|
| **batchexecute** | Google's internal RPC endpoint |
| **RPC ID** | 6-character identifier (e.g., `wXbhsf`, `s0tc2d`) |
| **f.req** | URL-encoded JSON payload |
| **at** | CSRF token (SNlM0e value) |
| **Anti-XSSI** | `)]}'` prefix on responses |
### Protocol Flow
```
1. Build request: [[[rpc_id, json_params, null, "generic"]]]
2. Encode to f.req parameter
3. POST to /_/LabsTailwindUi/data/batchexecute
4. Strip )]}' prefix from response
5. Parse chunked JSON, extract result
```
### Source of Truth
- **RPC method IDs:** `src/notebooklm/rpc/types.py`
- **Payload structures:** `docs/rpc-reference.md`
---
## Capturing RPC Calls
### Manual Capture (Chrome DevTools)
Best for quick investigation and bug reports.
1. Open Chrome → Navigate to `https://notebooklm.google.com/`
2. Open DevTools (`F12` or `Cmd+Option+I`)
3. Go to **Network** tab
4. Configure:
- [x] **Preserve log**
- [x] **Disable cache**
5. Filter by: `batchexecute`
6. **Perform ONE action** (isolate the exact RPC call)
7. Click the request to inspect
**From the request:**
- **Headers tab → URL `rpcids`**: The RPC method ID
- **Payload tab → `f.req`**: URL-encoded payload
- **Response tab**: Starts with `)]}'` prefix
### Decoding the Payload
**Browser console:**
```javascript
const encoded = "..."; // Paste f.req value
const decoded = decodeURIComponent(encoded);
const outer = JSON.parse(decoded);
console.log("RPC ID:", outer[0][0][0]);
console.log("Params:", JSON.parse(outer[0][0][1]));
```
**Python:**
```python
import json
from urllib.parse import unquote
def decode_f_req(encoded: str) -> dict:
decoded = unquote(encoded)
outer = json.loads(decoded)
inner = outer[0][0]
return {
"rpc_id": inner[0],
"params": json.loads(inner[1]) if inner[1] else None,
}
```
### Playwright Automation
Best for systematic capture and CI integration.
```python
from playwright.async_api import async_playwright
import json
from urllib.parse import unquote, parse_qs
async def setup_capture_session():
playwright = await async_playwright().start()
browser = await playwright.chromium.launch_persistent_context(
user_data_dir="./browser_state",
headless=False,
)
page = browser.pages[0] if browser.pages else await browser.new_page()
captured_rpcs = []
async def handle_request(request):
if "batchexecute" in request.url:
post_data = request.post_data
if post_data and "f.req" in post_data:
params = parse_qs(post_data)
f_req = params.get("f.req", [None])[0]
if f_req:
decoded = decode_f_req(f_req)
captured_rpcs.append(decoded)
page.on("request", handle_request)
return page, captured_rpcs
```
---
## Debugging Issues
### Enable Debug Mode
```bash
# See what RPC IDs the server returns
NOTEBOOKLM_DEBUG_RPC=1 notebooklm <command>
```
Output:
```
DEBUG: Looking for RPC ID: Ljjv0c
DEBUG: Found RPC IDs in response: ['Ljjv0c']
```
If IDs don't match, the method ID has changed - report it in a GitHub issue.
### Common Scenarios
#### "Session Expired" Errors
```python
# Check CSRF token
print(client.auth.csrf_token)
# Refresh auth
await client.refresh_auth()
```
**Solution:** Re-run `notebooklm login`
#### RPC Method Returns None
**Causes:**
- Rate limiting (Google returns empty result)
- Wrong RPC method ID
- Incorrect parameter structure
**Debug:**
```python
from notebooklm.rpc import decode_response
raw_response = await http_client.post(...)
print("Raw:", raw_response.text[:500])
result = decode_response(raw_response.text, "METHOD_ID")
print("Parsed:", result)
```
#### Parameter Order Issues
RPC parameters are **position-sensitive**:
```python
# WRONG - missing positional elements
params = [value, notebook_id]
# RIGHT - all positions filled
params = [value, notebook_id, None, None, settings]
```
**Debug:** Compare your params with captured traffic byte-by-byte.
#### Nested List Depth
Source IDs have different nesting requirements:
```python
# Single nesting (some methods)
["source_id"]
# Double nesting
[["source_id"]]
# Triple nesting (artifact generation)
[[["source_id"]]]
# Quad nesting (get_source_guide)
[[[["source_id"]]]]
```
**Debug:** Capture working traffic and count brackets.
### Response Parsing
```python
import json
import re
def parse_response(text: str, rpc_id: str):
"""Parse batchexecute response."""
# Strip anti-XSSI prefix
if text.startswith(")]}'"):
text = re.sub(r"^\)\]\}'\r?\n", "", text)
# Find wrb.fr chunk for our RPC ID
for line in text.split("\n"):
try:
chunk = json.loads(line)
if chunk[0] == "wrb.fr" and chunk[1] == rpc_id:
result = chunk[2]
return json.loads(result) if isinstance(result, str) else result
except (json.JSONDecodeError, IndexError):
continue
return None
```
---
## Adding New RPC Methods
### Workflow
```
1. Capture → 2. Decode → 3. Implement → 4. Test → 5. Document
```
### Step 1: Capture
Use Chrome DevTools or Playwright (see above).
**What to capture:**
- RPC ID from URL `rpcids` parameter
- Decoded `f.req` payload
- Response structure
### Step 2: Decode
Document each position in the params array:
```python
# Example: ADD_SOURCE for URL
params = [
[[None, None, [url], None, None, None, None, None]], # 0: Source data
notebook_id, # 1: Notebook ID
[2], # 2: Fixed flag
None, # 3: Optional settings
]
```
Key patterns:
- **Nested source IDs:** Count brackets carefully
- **Fixed flags:** Arrays like `[2]`, `[1]` that don't change
- **Optional positions:** Often `None`
### Step 3: Implement
**Add RPC method ID** (`src/notebooklm/rpc/types.py`):
```python
class RPCMethod(str, Enum):
NEW_METHOD = "AbCdEf" # 6-char ID from capture
```
**Add client method** (appropriate `_*.py` file):
```python
async def new_method(self, notebook_id: str, param: str) -> SomeResult:
"""Short description.
Args:
notebook_id: The notebook ID.
param: Description.
Returns:
Description of return value.
"""
params = [
param, # Position 0
notebook_id, # Position 1
[2], # Position 2: Fixed flag
]
result = await self._core.rpc_call(
RPCMethod.NEW_METHOD,
params,
source_path=f"/notebook/{notebook_id}",
)
if result is None:
return None
return SomeResult.from_api_response(result)
```
**Add dataclass if needed** (`src/notebooklm/types.py`):
```python
@dataclass
class SomeResult:
id: str
title: str
@classmethod
def from_api_response(cls, data: list[Any]) -> "SomeResult":
return cls(id=data[0], title=data[1])
```
### Step 4: Test
**Unit test** (`tests/unit/`):
```python
def test_encode_new_method():
params = ["value", "notebook_id", [2]]
result = encode_rpc_request(RPCMethod.NEW_METHOD, params)
assert "AbCdEf" in result
```
**Integration test** (`tests/integration/`):
```python
@pytest.mark.asyncio
async def test_new_method(mock_client):
mock_response = ["result_id", "Result Title"]
with patch('notebooklm._core.ClientCore.rpc_call', new_callable=AsyncMock) as mock:
mock.return_value = mock_response
result = await mock_client.some_api.new_method("nb_id", "param")
assert result.id == "result_id"
```
**E2E test** (`tests/e2e/`):
```python
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_new_method_e2e(client, read_only_notebook_id):
result = await client.some_api.new_method(read_only_notebook_id, "param")
assert result is not None
```
### Step 5: Document
Update `docs/rpc-reference.md`:
```markdown
### NEW_METHOD (`AbCdEf`)
**Purpose:** Short description
**Params:**
```python
params = [
some_value, # 0: Description
notebook_id, # 1: Notebook ID
[2], # 2: Fixed flag
]
```
**Response:** Description of response structure
**Source:** `_some_api.py:123`
```
---
## Common Pitfalls
### Wrong nesting level
Different methods need different source ID nesting. Check similar methods.
### Position sensitivity
Params are arrays, not dicts. Position matters:
```python
# WRONG - missing position 2
params = [value, notebook_id, settings]
# RIGHT - explicit None for unused positions
params = [value, notebook_id, None, settings]
```
### Forgetting source_path
Some methods require `source_path` for routing:
```python
# May fail without source_path
await self._core.rpc_call(RPCMethod.X, params)
# Correct
await self._core.rpc_call(
RPCMethod.X,
params,
source_path=f"/notebook/{notebook_id}",
)
```
### Response parsing
API returns nested arrays. Print raw response first:
```python
result = await self._core.rpc_call(...)
print(f"DEBUG: {result}") # See actual structure
```
---
## Checklist
- [ ] Captured RPC ID and params structure
- [ ] Added to `RPCMethod` enum in `rpc/types.py`
- [ ] Implemented method in appropriate `_*.py` file
- [ ] Added dataclass if needed in `types.py`
- [ ] Added CLI command if needed
- [ ] Unit test for encoding
- [ ] Integration test with mock
- [ ] E2E test (manual verification OK for rare operations)
- [ ] Updated `rpc-reference.md`
---
## LLM Agent Workflow
For AI agents discovering new RPC methods:
### Context
```
NotebookLM Protocol Facts:
- Endpoint: /_/LabsTailwindUi/data/batchexecute
- RPC IDs are 6-character strings (e.g., "wXbhsf")
- Payload: [[[rpc_id, json_params, null, "generic"]]]
- Response has )]}' anti-XSSI prefix
- Parameters are position-sensitive arrays
Source of Truth:
- Canonical RPC IDs: src/notebooklm/rpc/types.py
- Payload structures: docs/rpc-reference.md
```
### Discovery Prompt Template
```
Task: Discover the RPC call for [ACTION_NAME]
Steps:
1. Identify the UI element that triggers this action
2. Set up network interception for batchexecute
3. Trigger the UI action
4. Capture the RPC request
Document:
- RPC ID (6-character string)
- Payload structure with parameter positions
- Source ID nesting pattern
- Response structure
```
### Validation
```python
async def validate_rpc_call(rpc_id: str, params: list, expected_action: str):
from notebooklm import NotebookLMClient
async with await NotebookLMClient.from_storage() as client:
result = await client._rpc_call(RPCMethod(rpc_id), params)
assert result is not None, f"RPC {rpc_id} returned None"
return {"rpc_id": rpc_id, "action": expected_action, "status": "verified"}
```

View file

@ -116,7 +116,7 @@ When Google changes their internal APIs:
- Error message (especially any RPC error codes)
- Which operation failed
- When it started failing
3. See [RPC Capture Guide](reference/internals/rpc-capture.md) for debugging
3. See [RPC Development Guide](rpc-development.md) for debugging
### Self-Recovery

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -1,185 +0,0 @@
{
"title": "Agent Flashcards",
"cards": [
{
"front": "What is the core philosophy of the 'learn-claude-code' project?",
"back": "Modern AI agents work because the model is trained to be an agent; our job is to give it tools and stay out of the way."
},
{
"front": "What is the fundamental loop that every coding agent, like Claude Code, is based on?",
"back": "A loop where the model calls tools until it's done, and the results of those tools are appended to the message history for the next iteration."
},
{
"front": "According to the 'learn-claude-code' philosophy, the model is _____% of the agent, and the code is _____% of the agent.",
"back": "80, 20"
},
{
"front": "What is the core insight of the v0 `v0_bash_agent.py`?",
"back": "Bash is all you need; a single `bash` tool is sufficient to provide full agent capability, including reading, writing, and executing."
},
{
"front": "In `v0_bash_agent.py`, how is the concept of a subagent implemented without a dedicated 'Task' tool?",
"back": "By recursively calling itself via a bash command (e.g., `python v0_bash_agent.py \"subtask\"`), which spawns an isolated process with a fresh context."
},
{
"front": "What core insight does `v1_basic_agent.py` demonstrate?",
"back": "The concept of 'Model as Agent,' where the model is the primary decision-maker, and the code just provides tools and runs the execution loop."
},
{
"front": "What are the four essential tools introduced in `v1_basic_agent.py` that cover 90% of coding use cases?",
"back": "`bash`, `read_file`, `write_file`, and `edit_file`."
},
{
"front": "In an agent system, what is the purpose of the `read_file` tool?",
"back": "To read the contents of an existing file, allowing the agent to understand code."
},
{
"front": "Which tool in `v1_basic_agent.py` is used for surgical changes to existing code by replacing exact text?",
"back": "The `edit_file` tool."
},
{
"front": "What problem in multi-step tasks does `v2_todo_agent.py` aim to solve?",
"back": "Context Fade, where the model loses focus or forgets steps in a complex plan because the plan is not explicitly tracked."
},
{
"front": "What new tool is introduced in `v2_todo_agent.py` to enable structured planning?",
"back": "The `TodoWrite` tool, which allows the agent to maintain and update a visible task list."
},
{
"front": "What is the key design insight behind the constraints (e.g., max 20 items, one `in_progress` task) in the `TodoManager`?",
"back": "Structure constrains AND enables; the constraints force focus and make complex task completion possible by providing scaffolding."
},
{
"front": "In the `TodoManager`, what is the purpose of the `activeForm` field for a task item?",
"back": "It provides a present-tense description of the action being performed for the task currently marked as `in_progress`."
},
{
"front": "What problem arises when a single agent performs large exploration tasks before acting, as addressed in v3?",
"back": "Context Pollution, where the agent's history fills with exploration details, leaving little room for the primary task."
},
{
"front": "How does the v3 subagent mechanism solve the problem of context pollution?",
"back": "It spawns child agents with isolated contexts for subtasks, so the main agent only receives a clean summary as a result."
},
{
"front": "What is the name of the tool introduced in v3 to spawn child agents?",
"back": "The `Task` tool."
},
{
"front": "In the v3 subagent design, what is the purpose of the `AGENT_TYPES` registry?",
"back": "To define different types of agents (e.g., 'explore', 'code', 'plan') with specific capabilities, prompts, and tool access."
},
{
"front": "What is the key to context isolation when a subagent is executed in v3?",
"back": "The subagent is started with a fresh, empty message history (`sub_messages = []`), so it does not inherit the parent's context."
},
{
"front": "In the v3 `AGENT_TYPES` registry, what is the key difference in tool permissions for an 'explore' agent versus a 'code' agent?",
"back": "The 'explore' agent has read-only tools (like `bash` and `read_file`), while the 'code' agent has access to all tools, including those that write files."
},
{
"front": "What is the core insight of the v4 skills mechanism?",
"back": "Skills are knowledge packages, not tools; they teach the agent HOW to do something, rather than just giving it a new capability."
},
{
"front": "In v4, a Tool is what the model _____, while a Skill is how the model _____ to do something.",
"back": "CAN do, KNOWS"
},
{
"front": "What is the paradigm shift that skills embody, moving away from traditional AI development?",
"back": "Knowledge Externalization, where knowledge is stored in editable documents (`SKILL.md`) instead of being locked inside model parameters."
},
{
"front": "What is the main advantage of Knowledge Externalization over traditional model fine-tuning?",
"back": "Anyone can teach the model a new skill by editing a text file, without needing ML expertise, training data, or GPU clusters."
},
{
"front": "What is the standard file format for defining a skill in the v4 agent?",
"back": "A `SKILL.md` file containing YAML frontmatter for metadata and a Markdown body for instructions."
},
{
"front": "What are the two required metadata fields in a `SKILL.md` file's YAML frontmatter?",
"back": "`name` and `description`."
},
{
"front": "What is the concept of 'Progressive Disclosure' in the v4 skills mechanism?",
"back": "Loading knowledge in layers: first, lightweight metadata is always available, and second, the detailed skill body is loaded only when triggered."
},
{
"front": "What is the name of the tool introduced in v4 that allows the model to load domain expertise on-demand?",
"back": "The `Skill` tool."
},
{
"front": "What is the critical implementation detail for how skill content is injected into the conversation to preserve the prompt cache?",
"back": "The skill content is returned as a `tool_result` (part of a user message), not injected into the system prompt."
},
{
"front": "Why is it a bad practice to inject dynamic information into the system prompt on each turn of an agent loop?",
"back": "It invalidates the KV Cache, as the entire message prefix changes, leading to re-computation and drastically increased costs (20-50x)."
},
{
"front": "What is the KV Cache in the context of LLMs?",
"back": "A mechanism that stores the computed key-value states of previous tokens in a sequence so they don't need to be re-calculated for subsequent tokens."
},
{
"front": "A cache hit for an LLM API call requires that the new request has an _____ with the previous request.",
"back": "exact prefix match"
},
{
"front": "Which of these is a cache-breaking anti-pattern in agent development: append-only messages or message compression?",
"back": "Message compression, as it modifies past history and invalidates the cache from the point of replacement."
},
{
"front": "To optimize for cost and performance with LLM APIs, you should treat the conversation history as an _____, not an editable document.",
"back": "append-only log"
},
{
"front": "In the `learn-claude-code` project, how does using Skills represent a shift from 'training AI' to 'educating AI'?",
"back": "It turns implicit knowledge that required training into explicit, human-readable documents that can be written, version-controlled, and shared."
},
{
"front": "In v3, the pattern `Main Agent -> Subagent A -> Subagent B` is described as a strategy of _____.",
"back": "Divide and conquer"
},
{
"front": "What is the purpose of the `safe_path` function in the provided agent code?",
"back": "It's a security measure to ensure the file path provided by the model stays within the project's working directory."
},
{
"front": "In the v1 agent's `run_bash` function, what is one reason a command might be blocked?",
"back": "It is considered dangerous, containing patterns like `rm -rf /` or `sudo`."
},
{
"front": "What does the v3 `get_tools_for_agent` function do?",
"back": "It filters the list of available tools based on the specified `agent_type` to enforce capability restrictions."
},
{
"front": "Why do subagents in the v3 demo not get access to the `Task` tool?",
"back": "To prevent the possibility of infinite recursion (a subagent spawning another subagent)."
},
{
"front": "In v4, what information does the `SkillLoader` class initially load from all `SKILL.md` files at startup?",
"back": "Only the metadata (name and description) from the YAML frontmatter, to keep the initial context lean."
},
{
"front": "How does the v4 agent provide the model with hints about a skill's available resources, such as scripts or reference documents?",
"back": "When a skill's content is loaded, it includes a list of files found in optional subdirectories like `scripts/` and `references/`."
},
{
"front": "What is the primary difference between the `write_file` and `edit_file` tools?",
"back": "`write_file` creates or completely overwrites a file, while `edit_file` performs a surgical replacement of specific text within an existing file."
},
{
"front": "The core agent loop `while True: response = model(messages, tools) ...` demonstrates that the _____ controls the loop.",
"back": "model"
},
{
"front": "In v3, what is the role of a 'plan' agent type?",
"back": "To analyze a codebase and produce a numbered implementation plan without modifying any files."
},
{
"front": "The v4 `SkillLoader`'s `parse_skill_md` function uses a regular expression to separate the _____ from the Markdown body.",
"back": "YAML frontmatter"
}
]
}

View file

@ -1,361 +0,0 @@
# Agent Flashcards
## Card 1
**Q:** What is the core philosophy of the 'learn-claude-code' project?
**A:** Modern AI agents work because the model is trained to be an agent; our job is to give it tools and stay out of the way.
---
## Card 2
**Q:** What is the fundamental loop that every coding agent, like Claude Code, is based on?
**A:** A loop where the model calls tools until it's done, and the results of those tools are appended to the message history for the next iteration.
---
## Card 3
**Q:** According to the 'learn-claude-code' philosophy, the model is _____% of the agent, and the code is _____% of the agent.
**A:** 80, 20
---
## Card 4
**Q:** What is the core insight of the v0 `v0_bash_agent.py`?
**A:** Bash is all you need; a single `bash` tool is sufficient to provide full agent capability, including reading, writing, and executing.
---
## Card 5
**Q:** In `v0_bash_agent.py`, how is the concept of a subagent implemented without a dedicated 'Task' tool?
**A:** By recursively calling itself via a bash command (e.g., `python v0_bash_agent.py "subtask"`), which spawns an isolated process with a fresh context.
---
## Card 6
**Q:** What core insight does `v1_basic_agent.py` demonstrate?
**A:** The concept of 'Model as Agent,' where the model is the primary decision-maker, and the code just provides tools and runs the execution loop.
---
## Card 7
**Q:** What are the four essential tools introduced in `v1_basic_agent.py` that cover 90% of coding use cases?
**A:** `bash`, `read_file`, `write_file`, and `edit_file`.
---
## Card 8
**Q:** In an agent system, what is the purpose of the `read_file` tool?
**A:** To read the contents of an existing file, allowing the agent to understand code.
---
## Card 9
**Q:** Which tool in `v1_basic_agent.py` is used for surgical changes to existing code by replacing exact text?
**A:** The `edit_file` tool.
---
## Card 10
**Q:** What problem in multi-step tasks does `v2_todo_agent.py` aim to solve?
**A:** Context Fade, where the model loses focus or forgets steps in a complex plan because the plan is not explicitly tracked.
---
## Card 11
**Q:** What new tool is introduced in `v2_todo_agent.py` to enable structured planning?
**A:** The `TodoWrite` tool, which allows the agent to maintain and update a visible task list.
---
## Card 12
**Q:** What is the key design insight behind the constraints (e.g., max 20 items, one `in_progress` task) in the `TodoManager`?
**A:** Structure constrains AND enables; the constraints force focus and make complex task completion possible by providing scaffolding.
---
## Card 13
**Q:** In the `TodoManager`, what is the purpose of the `activeForm` field for a task item?
**A:** It provides a present-tense description of the action being performed for the task currently marked as `in_progress`.
---
## Card 14
**Q:** What problem arises when a single agent performs large exploration tasks before acting, as addressed in v3?
**A:** Context Pollution, where the agent's history fills with exploration details, leaving little room for the primary task.
---
## Card 15
**Q:** How does the v3 subagent mechanism solve the problem of context pollution?
**A:** It spawns child agents with isolated contexts for subtasks, so the main agent only receives a clean summary as a result.
---
## Card 16
**Q:** What is the name of the tool introduced in v3 to spawn child agents?
**A:** The `Task` tool.
---
## Card 17
**Q:** In the v3 subagent design, what is the purpose of the `AGENT_TYPES` registry?
**A:** To define different types of agents (e.g., 'explore', 'code', 'plan') with specific capabilities, prompts, and tool access.
---
## Card 18
**Q:** What is the key to context isolation when a subagent is executed in v3?
**A:** The subagent is started with a fresh, empty message history (`sub_messages = []`), so it does not inherit the parent's context.
---
## Card 19
**Q:** In the v3 `AGENT_TYPES` registry, what is the key difference in tool permissions for an 'explore' agent versus a 'code' agent?
**A:** The 'explore' agent has read-only tools (like `bash` and `read_file`), while the 'code' agent has access to all tools, including those that write files.
---
## Card 20
**Q:** What is the core insight of the v4 skills mechanism?
**A:** Skills are knowledge packages, not tools; they teach the agent HOW to do something, rather than just giving it a new capability.
---
## Card 21
**Q:** In v4, a Tool is what the model _____, while a Skill is how the model _____ to do something.
**A:** CAN do, KNOWS
---
## Card 22
**Q:** What is the paradigm shift that skills embody, moving away from traditional AI development?
**A:** Knowledge Externalization, where knowledge is stored in editable documents (`SKILL.md`) instead of being locked inside model parameters.
---
## Card 23
**Q:** What is the main advantage of Knowledge Externalization over traditional model fine-tuning?
**A:** Anyone can teach the model a new skill by editing a text file, without needing ML expertise, training data, or GPU clusters.
---
## Card 24
**Q:** What is the standard file format for defining a skill in the v4 agent?
**A:** A `SKILL.md` file containing YAML frontmatter for metadata and a Markdown body for instructions.
---
## Card 25
**Q:** What are the two required metadata fields in a `SKILL.md` file's YAML frontmatter?
**A:** `name` and `description`.
---
## Card 26
**Q:** What is the concept of 'Progressive Disclosure' in the v4 skills mechanism?
**A:** Loading knowledge in layers: first, lightweight metadata is always available, and second, the detailed skill body is loaded only when triggered.
---
## Card 27
**Q:** What is the name of the tool introduced in v4 that allows the model to load domain expertise on-demand?
**A:** The `Skill` tool.
---
## Card 28
**Q:** What is the critical implementation detail for how skill content is injected into the conversation to preserve the prompt cache?
**A:** The skill content is returned as a `tool_result` (part of a user message), not injected into the system prompt.
---
## Card 29
**Q:** Why is it a bad practice to inject dynamic information into the system prompt on each turn of an agent loop?
**A:** It invalidates the KV Cache, as the entire message prefix changes, leading to re-computation and drastically increased costs (20-50x).
---
## Card 30
**Q:** What is the KV Cache in the context of LLMs?
**A:** A mechanism that stores the computed key-value states of previous tokens in a sequence so they don't need to be re-calculated for subsequent tokens.
---
## Card 31
**Q:** A cache hit for an LLM API call requires that the new request has an _____ with the previous request.
**A:** exact prefix match
---
## Card 32
**Q:** Which of these is a cache-breaking anti-pattern in agent development: append-only messages or message compression?
**A:** Message compression, as it modifies past history and invalidates the cache from the point of replacement.
---
## Card 33
**Q:** To optimize for cost and performance with LLM APIs, you should treat the conversation history as an _____, not an editable document.
**A:** append-only log
---
## Card 34
**Q:** In the `learn-claude-code` project, how does using Skills represent a shift from 'training AI' to 'educating AI'?
**A:** It turns implicit knowledge that required training into explicit, human-readable documents that can be written, version-controlled, and shared.
---
## Card 35
**Q:** In v3, the pattern `Main Agent -> Subagent A -> Subagent B` is described as a strategy of _____.
**A:** Divide and conquer
---
## Card 36
**Q:** What is the purpose of the `safe_path` function in the provided agent code?
**A:** It's a security measure to ensure the file path provided by the model stays within the project's working directory.
---
## Card 37
**Q:** In the v1 agent's `run_bash` function, what is one reason a command might be blocked?
**A:** It is considered dangerous, containing patterns like `rm -rf /` or `sudo`.
---
## Card 38
**Q:** What does the v3 `get_tools_for_agent` function do?
**A:** It filters the list of available tools based on the specified `agent_type` to enforce capability restrictions.
---
## Card 39
**Q:** Why do subagents in the v3 demo not get access to the `Task` tool?
**A:** To prevent the possibility of infinite recursion (a subagent spawning another subagent).
---
## Card 40
**Q:** In v4, what information does the `SkillLoader` class initially load from all `SKILL.md` files at startup?
**A:** Only the metadata (name and description) from the YAML frontmatter, to keep the initial context lean.
---
## Card 41
**Q:** How does the v4 agent provide the model with hints about a skill's available resources, such as scripts or reference documents?
**A:** When a skill's content is loaded, it includes a list of files found in optional subdirectories like `scripts/` and `references/`.
---
## Card 42
**Q:** What is the primary difference between the `write_file` and `edit_file` tools?
**A:** `write_file` creates or completely overwrites a file, while `edit_file` performs a surgical replacement of specific text within an existing file.
---
## Card 43
**Q:** The core agent loop `while True: response = model(messages, tools) ...` demonstrates that the _____ controls the loop.
**A:** model
---
## Card 44
**Q:** In v3, what is the role of a 'plan' agent type?
**A:** To analyze a codebase and produce a numbered implementation plan without modifying any files.
---
## Card 45
**Q:** The v4 `SkillLoader`'s `parse_skill_md` function uses a regular expression to separate the _____ from the Markdown body.
**A:** YAML frontmatter
---

View file

@ -1,265 +0,0 @@
{
"title": "Agent Quiz",
"questions": [
{
"question": "What is the core philosophy of the `learn-claude-code` project, as stated in its documentation?",
"answerOptions": [
{
"text": "The agent's performance is primarily determined by clever engineering and complex code loops.",
"rationale": "The project's philosophy suggests that the code itself is only 20% of the solution, with the model's inherent capabilities being the most significant factor.",
"isCorrect": false
},
{
"text": "The model itself is the agent, and the surrounding code's main job is to provide it with tools and manage the execution loop.",
"rationale": "This aligns with the project's \"Model as Agent\" philosophy, emphasizing that the model's training to act as an agent is the key, not intricate code.",
"isCorrect": true
},
{
"text": "The number and variety of tools available to the agent are the most critical factors for its success.",
"rationale": "While tools are necessary, the core philosophy emphasizes the model's role as the decision-maker, not just the quantity of tools it can access.",
"isCorrect": false
},
{
"text": "Effective AI agents must rely solely on their internal, pre-trained knowledge without using external tools.",
"rationale": "The entire project is built around the concept of an agent that repeatedly interacts with its environment using tools.",
"isCorrect": false
}
],
"hint": "Consider the stated ratio of importance between the model and the code in the project's philosophy."
},
{
"question": "In the `v0_bash_agent.py` implementation, how is the concept of a subagent achieved?",
"answerOptions": [
{
"text": "By using a dedicated `Task` tool that spawns a child agent from a registry.",
"rationale": "The `Task` tool and an agent registry are features introduced in the more advanced `v3` version, not the minimalist `v0`.",
"isCorrect": false
},
{
"text": "By recursively executing the script as a new, isolated process via a `bash` command.",
"rationale": "This method leverages process isolation at the operating system level to create a subagent with a fresh context, which is the key insight of the `v0` agent.",
"isCorrect": true
},
{
"text": "By creating a new thread within the parent process to handle the sub-task.",
"rationale": "While a valid programming technique, the `v0` agent uses process spawning, not multithreading, to ensure complete context isolation.",
"isCorrect": false
},
{
"text": "By instantiating a new agent class within the code that maintains a separate history list.",
"rationale": "The `v0` implementation is much simpler and relies on an external shell command rather than internal class structures for sub-tasking.",
"isCorrect": false
}
],
"hint": "Think about how this minimalist agent uses its single tool to delegate work."
},
{
"question": "What primary problem is the `TodoWrite` tool in `v2_todo_agent.py` designed to solve for the agent?",
"answerOptions": [
{
"text": "The agent polluting its context window with excessive file content during exploration.",
"rationale": "This issue, known as 'context pollution', is the primary problem addressed by the subagent mechanism in `v3`.",
"isCorrect": false
},
{
"text": "The agent's inability to write new files or modify existing ones.",
"rationale": "File writing and editing capabilities are fundamental tools provided in the `v1` basic agent.",
"isCorrect": false
},
{
"text": "The model losing focus or forgetting the overall plan during complex, multi-step tasks.",
"rationale": "The `TodoWrite` tool makes the agent's plan explicit and persistent, addressing 'context fade' and helping it track progress.",
"isCorrect": true
},
{
"text": "The agent not knowing which type of specialized subagent to use for a task.",
"rationale": "The concept of specialized subagents is introduced in `v3`, whereas the `TodoWrite` tool is about managing a single agent's workflow.",
"isCorrect": false
}
],
"hint": "This tool helps make the agent's internal thought process visible and persistent."
},
{
"question": "According to the v3 documentation, what is the main advantage of using subagents?",
"answerOptions": [
{
"text": "It allows the agent to learn new domain-specific expertise from `SKILL.md` files.",
"rationale": "Loading expertise from external files is the key feature of the `v4` skills mechanism.",
"isCorrect": false
},
{
"text": "It provides a mechanism for structured planning and tracking task completion.",
"rationale": "Structured planning through a visible list is the core concept introduced with the `TodoManager` in `v2`.",
"isCorrect": false
},
{
"text": "It enables the agent to run multiple `bash` commands in parallel for faster execution.",
"rationale": "While parallelism is mentioned as a possibility, the primary benefit highlighted is the management of the agent's focus.",
"isCorrect": false
},
{
"text": "It isolates the context of a sub-task, preventing the main agent's history from becoming polluted.",
"rationale": "This is the central purpose of subagents in `v3`: to handle tasks like codebase exploration in a separate context so the main agent remains focused.",
"isCorrect": true
}
],
"hint": "Consider the problem that arises when an agent reads many large files to prepare for a small change."
},
{
"question": "The v4 documentation draws a clear distinction between 'Tools' and 'Skills'. What is this distinction?",
"answerOptions": [
{
"text": "Tools define what an agent *can do* (its capabilities), while Skills define *how* an agent knows to perform a task (its expertise).",
"rationale": "This correctly captures the essence of the distinction: Tools are about action/capability, whereas Skills are about knowledge/expertise.",
"isCorrect": true
},
{
"text": "Tools are built-in functionalities, while Skills are third-party plugins that must be downloaded.",
"rationale": "The documentation describes skills as locally stored, human-editable files, not necessarily as plugins from a marketplace.",
"isCorrect": false
},
{
"text": "Tools are used for general-purpose tasks like reading files, while Skills are only for code generation.",
"rationale": "Skills provide domain expertise for a wide range of tasks, such as PDF processing or code review, not just code generation.",
"isCorrect": false
},
{
"text": "Tools are implemented in Python code, whereas Skills are implemented using shell scripts.",
"rationale": "Skills are primarily human-readable Markdown documents that provide instructions; they are not themselves executable scripts.",
"isCorrect": false
}
],
"hint": "Think about the difference between having a hammer and knowing how to build a house."
},
{
"question": "What is the key benefit of the \"Knowledge Externalization\" paradigm introduced in v4?",
"answerOptions": [
{
"text": "It allows the agent's knowledge to be version-controlled, audited, and edited in plain text by anyone, without requiring model training.",
"rationale": "This concept democratizes the process of 'teaching' an agent, moving from expensive, expertise-heavy model training to simple document editing.",
"isCorrect": true
},
{
"text": "It forces the agent to write all its internal thoughts to an external log file for easier debugging.",
"rationale": "While logging is a development practice, Knowledge Externalization is about how the agent acquires and uses expertise, not how it logs its actions.",
"isCorrect": false
},
{
"text": "It caches the model's large parameter weights in the local file system to speed up agent initialization.",
"rationale": "This describes a form of model caching, which is a different concept from externalizing domain knowledge into editable documents.",
"isCorrect": false
},
{
"text": "It requires the agent to use external web APIs for all information, preventing it from using outdated internal knowledge.",
"rationale": "Knowledge Externalization refers to loading structured expertise from local `SKILL.md` files, not necessarily relying on web APIs.",
"isCorrect": false
}
],
"hint": "This paradigm shift makes customizing an agent's expertise as easy as editing a text document."
},
{
"question": "To maintain cost-efficiency by preserving the KV Cache, how does the `v4_skills_agent.py` inject skill content into the conversation?",
"answerOptions": [
{
"text": "By dynamically updating the system prompt with the new skill information before each model call.",
"rationale": "The documentation explicitly identifies this as a costly anti-pattern because modifying the system prompt invalidates the entire cache.",
"isCorrect": false
},
{
"text": "By appending the skill content as a `tool_result` message, which doesn't alter the preceding message history.",
"rationale": "This append-only method ensures the prefix of the conversation remains unchanged, allowing the provider to reuse the cached computations.",
"isCorrect": true
},
{
"text": "By performing a lightweight fine-tuning operation on the model in real-time.",
"rationale": "Real-time fine-tuning is computationally expensive and is the 'traditional' approach that the skills mechanism is designed to avoid.",
"isCorrect": false
},
{
"text": "By storing the skill content in a separate memory buffer that is not part of the model's main context.",
"rationale": "For the model to use the knowledge, it must be part of its context; the key is *how* it's added to that context.",
"isCorrect": false
}
],
"hint": "The correct method avoids changing the beginning or middle of the conversation history."
},
{
"question": "Which of these is NOT one of the four essential tools introduced in the `v1_basic_agent.py` to cover most coding use cases?",
"answerOptions": [
{
"text": "`bash`",
"rationale": "`bash` is included as the gateway tool for running any command-line operation.",
"isCorrect": false
},
{
"text": "`read_file`",
"rationale": "`read_file` is included as an essential tool for understanding existing code.",
"isCorrect": false
},
{
"text": "`TodoWrite`",
"rationale": "`TodoWrite` is the specialized tool for structured planning introduced in the `v2` agent.",
"isCorrect": true
},
{
"text": "`edit_file`",
"rationale": "`edit_file` is included for making surgical changes to existing files.",
"isCorrect": false
}
],
"hint": "The first version of the agent focused on core capabilities for interacting with the file system and shell, not on complex planning."
},
{
"question": "What is the fundamental logic of the \"core agent loop\" described throughout the project?",
"answerOptions": [
{
"text": "The agent first creates a complete, unchangeable plan and then executes each step in sequence.",
"rationale": "The agent loop is iterative and reactive; the result of one tool call informs the model's decision for the next one.",
"isCorrect": false
},
{
"text": "The model makes a tool call, the tool's result is added to the history, and this cycle repeats until the model stops calling tools.",
"rationale": "This accurately describes the iterative process where the model is the decision-maker, acting in a loop until the task is complete.",
"isCorrect": true
},
{
"text": "The user provides a precise sequence of tools for the agent to execute.",
"rationale": "In this agent model, the user provides a high-level goal, and the AI model itself decides which tools to use and in what order.",
"isCorrect": false
},
{
"text": "The agent's code analyzes the prompt and selects a single, optimal tool to resolve the entire request.",
"rationale": "The power of the agent comes from its ability to use multiple tools sequentially over several turns, not from picking just one.",
"isCorrect": false
}
],
"hint": "It is a repetitive cycle of thinking, acting, and observing the results."
},
{
"question": "In the `v3` subagent mechanism, what is the purpose of the `AGENT_TYPES` registry?",
"answerOptions": [
{
"text": "To define different subagent archetypes with specific roles, prompts, and restricted toolsets.",
"rationale": "This registry allows the main agent to spawn specialized subagents, such as a read-only 'explore' agent or a full-powered 'code' agent.",
"isCorrect": true
},
{
"text": "To list all the available skills and their descriptions for the `v4` agent.",
"rationale": "The listing of skills is handled by the `SkillLoader` class, which is a concept from `v4`, not `v3`.",
"isCorrect": false
},
{
"text": "To keep a real-time log of all active subagent processes and their current status.",
"rationale": "The registry is a static configuration defining agent types, not a dynamic process manager that tracks running instances.",
"isCorrect": false
},
{
"text": "To provide the input schema and validation rules for the `TodoWrite` tool.",
"rationale": "A tool's input schema is defined within the tool's own JSON definition, not in the agent type registry.",
"isCorrect": false
}
],
"hint": "This configuration allows the main agent to choose the right kind of 'specialist' for a sub-task."
}
]
}

View file

@ -1,101 +0,0 @@
# Agent Quiz
## Question 1
What is the core philosophy of the `learn-claude-code` project, as stated in its documentation?
- [ ] The agent's performance is primarily determined by clever engineering and complex code loops.
- [x] The model itself is the agent, and the surrounding code's main job is to provide it with tools and manage the execution loop.
- [ ] The number and variety of tools available to the agent are the most critical factors for its success.
- [ ] Effective AI agents must rely solely on their internal, pre-trained knowledge without using external tools.
**Hint:** Consider the stated ratio of importance between the model and the code in the project's philosophy.
## Question 2
In the `v0_bash_agent.py` implementation, how is the concept of a subagent achieved?
- [ ] By using a dedicated `Task` tool that spawns a child agent from a registry.
- [x] By recursively executing the script as a new, isolated process via a `bash` command.
- [ ] By creating a new thread within the parent process to handle the sub-task.
- [ ] By instantiating a new agent class within the code that maintains a separate history list.
**Hint:** Think about how this minimalist agent uses its single tool to delegate work.
## Question 3
What primary problem is the `TodoWrite` tool in `v2_todo_agent.py` designed to solve for the agent?
- [ ] The agent polluting its context window with excessive file content during exploration.
- [ ] The agent's inability to write new files or modify existing ones.
- [x] The model losing focus or forgetting the overall plan during complex, multi-step tasks.
- [ ] The agent not knowing which type of specialized subagent to use for a task.
**Hint:** This tool helps make the agent's internal thought process visible and persistent.
## Question 4
According to the v3 documentation, what is the main advantage of using subagents?
- [ ] It allows the agent to learn new domain-specific expertise from `SKILL.md` files.
- [ ] It provides a mechanism for structured planning and tracking task completion.
- [ ] It enables the agent to run multiple `bash` commands in parallel for faster execution.
- [x] It isolates the context of a sub-task, preventing the main agent's history from becoming polluted.
**Hint:** Consider the problem that arises when an agent reads many large files to prepare for a small change.
## Question 5
The v4 documentation draws a clear distinction between 'Tools' and 'Skills'. What is this distinction?
- [x] Tools define what an agent *can do* (its capabilities), while Skills define *how* an agent knows to perform a task (its expertise).
- [ ] Tools are built-in functionalities, while Skills are third-party plugins that must be downloaded.
- [ ] Tools are used for general-purpose tasks like reading files, while Skills are only for code generation.
- [ ] Tools are implemented in Python code, whereas Skills are implemented using shell scripts.
**Hint:** Think about the difference between having a hammer and knowing how to build a house.
## Question 6
What is the key benefit of the "Knowledge Externalization" paradigm introduced in v4?
- [x] It allows the agent's knowledge to be version-controlled, audited, and edited in plain text by anyone, without requiring model training.
- [ ] It forces the agent to write all its internal thoughts to an external log file for easier debugging.
- [ ] It caches the model's large parameter weights in the local file system to speed up agent initialization.
- [ ] It requires the agent to use external web APIs for all information, preventing it from using outdated internal knowledge.
**Hint:** This paradigm shift makes customizing an agent's expertise as easy as editing a text document.
## Question 7
To maintain cost-efficiency by preserving the KV Cache, how does the `v4_skills_agent.py` inject skill content into the conversation?
- [ ] By dynamically updating the system prompt with the new skill information before each model call.
- [x] By appending the skill content as a `tool_result` message, which doesn't alter the preceding message history.
- [ ] By performing a lightweight fine-tuning operation on the model in real-time.
- [ ] By storing the skill content in a separate memory buffer that is not part of the model's main context.
**Hint:** The correct method avoids changing the beginning or middle of the conversation history.
## Question 8
Which of these is NOT one of the four essential tools introduced in the `v1_basic_agent.py` to cover most coding use cases?
- [ ] `bash`
- [ ] `read_file`
- [x] `TodoWrite`
- [ ] `edit_file`
**Hint:** The first version of the agent focused on core capabilities for interacting with the file system and shell, not on complex planning.
## Question 9
What is the fundamental logic of the "core agent loop" described throughout the project?
- [ ] The agent first creates a complete, unchangeable plan and then executes each step in sequence.
- [x] The model makes a tool call, the tool's result is added to the history, and this cycle repeats until the model stops calling tools.
- [ ] The user provides a precise sequence of tools for the agent to execute.
- [ ] The agent's code analyzes the prompt and selects a single, optimal tool to resolve the entire request.
**Hint:** It is a repetitive cycle of thinking, acting, and observing the results.
## Question 10
In the `v3` subagent mechanism, what is the purpose of the `AGENT_TYPES` registry?
- [x] To define different subagent archetypes with specific roles, prompts, and restricted toolsets.
- [ ] To list all the available skills and their descriptions for the `v4` agent.
- [ ] To keep a real-time log of all active subagent processes and their current status.
- [ ] To provide the input schema and validation rules for the `TodoWrite` tool.
**Hint:** This configuration allows the main agent to choose the right kind of 'specialist' for a sub-task.

View file

@ -1,265 +0,0 @@
{
"title": "Agent Quiz",
"questions": [
{
"question": "What is the core philosophy of the `learn-claude-code` project, as stated in its documentation?",
"answerOptions": [
{
"text": "The agent's performance is primarily determined by clever engineering and complex code loops.",
"rationale": "The project's philosophy suggests that the code itself is only 20% of the solution, with the model's inherent capabilities being the most significant factor.",
"isCorrect": false
},
{
"text": "The model itself is the agent, and the surrounding code's main job is to provide it with tools and manage the execution loop.",
"rationale": "This aligns with the project's \"Model as Agent\" philosophy, emphasizing that the model's training to act as an agent is the key, not intricate code.",
"isCorrect": true
},
{
"text": "The number and variety of tools available to the agent are the most critical factors for its success.",
"rationale": "While tools are necessary, the core philosophy emphasizes the model's role as the decision-maker, not just the quantity of tools it can access.",
"isCorrect": false
},
{
"text": "Effective AI agents must rely solely on their internal, pre-trained knowledge without using external tools.",
"rationale": "The entire project is built around the concept of an agent that repeatedly interacts with its environment using tools.",
"isCorrect": false
}
],
"hint": "Consider the stated ratio of importance between the model and the code in the project's philosophy."
},
{
"question": "In the `v0_bash_agent.py` implementation, how is the concept of a subagent achieved?",
"answerOptions": [
{
"text": "By using a dedicated `Task` tool that spawns a child agent from a registry.",
"rationale": "The `Task` tool and an agent registry are features introduced in the more advanced `v3` version, not the minimalist `v0`.",
"isCorrect": false
},
{
"text": "By recursively executing the script as a new, isolated process via a `bash` command.",
"rationale": "This method leverages process isolation at the operating system level to create a subagent with a fresh context, which is the key insight of the `v0` agent.",
"isCorrect": true
},
{
"text": "By creating a new thread within the parent process to handle the sub-task.",
"rationale": "While a valid programming technique, the `v0` agent uses process spawning, not multithreading, to ensure complete context isolation.",
"isCorrect": false
},
{
"text": "By instantiating a new agent class within the code that maintains a separate history list.",
"rationale": "The `v0` implementation is much simpler and relies on an external shell command rather than internal class structures for sub-tasking.",
"isCorrect": false
}
],
"hint": "Think about how this minimalist agent uses its single tool to delegate work."
},
{
"question": "What primary problem is the `TodoWrite` tool in `v2_todo_agent.py` designed to solve for the agent?",
"answerOptions": [
{
"text": "The agent polluting its context window with excessive file content during exploration.",
"rationale": "This issue, known as 'context pollution', is the primary problem addressed by the subagent mechanism in `v3`.",
"isCorrect": false
},
{
"text": "The agent's inability to write new files or modify existing ones.",
"rationale": "File writing and editing capabilities are fundamental tools provided in the `v1` basic agent.",
"isCorrect": false
},
{
"text": "The model losing focus or forgetting the overall plan during complex, multi-step tasks.",
"rationale": "The `TodoWrite` tool makes the agent's plan explicit and persistent, addressing 'context fade' and helping it track progress.",
"isCorrect": true
},
{
"text": "The agent not knowing which type of specialized subagent to use for a task.",
"rationale": "The concept of specialized subagents is introduced in `v3`, whereas the `TodoWrite` tool is about managing a single agent's workflow.",
"isCorrect": false
}
],
"hint": "This tool helps make the agent's internal thought process visible and persistent."
},
{
"question": "According to the v3 documentation, what is the main advantage of using subagents?",
"answerOptions": [
{
"text": "It allows the agent to learn new domain-specific expertise from `SKILL.md` files.",
"rationale": "Loading expertise from external files is the key feature of the `v4` skills mechanism.",
"isCorrect": false
},
{
"text": "It provides a mechanism for structured planning and tracking task completion.",
"rationale": "Structured planning through a visible list is the core concept introduced with the `TodoManager` in `v2`.",
"isCorrect": false
},
{
"text": "It enables the agent to run multiple `bash` commands in parallel for faster execution.",
"rationale": "While parallelism is mentioned as a possibility, the primary benefit highlighted is the management of the agent's focus.",
"isCorrect": false
},
{
"text": "It isolates the context of a sub-task, preventing the main agent's history from becoming polluted.",
"rationale": "This is the central purpose of subagents in `v3`: to handle tasks like codebase exploration in a separate context so the main agent remains focused.",
"isCorrect": true
}
],
"hint": "Consider the problem that arises when an agent reads many large files to prepare for a small change."
},
{
"question": "The v4 documentation draws a clear distinction between 'Tools' and 'Skills'. What is this distinction?",
"answerOptions": [
{
"text": "Tools define what an agent *can do* (its capabilities), while Skills define *how* an agent knows to perform a task (its expertise).",
"rationale": "This correctly captures the essence of the distinction: Tools are about action/capability, whereas Skills are about knowledge/expertise.",
"isCorrect": true
},
{
"text": "Tools are built-in functionalities, while Skills are third-party plugins that must be downloaded.",
"rationale": "The documentation describes skills as locally stored, human-editable files, not necessarily as plugins from a marketplace.",
"isCorrect": false
},
{
"text": "Tools are used for general-purpose tasks like reading files, while Skills are only for code generation.",
"rationale": "Skills provide domain expertise for a wide range of tasks, such as PDF processing or code review, not just code generation.",
"isCorrect": false
},
{
"text": "Tools are implemented in Python code, whereas Skills are implemented using shell scripts.",
"rationale": "Skills are primarily human-readable Markdown documents that provide instructions; they are not themselves executable scripts.",
"isCorrect": false
}
],
"hint": "Think about the difference between having a hammer and knowing how to build a house."
},
{
"question": "What is the key benefit of the \"Knowledge Externalization\" paradigm introduced in v4?",
"answerOptions": [
{
"text": "It allows the agent's knowledge to be version-controlled, audited, and edited in plain text by anyone, without requiring model training.",
"rationale": "This concept democratizes the process of 'teaching' an agent, moving from expensive, expertise-heavy model training to simple document editing.",
"isCorrect": true
},
{
"text": "It forces the agent to write all its internal thoughts to an external log file for easier debugging.",
"rationale": "While logging is a development practice, Knowledge Externalization is about how the agent acquires and uses expertise, not how it logs its actions.",
"isCorrect": false
},
{
"text": "It caches the model's large parameter weights in the local file system to speed up agent initialization.",
"rationale": "This describes a form of model caching, which is a different concept from externalizing domain knowledge into editable documents.",
"isCorrect": false
},
{
"text": "It requires the agent to use external web APIs for all information, preventing it from using outdated internal knowledge.",
"rationale": "Knowledge Externalization refers to loading structured expertise from local `SKILL.md` files, not necessarily relying on web APIs.",
"isCorrect": false
}
],
"hint": "This paradigm shift makes customizing an agent's expertise as easy as editing a text document."
},
{
"question": "To maintain cost-efficiency by preserving the KV Cache, how does the `v4_skills_agent.py` inject skill content into the conversation?",
"answerOptions": [
{
"text": "By dynamically updating the system prompt with the new skill information before each model call.",
"rationale": "The documentation explicitly identifies this as a costly anti-pattern because modifying the system prompt invalidates the entire cache.",
"isCorrect": false
},
{
"text": "By appending the skill content as a `tool_result` message, which doesn't alter the preceding message history.",
"rationale": "This append-only method ensures the prefix of the conversation remains unchanged, allowing the provider to reuse the cached computations.",
"isCorrect": true
},
{
"text": "By performing a lightweight fine-tuning operation on the model in real-time.",
"rationale": "Real-time fine-tuning is computationally expensive and is the 'traditional' approach that the skills mechanism is designed to avoid.",
"isCorrect": false
},
{
"text": "By storing the skill content in a separate memory buffer that is not part of the model's main context.",
"rationale": "For the model to use the knowledge, it must be part of its context; the key is *how* it's added to that context.",
"isCorrect": false
}
],
"hint": "The correct method avoids changing the beginning or middle of the conversation history."
},
{
"question": "Which of these is NOT one of the four essential tools introduced in the `v1_basic_agent.py` to cover most coding use cases?",
"answerOptions": [
{
"text": "`bash`",
"rationale": "`bash` is included as the gateway tool for running any command-line operation.",
"isCorrect": false
},
{
"text": "`read_file`",
"rationale": "`read_file` is included as an essential tool for understanding existing code.",
"isCorrect": false
},
{
"text": "`TodoWrite`",
"rationale": "`TodoWrite` is the specialized tool for structured planning introduced in the `v2` agent.",
"isCorrect": true
},
{
"text": "`edit_file`",
"rationale": "`edit_file` is included for making surgical changes to existing files.",
"isCorrect": false
}
],
"hint": "The first version of the agent focused on core capabilities for interacting with the file system and shell, not on complex planning."
},
{
"question": "What is the fundamental logic of the \"core agent loop\" described throughout the project?",
"answerOptions": [
{
"text": "The agent first creates a complete, unchangeable plan and then executes each step in sequence.",
"rationale": "The agent loop is iterative and reactive; the result of one tool call informs the model's decision for the next one.",
"isCorrect": false
},
{
"text": "The model makes a tool call, the tool's result is added to the history, and this cycle repeats until the model stops calling tools.",
"rationale": "This accurately describes the iterative process where the model is the decision-maker, acting in a loop until the task is complete.",
"isCorrect": true
},
{
"text": "The user provides a precise sequence of tools for the agent to execute.",
"rationale": "In this agent model, the user provides a high-level goal, and the AI model itself decides which tools to use and in what order.",
"isCorrect": false
},
{
"text": "The agent's code analyzes the prompt and selects a single, optimal tool to resolve the entire request.",
"rationale": "The power of the agent comes from its ability to use multiple tools sequentially over several turns, not from picking just one.",
"isCorrect": false
}
],
"hint": "It is a repetitive cycle of thinking, acting, and observing the results."
},
{
"question": "In the `v3` subagent mechanism, what is the purpose of the `AGENT_TYPES` registry?",
"answerOptions": [
{
"text": "To define different subagent archetypes with specific roles, prompts, and restricted toolsets.",
"rationale": "This registry allows the main agent to spawn specialized subagents, such as a read-only 'explore' agent or a full-powered 'code' agent.",
"isCorrect": true
},
{
"text": "To list all the available skills and their descriptions for the `v4` agent.",
"rationale": "The listing of skills is handled by the `SkillLoader` class, which is a concept from `v4`, not `v3`.",
"isCorrect": false
},
{
"text": "To keep a real-time log of all active subagent processes and their current status.",
"rationale": "The registry is a static configuration defining agent types, not a dynamic process manager that tracks running instances.",
"isCorrect": false
},
{
"text": "To provide the input schema and validation rules for the `TodoWrite` tool.",
"rationale": "A tool's input schema is defined within the tool's own JSON definition, not in the agent type registry.",
"isCorrect": false
}
],
"hint": "This configuration allows the main agent to choose the right kind of 'specialist' for a sub-task."
}
]
}

View file

@ -1,53 +0,0 @@
"""Example: Upload files to NotebookLM using native file upload.
This example demonstrates how to use the native file upload feature
to add documents to NotebookLM without local text extraction.
"""
import asyncio
from pathlib import Path
from notebooklm import NotebookLMClient
from notebooklm.services import NotebookService, SourceService
async def main():
async with await NotebookLMClient.from_storage() as client:
notebook_svc = NotebookService(client)
source_svc = SourceService(client)
notebook = await notebook_svc.create("File Upload Demo")
print(f"Created notebook: {notebook.id} - {notebook.title}")
print("\n1. Uploading a PDF file...")
pdf_source = await source_svc.add_file(notebook.id, "research_paper.pdf")
print(f" Uploaded: {pdf_source.id} - {pdf_source.title}")
print("\n2. Uploading a markdown file...")
md_source = await source_svc.add_file(
notebook.id, "notes.md", mime_type="text/markdown"
)
print(f" Uploaded: {md_source.id} - {md_source.title}")
print("\n3. Uploading a text file (auto-detected MIME type)...")
txt_source = await source_svc.add_file(notebook.id, "documentation.txt")
print(f" Uploaded: {txt_source.id} - {txt_source.title}")
print("\n4. Uploading a Word document...")
docx_source = await source_svc.add_file(
notebook.id,
"report.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
print(f" Uploaded: {docx_source.id} - {docx_source.title}")
print(f"\nAll files uploaded successfully to notebook {notebook.id}!")
print("\n5. Querying the notebook...")
response = await client.query(
notebook.id, "Summarize the key points from all uploaded documents"
)
print(f"\nAI Response:\n{response['answer']}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,102 +0,0 @@
"""Example: Generate an Audio Overview (podcast) from URL sources.
This example demonstrates a complete podcast generation workflow:
1. Create a notebook
2. Add URL sources
3. Generate an audio podcast
4. Wait for completion with progress updates
5. Download the result
Prerequisites:
- Authentication configured via `notebooklm login` CLI command
- Valid Google account with NotebookLM access
"""
import asyncio
from notebooklm import NotebookLMClient, AudioFormat, AudioLength
async def main():
"""Generate a podcast from web sources."""
# Connect to NotebookLM using stored authentication
async with await NotebookLMClient.from_storage() as client:
# Step 1: Create a new notebook for our content
print("Creating notebook...")
notebook = await client.notebooks.create("AI Research Podcast")
print(f"Created notebook: {notebook.id}")
# Step 2: Add URL sources to the notebook
# The AI will use these sources to generate the podcast
urls = [
"https://en.wikipedia.org/wiki/Artificial_intelligence",
"https://en.wikipedia.org/wiki/Machine_learning",
]
print("\nAdding sources...")
for url in urls:
source = await client.sources.add_url(notebook.id, url)
print(f" Added: {source.title or url}")
# Step 3: Generate the audio overview (podcast)
# Options:
# audio_format: DEEP_DIVE (default), BRIEF, CRITIQUE, or DEBATE
# audio_length: SHORT, DEFAULT, or LONG
# instructions: Custom guidance for the AI hosts
print("\nStarting podcast generation...")
generation = await client.artifacts.generate_audio(
notebook.id,
audio_format=AudioFormat.DEEP_DIVE,
audio_length=AudioLength.DEFAULT,
instructions="Focus on practical applications and recent breakthroughs",
)
print(f"Generation started with task ID: {generation.task_id}")
# Step 4: Wait for completion with progress polling
# This can take 2-5 minutes for a full podcast
print("\nWaiting for generation to complete...")
print("(This typically takes 2-5 minutes)")
try:
final_status = await client.artifacts.wait_for_completion(
notebook.id,
generation.task_id,
initial_interval=5.0, # Start checking every 5 seconds
max_interval=15.0, # Max 15 seconds between checks
timeout=600.0, # 10 minute timeout
)
if final_status.is_complete:
print("Podcast generation complete!")
# Step 5: Download the audio file
output_path = "ai_podcast.mp4" # Audio is in MP4 container
print(f"\nDownloading to {output_path}...")
await client.artifacts.download_audio(
notebook.id,
output_path,
artifact_id=generation.task_id,
)
print(f"Downloaded successfully: {output_path}")
elif final_status.is_failed:
print(f"Generation failed: {final_status.error}")
except TimeoutError:
print("Generation timed out - it may still be processing")
print("Check the NotebookLM web UI for status")
# Optional: List all audio artifacts in the notebook
print("\nAll audio artifacts:")
audios = await client.artifacts.list_audio(notebook.id)
for audio in audios:
status = "Ready" if audio.is_completed else "Processing"
print(f" - {audio.title} ({status})")
# Cleanup note: The notebook persists after this script ends.
# Delete it manually or use: await client.notebooks.delete(notebook.id)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,158 +0,0 @@
"""Example: Use the Research API to discover and import sources.
This example demonstrates the research workflow:
1. Start a research session (fast or deep mode)
2. Poll for completion
3. Review discovered sources
4. Import selected sources into the notebook
The Research API searches the web or Google Drive for relevant sources
based on your query, providing AI-curated results.
Prerequisites:
- Authentication configured via `notebooklm login` CLI command
- Valid Google account with NotebookLM access
"""
import asyncio
from notebooklm import NotebookLMClient
async def main():
"""Demonstrate web research and source import."""
async with await NotebookLMClient.from_storage() as client:
# Create a notebook for the research
print("Creating notebook...")
notebook = await client.notebooks.create("Climate Research")
print(f"Created notebook: {notebook.id}")
# =====================================================================
# Fast Research Mode
# =====================================================================
# Fast research returns results quickly (10-30 seconds)
# Best for getting a quick overview of available sources
print("\n--- Fast Research Mode ---")
print("Starting fast web research...")
task = await client.research.start(
notebook.id,
query="climate change mitigation strategies",
source="web", # "web" or "drive"
mode="fast", # "fast" or "deep"
)
if not task:
print("Failed to start research")
return
print(f"Research task started: {task['task_id']}")
# Poll for results
print("Polling for results...")
max_attempts = 30
for attempt in range(max_attempts):
result = await client.research.poll(notebook.id)
if result["status"] == "completed":
print(
f"\nResearch complete! Found {len(result.get('sources', []))} sources"
)
break
elif result["status"] == "in_progress":
print(f" Still searching... (attempt {attempt + 1})")
await asyncio.sleep(2)
else:
print(f" Status: {result['status']}")
await asyncio.sleep(2)
else:
print("Research timed out")
return
# Display discovered sources
sources = result.get("sources", [])
print("\nDiscovered sources:")
for i, src in enumerate(sources[:10], 1): # Show first 10
title = src.get("title", "Untitled")
url = src.get("url", "")
print(f" {i}. {title}")
if url:
print(f" {url[:60]}...")
# Display AI summary if available
summary = result.get("summary", "")
if summary:
print(f"\nAI Summary:\n{summary[:500]}...")
# Import selected sources (first 3 for this example)
sources_to_import = sources[:3]
if sources_to_import:
print(f"\nImporting {len(sources_to_import)} sources...")
imported = await client.research.import_sources(
notebook.id,
task["task_id"],
sources_to_import,
)
print("Imported sources:")
for src in imported:
print(f" - {src['title']} (ID: {src['id']})")
# =====================================================================
# Deep Research Mode (Web only)
# =====================================================================
# Deep research takes longer (1-3 minutes) but provides:
# - More comprehensive source discovery
# - Detailed analysis and synthesis
# - Higher quality source recommendations
print("\n--- Deep Research Mode ---")
print("Starting deep web research...")
deep_task = await client.research.start(
notebook.id,
query="renewable energy policy effectiveness",
source="web",
mode="deep", # Deep mode for thorough research
)
if not deep_task:
print("Failed to start deep research")
return
print(f"Deep research task started: {deep_task['task_id']}")
print("Deep research takes 1-3 minutes...")
# Poll with longer intervals for deep research
max_attempts = 60
for attempt in range(max_attempts):
result = await client.research.poll(notebook.id)
if result["status"] == "completed":
print(f"\nDeep research complete!")
sources = result.get("sources", [])
print(f"Found {len(sources)} sources")
# Deep research often includes more detailed summaries
if result.get("summary"):
print(f"\nResearch synthesis:\n{result['summary'][:800]}...")
break
elif result["status"] == "in_progress":
if attempt % 5 == 0: # Log every 5 attempts
print(f" Deep analysis in progress... ({attempt * 3}s)")
await asyncio.sleep(3)
else:
print("Deep research timed out")
# Verify final notebook contents
print("\n--- Final Notebook Sources ---")
all_sources = await client.sources.list(notebook.id)
for src in all_sources:
print(f" - {src.title} ({src.source_type})")
print(f"\nTotal sources in notebook: {len(all_sources)}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to try ACT_ON_SOURCES for quiz/flashcard content."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
async def investigate_act_on_sources():
"""Try ACT_ON_SOURCES RPC with different action types."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735"
# Get source IDs from the notebook
async with await NotebookLMClient.from_storage() as client:
source_ids = await client._core.get_source_ids(notebook_id)
print(f"Notebook has {len(source_ids)} sources")
source_ids_nested = [[[sid]] for sid in source_ids]
# Actions to try - based on what we know works for mind maps
actions_to_try = [
"get_quiz",
"get_flashcards",
"quiz",
"flashcard",
"flashcards",
"quiz_content",
"flashcard_content",
"get_artifact_content",
]
for action in actions_to_try:
print(f"\n{'='*60}")
print(f"Trying action: {action}")
params = [
source_ids_nested,
None,
None,
None,
None,
[action, [["[CONTEXT]", ""]], ""],
None,
[2, None, [1]],
]
try:
result = await client._core.rpc_call(
RPCMethod.ACT_ON_SOURCES,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f" SUCCESS! Type: {type(result)}")
print(f" Preview: {str(result)[:500]}...")
with open(f"investigation_output/act_on_sources_{action}.json", "w") as f:
json.dump(result, f, indent=2, default=str)
else:
print(" No result (None)")
except Exception as e:
print(f" Error: {e}")
# Also try with artifact ID included
print(f"\n\n{'='*60}")
print("Trying with artifact ID in params...")
artifact_params = [
source_ids_nested,
flashcard_id, # Include artifact ID
None,
None,
None,
["get_content", [["[CONTEXT]", ""]], ""],
None,
[2, None, [1]],
]
try:
result = await client._core.rpc_call(
RPCMethod.ACT_ON_SOURCES,
artifact_params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f" SUCCESS! {str(result)[:500]}...")
else:
print(" No result (None)")
except Exception as e:
print(f" Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_act_on_sources())

View file

@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to examine each artifact position in detail."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
async def investigate_artifact_positions():
"""Examine each position in flashcard/quiz artifact data in detail."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
async with await NotebookLMClient.from_storage() as client:
# Get raw artifacts data
params = [[2], notebook_id, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"']
result = await client._core.rpc_call(
RPCMethod.LIST_ARTIFACTS,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if not result:
print("No artifacts found")
return
artifacts_data = result[0] if isinstance(result[0], list) else result
# Find flashcard artifact
flashcard_art = None
quiz_art = None
for art in artifacts_data:
if isinstance(art, list) and len(art) > 2 and art[2] == 4:
variant = None
if len(art) > 9 and isinstance(art[9], list) and len(art[9]) > 1:
if isinstance(art[9][1], list) and len(art[9][1]) > 0:
variant = art[9][1][0]
if variant == 1:
flashcard_art = art
elif variant == 2:
quiz_art = art
if flashcard_art:
print("=" * 80)
print("FLASHCARD ARTIFACT STRUCTURE")
print("=" * 80)
print(f"\nTitle: {flashcard_art[1]}")
print(f"Total length: {len(flashcard_art)} positions")
print()
for i, item in enumerate(flashcard_art):
print(f"\n[{i}] ", end="")
if item is None:
print("None")
elif isinstance(item, str):
if len(item) > 100:
print(f"String ({len(item)} chars): {item[:100]}...")
else:
print(f"String: {repr(item)}")
elif isinstance(item, (int, float, bool)):
print(f"{type(item).__name__}: {item}")
elif isinstance(item, list):
print(f"List[{len(item)}]:")
# Show nested structure
for j, sub in enumerate(item[:5]): # First 5 items
if isinstance(sub, list):
print(f" [{j}] List[{len(sub)}]: {str(sub)[:100]}...")
elif isinstance(sub, str):
print(f" [{j}] String: {repr(sub[:50]) if len(sub) > 50 else repr(sub)}...")
else:
print(f" [{j}] {type(sub).__name__}: {str(sub)[:100]}")
if len(item) > 5:
print(f" ... and {len(item) - 5} more items")
elif isinstance(item, dict):
print(f"Dict[{len(item)}]: {list(item.keys())[:5]}...")
# Also check if there's a quiz with more data
if quiz_art:
print("\n" + "=" * 80)
print("QUIZ ARTIFACT STRUCTURE")
print("=" * 80)
print(f"\nTitle: {quiz_art[1]}")
# Look specifically at positions that might hold content
interesting_positions = [6, 7, 8, 9, 10, 11, 12, 13]
for i in interesting_positions:
if i < len(quiz_art) and quiz_art[i] is not None:
item = quiz_art[i]
print(f"\n[{i}] ", end="")
if isinstance(item, list):
print(f"List[{len(item)}]:")
# Deeper exploration
print(f" Full content: {json.dumps(item, indent=2, default=str)[:500]}...")
else:
print(f"{type(item).__name__}: {item}")
if __name__ == "__main__":
asyncio.run(investigate_artifact_positions())

View file

@ -1,73 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to try EXPORT_ARTIFACT for quiz/flashcard content."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod, ExportType
async def investigate_export():
"""Try EXPORT_ARTIFACT RPC for quiz/flashcard content."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735"
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767"
async with await NotebookLMClient.from_storage() as client:
print("Testing EXPORT_ARTIFACT (Krh3pd) for flashcards...")
# Try export to Docs
params = [None, flashcard_id, None, "Flashcard Export", 1] # ExportType.DOCS = 1
print(f"\nParams: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.EXPORT_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got result! Type: {type(result)}")
print(f"Result: {json.dumps(result, indent=2, default=str)[:1000]}")
with open("investigation_output/export_flashcard_result.json", "w") as f:
json.dump(result, f, indent=2, default=str)
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
print("\n\nTesting EXPORT_ARTIFACT for quiz...")
params = [None, quiz_id, None, "Quiz Export", 1]
print(f"Params: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.EXPORT_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got result! Type: {type(result)}")
print(f"Result: {json.dumps(result, indent=2, default=str)[:1000]}")
with open("investigation_output/export_quiz_result.json", "w") as f:
json.dump(result, f, indent=2, default=str)
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_export())

View file

@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to analyze flashcard and quiz artifact structures.
This script:
1. Lists all type 4 (quiz/flashcard) artifacts in a notebook
2. Dumps the raw API response for analysis
3. Identifies where content/download URLs might be stored
"""
import asyncio
import json
import sys
from pathlib import Path
# Add src to path for development
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
def deep_search(obj, depth=0, path="", results=None):
"""Recursively search for string content that might be flashcard/quiz data."""
if results is None:
results = []
max_depth = 20
if depth > max_depth:
return results
if isinstance(obj, str):
# Look for JSON-like content
if len(obj) > 50 and ("{" in obj or "[" in obj):
results.append((path, "potential_json", obj[:200] + "..." if len(obj) > 200 else obj))
# Look for URLs
elif obj.startswith("http"):
results.append((path, "url", obj))
elif isinstance(obj, list):
for i, item in enumerate(obj):
deep_search(item, depth + 1, f"{path}[{i}]", results)
elif isinstance(obj, dict):
for k, v in obj.items():
deep_search(v, depth + 1, f"{path}.{k}", results)
return results
async def investigate_notebook(notebook_id: str | None = None):
"""Investigate flashcard and quiz artifact structures in a notebook."""
async with await NotebookLMClient.from_storage() as client:
# Get list of notebooks if no ID provided
if not notebook_id:
notebooks = await client.notebooks.list()
if not notebooks:
print("No notebooks found")
return
print("Available notebooks:")
for i, nb in enumerate(notebooks):
print(f" {i+1}. {nb.title} ({nb.id})")
# Use first notebook
notebook_id = notebooks[0].id
print(f"\nUsing notebook: {notebooks[0].title}")
# Get raw artifacts data
params = [[2], notebook_id, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"']
result = await client._core.rpc_call(
RPCMethod.LIST_ARTIFACTS,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if not result or not isinstance(result, list):
print("No artifacts found")
return
artifacts_data = result[0] if isinstance(result[0], list) else result
# Find type 4 (quiz/flashcard) artifacts
type4_artifacts = [
a for a in artifacts_data
if isinstance(a, list) and len(a) > 2 and a[2] == 4
]
if not type4_artifacts:
print("No quiz/flashcard artifacts found (type 4)")
print(f"\nFound {len(artifacts_data)} total artifacts")
for art in artifacts_data:
if isinstance(art, list) and len(art) > 2:
print(f" - Type {art[2]}: {art[1] if len(art) > 1 else 'unknown'}")
return
print(f"\nFound {len(type4_artifacts)} quiz/flashcard artifacts:")
for i, art in enumerate(type4_artifacts):
# Determine variant
variant = None
if len(art) > 9 and isinstance(art[9], list) and len(art[9]) > 1:
if isinstance(art[9][1], list) and len(art[9][1]) > 0:
variant = art[9][1][0]
variant_name = "flashcards" if variant == 1 else "quiz" if variant == 2 else "unknown"
title = art[1] if len(art) > 1 else "untitled"
status = art[4] if len(art) > 4 else "unknown"
print(f"\n{'='*60}")
print(f"Artifact {i+1}: {title}")
print(f" Type: {variant_name} (variant={variant})")
print(f" Status: {status} (3=completed)")
print(f" ID: {art[0]}")
print(f" Array length: {len(art)}")
# Dump structure overview
print(f"\n Structure overview:")
for idx, item in enumerate(art):
if item is None:
item_repr = "None"
elif isinstance(item, str):
item_repr = f"str[{len(item)}]: {item[:50]}..." if len(item) > 50 else f"str: {item}"
elif isinstance(item, list):
item_repr = f"list[{len(item)}]"
elif isinstance(item, dict):
item_repr = f"dict[{len(item)}]"
else:
item_repr = f"{type(item).__name__}: {item}"
print(f" [{idx}] {item_repr}")
# Deep search for interesting content
interesting = deep_search(art)
if interesting:
print(f"\n Interesting content found:")
for path, content_type, content in interesting[:20]: # Limit output
print(f" {path} ({content_type}): {content[:100]}...")
# Save full artifact data
output_dir = Path("investigation_output")
output_dir.mkdir(exist_ok=True)
filename = f"artifact_{variant_name}_{i+1}.json"
with open(output_dir / filename, "w") as f:
json.dump(art, f, indent=2, default=str)
print(f"\n Full data saved to: {output_dir / filename}")
async def main():
"""Main entry point."""
notebook_id = sys.argv[1] if len(sys.argv) > 1 else None
await investigate_notebook(notebook_id)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,96 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to try fetching individual artifact content.
Tests the GET_ARTIFACT RPC (BnLyuf) to see if it returns quiz/flashcard questions.
"""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
async def investigate_get_artifact():
"""Try GET_ARTIFACT RPC to fetch flashcard/quiz content."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91" # High School notebook
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735" # Agent Flashcards
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767" # Agent Quiz
async with await NotebookLMClient.from_storage() as client:
print("Testing GET_ARTIFACT RPC (BnLyuf)...")
# Try different parameter formats
param_formats = [
("Format 1: [id]", [flashcard_id]),
("Format 2: [[id]]", [[flashcard_id]]),
("Format 3: [nb_id, id]", [notebook_id, flashcard_id]),
("Format 4: [id, nb_id]", [flashcard_id, notebook_id]),
("Format 5: [[2], id]", [[2], flashcard_id]),
("Format 6: [[2], nb_id, id]", [[2], notebook_id, flashcard_id]),
]
for desc, params in param_formats:
print(f"\n{desc}: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.GET_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f" SUCCESS! Result type: {type(result)}")
if isinstance(result, list):
print(f" Length: {len(result)}")
# Save result
with open("investigation_output/get_artifact_result.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print(" Saved to get_artifact_result.json")
return result
else:
print(f" Result: {result}")
else:
print(" No result (None)")
except Exception as e:
print(f" Error: {e}")
# Also try LIST_ARTIFACTS_ALT
print("\n\nTesting LIST_ARTIFACTS_ALT RPC (LfTXoe)...")
alt_param_formats = [
("Format 1: [nb_id]", [notebook_id]),
("Format 2: [[2], nb_id]", [[2], notebook_id]),
("Format 3: [nb_id, id]", [notebook_id, flashcard_id]),
]
for desc, params in alt_param_formats:
print(f"\n{desc}: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.LIST_ARTIFACTS_ALT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f" SUCCESS! Result type: {type(result)}")
if isinstance(result, list):
print(f" Length: {len(result)}")
with open("investigation_output/list_artifacts_alt_result.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print(" Saved to list_artifacts_alt_result.json")
else:
print(f" Result: {result}")
else:
print(" No result (None)")
except Exception as e:
print(f" Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_get_artifact())

View file

@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to check GET_NOTEBOOK for embedded flashcard/quiz data."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
def find_content(obj, depth=0, path="", max_depth=25):
"""Recursively search for flashcard/quiz-like content."""
if depth > max_depth:
return []
results = []
if isinstance(obj, str):
lower = obj.lower()
# Look for quiz/flashcard-related strings
if any(kw in lower for kw in ["question", "answer", "correct", "option", "front", "back"]):
if len(obj) > 20: # Skip short strings
results.append((path, obj[:200]))
# Look for JSON content
if obj.startswith("{") or obj.startswith("["):
try:
parsed = json.loads(obj)
if isinstance(parsed, (dict, list)) and len(str(parsed)) > 100:
results.append((path + " (JSON)", str(parsed)[:300]))
except:
pass
elif isinstance(obj, list):
for i, item in enumerate(obj):
results.extend(find_content(item, depth + 1, f"{path}[{i}]"))
elif isinstance(obj, dict):
for k, v in obj.items():
results.extend(find_content(v, depth + 1, f"{path}.{k}"))
return results
async def investigate_notebook_detail():
"""Fetch notebook detail and look for quiz/flashcard content."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
async with await NotebookLMClient.from_storage() as client:
print("Fetching GET_NOTEBOOK...")
params = [[notebook_id]]
result = await client._core.rpc_call(
RPCMethod.GET_NOTEBOOK,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got notebook data, length: {len(str(result))} chars")
# Save full response
with open("investigation_output/notebook_detail.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print("Saved to investigation_output/notebook_detail.json")
# Search for quiz/flashcard content
print("\nSearching for quiz/flashcard content...")
findings = find_content(result)
if findings:
print(f"\nFound {len(findings)} potential matches:")
for path, content in findings[:30]:
print(f"\n {path}:")
print(f" {content}")
else:
print("No quiz/flashcard keywords found in notebook data")
else:
print("No result from GET_NOTEBOOK")
# Also try to fetch conversation history - maybe quiz state is stored there
print("\n\nFetching GET_CONVERSATION_HISTORY...")
try:
params = [notebook_id]
result = await client._core.rpc_call(
RPCMethod.GET_CONVERSATION_HISTORY,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got conversation history, length: {len(str(result))} chars")
with open("investigation_output/conversation_history.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print("Saved to investigation_output/conversation_history.json")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_notebook_detail())

View file

@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to try POLL_STUDIO for detailed artifact data."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
async def investigate_poll_studio():
"""Try POLL_STUDIO RPC with different parameter formats."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735"
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767"
async with await NotebookLMClient.from_storage() as client:
print("Testing POLL_STUDIO RPC (gArtLc) for flashcards...")
# Standard format from existing code
params = [flashcard_id, notebook_id, [2]]
print(f"\nParams: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.POLL_STUDIO,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got result! Type: {type(result)}, Length: {len(result) if isinstance(result, (list, dict, str)) else 'N/A'}")
with open("investigation_output/poll_studio_flashcard.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print("Saved to poll_studio_flashcard.json")
print(f"\nResult preview: {str(result)[:500]}...")
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
print("\n\nTesting POLL_STUDIO RPC for quiz...")
params = [quiz_id, notebook_id, [2]]
print(f"Params: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.POLL_STUDIO,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"Got result! Type: {type(result)}, Length: {len(result) if isinstance(result, (list, dict, str)) else 'N/A'}")
with open("investigation_output/poll_studio_quiz.json", "w") as f:
json.dump(result, f, indent=2, default=str)
print("Saved to poll_studio_quiz.json")
print(f"\nResult preview: {str(result)[:500]}...")
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
# Try with different modes/flags
print("\n\nTrying with extended params...")
extended_params = [
[flashcard_id, notebook_id, [2], None, True],
[flashcard_id, notebook_id, [2], 1],
[[2], flashcard_id, notebook_id],
]
for params in extended_params:
print(f"\nParams: {params}")
try:
result = await client._core.rpc_call(
RPCMethod.POLL_STUDIO,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f" SUCCESS! {str(result)[:200]}...")
else:
print(" No result")
except Exception as e:
print(f" Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_poll_studio())

View file

@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to try fetching quiz/flashcard content via chat-like requests."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
from notebooklm.rpc import RPCMethod
async def investigate_quiz_via_chat():
"""Try to fetch quiz/flashcard content through various approaches."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735"
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767"
async with await NotebookLMClient.from_storage() as client:
# First let's check if the chat API can retrieve quiz content
print("Attempting to retrieve quiz content via chat...\n")
# Try using an artifact-related prompt through chat.ask()
try:
print("Testing: Ask about flashcard content")
result = await client.chat.ask(
notebook_id,
"Show me the flashcards from this notebook"
)
print(f"Response: {result.answer[:500]}...")
print()
except Exception as e:
print(f"Error: {e}\n")
# Try to see if there's a specific RPC for getting artifact content
# Let's look at what methods are available in CREATE_ARTIFACT / GET_ARTIFACT style
print("\nTrying CREATE_VIDEO RPC with artifact ID to get content...")
# The xpWGLf RPC is CREATE_ARTIFACT - maybe it has a "get" mode
try:
params = [
[2],
notebook_id,
[
flashcard_id, # Try passing existing artifact ID
None,
4, # Type 4 = quiz/flashcard
]
]
result = await client._core.rpc_call(
RPCMethod.CREATE_ARTIFACT,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"CREATE_ARTIFACT result: {json.dumps(result, indent=2, default=str)[:500]}")
else:
print("No result")
except Exception as e:
print(f"Error: {e}")
# Check the streaming endpoint format - maybe quiz content comes through there
print("\n\nChecking if quiz/flashcard has special rendering requirements...")
# Get all artifacts and look more carefully at type 4
params = [[2], notebook_id, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"']
result = await client._core.rpc_call(
RPCMethod.LIST_ARTIFACTS,
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
artifacts_data = result[0] if isinstance(result[0], list) else result
for art in artifacts_data:
if isinstance(art, list) and len(art) > 2 and art[2] == 4:
print(f"\nType 4 artifact: {art[1]}")
print(f" Full raw data ({len(art)} elements):")
# Save complete data for offline analysis
with open(f"investigation_output/type4_full_{art[0][:8]}.json", "w") as f:
json.dump(art, f, indent=2, default=str)
print(f" Saved to investigation_output/type4_full_{art[0][:8]}.json")
if __name__ == "__main__":
asyncio.run(investigate_quiz_via_chat())

View file

@ -1,88 +0,0 @@
#!/usr/bin/env python3
"""Investigation script to test the v9rmvd RPC for quiz/flashcard content."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from notebooklm import NotebookLMClient
async def investigate_v9rmvd():
"""Test the v9rmvd RPC to fetch quiz/flashcard content."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767" # Agent Quiz
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735" # Agent Flashcards
async with await NotebookLMClient.from_storage() as client:
print("Testing v9rmvd RPC for quiz content...")
print(f"Quiz ID: {quiz_id}")
# Based on the curl, the params are just [artifact_id]
params = [quiz_id]
try:
result = await client._core.rpc_call(
"v9rmvd", # New RPC ID we discovered!
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"\n✓ SUCCESS! Got quiz content!")
print(f"Result type: {type(result)}")
if isinstance(result, list):
print(f"Result length: {len(result)}")
# Save full result
with open("investigation_output/quiz_content_v9rmvd.json", "w") as f:
json.dump(result, f, indent=2, default=str, ensure_ascii=False)
print("\nFull result saved to: investigation_output/quiz_content_v9rmvd.json")
# Preview the content
print(f"\nResult preview:\n{json.dumps(result, indent=2, default=str, ensure_ascii=False)[:2000]}")
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
print("\n" + "="*60)
print("Testing v9rmvd RPC for flashcard content...")
print(f"Flashcard ID: {flashcard_id}")
params = [flashcard_id]
try:
result = await client._core.rpc_call(
"v9rmvd",
params,
source_path=f"/notebook/{notebook_id}",
allow_null=True,
)
if result:
print(f"\n✓ SUCCESS! Got flashcard content!")
print(f"Result type: {type(result)}")
# Save full result
with open("investigation_output/flashcard_content_v9rmvd.json", "w") as f:
json.dump(result, f, indent=2, default=str, ensure_ascii=False)
print("\nFull result saved to: investigation_output/flashcard_content_v9rmvd.json")
# Preview
print(f"\nResult preview:\n{json.dumps(result, indent=2, default=str, ensure_ascii=False)[:2000]}")
else:
print("No result (None)")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(investigate_v9rmvd())

View file

@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""Direct HTTP call to test v9rmvd RPC."""
import asyncio
import json
import sys
from pathlib import Path
from urllib.parse import urlencode, quote
sys.path.insert(0, str(Path(__file__).parent / "src"))
import httpx
from notebooklm.auth import AuthTokens
from notebooklm.rpc.decoder import decode_response
BATCHEXECUTE_URL = "https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute"
def encode_rpc_manual(rpc_id: str, params: list) -> str:
"""Manually encode RPC request."""
params_json = json.dumps(params)
inner = [rpc_id, params_json, None, "generic"]
outer = [[inner]]
encoded = json.dumps(outer, separators=(",", ":"))
return f"f.req={quote(encoded)}"
async def test_v9rmvd():
"""Make direct HTTP call to v9rmvd RPC."""
notebook_id = "167481cd-23a3-4331-9a45-c8948900bf91"
quiz_id = "a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767"
flashcard_id = "173255d8-12b3-4c67-b925-a76ce6c71735"
# Load auth
auth = await AuthTokens.from_storage()
# Build URL
url_params = {
"rpcids": "v9rmvd",
"source-path": f"/notebook/{notebook_id}",
"f.sid": auth.session_id,
"hl": "en",
"_reqid": "123456",
"rt": "c",
}
url = f"{BATCHEXECUTE_URL}?{urlencode(url_params)}"
headers = {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Same-Domain": "1",
"Referer": "https://notebooklm.google.com/",
}
cookies = httpx.Cookies()
for name, value in auth.cookies.items():
cookies.set(name, value, domain="notebooklm.google.com")
async with httpx.AsyncClient(cookies=cookies, follow_redirects=True) as client:
print("="*60)
print("Testing QUIZ content...")
print(f"Quiz ID: {quiz_id}")
# Build request body - params is [artifact_id]
params = [quiz_id]
body = encode_rpc_manual("v9rmvd", params)
body += f"&at={auth.csrf_token}"
print(f"URL: {url}")
print(f"Body: {body[:200]}...")
response = await client.post(url, content=body, headers=headers)
print(f"Status: {response.status_code}")
if response.status_code == 200:
try:
result = decode_response(response.text, "v9rmvd", allow_null=True)
print(f"\n✓ SUCCESS!")
print(f"Result type: {type(result)}")
# Save full result
with open("investigation_output/quiz_content_v9rmvd.json", "w") as f:
json.dump(result, f, indent=2, default=str, ensure_ascii=False)
print("Saved to: investigation_output/quiz_content_v9rmvd.json")
# Pretty print preview
print(f"\nPreview:\n{json.dumps(result, indent=2, ensure_ascii=False)[:3000]}")
except Exception as e:
print(f"Decode error: {e}")
print(f"Raw response: {response.text[:2000]}")
else:
print(f"Error: {response.text[:500]}")
# Test flashcard
print("\n" + "="*60)
print("Testing FLASHCARD content...")
print(f"Flashcard ID: {flashcard_id}")
params = [flashcard_id]
body = encode_rpc_manual("v9rmvd", params)
body += f"&at={auth.csrf_token}"
response = await client.post(url, content=body, headers=headers)
print(f"Status: {response.status_code}")
if response.status_code == 200:
try:
result = decode_response(response.text, "v9rmvd", allow_null=True)
print(f"\n✓ SUCCESS!")
with open("investigation_output/flashcard_content_v9rmvd.json", "w") as f:
json.dump(result, f, indent=2, default=str, ensure_ascii=False)
print("Saved to: investigation_output/flashcard_content_v9rmvd.json")
print(f"\nPreview:\n{json.dumps(result, indent=2, ensure_ascii=False)[:3000]}")
except Exception as e:
print(f"Decode error: {e}")
print(f"Raw response: {response.text[:2000]}")
if __name__ == "__main__":
asyncio.run(test_v9rmvd())

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -1,85 +0,0 @@
[
"173255d8-12b3-4c67-b925-a76ce6c71735",
"Agent Flashcards",
4,
[
[
[
"a474cd35-6c21-4e72-94a0-c38b5491b449"
]
],
[
[
"735fcfef-9fbd-4c89-9789-6a9760587bec"
]
],
[
[
"4d3f7b07-e9e6-43d3-ab8b-184fa27a9f1e"
]
],
[
[
"a5ec927b-12eb-45d1-989f-12eb3db4ce53"
]
],
[
[
"c361a555-5c2d-42e2-94d0-a65da95be660"
]
],
[
[
"48f71a82-08d3-46fa-a37f-d657fb2f0723"
]
],
[
[
"d6ce2ec3-f98a-4529-acd5-08bff271cb3b"
]
],
[
[
"ef358221-3904-4dbc-be6f-e1e8dea63954"
]
]
],
3,
null,
null,
null,
null,
[
"",
[
1,
null,
null,
"en",
null,
null,
[
2,
2
],
null,
true
]
],
[
1768311102,
683814000
],
null,
null,
null,
null,
[
1768311058,
331340000
],
null,
null,
null,
1
]

View file

@ -1,85 +0,0 @@
[
"a0e4dca6-3bb0-4ed0-aea1-dfa91cf89767",
"Agent Quiz",
4,
[
[
[
"a474cd35-6c21-4e72-94a0-c38b5491b449"
]
],
[
[
"735fcfef-9fbd-4c89-9789-6a9760587bec"
]
],
[
[
"4d3f7b07-e9e6-43d3-ab8b-184fa27a9f1e"
]
],
[
[
"a5ec927b-12eb-45d1-989f-12eb3db4ce53"
]
],
[
[
"c361a555-5c2d-42e2-94d0-a65da95be660"
]
],
[
[
"48f71a82-08d3-46fa-a37f-d657fb2f0723"
]
],
[
[
"d6ce2ec3-f98a-4529-acd5-08bff271cb3b"
]
],
[
[
"ef358221-3904-4dbc-be6f-e1e8dea63954"
]
]
],
3,
null,
null,
null,
null,
[
"",
[
2,
null,
null,
"en",
null,
null,
null,
[
2,
2
],
true
]
],
[
1768346092,
144365000
],
null,
null,
null,
null,
[
1768346040,
459674000
],
null,
null,
null,
1
]

View file

@ -1,85 +0,0 @@
[
"835a5938-4a60-43a0-b2c3-6f01b435664a",
"Agent Architecture Quiz",
4,
[
[
[
"a474cd35-6c21-4e72-94a0-c38b5491b449"
]
],
[
[
"735fcfef-9fbd-4c89-9789-6a9760587bec"
]
],
[
[
"4d3f7b07-e9e6-43d3-ab8b-184fa27a9f1e"
]
],
[
[
"a5ec927b-12eb-45d1-989f-12eb3db4ce53"
]
],
[
[
"c361a555-5c2d-42e2-94d0-a65da95be660"
]
],
[
[
"48f71a82-08d3-46fa-a37f-d657fb2f0723"
]
],
[
[
"d6ce2ec3-f98a-4529-acd5-08bff271cb3b"
]
],
[
[
"ef358221-3904-4dbc-be6f-e1e8dea63954"
]
]
],
3,
null,
null,
null,
null,
[
"",
[
2,
null,
null,
"en",
null,
null,
null,
[
2,
2
],
true
]
],
[
1768311105,
796819000
],
null,
null,
null,
null,
[
1768311059,
696595000
],
null,
null,
null,
1
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
[project]
name = "notebooklm-py"
version = "0.1.4"
version = "0.2.0"
description = "Unofficial Python client for Google NotebookLM API"
dynamic = ["readme"]
requires-python = ">=3.10"

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -18,7 +18,7 @@ from ._logging import configure_logging
configure_logging()
__version__ = "0.1.3"
__version__ = "0.2.0"
# Public API: Authentication
from .auth import DEFAULT_STORAGE_PATH, AuthTokens

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View file

@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

18
uv.lock
View file

@ -432,7 +432,7 @@ wheels = [
[[package]]
name = "notebooklm-py"
version = "0.1.4"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
@ -448,6 +448,7 @@ all = [
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-httpx" },
{ name = "pytest-rerunfailures" },
{ name = "python-dotenv" },
{ name = "ruff" },
{ name = "vcrpy" },
@ -461,6 +462,7 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-httpx" },
{ name = "pytest-rerunfailures" },
{ name = "python-dotenv" },
{ name = "ruff" },
{ name = "vcrpy" },
@ -477,6 +479,7 @@ requires-dist = [
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
{ name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.30.0" },
{ name = "pytest-rerunfailures", marker = "extra == 'dev'", specifier = ">=14.0" },
{ name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.0.0" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
@ -610,6 +613,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" },
]
[[package]]
name = "pytest-rerunfailures"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"