@roomi-fields/notebooklm-mcp 1.5.4 → 1.5.8

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.
Files changed (133) hide show
  1. package/README.md +2 -2
  2. package/deployment/docs/01-INSTALL.md +21 -1
  3. package/deployment/docs/03-API.md +16 -10
  4. package/deployment/docs/05-TROUBLESHOOTING.md +19 -1
  5. package/deployment/docs/06-NOTEBOOK-LIBRARY.md +9 -9
  6. package/deployment/docs/07-AUTO-DISCOVERY.md +6 -15
  7. package/deployment/docs/08-WSL-USAGE.md +7 -7
  8. package/deployment/docs/09-MULTI-INTERFACE.md +6 -6
  9. package/deployment/docs/11-MULTI-ACCOUNT.md +1 -1
  10. package/dist/accounts/account-manager.d.ts +15 -0
  11. package/dist/accounts/account-manager.d.ts.map +1 -1
  12. package/dist/accounts/account-manager.js +104 -0
  13. package/dist/accounts/account-manager.js.map +1 -1
  14. package/dist/accounts/auto-login-manager.d.ts.map +1 -1
  15. package/dist/accounts/auto-login-manager.js +29 -25
  16. package/dist/accounts/auto-login-manager.js.map +1 -1
  17. package/dist/auth/auth-manager.d.ts +1 -1
  18. package/dist/auth/auth-manager.d.ts.map +1 -1
  19. package/dist/auth/auth-manager.js +14 -9
  20. package/dist/auth/auth-manager.js.map +1 -1
  21. package/dist/cli/help.js +5 -0
  22. package/dist/cli/help.js.map +1 -1
  23. package/dist/cli/setup-auth.js +18 -10
  24. package/dist/cli/setup-auth.js.map +1 -1
  25. package/dist/content/content-manager.d.ts +8 -0
  26. package/dist/content/content-manager.d.ts.map +1 -1
  27. package/dist/content/content-manager.js +411 -67
  28. package/dist/content/content-manager.js.map +1 -1
  29. package/dist/http-wrapper.d.ts.map +1 -1
  30. package/dist/http-wrapper.js +148 -3
  31. package/dist/http-wrapper.js.map +1 -1
  32. package/dist/session/browser-session.d.ts +12 -0
  33. package/dist/session/browser-session.d.ts.map +1 -1
  34. package/dist/session/browser-session.js +313 -65
  35. package/dist/session/browser-session.js.map +1 -1
  36. package/dist/session/shared-context-manager.d.ts.map +1 -1
  37. package/dist/session/shared-context-manager.js +22 -2
  38. package/dist/session/shared-context-manager.js.map +1 -1
  39. package/dist/startup/startup-manager.d.ts +8 -0
  40. package/dist/startup/startup-manager.d.ts.map +1 -1
  41. package/dist/startup/startup-manager.js +176 -28
  42. package/dist/startup/startup-manager.js.map +1 -1
  43. package/dist/stdio-http-proxy.js +91 -16
  44. package/dist/stdio-http-proxy.js.map +1 -1
  45. package/dist/tools/index.d.ts +1 -0
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +52 -32
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/utils/citation-extractor.d.ts +6 -7
  50. package/dist/utils/citation-extractor.d.ts.map +1 -1
  51. package/dist/utils/citation-extractor.js +138 -329
  52. package/dist/utils/citation-extractor.js.map +1 -1
  53. package/dist/utils/page-utils.d.ts +5 -0
  54. package/dist/utils/page-utils.d.ts.map +1 -1
  55. package/dist/utils/page-utils.js +73 -16
  56. package/dist/utils/page-utils.js.map +1 -1
  57. package/package.json +6 -2
  58. package/scripts/archive/add-and-activate-notebook.ps1 +4 -4
  59. package/scripts/archive/add-new-notebook.ps1 +4 -4
  60. package/scripts/archive/add-rom1pey.ps1 +2 -2
  61. package/scripts/archive/add-rpmonster.ps1 +2 -2
  62. package/scripts/archive/add-source-debug.ps1 +1 -1
  63. package/scripts/archive/add-source-e2e.ps1 +1 -1
  64. package/scripts/archive/add-source-visible.ps1 +1 -1
  65. package/scripts/archive/add-test-notebook.ps1 +1 -1
  66. package/scripts/archive/add-test-source.ps1 +1 -1
  67. package/scripts/archive/capture-screen.ps1 +1 -1
  68. package/scripts/archive/change-language.mjs +4 -3
  69. package/scripts/archive/change-language.ts +5 -3
  70. package/scripts/archive/create-notebook.ps1 +2 -2
  71. package/scripts/archive/create-rom1pey-notebook.ps1 +2 -2
  72. package/scripts/archive/create-rom1pey.ps1 +2 -2
  73. package/scripts/archive/debug-add-text-source.ps1 +4 -4
  74. package/scripts/archive/debug-selectors.ps1 +1 -1
  75. package/scripts/archive/discover-home.ps1 +2 -2
  76. package/scripts/archive/navigate-home-visible.ps1 +1 -1
  77. package/scripts/archive/navigate-home.ps1 +1 -1
  78. package/scripts/archive/run-e2e-english.ps1 +3 -3
  79. package/scripts/archive/run-e2e-rom1pey-v2.ps1 +4 -4
  80. package/scripts/archive/run-e2e-rom1pey.ps1 +4 -4
  81. package/scripts/archive/setup-english-test.ps1 +6 -6
  82. package/scripts/archive/setup-test-notebook.ps1 +1 -1
  83. package/scripts/archive/simple-add-source.ps1 +1 -1
  84. package/scripts/archive/t10.ps1 +1 -1
  85. package/scripts/archive/t20.ps1 +1 -1
  86. package/scripts/archive/t30.ps1 +1 -1
  87. package/scripts/archive/t31.ps1 +1 -1
  88. package/scripts/archive/t32.ps1 +1 -1
  89. package/scripts/archive/t53.ps1 +1 -1
  90. package/scripts/archive/test-access.ps1 +1 -1
  91. package/scripts/archive/test-add-delete-source.ps1 +4 -4
  92. package/scripts/archive/test-add-source-visible.ps1 +1 -1
  93. package/scripts/archive/test-add-source.ps1 +1 -1
  94. package/scripts/archive/test-add-text-debug.ps1 +2 -2
  95. package/scripts/archive/test-ask-headed.ps1 +1 -1
  96. package/scripts/archive/test-delete-source.ps1 +4 -4
  97. package/scripts/archive/test-e2e-notebook.ps1 +2 -2
  98. package/scripts/archive/test-english-notebook.ps1 +4 -4
  99. package/scripts/archive/test-english.ps1 +1 -1
  100. package/scripts/archive/test-full-custom-instructions.ps1 +1 -1
  101. package/scripts/archive/test-full-infographic.ps1 +1 -1
  102. package/scripts/archive/test-full-language.ps1 +1 -1
  103. package/scripts/archive/test-full-presentation.ps1 +1 -1
  104. package/scripts/archive/test-full-report.ps1 +1 -1
  105. package/scripts/archive/test-full-source-selection.ps1 +1 -1
  106. package/scripts/archive/test-full-video-brief.ps1 +1 -1
  107. package/scripts/archive/test-full-video-explainer.ps1 +1 -1
  108. package/scripts/archive/test-full-video-styles.ps1 +1 -1
  109. package/scripts/archive/test-headed-ask.ps1 +1 -1
  110. package/scripts/archive/test-headed-now.ps1 +2 -2
  111. package/scripts/archive/test-headed.ps1 +2 -2
  112. package/scripts/archive/test-hello.ps1 +1 -1
  113. package/scripts/archive/test-manual-headed.ps1 +1 -1
  114. package/scripts/archive/test-mathieu-quota.ps1 +1 -1
  115. package/scripts/archive/test-personal-notebook.ps1 +1 -1
  116. package/scripts/archive/test-rate-limit.ps1 +1 -1
  117. package/scripts/archive/test-real-ask.ps1 +1 -1
  118. package/scripts/archive/test-real-ask2.ps1 +1 -1
  119. package/scripts/archive/test-rom1pey.ps1 +1 -1
  120. package/scripts/archive/test-rotation-complete.ps1 +1 -1
  121. package/scripts/archive/test-rotation.ps1 +2 -2
  122. package/scripts/archive/test-show-browser.ps1 +1 -1
  123. package/scripts/archive/test-update-notebook.ps1 +1 -1
  124. package/scripts/archive/verify-language-slow.ps1 +1 -1
  125. package/scripts/archive/verify-language.ps1 +1 -1
  126. package/scripts/docker-entrypoint.sh +3 -7
  127. package/scripts/doctor.mjs +257 -0
  128. package/scripts/mcp-proxy-hidden.ps1 +31 -0
  129. package/scripts/mcp-wsl-helper.sh +1 -1
  130. package/scripts/start-server-hidden.vbs +16 -0
  131. package/scripts/start-server.ps1 +1 -1
  132. package/scripts/stop-server.bat +5 -0
  133. package/scripts/switch-account-language.sh +87 -128
@@ -50,22 +50,33 @@ export class ContentManager {
50
50
  const expectedNotebookUuid = initialUrl.match(/notebook\/([a-f0-9-]+)/)?.[1];
51
51
  log.info(` 🎯 Target notebook UUID: ${expectedNotebookUuid || 'NOT FOUND'}`);
52
52
  try {
53
+ const existingSourceNames = await this.getAllSourceLabels();
53
54
  // Click "Add source" button
54
55
  await this.clickAddSource();
56
+ // DEBUG: Screenshot after clicking add source to see what UI appeared
57
+ try {
58
+ await this.page.screenshot({
59
+ path: path.join(CONFIG.dataDir, 'debug-after-add-click.png'),
60
+ });
61
+ log.info(` 📸 Debug screenshot saved: debug-after-add-click.png`);
62
+ }
63
+ catch {
64
+ /* ignore */
65
+ }
55
66
  // Wait for upload dialog
56
67
  await this.waitForUploadDialog();
57
68
  // Select upload type and upload (pass expectedNotebookUuid for redirect detection)
58
69
  switch (input.type) {
59
70
  case 'file':
60
- return await this.uploadFile(input, expectedNotebookUuid);
71
+ return await this.uploadFile(input, expectedNotebookUuid, existingSourceNames);
61
72
  case 'url':
62
- return await this.uploadUrl(input, expectedNotebookUuid);
73
+ return await this.uploadUrl(input, expectedNotebookUuid, existingSourceNames);
63
74
  case 'text':
64
- return await this.uploadText(input, expectedNotebookUuid);
75
+ return await this.uploadText(input, expectedNotebookUuid, existingSourceNames);
65
76
  case 'google_drive':
66
- return await this.uploadGoogleDrive(input, expectedNotebookUuid);
77
+ return await this.uploadGoogleDrive(input, expectedNotebookUuid, existingSourceNames);
67
78
  case 'youtube':
68
- return await this.uploadYouTube(input, expectedNotebookUuid);
79
+ return await this.uploadYouTube(input, expectedNotebookUuid, existingSourceNames);
69
80
  default:
70
81
  return { success: false, error: `Unsupported source type: ${input.type}` };
71
82
  }
@@ -272,11 +283,11 @@ export class ContentManager {
272
283
  /**
273
284
  * Upload a local file
274
285
  */
275
- async uploadFile(input, expectedNotebookUuid) {
286
+ async uploadFile(input, expectedNotebookUuid, previousSourceNames = []) {
276
287
  if (!input.filePath) {
277
288
  return { success: false, error: 'File path is required' };
278
289
  }
279
- // Path traversal protection: resolve and validate the path
290
+ // Path traversal protection: resolve first, then validate startsWith(allowedDir)
280
291
  const resolvedPath = path.resolve(input.filePath);
281
292
  const allowedDir = path.resolve(CONFIG.dataDir);
282
293
  // Allow files from dataDir or current working directory
@@ -328,7 +339,7 @@ export class ContentManager {
328
339
  // Click upload/confirm button
329
340
  await this.clickUploadButton();
330
341
  // Wait for processing
331
- const result = await this.waitForSourceProcessing(input.title || path.basename(input.filePath), undefined, expectedNotebookUuid);
342
+ const result = await this.waitForSourceProcessing(input.title || path.basename(input.filePath), undefined, expectedNotebookUuid, previousSourceNames);
332
343
  return result;
333
344
  }
334
345
  catch (error) {
@@ -339,20 +350,31 @@ export class ContentManager {
339
350
  /**
340
351
  * Upload from URL
341
352
  */
342
- async uploadUrl(input, expectedNotebookUuid) {
353
+ async uploadUrl(input, expectedNotebookUuid, previousSourceNames = []) {
343
354
  if (!input.url) {
344
355
  return { success: false, error: 'URL is required' };
345
356
  }
346
357
  log.info(` 🌐 Adding URL: ${input.url}`);
347
358
  try {
348
359
  // Click on URL/Website option (bilingual selectors)
360
+ // NotebookLM UI may show source types as buttons, spans, divs, or list items
349
361
  const urlTypeSelectors = [
362
+ // Button-based (original dialog UI)
350
363
  ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'website'),
351
364
  ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'link'),
352
365
  ...i18nSelectors('button:has-text("{text}")', 'sourceTypes', 'url'),
366
+ // New UI (2025+): source types may be spans, divs, or clickable list items
367
+ ...i18nSelectors('span:has-text("{text}")', 'sourceTypes', 'website'),
368
+ ...i18nSelectors('div:has-text("{text}")', 'sourceTypes', 'website'),
369
+ ...i18nSelectors('[role="menuitem"]:has-text("{text}")', 'sourceTypes', 'website'),
370
+ ...i18nSelectors('[role="option"]:has-text("{text}")', 'sourceTypes', 'website'),
371
+ ...i18nSelectors('span:has-text("{text}")', 'sourceTypes', 'link'),
372
+ ...i18nSelectors('[role="menuitem"]:has-text("{text}")', 'sourceTypes', 'link'),
373
+ // Aria-label patterns
353
374
  '[data-type="url"]',
354
- '[aria-label*="website"]',
375
+ '[aria-label*="website" i]',
355
376
  '[aria-label*="URL"]',
377
+ '[aria-label*="link" i]:not([aria-label*="unlink"])',
356
378
  ];
357
379
  log.info(` 🔍 Looking for URL option...`);
358
380
  let foundUrlOption = false;
@@ -394,6 +416,7 @@ export class ContentManager {
394
416
  // Wait for input to appear after clicking option
395
417
  await randomDelay(500, 1000);
396
418
  // Find URL input (can be input OR textarea) - bilingual selectors
419
+ // IMPORTANT: Exclude the "Search the web" search bar which is always visible
397
420
  log.info(` 🔍 Looking for URL input...`);
398
421
  const urlInputSelectors = [
399
422
  // i18n placeholder selectors
@@ -412,7 +435,7 @@ export class ContentManager {
412
435
  'textarea[placeholder*="http"]',
413
436
  'input[name="url"]',
414
437
  'input[type="url"]',
415
- // Fallback dialog selectors
438
+ // Dialog-based selectors (old UI)
416
439
  '[role="dialog"] input[type="text"]',
417
440
  '[role="dialog"] input:not([type="hidden"])',
418
441
  '[role="dialog"] textarea',
@@ -420,6 +443,14 @@ export class ContentManager {
420
443
  '.mat-dialog-content textarea',
421
444
  '.mdc-dialog__content input',
422
445
  '.mdc-dialog__content textarea',
446
+ // New UI (2025+): overlay/panel/popover selectors (not [role="dialog"])
447
+ '[role="menu"] input[type="text"]',
448
+ '[role="menu"] textarea',
449
+ '.cdk-overlay-pane input[type="text"]',
450
+ '.cdk-overlay-pane textarea',
451
+ '.cdk-overlay-pane input:not([type="hidden"])',
452
+ '.mat-menu-panel input',
453
+ '.mat-menu-panel textarea',
423
454
  ];
424
455
  let urlInput = null;
425
456
  for (const selector of urlInputSelectors) {
@@ -435,31 +466,71 @@ export class ContentManager {
435
466
  continue;
436
467
  }
437
468
  }
438
- // Fallback: find any visible input or textarea in the dialog
469
+ // Fallback: find any visible input or textarea in dialog or overlay
439
470
  if (!urlInput) {
440
- log.info(` 🔍 Trying fallback: any visible input/textarea in dialog...`);
471
+ log.info(` 🔍 Trying fallback: any visible input/textarea in dialog or overlay...`);
472
+ // Search in multiple container types (dialog, overlay, menu, panel)
473
+ const containerSelectors = [
474
+ '[role="dialog"]',
475
+ '.cdk-overlay-pane',
476
+ '[role="menu"]',
477
+ '.mat-menu-panel',
478
+ ];
441
479
  try {
442
- // Try inputs first
443
- const allInputs = await this.page.locator('[role="dialog"] input').all();
444
- for (const input of allInputs) {
445
- if (await input.isVisible()) {
446
- urlInput = input;
447
- const placeholder = await input.getAttribute('placeholder');
448
- log.info(` ✅ Found input via fallback: placeholder="${placeholder}"`);
480
+ for (const container of containerSelectors) {
481
+ if (urlInput)
449
482
  break;
450
- }
451
- }
452
- // Try textareas if no input found
453
- if (!urlInput) {
454
- const allTextareas = await this.page.locator('[role="dialog"] textarea').all();
455
- for (const textarea of allTextareas) {
456
- if (await textarea.isVisible()) {
457
- urlInput = textarea;
458
- const placeholder = await textarea.getAttribute('placeholder');
459
- log.info(` ✅ Found textarea via fallback: placeholder="${placeholder}"`);
483
+ // Try inputs first
484
+ const allInputs = await this.page.locator(`${container} input`).all();
485
+ for (const input of allInputs) {
486
+ if (await input.isVisible()) {
487
+ urlInput = input;
488
+ const placeholder = await input.getAttribute('placeholder');
489
+ log.info(` ✅ Found input via fallback (${container}): placeholder="${placeholder}"`);
460
490
  break;
461
491
  }
462
492
  }
493
+ // Try textareas if no input found
494
+ if (!urlInput) {
495
+ const allTextareas = await this.page.locator(`${container} textarea`).all();
496
+ for (const textarea of allTextareas) {
497
+ if (await textarea.isVisible()) {
498
+ urlInput = textarea;
499
+ const placeholder = await textarea.getAttribute('placeholder');
500
+ log.info(` ✅ Found textarea via fallback (${container}): placeholder="${placeholder}"`);
501
+ break;
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ catch {
508
+ /* ignore */
509
+ }
510
+ }
511
+ // Last resort: find ANY visible input/textarea on page, excluding the search bar
512
+ if (!urlInput) {
513
+ log.info(` 🔍 Last resort: scanning ALL visible inputs (excluding search bar)...`);
514
+ try {
515
+ const allPageInputs = await this.page.locator('input, textarea').all();
516
+ for (const input of allPageInputs) {
517
+ if (!(await input.isVisible()))
518
+ continue;
519
+ const placeholder = (await input.getAttribute('placeholder')) || '';
520
+ // Skip the "Search the web" search bar and other non-URL inputs
521
+ if (placeholder.toLowerCase().includes('search'))
522
+ continue;
523
+ if (placeholder.toLowerCase().includes('ask'))
524
+ continue;
525
+ if (placeholder.toLowerCase().includes('chat'))
526
+ continue;
527
+ // Prefer inputs that look URL-related
528
+ const type = (await input.getAttribute('type')) || '';
529
+ if (type === 'search')
530
+ continue;
531
+ urlInput = input;
532
+ log.info(` ✅ Found input via last resort: placeholder="${placeholder}", type="${type}"`);
533
+ break;
463
534
  }
464
535
  }
465
536
  catch {
@@ -468,12 +539,10 @@ export class ContentManager {
468
539
  }
469
540
  // Debug: list all inputs/textareas if still not found
470
541
  if (!urlInput) {
471
- log.warning(` ⚠️ URL input not found, listing dialog elements...`);
542
+ log.warning(` ⚠️ URL input not found, listing ALL page inputs for debug...`);
472
543
  try {
473
- const inputs = await this.page
474
- .locator('[role="dialog"] input, [role="dialog"] textarea')
475
- .all();
476
- for (let i = 0; i < inputs.length; i++) {
544
+ const inputs = await this.page.locator('input, textarea').all();
545
+ for (let i = 0; i < Math.min(inputs.length, 20); i++) {
477
546
  const el = inputs[i];
478
547
  const tag = await el.evaluate((e) => e.tagName?.toLowerCase() || 'unknown');
479
548
  const type = await el.getAttribute('type');
@@ -483,7 +552,7 @@ export class ContentManager {
483
552
  }
484
553
  }
485
554
  catch (e) {
486
- log.warning(` ⚠️ Could not list dialog elements: ${e}`);
555
+ log.warning(` ⚠️ Could not list page elements: ${e}`);
487
556
  }
488
557
  throw new Error('URL input not found');
489
558
  }
@@ -494,7 +563,7 @@ export class ContentManager {
494
563
  log.info(` 🔍 Looking for upload button...`);
495
564
  await this.clickUploadButton();
496
565
  // Wait for processing
497
- const result = await this.waitForSourceProcessing(input.title || input.url, undefined, expectedNotebookUuid);
566
+ const result = await this.waitForSourceProcessing(input.title || input.url, undefined, expectedNotebookUuid, previousSourceNames);
498
567
  return result;
499
568
  }
500
569
  catch (error) {
@@ -505,7 +574,7 @@ export class ContentManager {
505
574
  /**
506
575
  * Upload text content
507
576
  */
508
- async uploadText(input, expectedNotebookUuid) {
577
+ async uploadText(input, expectedNotebookUuid, previousSourceNames = []) {
509
578
  if (!input.text) {
510
579
  return { success: false, error: 'Text content is required' };
511
580
  }
@@ -608,8 +677,9 @@ export class ContentManager {
608
677
  }
609
678
  throw new Error('Text input not found in dialog');
610
679
  }
611
- await textInput.fill(input.text);
612
- log.info(` ✅ Text entered (${input.text.length} chars)`);
680
+ let textToInsert = input.text;
681
+ await textInput.fill(textToInsert);
682
+ log.info(` ✅ Text entered (${textToInsert.length} chars)`);
613
683
  // Set title if provided
614
684
  log.info(` 🔍 Looking for title input...`);
615
685
  if (input.title) {
@@ -638,6 +708,9 @@ export class ContentManager {
638
708
  }
639
709
  if (!titleSet) {
640
710
  log.warning(` ⚠️ Title input NOT found - source will have default name`);
711
+ textToInsert = `${input.title}\n\n${input.text}`;
712
+ await textInput.fill(textToInsert);
713
+ log.info(` ✅ Fallback title injected into pasted text: ${input.title}`);
641
714
  // Debug: list all inputs in dialog
642
715
  try {
643
716
  const allInputs = await this.page.locator('[role="dialog"] input').all();
@@ -683,7 +756,7 @@ export class ContentManager {
683
756
  log.warning(` ⚠️ Could not check button state: ${e}`);
684
757
  }
685
758
  // Get first few words of text for later verification (NotebookLM uses text content as title)
686
- const textPreview = input.text.slice(0, 30).trim();
759
+ const textPreview = textToInsert.slice(0, 30).trim();
687
760
  log.info(` 📝 Text preview for verification: "${textPreview}..."`);
688
761
  // Click add button
689
762
  log.info(` 🔍 Looking for upload button...`);
@@ -691,7 +764,7 @@ export class ContentManager {
691
764
  // Wait for processing - NotebookLM names pasted text sources "Texte collé" in French or "Pasted text"
692
765
  // We'll look for either the expected name or "Texte collé"
693
766
  // Pass initialUuid to detect notebook redirection
694
- const result = await this.waitForSourceProcessing(input.title || 'Texte collé', textPreview, expectedNotebookUuid);
767
+ const result = await this.waitForSourceProcessing(input.title || 'Texte collé', textPreview, expectedNotebookUuid, previousSourceNames);
695
768
  return result;
696
769
  }
697
770
  catch (error) {
@@ -702,18 +775,18 @@ export class ContentManager {
702
775
  /**
703
776
  * Upload from Google Drive
704
777
  */
705
- async uploadGoogleDrive(input, expectedNotebookUuid) {
778
+ async uploadGoogleDrive(input, expectedNotebookUuid, previousSourceNames = []) {
706
779
  if (!input.url) {
707
780
  return { success: false, error: 'Google Drive URL is required' };
708
781
  }
709
782
  log.info(` 📂 Adding Google Drive source: ${input.url}`);
710
783
  // Similar to URL upload but with Google Drive specific handling
711
- return await this.uploadUrl({ ...input, type: 'url' }, expectedNotebookUuid);
784
+ return await this.uploadUrl({ ...input, type: 'url' }, expectedNotebookUuid, previousSourceNames);
712
785
  }
713
786
  /**
714
787
  * Upload YouTube video
715
788
  */
716
- async uploadYouTube(input, expectedNotebookUuid) {
789
+ async uploadYouTube(input, expectedNotebookUuid, previousSourceNames = []) {
717
790
  if (!input.url) {
718
791
  return { success: false, error: 'YouTube URL is required' };
719
792
  }
@@ -816,7 +889,7 @@ export class ContentManager {
816
889
  log.info(` ✅ YouTube URL entered`);
817
890
  await randomDelay(500, 1000);
818
891
  await this.clickUploadButton();
819
- const result = await this.waitForSourceProcessing(input.title || 'YouTube video', undefined, expectedNotebookUuid);
892
+ const result = await this.waitForSourceProcessing(input.title || 'YouTube video', undefined, expectedNotebookUuid, previousSourceNames);
820
893
  return result;
821
894
  }
822
895
  catch (error) {
@@ -861,13 +934,19 @@ export class ContentManager {
861
934
  continue;
862
935
  }
863
936
  }
864
- // Debug: list all buttons in dialog
865
- log.warning(` ⚠️ No upload button found, listing dialog buttons...`);
937
+ // Debug: list all buttons in dialog or overlay
938
+ log.warning(` ⚠️ No upload button found, listing buttons...`);
866
939
  try {
867
- const dialogButtons = await this.page.locator('[role="dialog"] button').all();
868
- for (let i = 0; i < Math.min(dialogButtons.length, 5); i++) {
869
- const text = await dialogButtons[i].textContent();
870
- log.info(` 🔍 Dialog button[${i}]: "${text?.trim()}"`);
940
+ const containers = ['[role="dialog"]', '.cdk-overlay-pane', '[role="menu"]'];
941
+ for (const container of containers) {
942
+ const containerButtons = await this.page.locator(`${container} button`).all();
943
+ if (containerButtons.length > 0) {
944
+ log.info(` 🔍 Buttons in ${container}:`);
945
+ for (let i = 0; i < Math.min(containerButtons.length, 5); i++) {
946
+ const text = await containerButtons[i].textContent();
947
+ log.info(` 🔍 Button[${i}]: "${text?.trim()}"`);
948
+ }
949
+ }
871
950
  }
872
951
  }
873
952
  catch {
@@ -883,27 +962,50 @@ export class ContentManager {
883
962
  * @param _textPreview Optional first words of text (for text sources - NotebookLM may use this as name)
884
963
  * @param expectedNotebookUuid Optional UUID of the notebook we expect to be on (to detect redirects)
885
964
  */
886
- async waitForSourceProcessing(sourceName, _textPreview, expectedNotebookUuid) {
965
+ async waitForSourceProcessing(sourceName, _textPreview, expectedNotebookUuid, previousSourceNames = []) {
887
966
  log.info(` ⏳ Waiting for source processing: ${sourceName}`);
888
967
  const timeout = 90000; // 1.5 minutes (sources can take time)
889
968
  const startTime = Date.now();
969
+ // COUNT-BASED DETECTION (primary method for 2025 UI):
970
+ // Capture source count NOW — dialog is still open, source not yet added to DOM.
971
+ // Later, if count increases, we know a source was successfully added.
972
+ let initialSourceCount = -1;
973
+ let initialSourceLabels = [];
974
+ try {
975
+ initialSourceCount = await this.page.locator('.single-source-container').count();
976
+ log.info(` 📊 Source count before processing: ${initialSourceCount}`);
977
+ initialSourceLabels = await this.getAllSourceLabels();
978
+ }
979
+ catch {
980
+ /* ignore */
981
+ }
890
982
  // First, wait a bit for the dialog to close (indicates upload started)
891
983
  await randomDelay(2000, 3000);
892
984
  while (Date.now() - startTime < timeout) {
893
985
  // Check for errors in the dialog or page
986
+ // NOTE: Avoid overly broad selectors like [class*="error"] which match
987
+ // random page elements and produce garbage error messages
894
988
  const errorSelectors = [
895
989
  '.error-message',
896
990
  '[role="alert"]:has-text("error")',
897
991
  '[role="alert"]:has-text("Error")',
898
992
  '.mdc-snackbar--error',
899
- '[class*="error"]',
993
+ '.mdc-snackbar--open:has-text("error")',
994
+ '[role="dialog"] [class*="error"]',
995
+ '.cdk-overlay-pane [class*="error"]',
900
996
  ];
901
997
  for (const errorSelector of errorSelectors) {
902
998
  try {
903
999
  const errorEl = this.page.locator(errorSelector).first();
904
1000
  if (await errorEl.isVisible({ timeout: 500 })) {
905
- const errorText = await errorEl.textContent();
906
- return { success: false, error: errorText || 'Upload failed', status: 'failed' };
1001
+ const errorText = (await errorEl.textContent())?.trim();
1002
+ // Skip garbage text that contains icon names (more_vert, etc.)
1003
+ if (errorText &&
1004
+ errorText.length < 200 &&
1005
+ !errorText.includes('more_vert') &&
1006
+ !errorText.includes('more_horiz')) {
1007
+ return { success: false, error: errorText || 'Upload failed', status: 'failed' };
1008
+ }
907
1009
  }
908
1010
  }
909
1011
  catch {
@@ -928,6 +1030,65 @@ export class ContentManager {
928
1030
  // If dialog closed, check if source appears in the sources list
929
1031
  if (!dialogVisible) {
930
1032
  log.info(` ℹ️ Dialog closed, checking for source in list...`);
1033
+ // PRIMARY: Count-based detection (most reliable for 2025 UI)
1034
+ // NotebookLM shows page titles (not URLs) in the source list, so name-based
1035
+ // detection fails for URL sources. Count-based detection works regardless.
1036
+ try {
1037
+ const currentCount = await this.page.locator('.single-source-container').count();
1038
+ log.info(` 📊 Source count: ${initialSourceCount} → ${currentCount}`);
1039
+ if (initialSourceCount >= 0 && currentCount > initialSourceCount) {
1040
+ let detectedSourceName;
1041
+ let detectedSourceId;
1042
+ try {
1043
+ const currentLabels = await this.getAllSourceLabels();
1044
+ detectedSourceName =
1045
+ this.findAddedSourceName(initialSourceLabels, currentLabels) ||
1046
+ this.findAddedSourceName(previousSourceNames, currentLabels);
1047
+ if (detectedSourceName) {
1048
+ let detectedIndex = -1;
1049
+ for (let i = currentLabels.length - 1; i >= 0; i--) {
1050
+ if (this.normalizeSourceName(currentLabels[i]) ===
1051
+ this.normalizeSourceName(detectedSourceName)) {
1052
+ detectedIndex = i;
1053
+ break;
1054
+ }
1055
+ }
1056
+ if (detectedIndex >= 0) {
1057
+ detectedSourceId = `source-${detectedIndex}`;
1058
+ }
1059
+ }
1060
+ }
1061
+ catch {
1062
+ // Continue below and fall back if needed
1063
+ }
1064
+ if (!detectedSourceName) {
1065
+ log.info(' ℹ️ Source count increased, but a stable source name is not available yet');
1066
+ }
1067
+ else {
1068
+ log.info(` ℹ️ Detected new source: ${detectedSourceName}`);
1069
+ log.success(` ✅ Source added! Count increased from ${initialSourceCount} to ${currentCount}`);
1070
+ return {
1071
+ success: true,
1072
+ sourceId: detectedSourceId,
1073
+ sourceName: detectedSourceName,
1074
+ status: 'ready',
1075
+ };
1076
+ }
1077
+ }
1078
+ }
1079
+ catch {
1080
+ /* ignore */
1081
+ }
1082
+ try {
1083
+ const currentNames = await this.getVisibleSourceNames();
1084
+ if (currentNames.some((name) => this.normalizeSourceName(name) === this.normalizeSourceName(sourceName))) {
1085
+ log.success(` ✅ Source added and matched requested source name: ${sourceName}`);
1086
+ return { success: true, sourceName, status: 'ready' };
1087
+ }
1088
+ }
1089
+ catch {
1090
+ /* ignore */
1091
+ }
931
1092
  // CRITICAL: Verify we're still on the correct notebook after dialog closes
932
1093
  // NotebookLM sometimes redirects to a NEW notebook when adding text sources!
933
1094
  const currentUrl = this.page.url();
@@ -1790,12 +1951,181 @@ export class ContentManager {
1790
1951
  hasAudioOverview,
1791
1952
  };
1792
1953
  }
1954
+ normalizeSourceName(name) {
1955
+ return name.replace(/\s+/g, ' ').trim().toLowerCase();
1956
+ }
1957
+ extractSourceName(rawText) {
1958
+ const lines = rawText
1959
+ .replace(/\r/g, '')
1960
+ .split('\n')
1961
+ .map((line) => line.replace(/\s+/g, ' ').trim())
1962
+ .filter(Boolean);
1963
+ const ignoredExact = new Set([
1964
+ 'sources',
1965
+ 'chat',
1966
+ 'studio',
1967
+ 'add sources',
1968
+ 'add source',
1969
+ 'select all sources',
1970
+ 'web',
1971
+ 'fast research',
1972
+ 'description',
1973
+ ]);
1974
+ for (const line of lines) {
1975
+ const lower = line.toLowerCase();
1976
+ if (ignoredExact.has(lower))
1977
+ continue;
1978
+ if (lower.includes('search the web for new sources'))
1979
+ continue;
1980
+ if (/^(check|drive_pdf|markdown|description|more_vert|more_horiz)$/i.test(line))
1981
+ continue;
1982
+ return line;
1983
+ }
1984
+ return null;
1985
+ }
1986
+ async getVisibleSourceRows(dedupe = true) {
1987
+ await this.ensureSourcesPanel();
1988
+ const rowReadySelectors = [
1989
+ '.single-source-container',
1990
+ 'mat-checkbox.select-checkbox',
1991
+ '.source-stretched-button',
1992
+ ];
1993
+ for (const selector of rowReadySelectors) {
1994
+ try {
1995
+ await this.page.waitForSelector(selector, { state: 'attached', timeout: 2000 });
1996
+ break;
1997
+ }
1998
+ catch {
1999
+ continue;
2000
+ }
2001
+ }
2002
+ await randomDelay(1200, 1800);
2003
+ const rows = [];
2004
+ const seen = new Set();
2005
+ const selectors = ['.single-source-container', 'mat-checkbox', '[class*="source-item"]'];
2006
+ for (const selector of selectors) {
2007
+ try {
2008
+ const elements = await this.page.$$(selector);
2009
+ if (elements.length === 0)
2010
+ continue;
2011
+ for (const element of elements) {
2012
+ try {
2013
+ const buttonLabel = (await element
2014
+ .$eval('.source-stretched-button', (node) => node.getAttribute('aria-label') || '')
2015
+ .catch(() => '')) || '';
2016
+ const checkboxLabel = (await element
2017
+ .$eval('input[type="checkbox"]', (node) => node.getAttribute('aria-label') || '')
2018
+ .catch(() => '')) || '';
2019
+ const text = (await element.textContent()) || '';
2020
+ const name = this.extractSourceName(buttonLabel) ||
2021
+ this.extractSourceName(checkboxLabel) ||
2022
+ this.extractSourceName(text);
2023
+ if (!name)
2024
+ continue;
2025
+ const normalized = this.normalizeSourceName(name);
2026
+ if (dedupe && seen.has(normalized))
2027
+ continue;
2028
+ seen.add(normalized);
2029
+ rows.push({ name, element });
2030
+ }
2031
+ catch {
2032
+ continue;
2033
+ }
2034
+ }
2035
+ if (rows.length > 0) {
2036
+ break;
2037
+ }
2038
+ }
2039
+ catch {
2040
+ continue;
2041
+ }
2042
+ }
2043
+ if (rows.length === 0) {
2044
+ log.warning(' ⚠️ No visible source rows parsed from current NotebookLM UI');
2045
+ }
2046
+ return rows;
2047
+ }
2048
+ async getVisibleSourceNames() {
2049
+ const rows = await this.getVisibleSourceRows();
2050
+ return rows.map((row) => row.name);
2051
+ }
2052
+ async getAllSourceLabels() {
2053
+ const rows = await this.getVisibleSourceRows(false);
2054
+ return rows.map((row) => row.name);
2055
+ }
2056
+ findAddedSourceName(previousLabels, currentLabels) {
2057
+ const counts = new Map();
2058
+ for (const label of previousLabels) {
2059
+ const normalized = this.normalizeSourceName(label);
2060
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
2061
+ }
2062
+ for (const label of currentLabels) {
2063
+ const normalized = this.normalizeSourceName(label);
2064
+ const count = counts.get(normalized) || 0;
2065
+ if (count === 0) {
2066
+ return label;
2067
+ }
2068
+ counts.set(normalized, count - 1);
2069
+ }
2070
+ return undefined;
2071
+ }
2072
+ async findVisibleSourceRow(sourceId, sourceName) {
2073
+ const rows = await this.getVisibleSourceRows(false);
2074
+ if (rows.length === 0) {
2075
+ return null;
2076
+ }
2077
+ const normalizedTargetName = sourceName ? this.normalizeSourceName(sourceName) : null;
2078
+ for (const [index, row] of rows.entries()) {
2079
+ if (sourceId && sourceId === `source-${index}`) {
2080
+ return row;
2081
+ }
2082
+ if (!normalizedTargetName) {
2083
+ continue;
2084
+ }
2085
+ const normalizedRowName = this.normalizeSourceName(row.name);
2086
+ if (normalizedRowName === normalizedTargetName ||
2087
+ normalizedRowName.includes(normalizedTargetName) ||
2088
+ normalizedTargetName.includes(normalizedRowName)) {
2089
+ return row;
2090
+ }
2091
+ }
2092
+ return null;
2093
+ }
2094
+ async dismissBlockingOverlays() {
2095
+ const backdrop = this.page
2096
+ .locator('.cdk-overlay-backdrop.cdk-overlay-backdrop-showing')
2097
+ .first();
2098
+ try {
2099
+ if (await backdrop.isVisible({ timeout: 300 })) {
2100
+ log.info(' ℹ️ Dismissing blocking overlay backdrop');
2101
+ await this.page.keyboard.press('Escape').catch(() => { });
2102
+ await randomDelay(200, 400);
2103
+ if (await backdrop.isVisible({ timeout: 300 }).catch(() => false)) {
2104
+ await backdrop.click({ force: true }).catch(() => { });
2105
+ await randomDelay(200, 400);
2106
+ }
2107
+ }
2108
+ }
2109
+ catch {
2110
+ // Ignore missing/backdrop timing issues
2111
+ }
2112
+ }
1793
2113
  /**
1794
2114
  * List all sources in the notebook
1795
2115
  */
1796
2116
  async listSources() {
1797
2117
  const sources = [];
1798
2118
  try {
2119
+ const rows = await this.getVisibleSourceRows(false);
2120
+ if (rows.length > 0) {
2121
+ log.info(` 📚 Parsed ${rows.length} visible source rows`);
2122
+ return rows.map((row, index) => ({
2123
+ id: `source-${index}`,
2124
+ name: row.name,
2125
+ type: 'document',
2126
+ status: 'ready',
2127
+ }));
2128
+ }
1799
2129
  // First ensure Sources panel is active
1800
2130
  await this.ensureSourcesPanel();
1801
2131
  await randomDelay(500, 800);
@@ -1919,8 +2249,10 @@ export class ContentManager {
1919
2249
  // First, ensure we're on the Sources panel
1920
2250
  await this.ensureSourcesPanel();
1921
2251
  await randomDelay(500, 800);
2252
+ await this.dismissBlockingOverlays();
2253
+ const matchedRow = await this.findVisibleSourceRow(sourceId, sourceName);
1922
2254
  // Find the source element
1923
- const sourceElement = await this.findSourceElement(sourceId, sourceName);
2255
+ const sourceElement = matchedRow?.element ?? (await this.findSourceElement(sourceId, sourceName));
1924
2256
  if (!sourceElement) {
1925
2257
  return {
1926
2258
  success: false,
@@ -1928,7 +2260,7 @@ export class ContentManager {
1928
2260
  };
1929
2261
  }
1930
2262
  // Get the source name for logging before deletion
1931
- let deletedSourceName = sourceName;
2263
+ let deletedSourceName = sourceName || matchedRow?.name;
1932
2264
  if (!deletedSourceName) {
1933
2265
  try {
1934
2266
  deletedSourceName = await sourceElement.$eval('.source-name, .title, [class*="name"], [class*="title"]', (e) => e.textContent?.trim() || 'Unknown');
@@ -1937,15 +2269,12 @@ export class ContentManager {
1937
2269
  deletedSourceName = sourceId || 'Unknown';
1938
2270
  }
1939
2271
  }
1940
- // Click on the source to select it
1941
- await sourceElement.click();
1942
- await randomDelay(300, 500);
1943
2272
  // Open the source menu (3-dot menu or right-click)
1944
2273
  const menuOpened = await this.openSourceMenu(sourceElement);
1945
2274
  if (!menuOpened) {
1946
2275
  // Try right-click as fallback
1947
2276
  log.info(` 🔍 Trying right-click on source...`);
1948
- await sourceElement.click({ button: 'right' });
2277
+ await sourceElement.click({ button: 'right', force: true });
1949
2278
  await randomDelay(300, 500);
1950
2279
  }
1951
2280
  // Click delete option
@@ -1961,7 +2290,9 @@ export class ContentManager {
1961
2290
  // Wait for source to be removed
1962
2291
  await randomDelay(1000, 2000);
1963
2292
  // Verify deletion by checking if source is still present
1964
- const stillExists = await this.findSourceElement(sourceId, sourceName);
2293
+ const stillExists = deletedSourceName
2294
+ ? await this.findSourceElement(undefined, deletedSourceName)
2295
+ : await this.findSourceElement(sourceId, sourceName);
1965
2296
  if (stillExists) {
1966
2297
  return {
1967
2298
  success: false,
@@ -1988,6 +2319,11 @@ export class ContentManager {
1988
2319
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1989
2320
  ) {
1990
2321
  log.info(` 🔍 Finding source: id="${sourceId}", name="${sourceName}"`);
2322
+ const visibleRow = await this.findVisibleSourceRow(sourceId, sourceName);
2323
+ if (visibleRow) {
2324
+ log.info(` ✅ Found source via visible source rows: "${visibleRow.name}"`);
2325
+ return visibleRow.element;
2326
+ }
1991
2327
  // METHOD 1: Direct text search (most reliable for NotebookLM)
1992
2328
  if (sourceName) {
1993
2329
  const directSelectors = [
@@ -2080,6 +2416,7 @@ export class ContentManager {
2080
2416
  async openSourceMenu(
2081
2417
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2082
2418
  sourceElement) {
2419
+ await this.dismissBlockingOverlays();
2083
2420
  const menuButtonSelectors = [
2084
2421
  // Material Design 3-dot menu button
2085
2422
  'button:has(mat-icon:has-text("more_vert"))',
@@ -2105,7 +2442,7 @@ export class ContentManager {
2105
2442
  const isVisible = await menuBtn.isVisible();
2106
2443
  if (isVisible) {
2107
2444
  log.info(` ✅ Found menu button: ${selector}`);
2108
- await menuBtn.click();
2445
+ await menuBtn.click({ force: true });
2109
2446
  await randomDelay(300, 500);
2110
2447
  return true;
2111
2448
  }
@@ -2117,7 +2454,13 @@ export class ContentManager {
2117
2454
  }
2118
2455
  // Hover over the source to reveal hidden menu button
2119
2456
  log.info(` 🔍 Hovering to reveal menu button...`);
2120
- await sourceElement.hover();
2457
+ try {
2458
+ await sourceElement.hover();
2459
+ }
2460
+ catch {
2461
+ log.warning(' ⚠️ Could not hover source row to reveal menu button');
2462
+ return false;
2463
+ }
2121
2464
  await randomDelay(500, 800);
2122
2465
  // Try again after hover
2123
2466
  for (const selector of menuButtonSelectors) {
@@ -2127,7 +2470,7 @@ export class ContentManager {
2127
2470
  const isVisible = await menuBtn.isVisible();
2128
2471
  if (isVisible) {
2129
2472
  log.info(` ✅ Found menu button after hover: ${selector}`);
2130
- await menuBtn.click();
2473
+ await menuBtn.click({ force: true });
2131
2474
  await randomDelay(300, 500);
2132
2475
  return true;
2133
2476
  }
@@ -2892,7 +3235,8 @@ export class ContentManager {
2892
3235
  const btn = this.page.locator(selector).first();
2893
3236
  if (await btn.isVisible({ timeout: 1000 })) {
2894
3237
  log.info(` ✅ Found Add note button: ${selector}`);
2895
- await realisticClick(this.page, selector, true);
3238
+ await btn.scrollIntoViewIfNeeded();
3239
+ await btn.click({ force: true, timeout: 5000 });
2896
3240
  addButtonFound = true;
2897
3241
  await randomDelay(500, 1000);
2898
3242
  break;