
Blog Notebooklm
- 1.6k installs
- 1.6k repo stars
- Updated July 23, 2026
- agricidaniel/claude-blog
Gather and synthesize source material from documents and references via NotebookLM to ground blog posts in verified research before writing.
About
blog-notebooklm connects NotebookLM research to the claude-blog pipeline, synthesizing documents into summaries, quotes, and source notes for upcoming posts. It grounds content ideation in verified references before briefs and outlines are written.
- Source ingestion and synthesis
- Citation-ready summaries
- NotebookLM integration
- Evidence gathering
- Topic grounding
Blog Notebooklm by the numbers
- 1,635 all-time installs (skills.sh)
- +91 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #759 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/agricidaniel/claude-blog --skill blog-notebooklmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | July 23, 2026 |
| Repository | agricidaniel/claude-blog ↗ |
What it does
Gather and synthesize source material from documents and references via NotebookLM to ground blog posts in verified research before writing.
Files
Blog NotebookLM: Source-Grounded Research from Your Documents
Query Google NotebookLM notebooks directly from Claude Code for citation-backed answers from Gemini. Each question opens a headless browser session, retrieves the answer exclusively from your uploaded documents, and closes. Responses are Tier 1 quality (user's own primary sources): zero hallucination risk. Answers satisfy the FLOW evidence triple: use the returned source title as the inline citation and the notebook URL plus retrieval date as the bibliography entry. This is the highest-confidence path to meeting the "verified source" bar that FLOW requires before any statistic goes public.
Quick Reference
| Command | What it does |
|---|---|
/blog notebooklm ask <question> | Query a notebook for source-grounded answers |
/blog notebooklm discover <url> | Smart-discover notebook content before cataloging |
/blog notebooklm library list | List all notebooks in library |
/blog notebooklm library add <url> | Add a notebook to library |
/blog notebooklm library search <query> | Search notebooks by keyword |
/blog notebooklm library remove <id> | Remove a notebook from library |
/blog notebooklm setup | One-time Google authentication (browser visible) |
/blog notebooklm status | Check authentication status |
/blog notebooklm cleanup | Clean browser state (preserves library) |
Prerequisites
- Google account with NotebookLM access
- Python 3.11+ (venv managed automatically by
run.py) - Google Chrome (installed automatically on first run via Patchright)
- One-time authentication setup (interactive Google login in visible browser)
Always Use run.py Wrapper
NEVER call scripts directly. ALWAYS use `python3 scripts/run.py [script]`:
# CORRECT:
python3 scripts/run.py auth_manager.py status
python3 scripts/run.py ask_question.py --question "..."
# WRONG -- fails without venv:
python3 scripts/auth_manager.py statusThe run.py wrapper automatically creates .venv, installs dependencies, sets up Chrome, and executes the target script.
Auth Check (Gate Pattern)
Before any query operation, check authentication:
python3 scripts/run.py auth_manager.py status- If authenticated: proceed with the query
- If not authenticated: inform user and guide to setup:
"NotebookLM requires Google login. Run /blog notebooklm setup to authenticate."
- When called internally (from blog-write or blog-researcher): return silently
with no error if not authenticated. Never block the writing workflow.
Setup Workflow
For /blog notebooklm setup:
# Opens a visible browser for manual Google login (one-time)
python3 scripts/run.py auth_manager.py setupTell the user: "A browser window will open. Please log in to your Google account." Authentication persists via browser profile + cookie injection (hybrid approach).
Other auth commands:
python3 scripts/run.py auth_manager.py status # Check auth
python3 scripts/run.py auth_manager.py reauth # Re-authenticate
python3 scripts/run.py auth_manager.py clear # Clear all auth dataQuery Workflow
For /blog notebooklm ask <question>:
Step 1: Check Auth
Run auth check (see gate pattern above). If not authenticated, guide to setup.
Step 2: Resolve Notebook
Determine which notebook to query:
- If
--notebook-urlprovided: use directly - If
--notebook-idprovided: look up in library - If neither: use active notebook from library
- If no active notebook: show library and ask user to select
Step 3: Ask the Question
# Basic query (uses active notebook)
python3 scripts/run.py ask_question.py --question "Your question here"
# Query specific notebook by ID
python3 scripts/run.py ask_question.py --question "..." --notebook-id notebook-id
# Query by URL directly
python3 scripts/run.py ask_question.py --question "..." --notebook-url "https://..."
# JSON output (for internal/programmatic use)
python3 scripts/run.py ask_question.py --question "..." --json
# Show browser for debugging
python3 scripts/run.py ask_question.py --question "..." --show-browserStep 4: Analyze and Follow Up
Every response ends with a follow-up prompt. Required behavior: 1. STOP: do not immediately respond to the user 2. ANALYZE: compare the answer to the user's original request 3. IDENTIFY GAPS: determine if more information is needed 4. ASK FOLLOW-UP: if gaps exist, immediately ask a follow-up question 5. REPEAT: continue until information is complete 6. SYNTHESIZE: combine all answers before responding to the user
Smart Discovery Workflow
For /blog notebooklm discover <url>:
When adding a notebook without knowing its content, query it first:
# Step 1: Discover content
python3 scripts/run.py ask_question.py \
--question "What is the content of this notebook? What topics are covered? Provide a complete overview briefly and concisely" \
--notebook-url "<URL>"
# Step 2: Add with discovered metadata
python3 scripts/run.py notebook_manager.py add \
--url "<URL>" \
--name "<Based on content>" \
--description "<Based on content>" \
--topics "<Extracted topics>"NEVER guess or use generic descriptions. Always discover or ask the user.
Library Management
# List all notebooks
python3 scripts/run.py notebook_manager.py list
# Add notebook (all params required -- discover or ask user!)
python3 scripts/run.py notebook_manager.py add \
--url "https://notebooklm.google.com/notebook/..." \
--name "Descriptive Name" \
--description "What this notebook contains" \
--topics "topic1,topic2,topic3"
# Search by keyword
python3 scripts/run.py notebook_manager.py search --query "keyword"
# Set active notebook
python3 scripts/run.py notebook_manager.py activate --id notebook-id
# Remove notebook
python3 scripts/run.py notebook_manager.py remove --id notebook-id
# Library statistics
python3 scripts/run.py notebook_manager.py statsInternal API (for blog-write / blog-researcher)
When invoked as a Task subagent from blog-write or blog-researcher:
Input (provided by calling skill):
question: Research question relevant to the blog topicnotebook_idornotebook_url: Which notebook to querycontext: "internal" (signals graceful fallback mode)
Process: 1. Check auth status: if not authenticated, return empty result silently 2. Query the notebook with the research question 3. Parse and return structured response
Output (returned to calling skill):
### NotebookLM Research
- **Source:** [Notebook name]
- **Question:** [What was asked]
- **Answer:** [Source-grounded response from user's documents]
- **Source Quality:** Tier 1 (user-uploaded primary documents)Graceful fallback: If auth is missing or query fails, return immediately with no error. The calling workflow continues with WebSearch-based research. Never block blog-write or blog-rewrite because NotebookLM is unavailable.
Data Storage
All data stored inside the skill directory:
scripts/data/library.json: Notebook metadata and libraryscripts/data/auth_info.json: Authentication statusscripts/data/browser_state/: Chrome profile with cookies
Security: All data directories are gitignored. Never commit auth or browser state.
Error Handling
| Error | Resolution |
|---|---|
| Not authenticated | Run /blog notebooklm setup |
| ModuleNotFoundError | Always use run.py wrapper |
| Browser crash | cleanup_manager.py --confirm --preserve-library, then re-auth |
| Rate limit (50/day) | Wait until midnight PST or switch Google account |
| Notebook not found | Check with notebook_manager.py list |
| Query timeout (120s) | Retry with simpler question or --show-browser to debug |
| MCP unavailable (internal) | Return silently: writing workflow uses WebSearch |
Limitations
- No session persistence (each question = new browser session)
- Rate limits on free Google accounts (50 queries/day)
- Manual upload required (user must add docs to NotebookLM web UI)
- Browser overhead (few seconds per question for launch + teardown)
- Local Claude Code only (not available in web UI)
Reference Documentation
Load on-demand: do NOT load all at startup:
references/commands.md: Full CLI commands, parameters, and workflow patternsreferences/troubleshooting.md: Error solutions, recovery procedures, debugging
NotebookLM Commands Reference
Complete CLI documentation for all NotebookLM skill scripts.
run.py: Universal Script Runner
Always use run.py to execute any script. It handles venv creation, dependency installation, Chrome setup, and proper execution.
python3 scripts/run.py <script_name>.py [arguments]ask_question.py: Query Interface
Ask questions to NotebookLM notebooks with automated browser interaction.
# Basic query (uses active notebook)
python3 scripts/run.py ask_question.py --question "Your question"
# Query specific notebook by ID
python3 scripts/run.py ask_question.py --question "..." --notebook-id notebook-id
# Query by URL directly
python3 scripts/run.py ask_question.py --question "..." --notebook-url "https://..."
# JSON output for structured responses
python3 scripts/run.py ask_question.py --question "..." --json
# Show browser for debugging
python3 scripts/run.py ask_question.py --question "..." --show-browserParameters:
| Parameter | Required | Description |
|---|---|---|
--question | Yes | Question to ask |
--notebook-id | No | Use notebook from library |
--notebook-url | No | Use URL directly |
--json | No | Output structured JSON |
--show-browser | No | Make browser visible |
JSON output format (with --json):
{
"status": "success",
"question": "What are the key findings?",
"answer": "The source-grounded response text...",
"notebook_id": "my-notebook",
"notebook_url": "https://notebooklm.google.com/notebook/...",
"timestamp": "2026-03-25T14:30:00Z"
}Returns: Answer text with follow-up prompt. Timeout: 120 seconds.
notebook_manager.py: Library Management
CRUD operations for the notebook library.
# Add notebook (all metadata required)
python3 scripts/run.py notebook_manager.py add \
--url "https://notebooklm.google.com/notebook/..." \
--name "Descriptive Name" \
--description "What this notebook contains" \
--topics "topic1,topic2,topic3"
# List all notebooks
python3 scripts/run.py notebook_manager.py list
# Search by keyword (searches name, description, topics)
python3 scripts/run.py notebook_manager.py search --query "keyword"
# Set active notebook (default for queries without --notebook-id)
python3 scripts/run.py notebook_manager.py activate --id notebook-id
# Remove notebook from library
python3 scripts/run.py notebook_manager.py remove --id notebook-id
# Show library statistics
python3 scripts/run.py notebook_manager.py statsCommands:
| Command | Description |
|---|---|
add | Add notebook (requires --url, --name, --description, --topics) |
list | Show all notebooks with metadata |
search | Find notebooks by keyword |
activate | Set default notebook for queries |
remove | Delete from library |
stats | Display library statistics |
Smart discovery (recommended before add):
# Query the notebook to learn its content first
python3 scripts/run.py ask_question.py \
--question "What is the content of this notebook? What topics are covered?" \
--notebook-url "<URL>"
# Then use discovered info for the add commandauth_manager.py: Authentication
Handle Google authentication and browser state.
python3 scripts/run.py auth_manager.py setup # Initial setup (browser visible)
python3 scripts/run.py auth_manager.py status # Check authentication
python3 scripts/run.py auth_manager.py reauth # Re-authenticate
python3 scripts/run.py auth_manager.py clear # Clear all auth dataCommands:
| Command | Description |
|---|---|
setup | Interactive Google login in visible browser (one-time) |
status | Check if authenticated (lightweight, no side effects) |
reauth | Clear and re-setup authentication |
clear | Remove all authentication data |
Auth architecture: Hybrid approach: persistent browser profile for fingerprint consistency + manual cookie injection from state.json (Playwright bug #36139 workaround).
cleanup_manager.py: Data Cleanup
Clean skill data with preservation options.
python3 scripts/run.py cleanup_manager.py # Preview (dry run)
python3 scripts/run.py cleanup_manager.py --confirm # Execute cleanup
python3 scripts/run.py cleanup_manager.py --confirm --preserve-library # Keep notebooks
python3 scripts/run.py cleanup_manager.py --confirm --force # Skip confirmationOptions:
| Option | Description |
|---|---|
--confirm | Actually perform cleanup (without this, preview only) |
--preserve-library | Keep notebook library, clean everything else |
--force | Skip confirmation prompt |
Workflow Patterns
Pattern: Research for Blog Writing
# 1. Check if relevant notebook exists
python3 scripts/run.py notebook_manager.py search --query "marketing"
# 2. Query for source-grounded data
python3 scripts/run.py ask_question.py \
--question "What are the latest conversion rate benchmarks?" \
--notebook-id marketing-research --json
# 3. Follow up for completeness
python3 scripts/run.py ask_question.py \
--question "Break down conversion rates by industry and channel" \
--notebook-id marketing-research --jsonPattern: Multi-Notebook Research
# Query different notebooks for a comprehensive view
python3 scripts/run.py ask_question.py --question "..." --notebook-id source-a
python3 scripts/run.py ask_question.py --question "..." --notebook-id source-b
# Synthesize answers from both sourcesPattern: Batch Questions
# Ask multiple focused questions (respect 50/day rate limit)
for question in "Q1" "Q2" "Q3"; do
python3 scripts/run.py ask_question.py --question "$question" --json
sleep 2 # Avoid rate limits
doneEnvironment Variables (Optional)
Create .env in skill root directory:
HEADLESS=false # Browser visibility (default: true)
SHOW_BROWSER=false # Default browser display
STEALTH_ENABLED=true # Human-like behavior simulation
TYPING_WPM_MIN=160 # Typing speed range
TYPING_WPM_MAX=240
DEFAULT_NOTEBOOK_ID= # Default notebook for queriesRate Limits
- Free Google accounts: ~50 queries/day
- Reset: midnight PST
- Mitigation: switch accounts with
auth_manager.py reauth
NotebookLM Troubleshooting Guide
Quick Fix Table
| Error | Solution |
|---|---|
| ModuleNotFoundError | Always use python3 scripts/run.py [script].py |
| Not authenticated | python3 scripts/run.py auth_manager.py setup (browser visible) |
| Browser crash | Kill Chrome, cleanup with --preserve-library, re-auth |
| Rate limit (50/day) | Wait until midnight PST or switch Google account |
| Notebook not found | Check with notebook_manager.py list |
| Query timeout | Retry with simpler question or --show-browser to debug |
Authentication Issues
Not authenticated error
python3 scripts/run.py auth_manager.py status # Confirm status
python3 scripts/run.py auth_manager.py setup # Browser visible for loginUser must manually log in to Google in the browser window.
Authentication expires frequently
python3 scripts/run.py cleanup_manager.py --confirm --preserve-library
python3 scripts/run.py auth_manager.py setup # Fresh loginUses hybrid auth: persistent browser profile + cookie injection (workaround for Playwright bug #36139).
Google blocks automated login
1. Use a dedicated Google account for automation 2. Browser is ALWAYS visible during setup (no headless auth) 3. Complete any 2FA challenges in the browser window
Browser Issues
Browser crashes or hangs
pkill -f chromium && pkill -f chrome # Kill hanging processes
python3 scripts/run.py cleanup_manager.py --confirm --preserve-library
python3 scripts/run.py auth_manager.py reauth # Re-authenticateBrowser not found
# run.py installs Chrome automatically on first run
python3 scripts/run.py auth_manager.py status
# If still failing, manual install:
source .venv/bin/activate
python -m patchright install chromiumTimeout waiting for selector
NotebookLM UI may have changed CSS selectors. Check config.py for current selectors (QUERY_INPUT_SELECTORS, RESPONSE_SELECTORS). Use --show-browser to visually debug.
Rate Limiting
Rate limit exceeded (50 queries/day)
Option 1: Wait: resets at midnight PST
Option 2: Switch accounts
python3 scripts/run.py auth_manager.py clear
python3 scripts/run.py auth_manager.py setup # Login with different accountNotebook Access Issues
Notebook not found in library
python3 scripts/run.py notebook_manager.py list
python3 scripts/run.py notebook_manager.py search --query "keyword"Wrong notebook being queried
python3 scripts/run.py notebook_manager.py list # Check active
python3 scripts/run.py notebook_manager.py activate --id correct-idVirtual Environment Issues
ModuleNotFoundError
Always use `run.py`: it handles venv automatically:
python3 scripts/run.py [any_script].py # Creates .venv if neededCorrupted venv
rm -rf .venv # Remove broken venv
python3 scripts/run.py auth_manager.py status # Auto-recreatesData Issues
Corrupted notebook library
cp data/library.json library.backup.json # Backup first
rm data/library.json # Reset
python3 scripts/run.py notebook_manager.py add --url ... --name ... # Re-addRecovery Procedures
Complete reset (keep library)
pkill -f chromium
python3 scripts/run.py cleanup_manager.py --confirm --preserve-library
rm -rf .venv
python3 scripts/run.py auth_manager.py setup # Rebuilds everythingComplete reset (fresh start)
pkill -f chromium
python3 scripts/run.py cleanup_manager.py --confirm --force
rm -rf .venv
python3 scripts/run.py auth_manager.py setupDebugging
# Enable visible browser for debugging
python3 scripts/run.py ask_question.py --question "test" --show-browser
# Check individual components
python3 scripts/run.py auth_manager.py status
python3 scripts/run.py notebook_manager.py listCommon Questions
Q: Why doesn't this work in Claude web UI? A: Requires local file system and browser access. Use Claude Code CLI only.
Q: Can I use multiple Google accounts? A: Yes, use auth_manager.py reauth to switch between accounts.
Q: Is Patchright safe? A: It's an anti-detection fork of Playwright. Uses Chrome, not Chromium, for better fingerprint consistency. Required because Google actively blocks standard Playwright automation.
#!/usr/bin/env python3
"""
NotebookLM Skill Scripts Package
Provides venv management helpers. NOTE: closes audit VULN-031: this module
no longer triggers `ensure_venv_and_run()` at import time. Callers must
invoke it explicitly (typically via `setup_environment.py --bootstrap` or
the `run.py` wrapper).
Reason: import-time side effects ran venv.create + pip install + 150 MB
Chrome download whenever any script in this package was imported (incl.
test discovery, IDE auto-import). Triggering install on import violates
the principle of least surprise and slows every cold start.
"""
import os
import sys
import subprocess
from pathlib import Path
def ensure_venv_and_run():
"""
Ensure virtual environment exists.
Call explicitly from a wrapper (e.g. `python -m setup_environment` or
`run.py`). Do NOT rely on this firing at import time.
"""
# Only do this if we're not already in the skill's venv
skill_dir = Path(__file__).parent.parent
venv_dir = skill_dir / ".venv"
# Check if we're in a venv
in_venv = hasattr(sys, 'real_prefix') or (
hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix
)
# Check if it's OUR venv
if in_venv:
venv_path = Path(sys.prefix)
if venv_path == venv_dir:
# We're already in the correct venv
return
# We need to set up or switch to our venv
if not venv_dir.exists():
print("🔧 First-time setup detected...")
print(" Creating isolated environment for NotebookLM skill...")
print(" This ensures clean dependency management...")
# Create venv
import venv
venv.create(venv_dir, with_pip=True)
# Install requirements
requirements_file = skill_dir / "requirements.txt"
if requirements_file.exists():
if os.name == 'nt': # Windows
pip_exe = venv_dir / "Scripts" / "pip.exe"
else:
pip_exe = venv_dir / "bin" / "pip"
print(" Installing dependencies in isolated environment...")
subprocess.run(
[str(pip_exe), "install", "-q", "-r", str(requirements_file)],
check=True
)
# Also install patchright's chromium
print(" Setting up browser automation...")
if os.name == 'nt':
python_exe = venv_dir / "Scripts" / "python.exe"
else:
python_exe = venv_dir / "bin" / "python"
subprocess.run(
[str(python_exe), "-m", "patchright", "install", "chromium"],
check=True,
capture_output=True
)
print("✅ Environment ready! All dependencies isolated in .venv/")
# If we're here and not in the venv, we should recommend using the venv
if not in_venv:
print("\n⚠️ Running outside virtual environment")
print(" Recommended: Use scripts/run.py to ensure clean execution")
print(" Or activate: source .venv/bin/activate")
# Audit VULN-031: removed import-time `ensure_venv_and_run()` call. Callers
# must invoke `ensure_venv_and_run()` explicitly. The `setup_environment.py`
# script and `run.py` wrapper both do this at top-level, so the user-facing
# behavior of running `python run.py ...` is unchanged.#!/usr/bin/env python3
"""
Simple NotebookLM Question Interface
Based on MCP server implementation - simplified without sessions
Implements hybrid auth approach:
- Persistent browser profile (user_data_dir) for fingerprint consistency
- Manual cookie injection from state.json for session cookies (Playwright bug workaround)
See: https://github.com/microsoft/playwright/issues/36139
"""
import argparse
import json
import sys
import time
import re
from datetime import datetime, timezone
from pathlib import Path
from patchright.sync_api import sync_playwright
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from auth_manager import AuthManager
from notebook_manager import NotebookLibrary
from config import QUERY_INPUT_SELECTORS, RESPONSE_SELECTORS
from browser_utils import BrowserFactory, StealthUtils
# Follow-up reminder (adapted from MCP server for stateless operation)
# Since we don't have persistent sessions, we encourage comprehensive questions
FOLLOW_UP_REMINDER = (
"\n\nEXTREMELY IMPORTANT: Is that ALL you need to know? "
"You can always ask another question! Think about it carefully: "
"before you reply to the user, review their original request and this answer. "
"If anything is still unclear or missing, ask me another comprehensive question "
"that includes all necessary context (since each question opens a new browser session)."
)
def ask_notebooklm(question: str, notebook_url: str, headless: bool = True) -> str:
"""
Ask a question to NotebookLM
Args:
question: Question to ask
notebook_url: NotebookLM notebook URL
headless: Run browser in headless mode
Returns:
Answer text from NotebookLM
"""
auth = AuthManager()
if not auth.is_authenticated():
print("⚠️ Not authenticated. Run: python auth_manager.py setup")
return None
print(f"💬 Asking: {question}")
print(f"📚 Notebook: {notebook_url}")
playwright = None
context = None
try:
# Start playwright
playwright = sync_playwright().start()
# Launch persistent browser context using factory
context = BrowserFactory.launch_persistent_context(
playwright,
headless=headless
)
# Navigate to notebook
page = context.new_page()
print(" 🌐 Opening notebook...")
page.goto(notebook_url, wait_until="domcontentloaded")
# Wait for NotebookLM
page.wait_for_url(re.compile(r"^https://notebooklm\.google\.com/"), timeout=10000)
# Wait for query input (MCP approach)
print(" ⏳ Waiting for query input...")
query_element = None
for selector in QUERY_INPUT_SELECTORS:
try:
query_element = page.wait_for_selector(
selector,
timeout=10000,
state="visible" # Only check visibility, not disabled!
)
if query_element:
print(f" ✓ Found input: {selector}")
break
except Exception:
continue
if not query_element:
print(" ❌ Could not find query input")
return None
# Type question (human-like, fast)
print(" ⏳ Typing question...")
# Use primary selector for typing
input_selector = QUERY_INPUT_SELECTORS[0]
StealthUtils.human_type(page, input_selector, question)
# Submit
print(" 📤 Submitting...")
page.keyboard.press("Enter")
# Small pause
StealthUtils.random_delay(500, 1500)
# Wait for response (MCP approach: poll for stable text)
print(" ⏳ Waiting for answer...")
answer = None
stable_count = 0
last_text = None
deadline = time.time() + 120 # 2 minutes timeout
while time.time() < deadline:
# Check if NotebookLM is still thinking (most reliable indicator)
try:
thinking_element = page.query_selector('div.thinking-message')
if thinking_element and thinking_element.is_visible():
time.sleep(1)
continue
except Exception:
pass
# Try to find response with MCP selectors
for selector in RESPONSE_SELECTORS:
try:
elements = page.query_selector_all(selector)
if elements:
# Get last (newest) response
latest = elements[-1]
text = latest.inner_text().strip()
if text:
if text == last_text:
stable_count += 1
if stable_count >= 3: # Stable for 3 polls
answer = text
break
else:
stable_count = 0
last_text = text
except Exception:
continue
if answer:
break
time.sleep(1)
if not answer:
print(" ❌ Timeout waiting for answer")
return None
print(" ✅ Got answer!")
# Add follow-up reminder to encourage Claude to ask more questions
return answer + FOLLOW_UP_REMINDER
except Exception as e:
# Closes audit VULN-021: don't dump full traceback (leaks local paths
# to caller / log). Gate verbose trace behind BLOG_DEBUG env.
print(f" ❌ Error: {type(e).__name__}: {e}", file=sys.stderr)
if os.environ.get("BLOG_DEBUG"):
import traceback
traceback.print_exc()
return None
finally:
# Always clean up
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description='Ask NotebookLM a question')
def _bounded(max_len: int):
# Closes audit VULN-037: cap CLI string args. Multi-MB --question
# would be typed character-by-character into the NotebookLM textarea,
# blowing up browser memory and orchestrator log volume.
def _check(s: str) -> str:
if len(s) > max_len:
raise argparse.ArgumentTypeError(
f"value too long ({len(s)} chars, max {max_len})"
)
return s
return _check
parser.add_argument('--question', required=True, type=_bounded(8000),
help='Question to ask (max 8000 chars)')
parser.add_argument('--notebook-url', help='NotebookLM notebook URL')
parser.add_argument('--notebook-id', help='Notebook ID from library')
parser.add_argument('--show-browser', action='store_true', help='Show browser')
parser.add_argument('--json', action='store_true', help='Output structured JSON')
args = parser.parse_args()
# Resolve notebook URL
notebook_url = args.notebook_url
if not notebook_url and args.notebook_id:
library = NotebookLibrary()
notebook = library.get_notebook(args.notebook_id)
if notebook:
notebook_url = notebook['url']
else:
print(f"❌ Notebook '{args.notebook_id}' not found")
return 1
if not notebook_url:
# Check for active notebook first
library = NotebookLibrary()
active = library.get_active_notebook()
if active:
notebook_url = active['url']
print(f"📚 Using active notebook: {active['name']}")
else:
# Show available notebooks
notebooks = library.list_notebooks()
if notebooks:
print("\n📚 Available notebooks:")
for nb in notebooks:
mark = " [ACTIVE]" if nb.get('id') == library.active_notebook_id else ""
print(f" {nb['id']}: {nb['name']}{mark}")
print("\nSpecify with --notebook-id or set active:")
print("python scripts/run.py notebook_manager.py activate --id ID")
else:
print("❌ No notebooks in library. Add one first:")
print("python scripts/run.py notebook_manager.py add --url URL --name NAME --description DESC --topics TOPICS")
return 1
# Ask the question
answer = ask_notebooklm(
question=args.question,
notebook_url=notebook_url,
headless=not args.show_browser
)
if answer:
if args.json:
output = {
"status": "success",
"question": args.question,
"answer": answer,
"notebook_id": args.notebook_id or "",
"notebook_url": notebook_url,
"timestamp": datetime.now(timezone.utc).isoformat()
}
print(json.dumps(output, indent=2))
else:
print("\n" + "=" * 60)
print(f"Question: {args.question}")
print("=" * 60)
print()
print(answer)
print()
print("=" * 60)
return 0
else:
if args.json:
output = {
"status": "error",
"question": args.question,
"answer": None,
"notebook_id": args.notebook_id or "",
"notebook_url": notebook_url or "",
"timestamp": datetime.now(timezone.utc).isoformat()
}
print(json.dumps(output, indent=2))
else:
print("\n❌ Failed to get answer")
return 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Authentication Manager for NotebookLM
Handles Google login and browser state persistence
Based on the MCP server implementation
Implements hybrid auth approach:
- Persistent browser profile (user_data_dir) for fingerprint consistency
- Manual cookie injection from state.json for session cookies (Playwright bug workaround)
See: https://github.com/microsoft/playwright/issues/36139
"""
import json
import time
import argparse
import shutil
import re
import sys
from pathlib import Path
from typing import Optional, Dict, Any
from patchright.sync_api import sync_playwright, BrowserContext
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from config import BROWSER_STATE_DIR, STATE_FILE, AUTH_INFO_FILE, DATA_DIR
from browser_utils import BrowserFactory
def _harden_perms(path) -> None:
"""Set restrictive permissions on a credential file or directory.
Files: 0o600 (owner read/write only).
Dirs: 0o700 (owner traverse only).
Recursively applies to all children of dirs.
Best-effort: failures are swallowed silently (Windows fs may lack POSIX mode bits).
"""
import os
from pathlib import Path
p = Path(path)
try:
if p.is_dir():
p.chmod(0o700)
for child in p.rglob("*"):
try:
child.chmod(0o600 if child.is_file() else 0o700)
except OSError:
pass
elif p.is_file():
p.chmod(0o600)
except OSError:
pass # Windows / readonly fs / etc.
class AuthManager:
"""
Manages authentication and browser state for NotebookLM
Features:
- Interactive Google login
- Browser state persistence
- Session restoration
- Account switching
"""
def __init__(self):
"""Initialize the authentication manager"""
# Ensure directories exist
DATA_DIR.mkdir(parents=True, exist_ok=True)
BROWSER_STATE_DIR.mkdir(parents=True, exist_ok=True)
self.state_file = STATE_FILE
self.auth_info_file = AUTH_INFO_FILE
self.browser_state_dir = BROWSER_STATE_DIR
def is_authenticated(self) -> bool:
"""Check if valid authentication exists"""
if not self.state_file.exists():
return False
# Check if state file is not too old (7 days)
age_days = (time.time() - self.state_file.stat().st_mtime) / 86400
if age_days > 7:
print(f"⚠️ Browser state is {age_days:.1f} days old, may need re-authentication")
return True
def get_auth_info(self) -> Dict[str, Any]:
"""Get authentication information"""
info = {
'authenticated': self.is_authenticated(),
'state_file': str(self.state_file),
'state_exists': self.state_file.exists()
}
if self.auth_info_file.exists():
try:
with open(self.auth_info_file, 'r') as f:
saved_info = json.load(f)
info.update(saved_info)
except Exception:
pass
if info['state_exists']:
age_hours = (time.time() - self.state_file.stat().st_mtime) / 3600
info['state_age_hours'] = age_hours
return info
def setup_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
"""
Perform interactive authentication setup
Args:
headless: Run browser in headless mode (False for login)
timeout_minutes: Maximum time to wait for login
Returns:
True if authentication successful
"""
print("🔐 Starting authentication setup...")
print(f" Timeout: {timeout_minutes} minutes")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# Launch using factory
context = BrowserFactory.launch_persistent_context(
playwright,
headless=headless
)
# Navigate to NotebookLM
page = context.new_page()
page.goto("https://notebooklm.google.com", wait_until="domcontentloaded")
# Check if already authenticated
if "notebooklm.google.com" in page.url and "accounts.google.com" not in page.url:
print(" ✅ Already authenticated!")
self._save_browser_state(context)
return True
# Wait for manual login
print("\n ⏳ Please log in to your Google account...")
print(f" ⏱️ Waiting up to {timeout_minutes} minutes for login...")
try:
# Wait for URL to change to NotebookLM (regex ensures it's the actual domain, not a parameter)
timeout_ms = int(timeout_minutes * 60 * 1000)
page.wait_for_url(re.compile(r"^https://notebooklm\.google\.com/"), timeout=timeout_ms)
print(f" ✅ Login successful!")
# Save authentication state
self._save_browser_state(context)
self._save_auth_info()
return True
except Exception as e:
print(f" ❌ Authentication timeout: {e}")
return False
except Exception as e:
print(f" ❌ Error: {e}")
return False
finally:
# Clean up browser resources
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def _save_browser_state(self, context: BrowserContext):
"""Save browser state to disk"""
try:
# Save storage state (cookies, localStorage)
context.storage_state(path=str(self.state_file))
# Harden permissions on credential file and parent dir (VULN-004 mitigation)
_harden_perms(self.state_file)
_harden_perms(self.browser_state_dir)
print(f" 💾 Saved browser state to: {self.state_file}")
except Exception as e:
print(f" ❌ Failed to save browser state: {e}")
raise
def _save_auth_info(self):
"""Save authentication metadata"""
try:
info = {
'authenticated_at': time.time(),
'authenticated_at_iso': time.strftime('%Y-%m-%d %H:%M:%S')
}
with open(self.auth_info_file, 'w') as f:
json.dump(info, f, indent=2)
# Harden permissions on auth info file (VULN-004 mitigation)
_harden_perms(self.auth_info_file)
except Exception:
pass # Non-critical
def clear_auth(self) -> bool:
"""
Clear all authentication data
Returns:
True if cleared successfully
"""
print("🗑️ Clearing authentication data...")
try:
# Remove browser state
if self.state_file.exists():
self.state_file.unlink()
print(" ✅ Removed browser state")
# Remove auth info
if self.auth_info_file.exists():
self.auth_info_file.unlink()
print(" ✅ Removed auth info")
# Clear entire browser state directory
if self.browser_state_dir.exists():
shutil.rmtree(self.browser_state_dir)
self.browser_state_dir.mkdir(parents=True, exist_ok=True)
print(" ✅ Cleared browser data")
return True
except Exception as e:
print(f" ❌ Error clearing auth: {e}")
return False
def re_auth(self, headless: bool = False, timeout_minutes: int = 10) -> bool:
"""
Perform re-authentication (clear and setup)
Args:
headless: Run browser in headless mode
timeout_minutes: Login timeout in minutes
Returns:
True if successful
"""
print("🔄 Starting re-authentication...")
# Clear existing auth
self.clear_auth()
# Setup new auth
return self.setup_auth(headless, timeout_minutes)
def validate_auth(self) -> bool:
"""
Validate that stored authentication works
Uses persistent context to match actual usage pattern
Returns:
True if authentication is valid
"""
if not self.is_authenticated():
return False
print("🔍 Validating authentication...")
playwright = None
context = None
try:
playwright = sync_playwright().start()
# Launch using factory
context = BrowserFactory.launch_persistent_context(
playwright,
headless=True
)
# Try to access NotebookLM
page = context.new_page()
page.goto("https://notebooklm.google.com", wait_until="domcontentloaded", timeout=30000)
# Check if we can access NotebookLM
if "notebooklm.google.com" in page.url and "accounts.google.com" not in page.url:
print(" ✅ Authentication is valid")
return True
else:
print(" ❌ Authentication is invalid (redirected to login)")
return False
except Exception as e:
print(f" ❌ Validation failed: {e}")
return False
finally:
if context:
try:
context.close()
except Exception:
pass
if playwright:
try:
playwright.stop()
except Exception:
pass
def main():
"""Command-line interface for authentication management"""
parser = argparse.ArgumentParser(description='Manage NotebookLM authentication')
subparsers = parser.add_subparsers(dest='command', help='Commands')
# Setup command
setup_parser = subparsers.add_parser('setup', help='Setup authentication')
setup_parser.add_argument('--headless', action='store_true', help='Run in headless mode')
setup_parser.add_argument('--timeout', type=float, default=10, help='Login timeout in minutes (default: 10)')
# Status command
subparsers.add_parser('status', help='Check authentication status')
# Validate command
subparsers.add_parser('validate', help='Validate authentication')
# Clear command
subparsers.add_parser('clear', help='Clear authentication')
# Re-auth command
reauth_parser = subparsers.add_parser('reauth', help='Re-authenticate (clear + setup)')
reauth_parser.add_argument('--timeout', type=float, default=10, help='Login timeout in minutes (default: 10)')
args = parser.parse_args()
# Initialize manager
auth = AuthManager()
# Execute command
if args.command == 'setup':
if auth.setup_auth(headless=args.headless, timeout_minutes=args.timeout):
print("\n✅ Authentication setup complete!")
print("You can now use ask_question.py to query NotebookLM")
else:
print("\n❌ Authentication setup failed")
exit(1)
elif args.command == 'status':
info = auth.get_auth_info()
print("\n🔐 Authentication Status:")
print(f" Authenticated: {'Yes' if info['authenticated'] else 'No'}")
if info.get('state_age_hours'):
print(f" State age: {info['state_age_hours']:.1f} hours")
if info.get('authenticated_at_iso'):
print(f" Last auth: {info['authenticated_at_iso']}")
print(f" State file: {info['state_file']}")
elif args.command == 'validate':
if auth.validate_auth():
print("Authentication is valid and working")
else:
print("Authentication is invalid or expired")
print("Run: auth_manager.py setup")
elif args.command == 'clear':
if auth.clear_auth():
print("Authentication cleared")
elif args.command == 'reauth':
if auth.re_auth(timeout_minutes=args.timeout):
print("\n✅ Re-authentication complete!")
else:
print("\n❌ Re-authentication failed")
exit(1)
else:
parser.print_help()
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""
Browser Session Management for NotebookLM
Individual browser session for persistent NotebookLM conversations
Based on the original NotebookLM API implementation
"""
import time
import sys
from typing import Any, Dict, Optional
from pathlib import Path
from patchright.sync_api import BrowserContext, Page
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))
from browser_utils import StealthUtils
class BrowserSession:
"""
Represents a single persistent browser session for NotebookLM
Each session gets its own Page (tab) within a shared BrowserContext,
allowing for contextual conversations where NotebookLM remembers
previous messages.
"""
def __init__(self, session_id: str, context: BrowserContext, notebook_url: str):
"""
Initialize a new browser session
Args:
session_id: Unique identifier for this session
context: Browser context (shared or dedicated)
notebook_url: Target NotebookLM URL for this session
"""
self.id = session_id
self.created_at = time.time()
self.last_activity = time.time()
self.message_count = 0
self.notebook_url = notebook_url
self.context = context
self.page = None
self.stealth = StealthUtils()
# Initialize the session
self._initialize()
def _initialize(self):
"""Initialize the browser session and navigate to NotebookLM"""
print(f"🚀 Creating session {self.id}...")
# Create new page (tab) in context
self.page = self.context.new_page()
print(f" 🌐 Navigating to NotebookLM...")
try:
# Navigate to notebook
self.page.goto(self.notebook_url, wait_until="domcontentloaded", timeout=30000)
# Check if login is needed
if "accounts.google.com" in self.page.url:
raise RuntimeError("Authentication required. Please run auth_manager.py setup first.")
# Wait for page to be ready
self._wait_for_ready()
# Simulate human inspection
self.stealth.random_mouse_movement(self.page)
self.stealth.random_delay(300, 600)
print(f"✅ Session {self.id} ready!")
except Exception as e:
print(f"❌ Failed to initialize session: {e}")
if self.page:
self.page.close()
raise
def _wait_for_ready(self):
"""Wait for NotebookLM page to be ready"""
try:
# Wait for chat input
self.page.wait_for_selector("textarea.query-box-input", timeout=10000, state="visible")
except Exception:
# Try alternative selector
self.page.wait_for_selector('textarea[aria-label="Feld für Anfragen"]', timeout=5000, state="visible")
def ask(self, question: str) -> Dict[str, Any]:
"""
Ask a question in this session
Args:
question: The question to ask
Returns:
Dict with status, question, answer, session_id
"""
try:
self.last_activity = time.time()
self.message_count += 1
print(f"💬 [{self.id}] Asking: {question}")
# Snapshot current answer to detect new response
previous_answer = self._snapshot_latest_response()
# Find chat input
chat_input_selector = "textarea.query-box-input"
try:
self.page.wait_for_selector(chat_input_selector, timeout=5000, state="visible")
except Exception:
chat_input_selector = 'textarea[aria-label="Feld für Anfragen"]'
self.page.wait_for_selector(chat_input_selector, timeout=5000, state="visible")
# Click and type with human-like behavior
self.stealth.realistic_click(self.page, chat_input_selector)
self.stealth.human_type(self.page, chat_input_selector, question)
# Small pause before submit
self.stealth.random_delay(300, 800)
# Submit
self.page.keyboard.press("Enter")
# Wait for response
print(" ⏳ Waiting for response...")
self.stealth.random_delay(1500, 3000)
# Get new answer
answer = self._wait_for_latest_answer(previous_answer)
if not answer:
raise Exception("Empty response from NotebookLM")
print(f" ✅ Got response ({len(answer)} chars)")
return {
"status": "success",
"question": question,
"answer": answer,
"session_id": self.id,
"notebook_url": self.notebook_url
}
except Exception as e:
print(f" ❌ Error: {e}")
return {
"status": "error",
"question": question,
"error": str(e),
"session_id": self.id
}
def _snapshot_latest_response(self) -> Optional[str]:
"""Get the current latest response text"""
try:
# Use correct NotebookLM selector
responses = self.page.query_selector_all(".to-user-container .message-text-content")
if responses:
return responses[-1].inner_text()
except Exception:
pass
return None
def _wait_for_latest_answer(self, previous_answer: Optional[str], timeout: int = 120) -> str:
"""Wait for and extract the new answer"""
start_time = time.time()
last_candidate = None
stable_count = 0
while time.time() - start_time < timeout:
# Check if NotebookLM is still thinking (most reliable indicator)
try:
thinking_element = self.page.query_selector('div.thinking-message')
if thinking_element and thinking_element.is_visible():
time.sleep(0.5)
continue
except Exception:
pass
try:
# Use correct NotebookLM selector
responses = self.page.query_selector_all(".to-user-container .message-text-content")
if responses:
latest_text = responses[-1].inner_text().strip()
# Check if it's a new response
if latest_text and latest_text != previous_answer:
# Check if text is stable (3 consecutive polls)
if latest_text == last_candidate:
stable_count += 1
if stable_count >= 3:
return latest_text
else:
stable_count = 1
last_candidate = latest_text
except Exception:
pass
time.sleep(0.5)
raise TimeoutError(f"No response received within {timeout} seconds")
def reset(self):
"""Reset the chat by reloading the page"""
print(f"🔄 Resetting session {self.id}...")
self.page.reload(wait_until="domcontentloaded")
self._wait_for_ready()
previous_count = self.message_count
self.message_count = 0
self.last_activity = time.time()
print(f"✅ Session reset (cleared {previous_count} messages)")
return previous_count
def close(self):
"""Close this session and clean up resources"""
print(f"🛑 Closing session {self.id}...")
if self.page:
try:
self.page.close()
except Exception as e:
print(f" ⚠️ Error closing page: {e}")
print(f"✅ Session {self.id} closed")
def get_info(self) -> Dict[str, Any]:
"""Get information about this session"""
return {
"id": self.id,
"created_at": self.created_at,
"last_activity": self.last_activity,
"age_seconds": time.time() - self.created_at,
"inactive_seconds": time.time() - self.last_activity,
"message_count": self.message_count,
"notebook_url": self.notebook_url
}
def is_expired(self, timeout_seconds: int = 900) -> bool:
"""Check if session has expired (default: 15 minutes)"""
return (time.time() - self.last_activity) > timeout_seconds
if __name__ == "__main__":
# Example usage
print("Browser Session Module - Use ask_question.py for main interface")
print("This module provides low-level browser session management.")"""
Browser Utilities for NotebookLM Skill
Handles browser launching, stealth features, and common interactions
"""
import json
import time
import random
from pathlib import Path
from typing import Optional, List
from patchright.sync_api import Playwright, BrowserContext, Page
from config import BROWSER_PROFILE_DIR, STATE_FILE, BROWSER_ARGS, USER_AGENT
def _harden_perms(path) -> None:
"""Set restrictive permissions on a credential file or directory.
Files: 0o600 (owner read/write only).
Dirs: 0o700 (owner traverse only).
Recursively applies to all children of dirs.
Best-effort: failures are swallowed silently (Windows fs may lack POSIX mode bits).
Duplicated here (also lives in auth_manager) to avoid an import cycle:
auth_manager already imports browser_utils.
"""
p = Path(path)
try:
if p.is_dir():
p.chmod(0o700)
for child in p.rglob("*"):
try:
child.chmod(0o600 if child.is_file() else 0o700)
except OSError:
pass
elif p.is_file():
p.chmod(0o600)
except OSError:
pass
class BrowserFactory:
"""Factory for creating configured browser contexts"""
@staticmethod
def launch_persistent_context(
playwright: Playwright,
headless: bool = True,
user_data_dir: str = str(BROWSER_PROFILE_DIR)
) -> BrowserContext:
"""
Launch a persistent browser context with anti-detection features
and cookie workaround.
"""
# Launch persistent context
context = playwright.chromium.launch_persistent_context(
user_data_dir=user_data_dir,
channel="chrome", # Use real Chrome
headless=headless,
no_viewport=True,
ignore_default_args=["--enable-automation"],
user_agent=USER_AGENT,
args=BROWSER_ARGS
)
# Harden the persistent profile dir recursively (VULN-004 mitigation).
# Patchright writes cookies, tokens, and cache here under default 0o755.
_harden_perms(user_data_dir)
# Cookie Workaround for Playwright bug #36139
# Session cookies (expires=-1) don't persist in user_data_dir automatically
BrowserFactory._inject_cookies(context)
return context
@staticmethod
def _inject_cookies(context: BrowserContext):
"""Inject cookies from state.json if available"""
if STATE_FILE.exists():
try:
with open(STATE_FILE, 'r') as f:
state = json.load(f)
if 'cookies' in state and len(state['cookies']) > 0:
context.add_cookies(state['cookies'])
# print(f" 🔧 Injected {len(state['cookies'])} cookies from state.json")
except Exception as e:
print(f" ⚠️ Could not load state.json: {e}")
class StealthUtils:
"""Human-like interaction utilities"""
@staticmethod
def random_delay(min_ms: int = 100, max_ms: int = 500):
"""Add random delay"""
time.sleep(random.uniform(min_ms / 1000, max_ms / 1000))
@staticmethod
def human_type(page: Page, selector: str, text: str, wpm_min: int = 320, wpm_max: int = 480):
"""Type with human-like speed"""
element = page.query_selector(selector)
if not element:
# Try waiting if not immediately found
try:
element = page.wait_for_selector(selector, timeout=2000)
except Exception:
pass
if not element:
print(f"⚠️ Element not found for typing: {selector}")
return
# Click to focus
element.click()
# Type
for char in text:
element.type(char, delay=random.uniform(25, 75))
if random.random() < 0.05:
time.sleep(random.uniform(0.15, 0.4))
@staticmethod
def realistic_click(page: Page, selector: str):
"""Click with realistic movement"""
element = page.query_selector(selector)
if not element:
return
# Optional: Move mouse to element (simplified)
box = element.bounding_box()
if box:
x = box['x'] + box['width'] / 2
y = box['y'] + box['height'] / 2
page.mouse.move(x, y, steps=5)
StealthUtils.random_delay(100, 300)
element.click()
StealthUtils.random_delay(100, 300)
#!/usr/bin/env python3
"""
Cleanup Manager for NotebookLM Skill
Manages cleanup of skill data and browser state
"""
import shutil
import argparse
from pathlib import Path
from typing import Dict, List, Any
class CleanupManager:
"""
Manages cleanup of NotebookLM skill data
Features:
- Preview what will be deleted
- Selective cleanup options
- Library preservation
- Safe deletion with confirmation
"""
def __init__(self):
"""Initialize the cleanup manager"""
# Skill directory paths
self.skill_dir = Path(__file__).parent.parent
self.data_dir = self.skill_dir / "data"
def get_cleanup_paths(self, preserve_library: bool = False) -> Dict[str, Any]:
"""
Get paths that would be cleaned up
Args:
preserve_library: Keep library.json if True
Returns:
Dict with paths and sizes
Note: .venv is NEVER deleted - it's part of the skill infrastructure
"""
paths = {
'browser_state': [],
'sessions': [],
'library': [],
'auth': [],
'other': []
}
total_size = 0
if self.data_dir.exists():
# Browser state
browser_state_dir = self.data_dir / "browser_state"
if browser_state_dir.exists():
for item in browser_state_dir.iterdir():
size = self._get_size(item)
paths['browser_state'].append({
'path': str(item),
'size': size,
'type': 'dir' if item.is_dir() else 'file'
})
total_size += size
# Sessions
sessions_file = self.data_dir / "sessions.json"
if sessions_file.exists():
size = sessions_file.stat().st_size
paths['sessions'].append({
'path': str(sessions_file),
'size': size,
'type': 'file'
})
total_size += size
# Library (unless preserved)
if not preserve_library:
library_file = self.data_dir / "library.json"
if library_file.exists():
size = library_file.stat().st_size
paths['library'].append({
'path': str(library_file),
'size': size,
'type': 'file'
})
total_size += size
# Auth info
auth_info = self.data_dir / "auth_info.json"
if auth_info.exists():
size = auth_info.stat().st_size
paths['auth'].append({
'path': str(auth_info),
'size': size,
'type': 'file'
})
total_size += size
# Other files in data dir (but NEVER .venv!)
for item in self.data_dir.iterdir():
if item.name not in ['browser_state', 'sessions.json', 'library.json', 'auth_info.json']:
size = self._get_size(item)
paths['other'].append({
'path': str(item),
'size': size,
'type': 'dir' if item.is_dir() else 'file'
})
total_size += size
return {
'categories': paths,
'total_size': total_size,
'total_items': sum(len(items) for items in paths.values())
}
def _get_size(self, path: Path) -> int:
"""Get size of file or directory in bytes"""
if path.is_file():
return path.stat().st_size
elif path.is_dir():
total = 0
try:
for item in path.rglob('*'):
if item.is_file():
total += item.stat().st_size
except Exception:
pass
return total
return 0
def _format_size(self, size: int) -> str:
"""Format size in human-readable form"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
def perform_cleanup(
self,
preserve_library: bool = False,
dry_run: bool = False
) -> Dict[str, Any]:
"""
Perform the actual cleanup
Args:
preserve_library: Keep library.json if True
dry_run: Preview only, don't delete
Returns:
Dict with cleanup results
"""
cleanup_data = self.get_cleanup_paths(preserve_library)
deleted_items = []
failed_items = []
deleted_size = 0
if dry_run:
return {
'dry_run': True,
'would_delete': cleanup_data['total_items'],
'would_free': cleanup_data['total_size']
}
# Perform deletion
for category, items in cleanup_data['categories'].items():
for item_info in items:
path = Path(item_info['path'])
try:
if path.exists():
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
deleted_items.append(str(path))
deleted_size += item_info['size']
print(f" ✅ Deleted: {path.name}")
except Exception as e:
failed_items.append({
'path': str(path),
'error': str(e)
})
print(f" ❌ Failed: {path.name} ({e})")
# Recreate browser_state dir if everything was deleted
if not preserve_library and not failed_items:
browser_state_dir = self.data_dir / "browser_state"
browser_state_dir.mkdir(parents=True, exist_ok=True)
return {
'deleted_items': deleted_items,
'failed_items': failed_items,
'deleted_size': deleted_size,
'deleted_count': len(deleted_items),
'failed_count': len(failed_items)
}
def print_cleanup_preview(self, preserve_library: bool = False):
"""Print a preview of what will be cleaned"""
data = self.get_cleanup_paths(preserve_library)
print("\n🔍 Cleanup Preview")
print("=" * 60)
for category, items in data['categories'].items():
if items:
print(f"\n📁 {category.replace('_', ' ').title()}:")
for item in items:
path = Path(item['path'])
size_str = self._format_size(item['size'])
type_icon = "📂" if item['type'] == 'dir' else "📄"
print(f" {type_icon} {path.name:<30} {size_str:>10}")
print("\n" + "=" * 60)
print(f"Total items: {data['total_items']}")
print(f"Total size: {self._format_size(data['total_size'])}")
if preserve_library:
print("\n📚 Library will be preserved")
print("\nThis preview shows what would be deleted.")
print("Use --confirm to actually perform the cleanup.")
def main():
"""Command-line interface for cleanup management"""
parser = argparse.ArgumentParser(
description='Clean up NotebookLM skill data',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Preview what will be deleted
python cleanup_manager.py
# Perform cleanup (delete everything)
python cleanup_manager.py --confirm
# Cleanup but keep library
python cleanup_manager.py --confirm --preserve-library
# Force cleanup without preview
python cleanup_manager.py --confirm --force
"""
)
parser.add_argument(
'--confirm',
action='store_true',
help='Actually perform the cleanup (without this, only preview)'
)
parser.add_argument(
'--preserve-library',
action='store_true',
help='Keep the notebook library (library.json)'
)
parser.add_argument(
'--force',
action='store_true',
help='Skip confirmation prompt'
)
args = parser.parse_args()
# Initialize manager
manager = CleanupManager()
if args.confirm:
# Show preview first unless forced
if not args.force:
manager.print_cleanup_preview(args.preserve_library)
print("\n⚠️ WARNING: This will delete the files shown above!")
print(" Note: .venv is preserved (part of skill infrastructure)")
response = input("Are you sure? (yes/no): ")
if response.lower() != 'yes':
print("Cleanup cancelled.")
return
# Perform cleanup
print("\n🗑️ Performing cleanup...")
result = manager.perform_cleanup(args.preserve_library, dry_run=False)
print(f"\n✅ Cleanup complete!")
print(f" Deleted: {result['deleted_count']} items")
print(f" Freed: {manager._format_size(result['deleted_size'])}")
if result['failed_count'] > 0:
print(f" ⚠️ Failed: {result['failed_count']} items")
else:
# Just show preview
manager.print_cleanup_preview(args.preserve_library)
print("\n💡 Note: Virtual environment (.venv) is never deleted")
print(" It's part of the skill infrastructure, not user data")
if __name__ == "__main__":
main()"""
Configuration for NotebookLM Skill
Centralizes constants, selectors, and paths
"""
import os
from pathlib import Path
# Paths
SKILL_DIR = Path(__file__).parent.parent
DATA_DIR = SKILL_DIR / "data"
BROWSER_STATE_DIR = DATA_DIR / "browser_state"
BROWSER_PROFILE_DIR = BROWSER_STATE_DIR / "browser_profile"
STATE_FILE = BROWSER_STATE_DIR / "state.json"
AUTH_INFO_FILE = DATA_DIR / "auth_info.json"
LIBRARY_FILE = DATA_DIR / "library.json"
# NotebookLM Selectors
QUERY_INPUT_SELECTORS = [
"textarea.query-box-input", # Primary
'textarea[aria-label="Feld für Anfragen"]', # Fallback German
'textarea[aria-label="Input for queries"]', # Fallback English
]
RESPONSE_SELECTORS = [
".to-user-container .message-text-content", # Primary
"[data-message-author='bot']",
"[data-message-author='assistant']",
]
# Browser Configuration
# Note: "--no-sandbox" intentionally removed (VULN-009 mitigation). Enabling
# sandbox-disabled mode without container isolation gives the renderer
# process full host access. If running inside a container that requires it
# (e.g. Docker without a non-root user), set env PATCHRIGHT_NO_SANDBOX=1
# and the launcher will append it conditionally.
BROWSER_ARGS = [
'--disable-blink-features=AutomationControlled', # Patches navigator.webdriver
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check'
]
if os.environ.get('PATCHRIGHT_NO_SANDBOX') == '1':
BROWSER_ARGS.append('--no-sandbox')
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
# Timeouts
LOGIN_TIMEOUT_MINUTES = 10
QUERY_TIMEOUT_SECONDS = 120
PAGE_LOAD_TIMEOUT = 30000
#!/usr/bin/env python3
"""
Notebook Library Management for NotebookLM
Manages a library of NotebookLM notebooks with metadata
Based on the MCP server implementation
"""
import json
import argparse
import uuid
import os
from pathlib import Path
from typing import Dict, List, Optional, Any
from datetime import datetime
class NotebookLibrary:
"""Manages a collection of NotebookLM notebooks with metadata"""
def __init__(self):
"""Initialize the notebook library"""
# Store data within the skill directory
skill_dir = Path(__file__).parent.parent
self.data_dir = skill_dir / "data"
self.data_dir.mkdir(parents=True, exist_ok=True)
self.library_file = self.data_dir / "library.json"
self.notebooks: Dict[str, Dict[str, Any]] = {}
self.active_notebook_id: Optional[str] = None
# Load existing library
self._load_library()
def _load_library(self):
"""Load library from disk"""
if self.library_file.exists():
try:
with open(self.library_file, 'r') as f:
data = json.load(f)
self.notebooks = data.get('notebooks', {})
self.active_notebook_id = data.get('active_notebook_id')
print(f"📚 Loaded library with {len(self.notebooks)} notebooks")
except Exception as e:
print(f"⚠️ Error loading library: {e}")
self.notebooks = {}
self.active_notebook_id = None
else:
self._save_library()
def _save_library(self):
"""Save library to disk"""
try:
data = {
'notebooks': self.notebooks,
'active_notebook_id': self.active_notebook_id,
'updated_at': datetime.now().isoformat()
}
with open(self.library_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"❌ Error saving library: {e}")
def add_notebook(
self,
url: str,
name: str,
description: str,
topics: List[str],
content_types: Optional[List[str]] = None,
use_cases: Optional[List[str]] = None,
tags: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Add a new notebook to the library
Args:
url: NotebookLM notebook URL
name: Display name for the notebook
description: What's in this notebook
topics: Topics covered
content_types: Types of content (optional)
use_cases: When to use this notebook (optional)
tags: Additional tags for organization (optional)
Returns:
The created notebook object
"""
# Generate ID from name
notebook_id = name.lower().replace(' ', '-').replace('_', '-')
# Check for duplicates
if notebook_id in self.notebooks:
raise ValueError(f"Notebook with ID '{notebook_id}' already exists")
# Create notebook object
notebook = {
'id': notebook_id,
'url': url,
'name': name,
'description': description,
'topics': topics,
'content_types': content_types or [],
'use_cases': use_cases or [],
'tags': tags or [],
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'use_count': 0,
'last_used': None
}
# Add to library
self.notebooks[notebook_id] = notebook
# Set as active if it's the first notebook
if len(self.notebooks) == 1:
self.active_notebook_id = notebook_id
self._save_library()
print(f"✅ Added notebook: {name} ({notebook_id})")
return notebook
def remove_notebook(self, notebook_id: str) -> bool:
"""
Remove a notebook from the library
Args:
notebook_id: ID of notebook to remove
Returns:
True if removed, False if not found
"""
if notebook_id in self.notebooks:
del self.notebooks[notebook_id]
# Clear active if it was removed
if self.active_notebook_id == notebook_id:
self.active_notebook_id = None
# Set new active if there are other notebooks
if self.notebooks:
self.active_notebook_id = list(self.notebooks.keys())[0]
self._save_library()
print(f"✅ Removed notebook: {notebook_id}")
return True
print(f"⚠️ Notebook not found: {notebook_id}")
return False
def update_notebook(
self,
notebook_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
topics: Optional[List[str]] = None,
content_types: Optional[List[str]] = None,
use_cases: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
url: Optional[str] = None
) -> Dict[str, Any]:
"""
Update notebook metadata
Args:
notebook_id: ID of notebook to update
Other args: Fields to update (None = keep existing)
Returns:
Updated notebook object
"""
if notebook_id not in self.notebooks:
raise ValueError(f"Notebook not found: {notebook_id}")
notebook = self.notebooks[notebook_id]
# Update fields if provided
if name is not None:
notebook['name'] = name
if description is not None:
notebook['description'] = description
if topics is not None:
notebook['topics'] = topics
if content_types is not None:
notebook['content_types'] = content_types
if use_cases is not None:
notebook['use_cases'] = use_cases
if tags is not None:
notebook['tags'] = tags
if url is not None:
notebook['url'] = url
notebook['updated_at'] = datetime.now().isoformat()
self._save_library()
print(f"✅ Updated notebook: {notebook['name']}")
return notebook
def get_notebook(self, notebook_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific notebook by ID"""
return self.notebooks.get(notebook_id)
def list_notebooks(self) -> List[Dict[str, Any]]:
"""List all notebooks in the library"""
return list(self.notebooks.values())
def search_notebooks(self, query: str) -> List[Dict[str, Any]]:
"""
Search notebooks by query
Args:
query: Search query (searches name, description, topics, tags)
Returns:
List of matching notebooks
"""
query_lower = query.lower()
results = []
for notebook in self.notebooks.values():
# Search in various fields
searchable = [
notebook['name'].lower(),
notebook['description'].lower(),
' '.join(notebook['topics']).lower(),
' '.join(notebook['tags']).lower(),
' '.join(notebook.get('use_cases', [])).lower()
]
if any(query_lower in field for field in searchable):
results.append(notebook)
return results
def select_notebook(self, notebook_id: str) -> Dict[str, Any]:
"""
Set a notebook as active
Args:
notebook_id: ID of notebook to activate
Returns:
The activated notebook
"""
if notebook_id not in self.notebooks:
raise ValueError(f"Notebook not found: {notebook_id}")
self.active_notebook_id = notebook_id
self._save_library()
notebook = self.notebooks[notebook_id]
print(f"✅ Activated notebook: {notebook['name']}")
return notebook
def get_active_notebook(self) -> Optional[Dict[str, Any]]:
"""Get the currently active notebook"""
if self.active_notebook_id:
return self.notebooks.get(self.active_notebook_id)
return None
def increment_use_count(self, notebook_id: str) -> Dict[str, Any]:
"""
Increment usage counter for a notebook
Args:
notebook_id: ID of notebook that was used
Returns:
Updated notebook
"""
if notebook_id not in self.notebooks:
raise ValueError(f"Notebook not found: {notebook_id}")
notebook = self.notebooks[notebook_id]
notebook['use_count'] += 1
notebook['last_used'] = datetime.now().isoformat()
self._save_library()
return notebook
def get_stats(self) -> Dict[str, Any]:
"""Get library statistics"""
total_notebooks = len(self.notebooks)
total_topics = set()
total_use_count = 0
for notebook in self.notebooks.values():
total_topics.update(notebook['topics'])
total_use_count += notebook['use_count']
# Find most used
most_used = None
if self.notebooks:
most_used = max(
self.notebooks.values(),
key=lambda n: n['use_count']
)
return {
'total_notebooks': total_notebooks,
'total_topics': len(total_topics),
'total_use_count': total_use_count,
'active_notebook': self.get_active_notebook(),
'most_used_notebook': most_used,
'library_path': str(self.library_file)
}
def main():
"""Command-line interface for notebook management"""
parser = argparse.ArgumentParser(description='Manage NotebookLM library')
subparsers = parser.add_subparsers(dest='command', help='Commands')
# Closes audit VULN-037: input length caps on CLI string args.
# `add_notebook` derives notebook_id = name.lower().replace(' ', '-')
# with no length limit, so an unbounded --name becomes a giant filesystem
# key in library.json.
def _bounded(max_len: int):
def _check(s: str) -> str:
if len(s) > max_len:
raise argparse.ArgumentTypeError(
f"value too long ({len(s)} chars, max {max_len})"
)
return s
return _check
# Add command
add_parser = subparsers.add_parser('add', help='Add a notebook')
add_parser.add_argument('--url', required=True, type=_bounded(2000),
help='NotebookLM URL (max 2000 chars)')
add_parser.add_argument('--name', required=True, type=_bounded(200),
help='Display name (max 200 chars)')
add_parser.add_argument('--description', required=True, type=_bounded(2000),
help='Description (max 2000 chars)')
add_parser.add_argument('--topics', required=True, type=_bounded(1000),
help='Comma-separated topics (max 1000 chars)')
add_parser.add_argument('--use-cases', type=_bounded(1000),
help='Comma-separated use cases (max 1000 chars)')
add_parser.add_argument('--tags', type=_bounded(500),
help='Comma-separated tags (max 500 chars)')
# List command
subparsers.add_parser('list', help='List all notebooks')
# Search command
search_parser = subparsers.add_parser('search', help='Search notebooks')
search_parser.add_argument('--query', required=True, help='Search query')
# Activate command
activate_parser = subparsers.add_parser('activate', help='Set active notebook')
activate_parser.add_argument('--id', required=True, help='Notebook ID')
# Remove command
remove_parser = subparsers.add_parser('remove', help='Remove a notebook')
remove_parser.add_argument('--id', required=True, help='Notebook ID')
# Stats command
subparsers.add_parser('stats', help='Show library statistics')
args = parser.parse_args()
# Initialize library
library = NotebookLibrary()
# Execute command
if args.command == 'add':
topics = [t.strip() for t in args.topics.split(',')]
use_cases = [u.strip() for u in args.use_cases.split(',')] if args.use_cases else None
tags = [t.strip() for t in args.tags.split(',')] if args.tags else None
notebook = library.add_notebook(
url=args.url,
name=args.name,
description=args.description,
topics=topics,
use_cases=use_cases,
tags=tags
)
print(json.dumps(notebook, indent=2))
elif args.command == 'list':
notebooks = library.list_notebooks()
if notebooks:
print("\n📚 Notebook Library:")
for notebook in notebooks:
active = " [ACTIVE]" if notebook['id'] == library.active_notebook_id else ""
print(f"\n 📓 {notebook['name']}{active}")
print(f" ID: {notebook['id']}")
print(f" Topics: {', '.join(notebook['topics'])}")
print(f" Uses: {notebook['use_count']}")
else:
print("📚 Library is empty. Add notebooks with: notebook_manager.py add")
elif args.command == 'search':
results = library.search_notebooks(args.query)
if results:
print(f"\n🔍 Found {len(results)} notebooks:")
for notebook in results:
print(f"\n 📓 {notebook['name']} ({notebook['id']})")
print(f" {notebook['description']}")
else:
print(f"🔍 No notebooks found for: {args.query}")
elif args.command == 'activate':
notebook = library.select_notebook(args.id)
print(f"Now using: {notebook['name']}")
elif args.command == 'remove':
if library.remove_notebook(args.id):
print("Notebook removed from library")
elif args.command == 'stats':
stats = library.get_stats()
print("\n📊 Library Statistics:")
print(f" Total notebooks: {stats['total_notebooks']}")
print(f" Total topics: {stats['total_topics']}")
print(f" Total uses: {stats['total_use_count']}")
if stats['active_notebook']:
print(f" Active: {stats['active_notebook']['name']}")
if stats['most_used_notebook']:
print(f" Most used: {stats['most_used_notebook']['name']} ({stats['most_used_notebook']['use_count']} uses)")
print(f" Library path: {stats['library_path']}")
else:
parser.print_help()
if __name__ == "__main__":
main()#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file=skills/blog-notebooklm/scripts/requirements.lock --strip-extras skills/blog-notebooklm/scripts/requirements.txt
#
greenlet==3.5.0 \
--hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \
--hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \
--hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \
--hash=sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce \
--hash=sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2 \
--hash=sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588 \
--hash=sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13 \
--hash=sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e \
--hash=sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a \
--hash=sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3 \
--hash=sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b \
--hash=sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033 \
--hash=sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628 \
--hash=sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136 \
--hash=sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b \
--hash=sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d \
--hash=sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2 \
--hash=sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb \
--hash=sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd \
--hash=sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b \
--hash=sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1 \
--hash=sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16 \
--hash=sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d \
--hash=sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106 \
--hash=sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba \
--hash=sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c \
--hash=sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc \
--hash=sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7 \
--hash=sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339 \
--hash=sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b \
--hash=sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae \
--hash=sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8 \
--hash=sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2 \
--hash=sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5 \
--hash=sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf \
--hash=sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f \
--hash=sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f \
--hash=sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2 \
--hash=sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb \
--hash=sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082 \
--hash=sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7 \
--hash=sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0 \
--hash=sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c \
--hash=sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853 \
--hash=sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988 \
--hash=sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3 \
--hash=sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858 \
--hash=sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37 \
--hash=sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977 \
--hash=sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4 \
--hash=sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8 \
--hash=sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86 \
--hash=sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f \
--hash=sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112 \
--hash=sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e \
--hash=sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2 \
--hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \
--hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \
--hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564
# via patchright
patchright==1.55.2 \
--hash=sha256:329c6c94e19181d59cb3fdfb126ea1156b218f57450bdca1eba3d0e98f8ccc45 \
--hash=sha256:64d10d3ef080acfed63b27b94106b380d85ec3d2577154ca1a5853d453451f3d \
--hash=sha256:7018eb12650077e87d8608fa3d0523f2328997c3973407cfa993014b254a705c \
--hash=sha256:8771ec426f8b19098426b00322043d93127490ff15a639933baed37bdafd75d5 \
--hash=sha256:8e0f3f3d1cff7e31f93d1725af35748e6ab77899244e5764a56450bcc52d9ef8 \
--hash=sha256:9f4ee976895a7083a253de1bfabfa36801e719800a7e98c39f7535924ba1c8b1 \
--hash=sha256:b4e36ec235f4a4e5845763327f6dd552866f96ab7fedda121d89ea95b62386d2 \
--hash=sha256:f9f5b7a386f18357c567905aaf4f26bb58b9c1d54f2f13e727a69be5fd06ef39
# via -r skills/blog-notebooklm/scripts/requirements.txt
pyee==13.0.1 \
--hash=sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8 \
--hash=sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228
# via patchright
python-dotenv==1.0.0 \
--hash=sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba \
--hash=sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a
# via -r skills/blog-notebooklm/scripts/requirements.txt
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via pyee
# NotebookLM Skill Dependencies
# These will be installed in the skill's local .venv
#
# For reproducible installs with hash verification, use the lock file:
# pip install --require-hashes -r requirements.lock
# Closes audit VULN-006 (lock file presence) and VULN-016 (patchright
# integrity verification via the lock's per-wheel sha256 hashes).
# Core browser automation with anti-detection
# Note: After installation, run: patchright install chrome
# (Chrome is required, not Chromium, for cross-platform reliability)
patchright==1.55.2
# Environment management
python-dotenv==1.0.0#!/usr/bin/env python3
"""
Universal runner for NotebookLM skill scripts
Ensures all scripts run with the correct virtual environment
"""
import os
import sys
import subprocess
from pathlib import Path
def get_venv_python():
"""Get the virtual environment Python executable"""
skill_dir = Path(__file__).parent.parent
venv_dir = skill_dir / ".venv"
if os.name == 'nt': # Windows
venv_python = venv_dir / "Scripts" / "python.exe"
else: # Unix/Linux/Mac
venv_python = venv_dir / "bin" / "python"
return venv_python
def ensure_venv():
"""Ensure virtual environment exists"""
skill_dir = Path(__file__).parent.parent
venv_dir = skill_dir / ".venv"
setup_script = skill_dir / "scripts" / "setup_environment.py"
# Check if venv exists
if not venv_dir.exists():
print("🔧 First-time setup: Creating virtual environment...")
print(" This may take a minute...")
# Run setup with system Python
result = subprocess.run([sys.executable, str(setup_script)])
if result.returncode != 0:
print("❌ Failed to set up environment")
sys.exit(1)
print("✅ Environment ready!")
return get_venv_python()
def main():
"""Main runner"""
if len(sys.argv) < 2:
print("Usage: python run.py <script_name> [args...]")
print("\nAvailable scripts:")
print(" ask_question.py - Query NotebookLM")
print(" notebook_manager.py - Manage notebook library")
print(" session_manager.py - Manage sessions")
print(" auth_manager.py - Handle authentication")
print(" cleanup_manager.py - Clean up skill data")
sys.exit(1)
script_name = sys.argv[1]
script_args = sys.argv[2:]
# Handle both "scripts/script.py" and "script.py" formats
if script_name.startswith('scripts/'):
# Remove the scripts/ prefix if provided
script_name = script_name[8:] # len('scripts/') = 8
# Ensure .py extension
if not script_name.endswith('.py'):
script_name += '.py'
# Get script path
skill_dir = Path(__file__).parent.parent
script_path = skill_dir / "scripts" / script_name
if not script_path.exists():
print(f"❌ Script not found: {script_name}")
print(f" Working directory: {Path.cwd()}")
print(f" Skill directory: {skill_dir}")
print(f" Looked for: {script_path}")
sys.exit(1)
# Ensure venv exists and get Python executable
venv_python = ensure_venv()
# Build command
cmd = [str(venv_python), str(script_path)] + script_args
# Run the script
try:
result = subprocess.run(cmd)
sys.exit(result.returncode)
except KeyboardInterrupt:
print("\n⚠️ Interrupted by user")
sys.exit(130)
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""
Environment Setup for NotebookLM Skill
Manages virtual environment and dependencies automatically
"""
import os
import sys
import subprocess
import venv
from pathlib import Path
class SkillEnvironment:
"""Manages skill-specific virtual environment"""
def __init__(self):
# Skill directory paths
self.skill_dir = Path(__file__).parent.parent
self.venv_dir = self.skill_dir / ".venv"
# Bug fix: requirements.txt actually lives in scripts/, not the skill
# root. Prior path looked at skill_dir/requirements.txt which never
# existed. Now also prefer the lock file when present (closes audit
# VULN-006 supply-chain detection gap).
self.lock_file = self.skill_dir / "scripts" / "requirements.lock"
self.requirements_file = self.skill_dir / "scripts" / "requirements.txt"
# Python executable in venv
if os.name == 'nt': # Windows
self.venv_python = self.venv_dir / "Scripts" / "python.exe"
self.venv_pip = self.venv_dir / "Scripts" / "pip.exe"
else: # Unix/Linux/Mac
self.venv_python = self.venv_dir / "bin" / "python"
self.venv_pip = self.venv_dir / "bin" / "pip"
def ensure_venv(self) -> bool:
"""Ensure virtual environment exists and is set up"""
# Check if we're already in the correct venv
if self.is_in_skill_venv():
print("✅ Already running in skill virtual environment")
return True
# Create venv if it doesn't exist
if not self.venv_dir.exists():
print(f"🔧 Creating virtual environment in {self.venv_dir.name}/")
try:
venv.create(self.venv_dir, with_pip=True)
print("✅ Virtual environment created")
except Exception as e:
print(f"❌ Failed to create venv: {e}")
return False
# Install/update dependencies. Prefer lock file when present.
if self.lock_file.exists():
install_args = ["install", "--require-hashes", "-r", str(self.lock_file)]
install_label = "lock file (hash-verified)"
elif self.requirements_file.exists():
install_args = ["install", "-r", str(self.requirements_file)]
install_label = "requirements.txt (no hash verification)"
else:
install_args = None
if install_args:
print(f"📦 Installing dependencies from {install_label}...")
try:
# Upgrade pip first
subprocess.run(
[str(self.venv_pip), "install", "--upgrade", "pip"],
check=True,
capture_output=True,
text=True
)
# Install requirements (lock or .txt)
result = subprocess.run(
[str(self.venv_pip)] + install_args,
check=True,
capture_output=True,
text=True
)
print("✅ Dependencies installed")
# Install Chrome for Patchright (not Chromium!)
# Using real Chrome ensures cross-platform reliability and consistent browser fingerprinting
# See: https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python#anti-detection
print("🌐 Installing Google Chrome for Patchright...")
try:
subprocess.run(
[str(self.venv_python), "-m", "patchright", "install", "chrome"],
check=True,
capture_output=True,
text=True
)
print("✅ Chrome installed")
except subprocess.CalledProcessError as e:
print(f"⚠️ Warning: Failed to install Chrome: {e}")
print(" You may need to run manually: python -m patchright install chrome")
print(" Chrome is required (not Chromium) for reliability!")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Failed to install dependencies: {e}")
print(f" Output: {e.output if hasattr(e, 'output') else 'No output'}")
return False
else:
print("⚠️ No requirements.txt found, skipping dependency installation")
return True
def is_in_skill_venv(self) -> bool:
"""Check if we're already running in the skill's venv"""
if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
# We're in a venv, check if it's ours
venv_path = Path(sys.prefix)
return venv_path == self.venv_dir
return False
def get_python_executable(self) -> str:
"""Get the correct Python executable to use"""
if self.venv_python.exists():
return str(self.venv_python)
return sys.executable
def run_script(self, script_name: str, args: list = None) -> int:
"""Run a script with the virtual environment"""
script_path = self.skill_dir / "scripts" / script_name
if not script_path.exists():
print(f"❌ Script not found: {script_path}")
return 1
# Ensure venv is set up
if not self.ensure_venv():
print("❌ Failed to set up environment")
return 1
# Build command
cmd = [str(self.venv_python), str(script_path)]
if args:
cmd.extend(args)
print(f"🚀 Running: {script_name} with venv Python")
try:
# Run the script with venv Python
result = subprocess.run(cmd)
return result.returncode
except Exception as e:
print(f"❌ Failed to run script: {e}")
return 1
def activate_instructions(self) -> str:
"""Get instructions for manual activation"""
if os.name == 'nt':
activate = self.venv_dir / "Scripts" / "activate.bat"
return f"Run: {activate}"
else:
activate = self.venv_dir / "bin" / "activate"
return f"Run: source {activate}"
def main():
"""Main entry point for environment setup"""
import argparse
parser = argparse.ArgumentParser(
description='Setup NotebookLM skill environment'
)
parser.add_argument(
'--check',
action='store_true',
help='Check if environment is set up'
)
parser.add_argument(
'--run',
help='Run a script with the venv (e.g., --run ask_question.py)'
)
parser.add_argument(
'args',
nargs='*',
help='Arguments to pass to the script'
)
args = parser.parse_args()
env = SkillEnvironment()
if args.check:
if env.venv_dir.exists():
print(f"✅ Virtual environment exists: {env.venv_dir}")
print(f" Python: {env.get_python_executable()}")
print(f" To activate manually: {env.activate_instructions()}")
else:
print(f"❌ No virtual environment found")
print(f" Run setup_environment.py to create it")
return
if args.run:
# Run a script with venv
return env.run_script(args.run, args.args)
# Default: ensure environment is set up
if env.ensure_venv():
print("\n✅ Environment ready!")
print(f" Virtual env: {env.venv_dir}")
print(f" Python: {env.get_python_executable()}")
print(f"\nTo activate manually: {env.activate_instructions()}")
print(f"Or run scripts directly: python setup_environment.py --run script_name.py")
else:
print("\n❌ Environment setup failed")
return 1
if __name__ == "__main__":
sys.exit(main() or 0)