deepdiver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1441 @@
1
+ """
2
+ NotebookLM Automation Module
3
+ Part of DeepDiver - NotebookLM Podcast Automation System
4
+
5
+ This module handles browser automation for NotebookLM interactions,
6
+ including login, document upload, podcast generation, and file management.
7
+
8
+ Assembly Team: Jerry ⚑, Nyro ♠️, Aureon 🌿, JamAI 🎸, Synth 🧡
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import time
17
+ import requests
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Any
20
+ from urllib.parse import urljoin
21
+
22
+ import yaml
23
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
24
+
25
+
26
+ # ═══════════════════════════════════════════════════════════════
27
+ # CDP URL RESOLUTION - Chrome DevTools Protocol
28
+ # ♠️ Nyro: Three-tier priority chain for multi-network support
29
+ # ═══════════════════════════════════════════════════════════════
30
+
31
+ def get_cdp_url(override: str = None, config_path: str = "deepdiver/deepdiver.yaml") -> str:
32
+ """
33
+ Get CDP (Chrome DevTools Protocol) URL using priority chain
34
+
35
+ Priority order:
36
+ 1. override parameter (highest - explicit function call)
37
+ 2. DEEPDIVER_CDP_URL environment variable (session-specific)
38
+ 3. CDP_URL from config file (persistent user config)
39
+ 4. http://localhost:9222 (fallback default)
40
+
41
+ Args:
42
+ override: Explicit CDP URL (e.g., from --cdp-url flag)
43
+ config_path: Path to configuration file
44
+
45
+ Returns:
46
+ CDP URL string
47
+
48
+ Examples:
49
+ # Command-line override (highest priority)
50
+ get_cdp_url('http://192.168.1.100:9222')
51
+
52
+ # Environment variable
53
+ export DEEPDIVER_CDP_URL=http://10.0.0.5:9222
54
+ get_cdp_url() # β†’ http://10.0.0.5:9222
55
+
56
+ # Config file
57
+ # deepdiver.yaml contains: CDP_URL: http://server:9222
58
+ get_cdp_url() # β†’ http://server:9222
59
+
60
+ # Fallback
61
+ get_cdp_url() # β†’ http://localhost:9222
62
+ """
63
+ # Priority 1: Explicit override parameter
64
+ if override:
65
+ return override
66
+
67
+ # Priority 2: Environment variable
68
+ env_cdp = os.environ.get('DEEPDIVER_CDP_URL')
69
+ if env_cdp:
70
+ return env_cdp
71
+
72
+ # Priority 3: Config file
73
+ if os.path.exists(config_path):
74
+ try:
75
+ with open(config_path, 'r') as f:
76
+ config = yaml.safe_load(f)
77
+ if config and 'BROWSER_SETTINGS' in config:
78
+ cdp_url = config['BROWSER_SETTINGS'].get('cdp_url')
79
+ if cdp_url:
80
+ return cdp_url
81
+ except Exception:
82
+ pass # Fall through to default
83
+
84
+ # Priority 4: Default localhost (Chrome DevTools Protocol standard port)
85
+ return 'http://localhost:9222'
86
+
87
+
88
+ # ═══════════════════════════════════════════════════════════════
89
+ # CHROME CDP HELPER FUNCTIONS
90
+ # β™ οΈπŸŒΏπŸŽΈπŸ§΅ G.Music Assembly - Auto-launch Chrome for init
91
+ # ═══════════════════════════════════════════════════════════════
92
+
93
+ def find_chrome_executable() -> Optional[str]:
94
+ """
95
+ Find Chrome/Chromium executable on the system
96
+
97
+ Returns:
98
+ str: Chrome command name, or None if not found
99
+ """
100
+ candidates = ['google-chrome', 'chromium', 'chromium-browser', 'chrome']
101
+ for cmd in candidates:
102
+ if shutil.which(cmd):
103
+ return cmd
104
+ return None
105
+
106
+
107
+ def check_chrome_cdp_running(cdp_url: str = 'http://localhost:9222') -> bool:
108
+ """
109
+ Check if Chrome CDP is running at specified URL
110
+
111
+ Args:
112
+ cdp_url: CDP URL to check (default: http://localhost:9222)
113
+
114
+ Returns:
115
+ bool: True if Chrome CDP is accessible, False otherwise
116
+ """
117
+ try:
118
+ # Extract host and port from CDP URL
119
+ if '://' in cdp_url:
120
+ cdp_url = cdp_url.split('://')[1]
121
+
122
+ # Handle localhost vs IP
123
+ if cdp_url.startswith('localhost:'):
124
+ port = cdp_url.split(':')[1]
125
+ test_url = f'http://localhost:{port}/json/version'
126
+ else:
127
+ test_url = f'http://{cdp_url}/json/version'
128
+
129
+ response = requests.get(test_url, timeout=2)
130
+ return response.status_code == 200
131
+ except:
132
+ return False
133
+
134
+
135
+ def launch_chrome_cdp(port: int = 9222, user_data_dir: str = None) -> bool:
136
+ """
137
+ Launch Chrome with CDP enabled
138
+
139
+ Args:
140
+ port: CDP port number (default: 9222)
141
+ user_data_dir: Chrome user data directory
142
+
143
+ Returns:
144
+ bool: True if Chrome launched successfully, False otherwise
145
+ """
146
+ chrome_cmd = find_chrome_executable()
147
+ if not chrome_cmd:
148
+ return False
149
+
150
+ if user_data_dir is None:
151
+ user_data_dir = os.path.expanduser('~/.chrome-deepdiver')
152
+
153
+ try:
154
+ subprocess.Popen([
155
+ chrome_cmd,
156
+ f'--remote-debugging-port={port}',
157
+ f'--user-data-dir={user_data_dir}'
158
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
159
+
160
+ # Wait for Chrome to start
161
+ time.sleep(3)
162
+ return check_chrome_cdp_running(f'http://localhost:{port}')
163
+ except Exception:
164
+ return False
165
+
166
+
167
+ class NotebookLMAutomator:
168
+ """
169
+ Main automation class for NotebookLM interactions.
170
+
171
+ Handles browser automation, authentication, document upload,
172
+ podcast generation, and file management through Playwright.
173
+ """
174
+
175
+ def __init__(self, config_path: str = "deepdiver/deepdiver.yaml", cdp_url_override: str = None):
176
+ """
177
+ Initialize the NotebookLM automator with configuration.
178
+
179
+ Args:
180
+ config_path: Path to configuration file
181
+ cdp_url_override: Optional CDP URL override (highest priority)
182
+ """
183
+ # Set up logger FIRST so other methods can use it
184
+ self.logger = self._setup_logging()
185
+
186
+ self.config_path = config_path
187
+ self.config = self._load_config(config_path)
188
+ self.browser: Optional[Browser] = None
189
+ self.context: Optional[BrowserContext] = None
190
+ self.page: Optional[Page] = None
191
+
192
+ # NotebookLM specific settings
193
+ self.base_url = self.config.get('NOTEBOOKLM_SETTINGS', {}).get('base_url', 'https://notebooklm.google.com')
194
+
195
+ # Browser settings - Use CDP URL priority chain
196
+ self.cdp_url = get_cdp_url(override=cdp_url_override, config_path=config_path)
197
+ self.user_data_dir = self.config.get('BROWSER_SETTINGS', {}).get('user_data_dir', '/tmp/chrome-deepdiver')
198
+ self.headless = self.config.get('BROWSER_SETTINGS', {}).get('headless', False)
199
+
200
+ # General timeout from browser settings (in seconds), converted to ms
201
+ self.timeout = self.config.get('BROWSER_SETTINGS', {}).get('timeout', 30) * 1000
202
+
203
+ self.logger.info("β™ οΈπŸŒΏπŸŽΈπŸ§΅ NotebookLMAutomator initialized")
204
+ self.logger.info(f"πŸ”— CDP URL: {self.cdp_url}")
205
+
206
+ def _load_config(self, config_path: str) -> Dict[str, Any]:
207
+ """Load configuration from YAML file."""
208
+ try:
209
+ with open(config_path, 'r') as f:
210
+ return yaml.safe_load(f)
211
+ except FileNotFoundError:
212
+ self.logger.warning(f"Configuration file {config_path} not found, using defaults")
213
+ return {}
214
+ except yaml.YAMLError as e:
215
+ self.logger.error(f"Error parsing configuration: {e}")
216
+ return {}
217
+
218
+ def _setup_logging(self) -> logging.Logger:
219
+ """Set up logging configuration."""
220
+ logger = logging.getLogger('NotebookLMAutomator')
221
+ logger.setLevel(logging.INFO)
222
+
223
+ if not logger.handlers:
224
+ handler = logging.StreamHandler()
225
+ formatter = logging.Formatter(
226
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
227
+ )
228
+ handler.setFormatter(formatter)
229
+ logger.addHandler(handler)
230
+
231
+ return logger
232
+
233
+ async def connect_to_browser(self) -> bool:
234
+ """
235
+ Connect to existing Chrome browser via Chrome DevTools Protocol.
236
+
237
+ Returns:
238
+ bool: True if connection successful, False otherwise
239
+ """
240
+ try:
241
+ self.logger.info("πŸ”— Connecting to Chrome browser via CDP...")
242
+
243
+ playwright = await async_playwright().start()
244
+
245
+ # Connect to existing browser
246
+ self.browser = await playwright.chromium.connect_over_cdp(self.cdp_url)
247
+
248
+ # Get the first available context
249
+ contexts = self.browser.contexts
250
+ if contexts:
251
+ self.context = contexts[0]
252
+ else:
253
+ self.context = await self.browser.new_context()
254
+
255
+ # Get the first available page or create new one
256
+ pages = self.context.pages
257
+ if pages:
258
+ self.page = pages[0]
259
+ else:
260
+ self.page = await self.context.new_page()
261
+
262
+ self.logger.info("βœ… Successfully connected to Chrome browser")
263
+ return True
264
+
265
+ except Exception as e:
266
+ self.logger.error(f"❌ Failed to connect to browser: {e}")
267
+ return False
268
+
269
+ async def navigate_to_notebooklm(self) -> bool:
270
+ """
271
+ Navigate to NotebookLM and verify the page loaded correctly.
272
+
273
+ Returns:
274
+ bool: True if navigation successful, False otherwise
275
+ """
276
+ try:
277
+ if not self.page:
278
+ self.logger.error("❌ No browser page available")
279
+ return False
280
+
281
+ self.logger.info(f"🌐 Navigating to {self.base_url}...")
282
+ # Use a longer timeout for navigation, and wait for a specific element
283
+ navigation_timeout = self.config.get('NOTEBOOKLM_SETTINGS', {}).get('login_timeout', 60) * 1000
284
+
285
+ await self.page.goto(self.base_url, timeout=navigation_timeout)
286
+
287
+ # Wait for a selector that indicates the main interface is loaded
288
+ ready_selector = 'button[aria-label="Create new notebook"]';
289
+ await self.page.wait_for_selector(ready_selector, timeout=navigation_timeout)
290
+
291
+ # Check if we're on the correct page
292
+ current_url = self.page.url
293
+ if 'notebooklm.google.com' in current_url:
294
+ self.logger.info("βœ… Successfully navigated to NotebookLM")
295
+ return True
296
+ else:
297
+ self.logger.warning(f"⚠️ Unexpected URL: {current_url}")
298
+ return False
299
+
300
+ except Exception as e:
301
+ self.logger.error(f"❌ Failed to navigate to NotebookLM: {e}")
302
+ if self.page:
303
+ screenshot_path = "failed_navigation_screenshot.png"
304
+ await self.page.screenshot(path=screenshot_path)
305
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
306
+ return False
307
+
308
+ async def check_authentication(self) -> bool:
309
+ """
310
+ Check if user is authenticated with Google account.
311
+
312
+ Returns:
313
+ bool: True if authenticated, False otherwise
314
+ """
315
+ try:
316
+ if not self.page:
317
+ return False
318
+
319
+ # Look for user profile indicators first, as they are a stronger signal
320
+ profile_indicators = [
321
+ 'button[aria-label*="Google Account"]', # More specific
322
+ 'button[data-testid="user-menu"]',
323
+ '.user-avatar',
324
+ '[data-cy="user-menu"]'
325
+ ]
326
+
327
+ for selector in profile_indicators:
328
+ try:
329
+ element = await self.page.wait_for_selector(selector, timeout=5000)
330
+ if element:
331
+ self.logger.info("βœ… User appears to be authenticated")
332
+ return True
333
+ except:
334
+ continue
335
+
336
+ # If no profile indicators are found, then check for sign-in buttons
337
+ auth_indicators = [
338
+ 'button[data-testid="sign-in"]',
339
+ 'button:has-text("Sign in")'
340
+ # Removed 'a[href*="accounts.google.com"]' as it can be a false positive
341
+ ]
342
+
343
+ for selector in auth_indicators:
344
+ try:
345
+ element = await self.page.wait_for_selector(selector, timeout=5000)
346
+ if element:
347
+ self.logger.warning("⚠️ Authentication required - user not signed in")
348
+ if self.page:
349
+ screenshot_path = "auth_failed_screenshot.png"
350
+ await self.page.screenshot(path=screenshot_path)
351
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
352
+ return False
353
+ except:
354
+ continue
355
+
356
+ self.logger.warning("⚠️ Authentication status unclear, assuming authenticated for now.")
357
+ # If neither profile nor sign-in indicators are found, it's ambiguous.
358
+ # Let's assume the user is logged in and let the next steps fail if they are not.
359
+ # This is better than getting stuck in a loop here.
360
+ return True
361
+
362
+ except Exception as e:
363
+ self.logger.error(f"❌ Error checking authentication: {e}")
364
+ if self.page:
365
+ screenshot_path = "auth_error_screenshot.png"
366
+ await self.page.screenshot(path=screenshot_path)
367
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
368
+ return False
369
+
370
+ async def upload_document(self, file_path: str, notebook_id: str = None) -> Optional[str]:
371
+ """
372
+ Upload a document to NotebookLM.
373
+
374
+ Args:
375
+ file_path (str): Path to the document to upload
376
+ notebook_id (str): Optional. If provided, add source to this existing notebook.
377
+ If None, create a new notebook (legacy behavior).
378
+
379
+ Returns:
380
+ Optional[str]: Notebook ID where document was uploaded, or None if upload failed
381
+ """
382
+ try:
383
+ if not self.page:
384
+ self.logger.error("❌ No browser page available")
385
+ return None
386
+
387
+ if not os.path.exists(file_path):
388
+ self.logger.error(f"❌ File not found: {file_path}")
389
+ return None
390
+
391
+ self.logger.info(f"πŸ“„ Uploading document: {file_path}")
392
+
393
+ current_notebook_id = notebook_id
394
+
395
+ # If notebook_id provided, navigate to that notebook
396
+ if notebook_id:
397
+ self.logger.info(f"πŸ““ Navigating to existing notebook: {notebook_id}")
398
+ if not await self.navigate_to_notebook(notebook_id=notebook_id):
399
+ self.logger.error(f"❌ Failed to navigate to notebook {notebook_id}")
400
+ return None
401
+ else:
402
+ # Check if we are on the main page by looking for "Recent notebooks"
403
+ try:
404
+ recent_notebooks_header = await self.page.is_visible('h2:has-text("Recent notebooks")')
405
+ except:
406
+ recent_notebooks_header = False
407
+
408
+ if recent_notebooks_header:
409
+ try:
410
+ self.logger.info("πŸ““ On main page, creating a new notebook...")
411
+
412
+ # Use create_notebook() to capture notebook ID
413
+ notebook_data = await self.create_notebook()
414
+ if not notebook_data:
415
+ self.logger.error("❌ Failed to create new notebook")
416
+ return None
417
+
418
+ current_notebook_id = notebook_data['id']
419
+ self.logger.info(f"βœ… New notebook created with ID: {current_notebook_id}")
420
+
421
+ except Exception as e:
422
+ self.logger.error(f"❌ Failed to create a new notebook: {e}")
423
+ if self.page:
424
+ await self.page.screenshot(path="create_notebook_failed.png")
425
+ self.logger.info("πŸ“Έ Screenshot saved to create_notebook_failed.png")
426
+ return None
427
+ else:
428
+ # Already in a notebook, try to extract ID from URL
429
+ current_url = self.page.url
430
+ if '/notebook/' in current_url:
431
+ parts = current_url.split('/notebook/')
432
+ if len(parts) > 1:
433
+ current_notebook_id = parts[1].split('?')[0].split('#')[0].split('/')[0]
434
+ self.logger.info(f"πŸ““ Already in notebook: {current_notebook_id}")
435
+ else:
436
+ self.logger.warning("⚠️ Not on main page and not in a notebook, URL: {current_url}")
437
+
438
+ # ═══════════════════════════════════════════════════════════════
439
+ # SOURCES TAB NAVIGATION
440
+ # ♠️ Jerry: After first upload, NotebookLM switches to Chat tab
441
+ # We need to navigate back to Sources tab to find upload button
442
+ # ═══════════════════════════════════════════════════════════════
443
+
444
+ # Find Sources tab
445
+ sources_tab_selector = 'div[role="tab"]:has-text("Sources")'
446
+ try:
447
+ sources_tab = await self.page.wait_for_selector(sources_tab_selector, timeout=5000)
448
+ if sources_tab:
449
+ # Check if already active
450
+ is_active = await sources_tab.get_attribute('aria-selected')
451
+ if is_active != 'true':
452
+ self.logger.info("πŸ“‘ Switching to Sources tab...")
453
+ await sources_tab.click()
454
+ await self.page.wait_for_timeout(500) # Wait for tab switch animation
455
+ self.logger.info("βœ… On Sources tab")
456
+ else:
457
+ self.logger.info("βœ… Already on Sources tab")
458
+ except Exception as e:
459
+ self.logger.warning(f"⚠️ Could not find Sources tab: {e}")
460
+ # Continue anyway - might already be on Sources tab
461
+
462
+ # Check if notebook already has sources - if so, click "+ Add" button first
463
+ # ♠️ Jerry: When sources exist, need to click Add button to show upload options
464
+ add_button_selectors = [
465
+ 'button.add-source-button', # Specific class
466
+ 'button[aria-label="Add source"]', # Exact aria-label
467
+ 'button[mattooltip="Add source"]', # Mat tooltip
468
+ 'button[mat-stroked-button]:has-text("Add")', # Mat stroked button with Add text
469
+ 'button:has-text("Add")', # Fallback
470
+ ]
471
+
472
+ for selector in add_button_selectors:
473
+ try:
474
+ add_button = await self.page.wait_for_selector(selector, timeout=2000)
475
+ if add_button:
476
+ is_visible = await add_button.is_visible()
477
+ if is_visible:
478
+ self.logger.info("βž• Clicking Add button to show source options...")
479
+ await add_button.click()
480
+ await self.page.wait_for_timeout(1000)
481
+ break
482
+ except:
483
+ continue
484
+
485
+ # Now we should be inside a notebook on Sources tab, look for the upload button.
486
+ # ♠️ Nyro: Real NotebookLM upload button selector from Jerry ⚑
487
+ upload_selectors = [
488
+ 'button[xapscottyuploadertrigger]', # Primary upload trigger
489
+ 'button[aria-label="Upload sources from your computer"]', # Upload button aria label
490
+ 'mat-card.create-new-action-button', # Legacy selector
491
+ 'button:has-text("Upload sources")', # Upload dialog button
492
+ 'button:has-text("Add source")', # Alternative text
493
+ 'mat-chip:has-text("Upload")', # Upload chip after Add button
494
+ 'input[type="file"]', # Direct file input
495
+ ]
496
+
497
+ upload_element = None
498
+ for selector in upload_selectors:
499
+ try:
500
+ # Use a longer timeout for finding the upload element
501
+ element = await self.page.wait_for_selector(selector, timeout=30000)
502
+ if element:
503
+ upload_element = element
504
+ break
505
+ except:
506
+ continue
507
+
508
+ if not upload_element:
509
+ self.logger.error("❌ Could not find upload element")
510
+ if self.page:
511
+ await self.page.screenshot(path="upload_element_not_found.png")
512
+ self.logger.info("πŸ“Έ Screenshot saved to upload_element_not_found.png")
513
+ return False
514
+
515
+ self.logger.info(f"Found upload element: {upload_element}")
516
+ upload_element_tag_name = await upload_element.evaluate('el => el.tagName')
517
+ self.logger.info(f"Upload element tag name: {upload_element_tag_name}")
518
+
519
+ # Handle file input
520
+ if upload_element_tag_name == 'INPUT':
521
+ # Direct file input element
522
+ await upload_element.set_input_files(file_path)
523
+ else:
524
+ # Click upload button to activate file dialog
525
+ await upload_element.click()
526
+ await self.page.wait_for_timeout(1000)
527
+
528
+ # Find hidden file input (NotebookLM uses hidden input with aria-hidden="true")
529
+ # Don't wait for visibility - set files directly on hidden input
530
+ file_input_selectors = [
531
+ 'input[type="file"][name="Filedata"]', # NotebookLM specific
532
+ 'input[type="file"]', # Generic fallback
533
+ ]
534
+
535
+ file_input = None
536
+ for selector in file_input_selectors:
537
+ try:
538
+ # Use query_selector to get element even if hidden
539
+ file_input = await self.page.query_selector(selector)
540
+ if file_input:
541
+ self.logger.info(f"βœ… Found file input: {selector}")
542
+ break
543
+ except:
544
+ continue
545
+
546
+ if file_input:
547
+ # Set files on hidden input directly
548
+ await file_input.set_input_files(file_path)
549
+ else:
550
+ self.logger.error("❌ Could not find file input element")
551
+ return None
552
+
553
+ # Wait for upload to complete
554
+ await self.page.wait_for_timeout(5000)
555
+
556
+ self.logger.info("βœ… Document upload completed")
557
+ self.logger.info(f"πŸ“‹ Uploaded to notebook: {current_notebook_id}")
558
+ return current_notebook_id
559
+
560
+ except Exception as e:
561
+ self.logger.error(f"❌ Failed to upload document: {e}")
562
+ return None
563
+
564
+ async def add_url_source(self, url: str, notebook_id: str = None) -> Optional[str]:
565
+ """
566
+ Add a URL source (website, YouTube, etc.) to NotebookLM.
567
+
568
+ Args:
569
+ url (str): URL to add as source (SimExp session, website, YouTube, etc.)
570
+ notebook_id (str): Optional. If provided, add source to this existing notebook.
571
+ If None, create a new notebook.
572
+
573
+ Returns:
574
+ Optional[str]: Notebook ID where URL was added, or None if add failed
575
+ """
576
+ try:
577
+ if not self.page:
578
+ self.logger.error("❌ No browser page available")
579
+ return None
580
+
581
+ self.logger.info(f"πŸ”— Adding URL source: {url}")
582
+
583
+ current_notebook_id = notebook_id
584
+
585
+ # Navigate to notebook or create new one (same logic as file upload)
586
+ if notebook_id:
587
+ self.logger.info(f"πŸ““ Navigating to existing notebook: {notebook_id}")
588
+ if not await self.navigate_to_notebook(notebook_id=notebook_id):
589
+ self.logger.error(f"❌ Failed to navigate to notebook {notebook_id}")
590
+ return None
591
+ else:
592
+ # Check if on main page or in notebook
593
+ try:
594
+ recent_notebooks_header = await self.page.is_visible('h2:has-text("Recent notebooks")')
595
+ except:
596
+ recent_notebooks_header = False
597
+
598
+ if recent_notebooks_header:
599
+ # Create new notebook
600
+ notebook_data = await self.create_notebook()
601
+ if not notebook_data:
602
+ self.logger.error("❌ Failed to create new notebook")
603
+ return None
604
+ current_notebook_id = notebook_data['id']
605
+ else:
606
+ # Extract ID from current URL
607
+ current_url = self.page.url
608
+ if '/notebook/' in current_url:
609
+ parts = current_url.split('/notebook/')
610
+ if len(parts) > 1:
611
+ current_notebook_id = parts[1].split('?')[0].split('#')[0].split('/')[0]
612
+
613
+ # Check if "Add sources" dialog is already open (happens with new notebooks)
614
+ # If so, we can skip navigating to Sources tab
615
+ dialog_already_open = False
616
+ try:
617
+ existing_dialog = await self.page.query_selector('.cdk-overlay-pane')
618
+ if existing_dialog:
619
+ is_visible = await existing_dialog.is_visible()
620
+ if is_visible:
621
+ self.logger.info("βœ… Add sources dialog already open, skipping Sources tab navigation")
622
+ dialog_already_open = True
623
+ except:
624
+ pass
625
+
626
+ # Navigate to Sources tab only if dialog is not already open
627
+ if not dialog_already_open:
628
+ sources_tab_selector = 'div[role="tab"]:has-text("Sources")'
629
+ try:
630
+ sources_tab = await self.page.wait_for_selector(sources_tab_selector, timeout=5000)
631
+ if sources_tab:
632
+ is_active = await sources_tab.get_attribute('aria-selected')
633
+ if is_active != 'true':
634
+ self.logger.info("πŸ“‘ Switching to Sources tab...")
635
+ await sources_tab.click()
636
+ await self.page.wait_for_timeout(500)
637
+ except Exception as e:
638
+ self.logger.warning(f"⚠️ Could not find Sources tab: {e}")
639
+
640
+ # Check if notebook already has sources - if so, click "+ Add" button first
641
+ # ♠️ Jerry: When sources exist, need to click Add button to show upload options
642
+ add_button_selectors = [
643
+ 'button:has-text("Add")',
644
+ 'button[aria-label*="Add"]',
645
+ 'button:has-text("+ Add")'
646
+ ]
647
+
648
+ for selector in add_button_selectors:
649
+ try:
650
+ add_button = await self.page.wait_for_selector(selector, timeout=2000)
651
+ if add_button:
652
+ is_visible = await add_button.is_visible()
653
+ if is_visible:
654
+ self.logger.info("βž• Clicking Add button to show source options...")
655
+ await add_button.click()
656
+ await self.page.wait_for_timeout(1000)
657
+ break
658
+ except:
659
+ continue
660
+
661
+ # Detect URL type and select appropriate chip
662
+ # ♠️ Jerry: YouTube URLs need YouTube chip, others need Website chip
663
+ is_youtube = 'youtube.com' in url.lower() or 'youtu.be' in url.lower()
664
+
665
+ if is_youtube:
666
+ chip_type = "YouTube"
667
+ chip_selectors = [
668
+ 'mat-chip:has-text("YouTube")',
669
+ 'button:has-text("YouTube")',
670
+ 'mat-chip:has(mat-icon:has-text("video_youtube"))',
671
+ '[aria-label*="YouTube"]'
672
+ ]
673
+ else:
674
+ chip_type = "Website"
675
+ chip_selectors = [
676
+ 'mat-chip:has-text("Website")',
677
+ 'button:has-text("Website")',
678
+ '[aria-label*="Website"]'
679
+ ]
680
+
681
+ self.logger.info(f"πŸ” Looking for {chip_type} chip...")
682
+ source_chip = None
683
+ for selector in chip_selectors:
684
+ try:
685
+ element = await self.page.wait_for_selector(selector, timeout=5000)
686
+ if element:
687
+ source_chip = element
688
+ self.logger.info(f"βœ… Found {chip_type} chip: {selector}")
689
+ break
690
+ except:
691
+ continue
692
+
693
+ if not source_chip:
694
+ self.logger.error(f"❌ Could not find {chip_type} chip")
695
+ return None
696
+
697
+ # Click the appropriate chip
698
+ self.logger.info(f"πŸ–±οΈ Clicking {chip_type} chip...")
699
+ await source_chip.click()
700
+
701
+ # Wait for dialog/modal to appear
702
+ self.logger.info("⏳ Waiting for URL dialog to appear...")
703
+ dialog_appeared = False
704
+ dialog_selectors = [
705
+ 'div[role="dialog"]',
706
+ '.cdk-overlay-pane',
707
+ '.mat-dialog-container',
708
+ 'mat-dialog-container'
709
+ ]
710
+
711
+ dialog = None
712
+ for selector in dialog_selectors:
713
+ try:
714
+ dialog = await self.page.wait_for_selector(selector, timeout=10000, state='visible')
715
+ if dialog:
716
+ dialog_appeared = True
717
+ self.logger.info(f"βœ… Dialog appeared: {selector}")
718
+ break
719
+ except:
720
+ continue
721
+
722
+ if not dialog_appeared:
723
+ self.logger.warning("⚠️ Dialog did not appear, will try to find input anyway...")
724
+ await self.page.wait_for_timeout(3000) # Wait a bit more
725
+
726
+ # Find URL input field - search more broadly including within dialog
727
+ self.logger.info("πŸ” Looking for URL input field...")
728
+ url_input_selectors = [
729
+ # NotebookLM uses a textarea for URL input!
730
+ '.cdk-overlay-pane textarea',
731
+ 'div[role="dialog"] textarea',
732
+ 'textarea[formcontrolname="newUrl"]',
733
+ 'textarea#mat-input-0',
734
+ 'textarea.text-area',
735
+ # Fallback to inputs
736
+ '.cdk-overlay-pane input',
737
+ 'div[role="dialog"] input',
738
+ '.mat-dialog-container input',
739
+ # Then try specific attributes
740
+ 'input[placeholder*="URL"]',
741
+ 'textarea[placeholder*="URL"]',
742
+ 'input[placeholder*="paste"]',
743
+ 'textarea[placeholder*="paste"]',
744
+ ]
745
+
746
+ url_input = None
747
+ for selector in url_input_selectors:
748
+ try:
749
+ elements = await self.page.query_selector_all(selector)
750
+ for element in elements:
751
+ try:
752
+ is_visible = await element.is_visible()
753
+ if is_visible:
754
+ # Check if it's in the dialog if we found one
755
+ if dialog_appeared and dialog:
756
+ # Try to see if this element is within the dialog
757
+ try:
758
+ bounding_box = await element.bounding_box()
759
+ if bounding_box:
760
+ url_input = element
761
+ self.logger.info(f"βœ… Found URL input in dialog: {selector}")
762
+ break
763
+ except:
764
+ pass
765
+ else:
766
+ # No dialog, just use first visible
767
+ url_input = element
768
+ self.logger.info(f"βœ… Found URL input: {selector}")
769
+ break
770
+ except:
771
+ continue
772
+ if url_input:
773
+ break
774
+ except:
775
+ continue
776
+
777
+ if not url_input:
778
+ self.logger.error("❌ Could not find URL input field")
779
+ # Save screenshot for debugging
780
+ await self.page.screenshot(path="debug/url_input_not_found.png")
781
+ self.logger.info("πŸ“Έ Screenshot saved: debug/url_input_not_found.png")
782
+ return None
783
+
784
+ # Enter URL
785
+ self.logger.info(f"⌨️ Typing URL: {url}")
786
+ await url_input.click()
787
+ await self.page.wait_for_timeout(500)
788
+ await url_input.fill(url) # Use fill instead of keyboard.type - it's faster and more reliable
789
+ await self.page.wait_for_timeout(1000)
790
+
791
+ # Click Insert button instead of pressing Enter
792
+ self.logger.info("πŸ” Looking for Insert button...")
793
+ insert_button_selectors = [
794
+ 'button:has-text("Insert")',
795
+ 'button.mdc-button:has-text("Insert")',
796
+ '.cdk-overlay-pane button:has-text("Insert")',
797
+ 'button[type="submit"]',
798
+ 'button:has(.mdc-button__label:has-text("Insert"))'
799
+ ]
800
+
801
+ insert_button = None
802
+ for selector in insert_button_selectors:
803
+ try:
804
+ element = await self.page.wait_for_selector(selector, timeout=5000)
805
+ if element:
806
+ is_visible = await element.is_visible()
807
+ if is_visible:
808
+ insert_button = element
809
+ self.logger.info(f"βœ… Found Insert button: {selector}")
810
+ break
811
+ except:
812
+ continue
813
+
814
+ if insert_button:
815
+ self.logger.info("πŸ–±οΈ Clicking Insert button...")
816
+ await insert_button.click()
817
+ await self.page.wait_for_timeout(5000) # Wait for URL to be processed
818
+ else:
819
+ # Fallback: press Enter if Insert button not found
820
+ self.logger.warning("⚠️ Insert button not found, trying Enter key...")
821
+ await self.page.keyboard.press('Enter')
822
+ await self.page.wait_for_timeout(5000)
823
+
824
+ self.logger.info("βœ… URL source added successfully")
825
+ self.logger.info(f"πŸ“‹ Added to notebook: {current_notebook_id}")
826
+ return current_notebook_id
827
+
828
+ except Exception as e:
829
+ self.logger.error(f"❌ Failed to add URL source: {e}")
830
+ return None
831
+
832
+ async def add_source(self, source: str, notebook_id: str = None) -> Optional[str]:
833
+ """
834
+ Smart source addition - automatically detects source type and routes appropriately.
835
+
836
+ This is the recommended high-level method for adding any source to NotebookLM.
837
+ It intelligently detects whether the source is a URL or file path and calls
838
+ the appropriate underlying method.
839
+
840
+ Args:
841
+ source (str): Source to add - can be:
842
+ - URL (http://..., https://...)
843
+ - File path (relative or absolute)
844
+ notebook_id (str): Optional. If provided, add source to this existing notebook.
845
+ If None, create a new notebook.
846
+
847
+ Returns:
848
+ Optional[str]: Notebook ID where source was added, or None if add failed
849
+
850
+ Examples:
851
+ # Add URL source
852
+ await automator.add_source("https://example.com/article")
853
+
854
+ # Add file source
855
+ await automator.add_source("/path/to/document.pdf")
856
+
857
+ # Add to existing notebook
858
+ await automator.add_source("research.pdf", notebook_id="abc123")
859
+ """
860
+ try:
861
+ # Detect source type by checking for URL prefix
862
+ if source.startswith(('http://', 'https://')):
863
+ self.logger.info(f"πŸ” Detected URL source: {source}")
864
+ return await self.add_url_source(source, notebook_id)
865
+ else:
866
+ self.logger.info(f"πŸ” Detected file source: {source}")
867
+ return await self.upload_document(source, notebook_id)
868
+
869
+ except Exception as e:
870
+ self.logger.error(f"❌ Failed to add source: {e}")
871
+ return None
872
+
873
+ async def generate_audio_overview(self, title: str = "Generated Podcast") -> bool:
874
+ """
875
+ Generate an Audio Overview (podcast) from uploaded documents.
876
+
877
+ Args:
878
+ title (str): Title for the generated podcast
879
+
880
+ Returns:
881
+ bool: True if generation successful, False otherwise
882
+ """
883
+ try:
884
+ if not self.page:
885
+ self.logger.error("❌ No browser page available")
886
+ return False
887
+
888
+ self.logger.info(f"πŸŽ™οΈ Generating Audio Overview: {title}")
889
+
890
+ # Look for Audio Overview button
891
+ audio_selectors = [
892
+ 'button:has-text("Audio Overview")',
893
+ 'button:has-text("Generate Audio")',
894
+ 'button:has-text("Create Podcast")',
895
+ '[data-testid="audio-overview"]',
896
+ '.audio-overview-button'
897
+ ]
898
+
899
+ audio_button = None
900
+ for selector in audio_selectors:
901
+ try:
902
+ element = await self.page.wait_for_selector(selector, timeout=10000)
903
+ if element:
904
+ audio_button = element
905
+ break
906
+ except:
907
+ continue
908
+
909
+ if not audio_button:
910
+ self.logger.error("❌ Could not find Audio Overview button")
911
+ return False
912
+
913
+ # Click to start generation
914
+ await audio_button.click()
915
+ self.logger.info("πŸ”„ Audio Overview generation started...")
916
+
917
+ # Wait for generation to complete
918
+ # This timeout should be adjusted based on actual generation time
919
+ generation_timeout = self.config.get('NOTEBOOKLM_SETTINGS', {}).get('generation_timeout', 300000)
920
+ await self.page.wait_for_timeout(generation_timeout)
921
+
922
+ self.logger.info("βœ… Audio Overview generation completed")
923
+ return True
924
+
925
+ except Exception as e:
926
+ self.logger.error(f"❌ Failed to generate Audio Overview: {e}")
927
+ return False
928
+
929
+ async def download_audio(self, output_path: str) -> bool:
930
+ """
931
+ Download the generated audio file.
932
+
933
+ Args:
934
+ output_path (str): Path where to save the audio file
935
+
936
+ Returns:
937
+ bool: True if download successful, False otherwise
938
+ """
939
+ try:
940
+ if not self.page:
941
+ self.logger.error("❌ No browser page available")
942
+ return False
943
+
944
+ self.logger.info(f"⬇️ Downloading audio to: {output_path}")
945
+
946
+ # Look for download button
947
+ download_selectors = [
948
+ 'button:has-text("Download")',
949
+ 'button:has-text("Save")',
950
+ 'a[download]',
951
+ '[data-testid="download-button"]',
952
+ '.download-button'
953
+ ]
954
+
955
+ download_button = None
956
+ for selector in download_selectors:
957
+ try:
958
+ element = await self.page.wait_for_selector(selector, timeout=10000)
959
+ if element:
960
+ download_button = element
961
+ break
962
+ except:
963
+ continue
964
+
965
+ if not download_button:
966
+ self.logger.error("❌ Could not find download button")
967
+ return False
968
+
969
+ # Set up download handling
970
+ async with self.page.expect_download() as download_info:
971
+ await download_button.click()
972
+
973
+ download = await download_info.value
974
+ await download.save_as(output_path)
975
+
976
+ self.logger.info("βœ… Audio download completed")
977
+ return True
978
+
979
+ except Exception as e:
980
+ self.logger.error(f"❌ Failed to download audio: {e}")
981
+ return False
982
+
983
+ async def create_notebook(self) -> Optional[Dict[str, Any]]:
984
+ """
985
+ Create a new notebook and capture its identity.
986
+
987
+ Returns:
988
+ Optional[Dict[str, Any]]: Notebook metadata including id, url, and created_at
989
+ Returns None if creation fails
990
+ """
991
+ try:
992
+ if not self.page:
993
+ self.logger.error("❌ No browser page available")
994
+ return None
995
+
996
+ # Store the current URL to detect navigation
997
+ initial_url = self.page.url
998
+
999
+ # Multi-selector strategy for create button
1000
+ create_selectors = [
1001
+ 'button[aria-label="Create new notebook"]',
1002
+ 'button:has-text("Create new notebook")',
1003
+ 'button:has-text("New notebook")',
1004
+ '[data-testid="create-notebook"]',
1005
+ 'button.create-notebook-btn'
1006
+ ]
1007
+
1008
+ create_button = None
1009
+ for selector in create_selectors:
1010
+ try:
1011
+ element = await self.page.wait_for_selector(selector, timeout=5000)
1012
+ if element:
1013
+ create_button = element
1014
+ self.logger.info(f"βœ… Found create button: {selector}")
1015
+ break
1016
+ except:
1017
+ continue
1018
+
1019
+ if not create_button:
1020
+ self.logger.error("❌ Could not find 'Create new notebook' button")
1021
+ screenshot_path = "debug/create_button_not_found.png"
1022
+ await self.page.screenshot(path=screenshot_path)
1023
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1024
+ return None
1025
+
1026
+ self.logger.info("πŸ““ Creating new notebook...")
1027
+ await create_button.click()
1028
+
1029
+ # Wait for navigation and page load (using load instead of networkidle for better reliability)
1030
+ try:
1031
+ await self.page.wait_for_load_state('load', timeout=15000)
1032
+ except:
1033
+ # If load state times out, continue anyway - the navigation might still have worked
1034
+ self.logger.warning("⚠️ Load state timeout, but continuing...")
1035
+ pass
1036
+
1037
+ # Get the new URL
1038
+ new_url = self.page.url
1039
+
1040
+ # Verify we navigated away from the initial URL
1041
+ if new_url == initial_url:
1042
+ self.logger.warning("⚠️ URL did not change after clicking create button")
1043
+ # Wait a bit more and try again
1044
+ await asyncio.sleep(2)
1045
+ new_url = self.page.url
1046
+
1047
+ # Extract notebook ID from URL
1048
+ # Expected format: https://notebooklm.google.com/notebook/{notebook_id}
1049
+ notebook_id = None
1050
+ if '/notebook/' in new_url:
1051
+ parts = new_url.split('/notebook/')
1052
+ if len(parts) > 1:
1053
+ # Get the ID (might have query params, so split on ? first)
1054
+ notebook_id = parts[1].split('?')[0].split('#')[0]
1055
+
1056
+ if not notebook_id:
1057
+ self.logger.warning("⚠️ Could not extract notebook ID from URL")
1058
+ self.logger.info(f"Current URL: {new_url}")
1059
+ # Try alternative extraction methods
1060
+ # Some URLs might be like: /notebook/abc123/sources or /notebook/abc123/overview
1061
+ if '/notebook/' in new_url:
1062
+ path_parts = new_url.split('/')
1063
+ notebook_idx = path_parts.index('notebook')
1064
+ if len(path_parts) > notebook_idx + 1:
1065
+ notebook_id = path_parts[notebook_idx + 1]
1066
+
1067
+ # Wait for notebook UI to be ready
1068
+ try:
1069
+ await self.page.wait_for_selector('mat-card.create-new-action-button', timeout=15000)
1070
+ self.logger.info("βœ… Notebook UI loaded successfully")
1071
+ except:
1072
+ self.logger.warning("⚠️ Notebook UI selector not found, but continuing...")
1073
+
1074
+ # Create metadata object
1075
+ from datetime import datetime
1076
+ notebook_data = {
1077
+ 'id': notebook_id or 'unknown',
1078
+ 'url': new_url,
1079
+ 'created_at': datetime.now().isoformat(),
1080
+ 'title': 'Untitled Notebook', # Can be updated later
1081
+ 'sources': [],
1082
+ 'active': True
1083
+ }
1084
+
1085
+ self.logger.info(f"βœ… Notebook created successfully!")
1086
+ self.logger.info(f"πŸ“‹ Notebook ID: {notebook_data['id']}")
1087
+ self.logger.info(f"πŸ”— Notebook URL: {notebook_data['url']}")
1088
+
1089
+ return notebook_data
1090
+
1091
+ except Exception as e:
1092
+ self.logger.error(f"❌ Failed to create notebook: {e}")
1093
+ if self.page:
1094
+ try:
1095
+ screenshot_path = "debug/create_notebook_error.png"
1096
+ await self.page.screenshot(path=screenshot_path, timeout=5000)
1097
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1098
+ except:
1099
+ self.logger.warning("⚠️ Could not save screenshot")
1100
+ return None
1101
+
1102
+ async def navigate_to_notebook(self, notebook_id: str = None, notebook_url: str = None) -> bool:
1103
+ """
1104
+ Navigate to an existing notebook by ID or URL.
1105
+
1106
+ Args:
1107
+ notebook_id (str): The notebook ID to navigate to
1108
+ notebook_url (str): The full notebook URL (alternative to notebook_id)
1109
+
1110
+ Returns:
1111
+ bool: True if navigation successful, False otherwise
1112
+ """
1113
+ try:
1114
+ if not self.page:
1115
+ self.logger.error("❌ No browser page available")
1116
+ return False
1117
+
1118
+ # Construct URL if only ID is provided
1119
+ target_url = notebook_url
1120
+ if not target_url and notebook_id:
1121
+ target_url = f"{self.base_url}/notebook/{notebook_id}"
1122
+
1123
+ if not target_url:
1124
+ self.logger.error("❌ Must provide either notebook_id or notebook_url")
1125
+ return False
1126
+
1127
+ self.logger.info(f"πŸ”„ Navigating to notebook: {target_url}")
1128
+
1129
+ # Navigate to the notebook URL
1130
+ await self.page.goto(target_url, timeout=30000)
1131
+
1132
+ # Wait for load state (use 'load' instead of 'networkidle' - networkidle can hang with background polling)
1133
+ try:
1134
+ await self.page.wait_for_load_state('load', timeout=10000)
1135
+ except:
1136
+ # If load state times out, continue anyway - URL check below will verify
1137
+ self.logger.warning("⚠️ Load state timeout, but continuing with URL verification...")
1138
+
1139
+ # Verify notebook loaded successfully
1140
+ # First check if URL contains /notebook/ - most reliable indicator
1141
+ current_url = self.page.url
1142
+ if '/notebook/' in current_url:
1143
+ self.logger.info(f"βœ… Notebook URL verified: {current_url}")
1144
+ self.logger.info(f"βœ… Successfully navigated to notebook")
1145
+ return True
1146
+
1147
+ # Multi-selector strategy for notebook verification
1148
+ notebook_indicators = [
1149
+ 'mat-card.create-new-action-button', # Sources panel
1150
+ 'h2:has-text("Add sources")', # Add sources dialog header
1151
+ 'div[role="dialog"]', # Any dialog (add sources)
1152
+ 'button:has-text("Audio Overview")', # Audio Overview button
1153
+ '[data-testid="notebook-content"]', # Notebook content area
1154
+ '.notebook-title', # Notebook title
1155
+ 'div.sources-panel' # Sources panel
1156
+ ]
1157
+
1158
+ notebook_loaded = False
1159
+ for selector in notebook_indicators:
1160
+ try:
1161
+ element = await self.page.wait_for_selector(selector, timeout=10000)
1162
+ if element:
1163
+ self.logger.info(f"βœ… Notebook verified: {selector}")
1164
+ notebook_loaded = True
1165
+ break
1166
+ except:
1167
+ continue
1168
+
1169
+ if not notebook_loaded:
1170
+ self.logger.warning("⚠️ Could not verify notebook UI elements")
1171
+ screenshot_path = "debug/notebook_verification_failed.png"
1172
+ await self.page.screenshot(path=screenshot_path)
1173
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1174
+ # Don't fail completely - URL navigation might still have worked
1175
+ return True
1176
+
1177
+ current_url = self.page.url
1178
+ self.logger.info(f"βœ… Successfully navigated to notebook")
1179
+ self.logger.info(f"πŸ”— Current URL: {current_url}")
1180
+
1181
+ return True
1182
+
1183
+ except Exception as e:
1184
+ self.logger.error(f"❌ Failed to navigate to notebook: {e}")
1185
+ if self.page:
1186
+ screenshot_path = "debug/navigate_notebook_error.png"
1187
+ await self.page.screenshot(path=screenshot_path)
1188
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1189
+ return False
1190
+
1191
+ async def share_notebook(self, email: str, role: str = 'editor') -> bool:
1192
+ """
1193
+ Share the current notebook with a collaborator via email.
1194
+
1195
+ Args:
1196
+ email (str): Email address of the person to share with
1197
+ role (str): Role to grant ('editor' or 'viewer')
1198
+
1199
+ Returns:
1200
+ bool: True if sharing successful, False otherwise
1201
+ """
1202
+ try:
1203
+ if not self.page:
1204
+ self.logger.error("❌ No browser page available")
1205
+ return False
1206
+
1207
+ self.logger.info(f"πŸ‘₯ Sharing notebook with {email} as {role}...")
1208
+
1209
+ # Multi-selector strategy for share button
1210
+ share_button_selectors = [
1211
+ 'button[aria-label="Share"]',
1212
+ 'button:has-text("Share")',
1213
+ 'button[title="Share"]',
1214
+ '[data-testid="share-button"]',
1215
+ 'button.share-button'
1216
+ ]
1217
+
1218
+ # Find and click share button
1219
+ share_button = None
1220
+ for selector in share_button_selectors:
1221
+ try:
1222
+ element = await self.page.wait_for_selector(selector, timeout=5000)
1223
+ if element:
1224
+ share_button = element
1225
+ self.logger.info(f"βœ… Found share button: {selector}")
1226
+ break
1227
+ except:
1228
+ continue
1229
+
1230
+ if not share_button:
1231
+ self.logger.error("❌ Could not find share button")
1232
+ screenshot_path = "debug/share_button_not_found.png"
1233
+ await self.page.screenshot(path=screenshot_path, timeout=5000)
1234
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1235
+ return False
1236
+
1237
+ # Click share button
1238
+ await share_button.click()
1239
+ await asyncio.sleep(1)
1240
+
1241
+ # Wait for share dialog to appear
1242
+ dialog_selectors = [
1243
+ 'div[role="dialog"]',
1244
+ '.share-dialog',
1245
+ '[data-testid="share-dialog"]',
1246
+ 'div.modal'
1247
+ ]
1248
+
1249
+ dialog_found = False
1250
+ for selector in dialog_selectors:
1251
+ try:
1252
+ await self.page.wait_for_selector(selector, timeout=5000)
1253
+ dialog_found = True
1254
+ self.logger.info(f"βœ… Share dialog opened: {selector}")
1255
+ break
1256
+ except:
1257
+ continue
1258
+
1259
+ if not dialog_found:
1260
+ self.logger.warning("⚠️ Could not verify share dialog opened")
1261
+
1262
+ # Find email input field
1263
+ email_input_selectors = [
1264
+ 'input[type="email"]',
1265
+ 'input[aria-label*="email"]',
1266
+ 'input[aria-label*="Add people"]',
1267
+ 'input[placeholder*="email"]',
1268
+ 'input.share-email-input'
1269
+ ]
1270
+
1271
+ email_input = None
1272
+ for selector in email_input_selectors:
1273
+ try:
1274
+ element = await self.page.wait_for_selector(selector, timeout=5000)
1275
+ if element:
1276
+ email_input = element
1277
+ self.logger.info(f"βœ… Found email input: {selector}")
1278
+ break
1279
+ except:
1280
+ continue
1281
+
1282
+ if not email_input:
1283
+ self.logger.error("❌ Could not find email input field")
1284
+ screenshot_path = "debug/email_input_not_found.png"
1285
+ await self.page.screenshot(path=screenshot_path, timeout=5000)
1286
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1287
+ return False
1288
+
1289
+ # Type email address
1290
+ await email_input.click()
1291
+ await asyncio.sleep(0.5)
1292
+ await self.page.keyboard.type(email, delay=50)
1293
+ await asyncio.sleep(1)
1294
+
1295
+ # Select role if dropdown available
1296
+ if role != 'editor':
1297
+ role_selectors = [
1298
+ 'select[aria-label*="role"]',
1299
+ 'button[aria-label*="Can edit"]',
1300
+ '.role-selector'
1301
+ ]
1302
+
1303
+ for selector in role_selectors:
1304
+ try:
1305
+ role_element = await self.page.wait_for_selector(selector, timeout=3000)
1306
+ if role_element:
1307
+ await role_element.click()
1308
+ await asyncio.sleep(0.5)
1309
+
1310
+ # Click viewer option
1311
+ viewer_selectors = [
1312
+ 'li:has-text("Can view")',
1313
+ 'button:has-text("Viewer")',
1314
+ '[data-value="viewer"]'
1315
+ ]
1316
+
1317
+ for viewer_sel in viewer_selectors:
1318
+ try:
1319
+ viewer_option = await self.page.wait_for_selector(viewer_sel, timeout=2000)
1320
+ if viewer_option:
1321
+ await viewer_option.click()
1322
+ break
1323
+ except:
1324
+ continue
1325
+ break
1326
+ except:
1327
+ continue
1328
+
1329
+ # Send/Submit invitation
1330
+ send_button_selectors = [
1331
+ 'button:has-text("Send")',
1332
+ 'button:has-text("Share")',
1333
+ 'button:has-text("Invite")',
1334
+ 'button[aria-label="Send"]',
1335
+ 'button[type="submit"]'
1336
+ ]
1337
+
1338
+ send_button = None
1339
+ for selector in send_button_selectors:
1340
+ try:
1341
+ element = await self.page.wait_for_selector(selector, timeout=5000)
1342
+ if element:
1343
+ # Check if button is enabled
1344
+ is_disabled = await element.get_attribute('disabled')
1345
+ if not is_disabled:
1346
+ send_button = element
1347
+ self.logger.info(f"βœ… Found send button: {selector}")
1348
+ break
1349
+ except:
1350
+ continue
1351
+
1352
+ if not send_button:
1353
+ self.logger.error("❌ Could not find send button")
1354
+ # Try pressing Enter as fallback
1355
+ self.logger.info("⚑ Trying Enter key as fallback...")
1356
+ await self.page.keyboard.press('Enter')
1357
+ await asyncio.sleep(2)
1358
+ else:
1359
+ await send_button.click()
1360
+ await asyncio.sleep(2)
1361
+
1362
+ self.logger.info(f"βœ… Notebook shared with {email}")
1363
+ return True
1364
+
1365
+ except Exception as e:
1366
+ self.logger.error(f"❌ Failed to share notebook: {e}")
1367
+ if self.page:
1368
+ try:
1369
+ screenshot_path = "debug/share_error.png"
1370
+ await self.page.screenshot(path=screenshot_path, timeout=5000)
1371
+ self.logger.info(f"πŸ“Έ Screenshot saved to {screenshot_path}")
1372
+ except:
1373
+ pass
1374
+ return False
1375
+
1376
+ async def get_page_content(self) -> Optional[str]:
1377
+ """
1378
+ Get the HTML content of the current page.
1379
+
1380
+ Returns:
1381
+ Optional[str]: The HTML content of the page, or None if an error occurs.
1382
+ """
1383
+ try:
1384
+ if not self.page:
1385
+ self.logger.error("❌ No browser page available")
1386
+ return None
1387
+
1388
+ content = await self.page.content()
1389
+ return content
1390
+
1391
+ except Exception as e:
1392
+ self.logger.error(f"❌ Failed to get page content: {e}")
1393
+ return None
1394
+
1395
+ async def close(self):
1396
+ """Close browser connections and cleanup resources."""
1397
+ try:
1398
+ if self.page:
1399
+ await self.page.close()
1400
+ if self.context:
1401
+ await self.context.close()
1402
+ if self.browser:
1403
+ await self.browser.close()
1404
+
1405
+ self.logger.info("πŸ”’ Browser connections closed")
1406
+ except Exception as e:
1407
+ self.logger.error(f"❌ Error closing browser: {e}")
1408
+
1409
+
1410
+ # Example usage and testing
1411
+ async def test_notebooklm_connection():
1412
+ """Test function to verify NotebookLM automation setup."""
1413
+ automator = NotebookLMAutomator()
1414
+
1415
+ try:
1416
+ # Test browser connection
1417
+ if await automator.connect_to_browser():
1418
+ print("βœ… Browser connection successful")
1419
+
1420
+ # Test navigation
1421
+ if await automator.navigate_to_notebooklm():
1422
+ print("βœ… NotebookLM navigation successful")
1423
+
1424
+ # Test authentication check
1425
+ auth_status = await automator.check_authentication()
1426
+ print(f"πŸ” Authentication status: {'βœ… Authenticated' if auth_status else '❌ Not authenticated'}")
1427
+ else:
1428
+ print("❌ NotebookLM navigation failed")
1429
+ else:
1430
+ print("❌ Browser connection failed")
1431
+
1432
+ except Exception as e:
1433
+ print(f"❌ Test failed: {e}")
1434
+
1435
+ finally:
1436
+ await automator.close()
1437
+
1438
+
1439
+ if __name__ == "__main__":
1440
+ # Run test if executed directly
1441
+ asyncio.run(test_notebooklm_connection())