Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **`--log` flag** - Save outputs to log files in logs/ directory
- By default, only prints output to console (no files saved)
- When enabled, saves original text, markdown (if --markdown used), and comparison stats
- Usage: `python scripts/run.py ask_question.py --question "..." --log`

- **`--markdown` flag** - Get formatted markdown output from NotebookLM
- Clicks the copy button in the NotebookLM UI to get clean markdown
- Useful for responses with code blocks, lists, tables, and other formatting
- Automatically falls back to plain text if copy button fails
- Usage: `python scripts/run.py ask_question.py --question "..." --markdown`

### Changed
- **Logging behavior** - Logs are now opt-in via `--log` flag
- Previously, logs were always saved when `--markdown` was enabled
- Now, no files are created unless user explicitly uses `--log`

## [1.3.0] - 2025-11-21

### Added
Expand Down
9 changes: 8 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ python scripts/run.py ask_question.py --question "..." --notebook-url "https://.

# Show browser for debugging
python scripts/run.py ask_question.py --question "..." --show-browser

# Get formatted markdown output (for code, lists, tables, etc.)
python scripts/run.py ask_question.py --question "..." --markdown
```

## Follow-Up Mechanism (CRITICAL)
Expand Down Expand Up @@ -162,9 +165,13 @@ python scripts/run.py notebook_manager.py stats

### Question Interface (`ask_question.py`)
```bash
python scripts/run.py ask_question.py --question "..." [--notebook-id ID] [--notebook-url URL] [--show-browser]
python scripts/run.py ask_question.py --question "..." [--notebook-id ID] [--notebook-url URL] [--show-browser] [--markdown] [--log]
```

**Options:**
- `--markdown` - Get formatted markdown output via copy button (recommended for longer responses with formatting)
- `--log` - Save outputs to log files in logs/ directory (original, markdown, and stats)

### Data Cleanup (`cleanup_manager.py`)
```bash
python scripts/run.py cleanup_manager.py # Preview cleanup
Expand Down
226 changes: 205 additions & 21 deletions scripts/ask_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,120 @@
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent))

# Get project root for logs directory
PROJECT_ROOT = Path(__file__).parent.parent
LOGS_DIR = PROJECT_ROOT / "logs"
LOGS_DIR.mkdir(exist_ok=True)

from auth_manager import AuthManager
from notebook_manager import NotebookLibrary
from config import QUERY_INPUT_SELECTORS, RESPONSE_SELECTORS
from browser_utils import BrowserFactory, StealthUtils
from logger import QueryLogger


def _try_copy_button(response_element, page) -> str:
"""
Try to click the copy button associated with a specific response element.

Args:
response_element: The Playwright element handle for the response
page: The Playwright page object

Returns:
Clipboard text if successful, None otherwise
"""
try:
# Search for the copy button within the same container as the response
# This ensures we get the copy button for THIS response, not an old one
result = page.evaluate("""(element) => {
// Find the container of this response
let container = element;

// Try different container levels
const possibleContainers = [
element,
element.parentElement, // Parent
element.parentElement?.parentElement, // Grandparent
element.closest('.to-user-container'), // Closest message container
element.closest('[data-message-author="bot"]'), // Bot message container
element.closest('[data-message-author="assistant"]'), // Assistant container
];

let copyButton = null;

// Search for copy button in each container level
for (const cont of possibleContainers) {
if (!cont) continue;

// Try multiple selectors for copy button
const selectors = [
'button[aria-label="Copy model response to clipboard"]',
'button[aria-label*="copy" i]',
'button[class*="copy" i]',
'button[title*="copy" i]',
'.copy-button',
'button[aria-label*="Copy"]',
];

for (const selector of selectors) {
const buttons = cont.querySelectorAll(selector);
if (buttons.length > 0) {
// Get the first copy button in this container
copyButton = buttons[0];
break;
}
}

if (copyButton) break;
}

if (!copyButton) {
return { found: false, error: 'No copy button found in response container' };
}

// Click the button
copyButton.click();

return { found: true, buttonHTML: copyButton.outerHTML };
}""", response_element)

if not result or not result.get('found'):
print(f" ! Copy button not found: {result.get('error', 'Unknown error')}")
return None

print(" ✓ Clicked copy button")

# Wait for clipboard to be populated
StealthUtils.random_delay(500, 1000)

# Read clipboard
clipboard_text = page.evaluate("() => navigator.clipboard.readText()")

if not clipboard_text:
print(" ! Clipboard is empty")
return None

print(f" 📋 Got clipboard content ({len(clipboard_text)} chars)")

# Validate clipboard content matches response roughly
# The clipboard might have markdown formatting, so it could be longer
# But it shouldn't be drastically different
response_text = response_element.inner_text().strip()
clipboard_ratio = len(clipboard_text) / len(response_text) if len(response_text) > 0 else 0

# Accept clipboard if ratio is reasonable (0.3 to 5.0)
# Markdown formatting can make it significantly longer or shorter
if 0.3 <= clipboard_ratio <= 5.0:
print(f" ✓ Clipboard content validated (ratio: {clipboard_ratio:.2f})")
return clipboard_text
else:
print(f" ! Clipboard content seems off (ratio: {clipboard_ratio:.2f}), ignoring")
return None

except Exception as e:
print(f" ! Copy button error: {e}")
return None


# Follow-up reminder (adapted from MCP server for stateless operation)
Expand All @@ -37,17 +147,18 @@
)


def ask_notebooklm(question: str, notebook_url: str, headless: bool = True) -> str:
def ask_notebooklm(question: str, notebook_url: str, headless: bool = True, use_markdown: bool = False) -> dict:
"""
Ask a question to NotebookLM

Args:
question: Question to ask
notebook_url: NotebookLM notebook URL
headless: Run browser in headless mode
use_markdown: If True, try to get formatted markdown via copy button

Returns:
Answer text from NotebookLM
Dictionary with 'original' and 'markdown' keys (markdown may be None)
"""
auth = AuthManager()

Expand Down Expand Up @@ -117,11 +228,23 @@ def ask_notebooklm(question: str, notebook_url: str, headless: bool = True) -> s
# Wait for response (MCP approach: poll for stable text)
print(" ⏳ Waiting for answer...")

answer = None
result = {
'original': None,
'markdown': None,
'success': False
}
stable_count = 0
last_text = None
deadline = time.time() + 120 # 2 minutes timeout

# Rate limit detection patterns
RATE_LIMIT_PATTERNS = [
"The system was unable to answer",
"Unable to answer",
"Daily limit reached",
"Rate limit exceeded",
]

while time.time() < deadline:
# Check if NotebookLM is still thinking (most reliable indicator)
try:
Expand All @@ -133,38 +256,73 @@ def ask_notebooklm(question: str, notebook_url: str, headless: bool = True) -> s
pass

# Try to find response with MCP selectors
current_element = None
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()
current_element = elements[-1]
text = current_element.inner_text().strip()

if text:
# Check for rate limit messages
is_rate_limit = any(pattern.lower() in text.lower() for pattern in RATE_LIMIT_PATTERNS)
if is_rate_limit:
print(f" ⚠️ Rate limit detected: {text}")
result['original'] = text
result['markdown'] = None
result['success'] = True
break

if text == last_text:
stable_count += 1
if stable_count >= 3: # Stable for 3 polls
answer = text
print(f" ✓ Response stable (length: {len(text)} chars)")

# Always store original text
result['original'] = text

# Try copy button if markdown is enabled and response is long enough
if use_markdown and len(text) >= 100:
print(" 📋 Trying copy button for clean markdown...")
markdown = _try_copy_button(current_element, page)
if markdown:
result['markdown'] = markdown
else:
print(" ! Copy button failed, using original text")
result['markdown'] = None
elif use_markdown:
print(" ✓ Response too short for copy button (< 100 chars)")
result['markdown'] = None

result['success'] = True
break
else:
stable_count = 0
last_text = text
print(f" ⏳ Response changing... (length: {len(text)} chars)")
except:
continue

if answer:
if result['success']:
break

time.sleep(1)

if not answer:
if not result['success']:
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
if result['original']:
result['original'] = result['original'] + FOLLOW_UP_REMINDER
if result['markdown']:
result['markdown'] = result['markdown'] + FOLLOW_UP_REMINDER

return result

except Exception as e:
print(f" ❌ Error: {e}")
Expand Down Expand Up @@ -194,6 +352,8 @@ def main():
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('--markdown', action='store_true', help='Get formatted markdown output via copy button (saves both original and markdown)')
parser.add_argument('--log', action='store_true', help='Save outputs to log files in logs/ directory')

args = parser.parse_args()

Expand Down Expand Up @@ -232,25 +392,49 @@ def main():
return 1

# Ask the question
answer = ask_notebooklm(
result = ask_notebooklm(
question=args.question,
notebook_url=notebook_url,
headless=not args.show_browser
headless=not args.show_browser,
use_markdown=args.markdown
)

if answer:
print("\n" + "=" * 60)
print(f"Question: {args.question}")
print("=" * 60)
print()
print(answer)
print()
print("=" * 60)
return 0
else:
if not result:
print("\n❌ Failed to get answer")
return 1

# Determine which output to display
display_answer = result['markdown'] if (args.markdown and result['markdown']) else result['original']

# Print the answer to console
print("\n" + "=" * 60)
print(f"Question: {args.question}")
print("=" * 60)
if args.markdown and result['markdown']:
print("📋 Output: Markdown (from copy button)")
elif args.markdown:
print("📄 Output: Original (copy button failed or response too short)")
else:
print("📄 Output: Original")
print("=" * 60)
print()
print(display_answer)
print()
print("=" * 60)

# Save outputs to logs directory only if --log option is enabled
if args.log:
logger = QueryLogger(LOGS_DIR)
saved_files = logger.save_query_results(
question=args.question,
notebook_url=notebook_url,
result=result,
use_markdown=args.markdown
)
logger.print_save_summary(saved_files)

return 0


if __name__ == "__main__":
sys.exit(main())
3 changes: 2 additions & 1 deletion scripts/browser_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def launch_persistent_context(
no_viewport=True,
ignore_default_args=["--enable-automation"],
user_agent=USER_AGENT,
args=BROWSER_ARGS
args=BROWSER_ARGS,
permissions=["clipboard-read", "clipboard-write"] # Grant clipboard permissions
)

# Cookie Workaround for Playwright bug #36139
Expand Down
Loading