notebooklm-mcp-ultimate 2.3.0 → 2.3.2

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 (46) hide show
  1. package/dist/api/batch-execute-client.d.ts +9 -0
  2. package/dist/api/batch-execute-client.d.ts.map +1 -1
  3. package/dist/api/batch-execute-client.js +36 -0
  4. package/dist/api/batch-execute-client.js.map +1 -1
  5. package/dist/auth/auth-manager.d.ts +14 -0
  6. package/dist/auth/auth-manager.d.ts.map +1 -1
  7. package/dist/auth/auth-manager.js +55 -1
  8. package/dist/auth/auth-manager.js.map +1 -1
  9. package/dist/auth/cookie-store.d.ts +2 -1
  10. package/dist/auth/cookie-store.d.ts.map +1 -1
  11. package/dist/auth/cookie-store.js +16 -1
  12. package/dist/auth/cookie-store.js.map +1 -1
  13. package/dist/config.js +1 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/operations/notebook-crud-operations.d.ts +5 -9
  16. package/dist/operations/notebook-crud-operations.d.ts.map +1 -1
  17. package/dist/operations/notebook-crud-operations.js +273 -70
  18. package/dist/operations/notebook-crud-operations.js.map +1 -1
  19. package/dist/session/browser-session.d.ts +70 -0
  20. package/dist/session/browser-session.d.ts.map +1 -1
  21. package/dist/session/browser-session.js +840 -5
  22. package/dist/session/browser-session.js.map +1 -1
  23. package/dist/session/shared-context-manager.d.ts.map +1 -1
  24. package/dist/session/shared-context-manager.js +1 -0
  25. package/dist/session/shared-context-manager.js.map +1 -1
  26. package/dist/tools/handlers/notebook-crud-handlers.d.ts +10 -0
  27. package/dist/tools/handlers/notebook-crud-handlers.d.ts.map +1 -1
  28. package/dist/tools/handlers/notebook-crud-handlers.js +37 -1
  29. package/dist/tools/handlers/notebook-crud-handlers.js.map +1 -1
  30. package/dist/tools/handlers/research-handlers.d.ts +7 -1
  31. package/dist/tools/handlers/research-handlers.d.ts.map +1 -1
  32. package/dist/tools/handlers/research-handlers.js +34 -1
  33. package/dist/tools/handlers/research-handlers.js.map +1 -1
  34. package/dist/tools/handlers/source-handlers.d.ts +96 -9
  35. package/dist/tools/handlers/source-handlers.d.ts.map +1 -1
  36. package/dist/tools/handlers/source-handlers.js +214 -78
  37. package/dist/tools/handlers/source-handlers.js.map +1 -1
  38. package/dist/tools/handlers.d.ts +105 -8
  39. package/dist/tools/handlers.d.ts.map +1 -1
  40. package/dist/tools/handlers.js +3 -3
  41. package/dist/tools/handlers.js.map +1 -1
  42. package/dist/utils/page-utils.d.ts +12 -0
  43. package/dist/utils/page-utils.d.ts.map +1 -1
  44. package/dist/utils/page-utils.js +47 -0
  45. package/dist/utils/page-utils.js.map +1 -1
  46. package/package.json +7 -3
@@ -13,7 +13,8 @@
13
13
  * Based on the Python implementation from browser_session.py
14
14
  */
15
15
  import { humanType, randomDelay } from "../utils/stealth-utils.js";
16
- import { waitForLatestAnswer, snapshotAllResponses, } from "../utils/page-utils.js";
16
+ import { waitForLatestAnswer, snapshotAllResponses, extractCSRFFromPage, } from "../utils/page-utils.js";
17
+ import { csrfManager } from "../api/csrf-manager.js";
17
18
  import { CONFIG } from "../config.js";
18
19
  import { log } from "../utils/logger.js";
19
20
  import { RateLimitError } from "../errors.js";
@@ -67,13 +68,16 @@ export class BrowserSession {
67
68
  }
68
69
  }
69
70
  log.success(` ✅ Created new page`);
71
+ // Set iPad Mini viewport for NotebookLM responsive UI
72
+ await this.page.setViewportSize({ width: 768, height: 1024 });
70
73
  // Navigate to notebook
71
74
  log.info(` 🌐 Navigating to: ${this.notebookUrl}`);
72
75
  await this.page.goto(this.notebookUrl, {
73
76
  waitUntil: "domcontentloaded",
74
77
  timeout: CONFIG.browserTimeout,
75
78
  });
76
- // Wait for page to stabilize
79
+ // Wait for Angular to finish rendering (use load instead of networkidle to avoid timeout)
80
+ await this.page.waitForLoadState('load').catch(() => { });
77
81
  await randomDelay(2000, 3000);
78
82
  // Check if we need to login
79
83
  const isAuthenticated = await this.authManager.validateCookiesExpiry(this.context);
@@ -106,6 +110,17 @@ export class BrowserSession {
106
110
  // Wait for NotebookLM interface to load
107
111
  log.info(` ⏳ Waiting for NotebookLM interface...`);
108
112
  await this.waitForNotebookLMReady();
113
+ // Extract CSRF token from browser and set on API client
114
+ // This ensures API calls use the same session as the browser
115
+ if (this.page) {
116
+ const csrfToken = await extractCSRFFromPage(this.page);
117
+ if (csrfToken) {
118
+ csrfManager.setCachedToken(csrfToken);
119
+ // Also save CSRF to disk for cold-start API usage
120
+ await this.authManager.saveCSRFToken(csrfToken);
121
+ log.success(` 🔑 CSRF token synced to API client`);
122
+ }
123
+ }
109
124
  this.initialized = true;
110
125
  this.updateActivity();
111
126
  log.success(`✅ Session ${this.sessionId} initialized successfully`);
@@ -137,7 +152,7 @@ export class BrowserSession {
137
152
  // PRIMARY: Exact Python selector - textarea.query-box-input
138
153
  log.info(" ⏳ Waiting for chat input (textarea.query-box-input)...");
139
154
  await this.page.waitForSelector("textarea.query-box-input", {
140
- timeout: 10000, // Python uses 10s timeout
155
+ timeout: 15000, // Extended for headless tests
141
156
  state: "visible", // ONLY check visibility (NO disabled check!)
142
157
  });
143
158
  log.success(" ✅ Chat input ready!");
@@ -145,10 +160,12 @@ export class BrowserSession {
145
160
  catch {
146
161
  // Fix 5: Try multiple locale-agnostic fallback selectors instead of German-only
147
162
  const fallbackSelectors = [
163
+ 'textarea[aria-label="Query box"]', // Exact aria-label (verified)
164
+ 'textarea[placeholder="Start typing..."]', // Exact placeholder (verified)
165
+ 'textarea[placeholder*="Start typing"]', // Partial match
166
+ 'textarea.mat-mdc-autocomplete-trigger', // Material autocomplete
148
167
  'textarea[aria-label*="query"]', // English partial
149
168
  'textarea[aria-label*="question"]', // English
150
- 'textarea[aria-label*="Ask"]', // English
151
- 'textarea[aria-label="Feld für Anfragen"]', // German (original)
152
169
  'textarea[role="textbox"]', // Generic fallback
153
170
  ];
154
171
  let found = false;
@@ -599,6 +616,824 @@ export class BrowserSession {
599
616
  throw error;
600
617
  }
601
618
  }
619
+ /**
620
+ * Add a text source to the notebook via browser UI
621
+ *
622
+ * @param title - Title for the source
623
+ * @param content - Text content to add
624
+ * @param sendProgress - Optional progress callback
625
+ * @returns true if source was added successfully
626
+ */
627
+ async addTextSourceViaUI(title, content, sendProgress) {
628
+ if (!this.page || !this.initialized) {
629
+ throw new Error("Session not initialized");
630
+ }
631
+ log.info(`📝 [${this.sessionId}] Adding text source via UI: "${title}"`);
632
+ try {
633
+ const page = this.page;
634
+ await sendProgress?.("Opening source panel...", 1, 6);
635
+ // Dismiss any overlay/modal that might block clicks (e.g., new notebook onboarding)
636
+ const overlay = page.locator('.cdk-overlay-backdrop-showing');
637
+ if (await overlay.isVisible({ timeout: 1000 }).catch(() => false)) {
638
+ log.info(" 🔄 Dismissing overlay modal...");
639
+ await page.keyboard.press('Escape');
640
+ await randomDelay(500, 800);
641
+ }
642
+ // Click on Sources tab first (iPad Mini viewport uses role="tab")
643
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
644
+ if (await sourcesTab.isVisible({ timeout: 3000 })) {
645
+ await sourcesTab.click();
646
+ await randomDelay(500, 800);
647
+ }
648
+ // Click the "Add sources" button (found via debug: button:has-text("Add sources"))
649
+ const addSourceSelectors = [
650
+ 'button:has-text("Add sources")', // Primary - found via debug
651
+ 'button[aria-label*="Add source"]',
652
+ 'button[aria-label*="add source"]',
653
+ '[data-tooltip*="Add source"]',
654
+ 'button.add-source-button',
655
+ ];
656
+ let addButtonClicked = false;
657
+ for (const sel of addSourceSelectors) {
658
+ try {
659
+ const btn = page.locator(sel).first();
660
+ if (await btn.isVisible({ timeout: 2000 })) {
661
+ await btn.click();
662
+ addButtonClicked = true;
663
+ log.info(` ✅ Clicked add source button: ${sel}`);
664
+ break;
665
+ }
666
+ }
667
+ catch {
668
+ continue;
669
+ }
670
+ }
671
+ if (!addButtonClicked) {
672
+ // Try keyboard shortcut or alternative approach
673
+ log.warning(" ⚠️ Could not find add source button, trying alternative...");
674
+ // Some UIs use a menu - try clicking on Sources header
675
+ const sourcesHeader = page.locator('text="Sources"').first();
676
+ if (await sourcesHeader.isVisible({ timeout: 2000 })) {
677
+ await sourcesHeader.click();
678
+ await randomDelay(500, 1000);
679
+ }
680
+ }
681
+ await randomDelay(1000, 1500);
682
+ await sendProgress?.("Selecting text source option...", 2, 5);
683
+ // Select "Copied text" or "Paste text" option
684
+ const textOptionSelectors = [
685
+ 'button:has-text("Copied text")', // Primary option
686
+ 'button:has-text("Paste text")', // Alternative
687
+ '[role="menuitem"]:has-text("Copied text")',
688
+ '[role="menuitem"]:has-text("Paste text")',
689
+ 'button:has-text("Text")', // Generic fallback
690
+ ];
691
+ let textOptionClicked = false;
692
+ for (const sel of textOptionSelectors) {
693
+ try {
694
+ const opt = page.locator(sel).first();
695
+ if (await opt.isVisible({ timeout: 2000 })) {
696
+ await opt.click();
697
+ textOptionClicked = true;
698
+ log.info(` ✅ Selected text source option: ${sel}`);
699
+ break;
700
+ }
701
+ }
702
+ catch {
703
+ continue;
704
+ }
705
+ }
706
+ if (!textOptionClicked) {
707
+ throw new Error("Could not find text source option in menu");
708
+ }
709
+ await randomDelay(1000, 1500);
710
+ await sendProgress?.("Filling in source details...", 3, 5);
711
+ // Fill in the title field
712
+ const titleInputSelectors = [
713
+ 'input[placeholder*="title"]',
714
+ 'input[placeholder*="Title"]',
715
+ 'input[aria-label*="title"]',
716
+ 'input[aria-label*="Title"]',
717
+ 'input[placeholder*="name"]',
718
+ // German
719
+ 'input[placeholder*="Titel"]',
720
+ // Generic - first input in dialog
721
+ '[role="dialog"] input:first-of-type',
722
+ ];
723
+ for (const sel of titleInputSelectors) {
724
+ try {
725
+ const input = page.locator(sel).first();
726
+ if (await input.isVisible({ timeout: 2000 })) {
727
+ await input.fill(title);
728
+ log.info(` ✅ Filled title: ${sel}`);
729
+ break;
730
+ }
731
+ }
732
+ catch {
733
+ continue;
734
+ }
735
+ }
736
+ // Fill in the content field (textarea)
737
+ const contentSelectors = [
738
+ 'textarea[placeholder*="Paste"]',
739
+ 'textarea[placeholder*="paste"]',
740
+ 'textarea[placeholder*="content"]',
741
+ 'textarea[aria-label*="content"]',
742
+ 'textarea[aria-label*="text"]',
743
+ // German
744
+ 'textarea[placeholder*="Einfügen"]',
745
+ // Generic - textarea in dialog
746
+ '[role="dialog"] textarea',
747
+ ];
748
+ for (const sel of contentSelectors) {
749
+ try {
750
+ const textarea = page.locator(sel).first();
751
+ if (await textarea.isVisible({ timeout: 2000 })) {
752
+ await textarea.fill(content);
753
+ log.info(` ✅ Filled content: ${sel}`);
754
+ break;
755
+ }
756
+ }
757
+ catch {
758
+ continue;
759
+ }
760
+ }
761
+ await randomDelay(500, 1000);
762
+ await sendProgress?.("Submitting source...", 4, 5);
763
+ // Click submit/insert button
764
+ const submitSelectors = [
765
+ 'button:has-text("Insert")',
766
+ 'button:has-text("Add")',
767
+ 'button:has-text("Save")',
768
+ 'button:has-text("Submit")',
769
+ // German
770
+ 'button:has-text("Einfügen")',
771
+ 'button:has-text("Hinzufügen")',
772
+ // Generic primary button in dialog
773
+ '[role="dialog"] button[type="submit"]',
774
+ '[role="dialog"] button.primary',
775
+ ];
776
+ let submitted = false;
777
+ for (const sel of submitSelectors) {
778
+ try {
779
+ const btn = page.locator(sel).first();
780
+ if (await btn.isVisible({ timeout: 2000 })) {
781
+ await btn.click();
782
+ submitted = true;
783
+ log.info(` ✅ Clicked submit: ${sel}`);
784
+ break;
785
+ }
786
+ }
787
+ catch {
788
+ continue;
789
+ }
790
+ }
791
+ if (!submitted) {
792
+ throw new Error("Could not find submit button");
793
+ }
794
+ // Wait for source to be processed
795
+ await randomDelay(2000, 3000);
796
+ await sendProgress?.("Source added, waiting for processing...", 5, 5);
797
+ this.updateActivity();
798
+ log.success(`✅ [${this.sessionId}] Text source added via UI: "${title}"`);
799
+ return true;
800
+ }
801
+ catch (error) {
802
+ log.error(`❌ [${this.sessionId}] Failed to add text source via UI: ${error}`);
803
+ throw error;
804
+ }
805
+ }
806
+ /**
807
+ * Add a file source to the notebook via browser UI
808
+ *
809
+ * @param filePath - Local file path to upload
810
+ * @param sendProgress - Optional progress callback
811
+ * @returns true if source was added successfully
812
+ */
813
+ async addFileSourceViaUI(filePath, sendProgress) {
814
+ if (!this.page || !this.initialized) {
815
+ throw new Error("Session not initialized");
816
+ }
817
+ log.info(`📁 [${this.sessionId}] Adding file source via UI: "${filePath}"`);
818
+ try {
819
+ const page = this.page;
820
+ await sendProgress?.("Opening source panel...", 1, 6);
821
+ // Dismiss any overlay/modal that might block clicks (e.g., new notebook onboarding)
822
+ const overlay = page.locator('.cdk-overlay-backdrop-showing');
823
+ if (await overlay.isVisible({ timeout: 1000 }).catch(() => false)) {
824
+ log.info(" 🔄 Dismissing overlay modal...");
825
+ await page.keyboard.press('Escape');
826
+ await randomDelay(500, 800);
827
+ }
828
+ // Click on Sources tab first (iPad Mini viewport uses role="tab")
829
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
830
+ if (await sourcesTab.isVisible({ timeout: 3000 })) {
831
+ await sourcesTab.click();
832
+ await randomDelay(500, 800);
833
+ }
834
+ // Click add source button
835
+ const addBtn = page.locator('button:has-text("Add sources"), button[aria-label*="Add source"]').first();
836
+ await addBtn.click({ timeout: 5000 });
837
+ await randomDelay(1000, 1500);
838
+ await sendProgress?.("Selecting file upload option...", 2, 6);
839
+ // Select file upload option
840
+ const fileOptionSelectors = [
841
+ 'button:has-text("Upload")',
842
+ 'button:has-text("File")',
843
+ '[role="menuitem"]:has-text("Upload")',
844
+ '[role="menuitem"]:has-text("File")',
845
+ ];
846
+ let selected = false;
847
+ for (const sel of fileOptionSelectors) {
848
+ try {
849
+ const opt = page.locator(sel).first();
850
+ if (await opt.isVisible({ timeout: 2000 })) {
851
+ await opt.click();
852
+ log.info(` ✅ Selected file option: ${sel}`);
853
+ selected = true;
854
+ break;
855
+ }
856
+ }
857
+ catch {
858
+ continue;
859
+ }
860
+ }
861
+ if (!selected) {
862
+ throw new Error("Could not find file upload option");
863
+ }
864
+ await randomDelay(1500, 2000);
865
+ await sendProgress?.("Selecting file...", 3, 4);
866
+ // Look for the file input or a button that triggers file chooser
867
+ // NotebookLM may show a drag-drop area with a "browse" link or hidden file input
868
+ const fileInputSelectors = [
869
+ 'input[type="file"]',
870
+ '.cdk-overlay-pane input[type="file"]',
871
+ '[role="dialog"] input[type="file"]',
872
+ ];
873
+ let fileInput = null;
874
+ for (const sel of fileInputSelectors) {
875
+ try {
876
+ const input = page.locator(sel).first();
877
+ // File inputs are often hidden, so just check if it exists
878
+ if (await input.count() > 0) {
879
+ fileInput = input;
880
+ log.info(` ✅ Found file input: ${sel}`);
881
+ break;
882
+ }
883
+ }
884
+ catch {
885
+ continue;
886
+ }
887
+ }
888
+ if (fileInput) {
889
+ // Direct file input - use setInputFiles
890
+ await fileInput.setInputFiles(filePath);
891
+ log.info(` ✅ File set via input: ${filePath}`);
892
+ }
893
+ else {
894
+ // Try clicking a browse/choose button with file chooser
895
+ const browseSelectors = [
896
+ 'button:has-text("browse")',
897
+ 'button:has-text("Browse")',
898
+ 'button:has-text("Choose")',
899
+ 'a:has-text("browse")',
900
+ '.cdk-overlay-pane button',
901
+ ];
902
+ let clicked = false;
903
+ for (const sel of browseSelectors) {
904
+ try {
905
+ const [fileChooser] = await Promise.all([
906
+ page.waitForEvent('filechooser', { timeout: 5000 }),
907
+ page.locator(sel).first().click({ timeout: 2000 })
908
+ ]);
909
+ await fileChooser.setFiles(filePath);
910
+ log.info(` ✅ File selected via chooser: ${filePath}`);
911
+ clicked = true;
912
+ break;
913
+ }
914
+ catch {
915
+ continue;
916
+ }
917
+ }
918
+ if (!clicked) {
919
+ throw new Error("Could not find file input or browse button");
920
+ }
921
+ }
922
+ await sendProgress?.("Processing upload...", 4, 4);
923
+ await randomDelay(3000, 5000); // File upload takes time
924
+ this.updateActivity();
925
+ log.success(`✅ [${this.sessionId}] File source added via UI: "${filePath}"`);
926
+ return true;
927
+ }
928
+ catch (error) {
929
+ log.error(`❌ [${this.sessionId}] Failed to add file source via UI: ${error}`);
930
+ throw error;
931
+ }
932
+ }
933
+ /**
934
+ * Add a URL source to the notebook via browser UI
935
+ *
936
+ * @param url - URL to add as source
937
+ * @param sendProgress - Optional progress callback
938
+ * @returns true if source was added successfully
939
+ */
940
+ async addURLSourceViaUI(url, sendProgress) {
941
+ if (!this.page || !this.initialized) {
942
+ throw new Error("Session not initialized");
943
+ }
944
+ log.info(`🔗 [${this.sessionId}] Adding URL source via UI: "${url}"`);
945
+ try {
946
+ const page = this.page;
947
+ await sendProgress?.("Opening source panel...", 1, 5);
948
+ // Dismiss any overlay/modal that might block clicks (e.g., new notebook onboarding)
949
+ const overlay = page.locator('.cdk-overlay-backdrop-showing');
950
+ if (await overlay.isVisible({ timeout: 1000 }).catch(() => false)) {
951
+ log.info(" 🔄 Dismissing overlay modal...");
952
+ await page.keyboard.press('Escape');
953
+ await randomDelay(500, 800);
954
+ }
955
+ // Click on Sources tab first (iPad Mini viewport uses role="tab")
956
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
957
+ if (await sourcesTab.isVisible({ timeout: 3000 })) {
958
+ await sourcesTab.click();
959
+ await randomDelay(500, 800);
960
+ }
961
+ // Click add source button
962
+ const addBtn = page.locator('button:has-text("Add sources"), button[aria-label*="Add source"]').first();
963
+ await addBtn.click({ timeout: 5000 });
964
+ await randomDelay(1000, 1500);
965
+ await sendProgress?.("Selecting website option...", 2, 5);
966
+ // Select website/URL option
967
+ const urlOptionSelectors = [
968
+ 'button:has-text("Websites")',
969
+ 'button:has-text("Website")',
970
+ '[role="menuitem"]:has-text("Websites")',
971
+ '[role="menuitem"]:has-text("Website")',
972
+ ];
973
+ for (const sel of urlOptionSelectors) {
974
+ try {
975
+ const opt = page.locator(sel).first();
976
+ if (await opt.isVisible({ timeout: 2000 })) {
977
+ await opt.click();
978
+ log.info(` ✅ Selected URL option: ${sel}`);
979
+ break;
980
+ }
981
+ }
982
+ catch {
983
+ continue;
984
+ }
985
+ }
986
+ await randomDelay(1500, 2000);
987
+ await sendProgress?.("Entering URL...", 3, 4);
988
+ // Fill URL textarea - NotebookLM uses textarea with "Paste any links" placeholder
989
+ const urlTextareaSelectors = [
990
+ 'textarea[placeholder*="Paste any links"]',
991
+ 'textarea[placeholder*="Paste"]',
992
+ '.cdk-overlay-pane textarea',
993
+ '[role="dialog"] textarea',
994
+ ];
995
+ let filled = false;
996
+ for (const sel of urlTextareaSelectors) {
997
+ try {
998
+ const textarea = page.locator(sel).first();
999
+ if (await textarea.isVisible({ timeout: 1500 })) {
1000
+ await textarea.fill(url);
1001
+ log.info(` ✅ Filled URL: ${sel}`);
1002
+ filled = true;
1003
+ break;
1004
+ }
1005
+ }
1006
+ catch {
1007
+ continue;
1008
+ }
1009
+ }
1010
+ if (!filled) {
1011
+ throw new Error("Could not find URL textarea");
1012
+ }
1013
+ await randomDelay(500, 1000);
1014
+ await sendProgress?.("Submitting...", 4, 4);
1015
+ // Submit - look in dialog context
1016
+ const submitSelectors = [
1017
+ '.cdk-overlay-pane button:has-text("Insert")',
1018
+ '[role="dialog"] button:has-text("Insert")',
1019
+ 'button:has-text("Insert")',
1020
+ ];
1021
+ for (const sel of submitSelectors) {
1022
+ try {
1023
+ const btn = page.locator(sel).first();
1024
+ if (await btn.isVisible({ timeout: 1500 })) {
1025
+ await btn.click();
1026
+ log.info(` ✅ Clicked submit: ${sel}`);
1027
+ break;
1028
+ }
1029
+ }
1030
+ catch {
1031
+ continue;
1032
+ }
1033
+ }
1034
+ // Wait for dialog to close
1035
+ await page.waitForSelector('textarea[placeholder*="Paste any links"]', {
1036
+ state: 'hidden',
1037
+ timeout: 30000
1038
+ }).catch(() => { });
1039
+ await randomDelay(2000, 3000);
1040
+ // Verify source was added by checking source count
1041
+ const sourceCount = await page.locator('.source-item, [data-source-id], mat-list-item').count();
1042
+ const sourceAdded = sourceCount > 0;
1043
+ this.updateActivity();
1044
+ if (sourceAdded) {
1045
+ log.success(`✅ [${this.sessionId}] URL source added via UI: "${url}" (${sourceCount} sources)`);
1046
+ return true;
1047
+ }
1048
+ else {
1049
+ log.warning(`⚠️ [${this.sessionId}] Source may not have been added: "${url}"`);
1050
+ return true; // Still return true as click succeeded, source might be processing
1051
+ }
1052
+ }
1053
+ catch (error) {
1054
+ log.error(`❌ [${this.sessionId}] Failed to add URL source via UI: ${error}`);
1055
+ throw error;
1056
+ }
1057
+ }
1058
+ /**
1059
+ * Add a YouTube source to the notebook via browser UI
1060
+ *
1061
+ * @param youtubeUrl - YouTube video URL
1062
+ * @param sendProgress - Optional progress callback
1063
+ * @returns true if source was added successfully
1064
+ */
1065
+ async addYouTubeSourceViaUI(youtubeUrl, sendProgress) {
1066
+ if (!this.page || !this.initialized) {
1067
+ throw new Error("Session not initialized");
1068
+ }
1069
+ log.info(`🎥 [${this.sessionId}] Adding YouTube source via UI: "${youtubeUrl}"`);
1070
+ try {
1071
+ const page = this.page;
1072
+ await sendProgress?.("Opening source panel...", 1, 6);
1073
+ // Dismiss any overlay/modal that might block clicks (e.g., new notebook onboarding)
1074
+ const overlay = page.locator('.cdk-overlay-backdrop-showing');
1075
+ if (await overlay.isVisible({ timeout: 1000 }).catch(() => false)) {
1076
+ log.info(" 🔄 Dismissing overlay modal...");
1077
+ await page.keyboard.press('Escape');
1078
+ await randomDelay(500, 800);
1079
+ }
1080
+ // Click on Sources tab first (iPad Mini viewport uses role="tab")
1081
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
1082
+ if (await sourcesTab.isVisible({ timeout: 3000 })) {
1083
+ await sourcesTab.click();
1084
+ await randomDelay(500, 800);
1085
+ }
1086
+ // Click add source button
1087
+ const addBtn = page.locator('button:has-text("Add sources"), button[aria-label*="Add source"]').first();
1088
+ await addBtn.click({ timeout: 5000 });
1089
+ await randomDelay(1000, 1500);
1090
+ await sendProgress?.("Selecting Website/YouTube option...", 2, 6);
1091
+ // Select Website option - opens "Website and YouTube URLs" dialog
1092
+ const urlOptionSelectors = [
1093
+ 'button:has-text("Website")',
1094
+ '[role="menuitem"]:has-text("Website")',
1095
+ 'button:has-text("Link")',
1096
+ 'button:has-text("URL")',
1097
+ 'button:has-text("YouTube")',
1098
+ '[role="menuitem"]:has-text("YouTube")',
1099
+ ];
1100
+ let selected = false;
1101
+ for (const sel of urlOptionSelectors) {
1102
+ try {
1103
+ const opt = page.locator(sel).first();
1104
+ if (await opt.isVisible({ timeout: 2000 })) {
1105
+ await opt.click();
1106
+ log.info(` ✅ Selected URL option: ${sel}`);
1107
+ selected = true;
1108
+ break;
1109
+ }
1110
+ }
1111
+ catch {
1112
+ continue;
1113
+ }
1114
+ }
1115
+ if (!selected) {
1116
+ throw new Error("Could not find Website/YouTube source option");
1117
+ }
1118
+ await randomDelay(2000, 2500); // Wait for dialog to fully render
1119
+ await sendProgress?.("Entering YouTube URL...", 3, 4);
1120
+ // Fill URL textarea - NotebookLM uses textarea with "Paste any links" placeholder
1121
+ const urlTextareaSelectors = [
1122
+ 'textarea[placeholder*="Paste any links"]',
1123
+ 'textarea[placeholder*="Paste"]',
1124
+ 'textarea[placeholder*="paste"]',
1125
+ 'textarea[placeholder*="link"]',
1126
+ 'textarea[placeholder*="URL"]',
1127
+ '.cdk-overlay-pane textarea',
1128
+ '[role="dialog"] textarea',
1129
+ ];
1130
+ let filled = false;
1131
+ for (const sel of urlTextareaSelectors) {
1132
+ try {
1133
+ const textarea = page.locator(sel).first();
1134
+ if (await textarea.isVisible({ timeout: 1500 })) {
1135
+ await textarea.fill(youtubeUrl);
1136
+ log.info(` ✅ Filled YouTube URL: ${sel}`);
1137
+ filled = true;
1138
+ break;
1139
+ }
1140
+ }
1141
+ catch {
1142
+ continue;
1143
+ }
1144
+ }
1145
+ if (!filled) {
1146
+ throw new Error("Could not find URL textarea");
1147
+ }
1148
+ await randomDelay(500, 1000);
1149
+ await sendProgress?.("Submitting...", 4, 4);
1150
+ // Submit - look in dialog context first
1151
+ const submitSelectors = [
1152
+ '.cdk-overlay-pane button:has-text("Insert")',
1153
+ '.cdk-overlay-pane button:has-text("Add")',
1154
+ '[role="dialog"] button:has-text("Insert")',
1155
+ '[role="dialog"] button:has-text("Add")',
1156
+ 'button:has-text("Insert")',
1157
+ 'button:has-text("Add")',
1158
+ 'button[type="submit"]',
1159
+ ];
1160
+ let submitted = false;
1161
+ for (const sel of submitSelectors) {
1162
+ try {
1163
+ const btn = page.locator(sel).first();
1164
+ if (await btn.isVisible({ timeout: 1500 })) {
1165
+ await btn.click();
1166
+ log.info(` ✅ Clicked submit: ${sel}`);
1167
+ submitted = true;
1168
+ break;
1169
+ }
1170
+ }
1171
+ catch {
1172
+ continue;
1173
+ }
1174
+ }
1175
+ if (!submitted) {
1176
+ log.warning(' ⚠️ Submit button not found, dialog may have auto-submitted');
1177
+ }
1178
+ await randomDelay(3000, 4000); // YouTube processing takes longer
1179
+ this.updateActivity();
1180
+ log.success(`✅ [${this.sessionId}] YouTube source added via UI: "${youtubeUrl}"`);
1181
+ return true;
1182
+ }
1183
+ catch (error) {
1184
+ log.error(`❌ [${this.sessionId}] Failed to add YouTube source via UI: ${error}`);
1185
+ throw error;
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Search the web for new sources and add them to the notebook
1190
+ *
1191
+ * @param query - Search query
1192
+ * @param sendProgress - Optional progress callback
1193
+ * @returns true if search was submitted successfully
1194
+ */
1195
+ async searchWebForSourcesViaUI(query, sendProgress) {
1196
+ if (!this.page || !this.initialized) {
1197
+ throw new Error("Session not initialized");
1198
+ }
1199
+ log.info(`🔍 [${this.sessionId}] Searching web for sources: "${query}"`);
1200
+ try {
1201
+ const page = this.page;
1202
+ await sendProgress?.("Opening search panel...", 1, 3);
1203
+ // Find the search input in the sources panel
1204
+ const searchInputSelectors = [
1205
+ 'input[placeholder*="Search the web"]',
1206
+ 'input[placeholder*="Search"]',
1207
+ 'input[aria-label*="Search"]',
1208
+ '.sources-panel input[type="text"]',
1209
+ ];
1210
+ let searchInput = null;
1211
+ for (const sel of searchInputSelectors) {
1212
+ try {
1213
+ const input = page.locator(sel).first();
1214
+ if (await input.isVisible({ timeout: 2000 })) {
1215
+ searchInput = input;
1216
+ log.info(` ✅ Found search input: ${sel}`);
1217
+ break;
1218
+ }
1219
+ }
1220
+ catch {
1221
+ continue;
1222
+ }
1223
+ }
1224
+ if (!searchInput) {
1225
+ throw new Error("Could not find web search input");
1226
+ }
1227
+ await sendProgress?.("Entering search query...", 2, 3);
1228
+ // Fill the search query
1229
+ await searchInput.fill(query);
1230
+ await randomDelay(500, 1000);
1231
+ await sendProgress?.("Submitting search...", 3, 3);
1232
+ // Click the submit button (arrow icon)
1233
+ const submitSelectors = [
1234
+ 'button[aria-label*="Search"]',
1235
+ 'button[aria-label*="Submit"]',
1236
+ 'button:has(mat-icon:has-text("arrow_forward"))',
1237
+ 'button:has(span:has-text("arrow_forward"))',
1238
+ '.sources-panel button[type="submit"]',
1239
+ ];
1240
+ let submitted = false;
1241
+ for (const sel of submitSelectors) {
1242
+ try {
1243
+ const btn = page.locator(sel).first();
1244
+ if (await btn.isVisible({ timeout: 1500 })) {
1245
+ await btn.click();
1246
+ log.info(` ✅ Clicked search submit: ${sel}`);
1247
+ submitted = true;
1248
+ break;
1249
+ }
1250
+ }
1251
+ catch {
1252
+ continue;
1253
+ }
1254
+ }
1255
+ if (!submitted) {
1256
+ // Try pressing Enter as fallback
1257
+ await searchInput.press('Enter');
1258
+ log.info(` ✅ Pressed Enter to submit search`);
1259
+ }
1260
+ await randomDelay(3000, 5000); // Wait for search results
1261
+ this.updateActivity();
1262
+ log.success(`✅ [${this.sessionId}] Web search submitted: "${query}"`);
1263
+ return true;
1264
+ }
1265
+ catch (error) {
1266
+ log.error(`❌ [${this.sessionId}] Failed to search web for sources: ${error}`);
1267
+ throw error;
1268
+ }
1269
+ }
1270
+ /**
1271
+ * List sources in the notebook via browser UI
1272
+ *
1273
+ * @returns Array of source objects with title and id
1274
+ */
1275
+ async listSourcesViaUI() {
1276
+ if (!this.page || !this.initialized) {
1277
+ throw new Error("Session not initialized");
1278
+ }
1279
+ log.info(`📚 [${this.sessionId}] Listing sources via UI...`);
1280
+ try {
1281
+ const page = this.page;
1282
+ // Click on Sources tab first (iPad Mini viewport uses role="tab")
1283
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
1284
+ if (await sourcesTab.isVisible({ timeout: 5000 })) {
1285
+ await sourcesTab.click();
1286
+ await randomDelay(1000, 1500);
1287
+ }
1288
+ // Use JS evaluation to get source titles (most reliable method)
1289
+ // Sources have button.source-stretched-button with aria-label containing the title
1290
+ const sourceData = await page.evaluate(`
1291
+ Array.from(document.querySelectorAll('button[aria-label]'))
1292
+ .filter(b => b.classList.contains('source-stretched-button'))
1293
+ .map(b => ({
1294
+ title: b.getAttribute('aria-label') || '',
1295
+ id: b.closest('[draggable]')?.querySelector('[id*="source"]')?.id || undefined
1296
+ }))
1297
+ .filter(s => s.title.length > 0)
1298
+ `);
1299
+ log.success(`✅ [${this.sessionId}] Found ${sourceData.length} sources via UI`);
1300
+ this.updateActivity();
1301
+ return sourceData;
1302
+ }
1303
+ catch (error) {
1304
+ log.error(`❌ [${this.sessionId}] Failed to list sources via UI: ${error}`);
1305
+ return [];
1306
+ }
1307
+ }
1308
+ /**
1309
+ * Delete a source by title via browser UI
1310
+ *
1311
+ * @param sourceTitle - The title/name of the source to delete
1312
+ * @param sendProgress - Optional progress callback
1313
+ * @returns true if source was deleted successfully
1314
+ */
1315
+ async deleteSourceViaUI(sourceTitle, sendProgress) {
1316
+ if (!this.page || !this.initialized) {
1317
+ throw new Error("Session not initialized");
1318
+ }
1319
+ log.info(`🗑️ [${this.sessionId}] Deleting source via UI: ${sourceTitle}`);
1320
+ try {
1321
+ const page = this.page;
1322
+ // Click on Sources tab first
1323
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
1324
+ if (await sourcesTab.isVisible({ timeout: 5000 })) {
1325
+ await sourcesTab.click();
1326
+ await randomDelay(1000, 1500);
1327
+ }
1328
+ // Find the source button by aria-label (title)
1329
+ const sourceButton = page.locator(`button.source-stretched-button[aria-label="${sourceTitle}"]`).first();
1330
+ if (!await sourceButton.isVisible({ timeout: 5000 })) {
1331
+ log.error(`❌ [${this.sessionId}] Source not found: ${sourceTitle}`);
1332
+ return false;
1333
+ }
1334
+ // Find the more button (⋮) in the same source container
1335
+ // IDs are like: source-item-more-button-{uuid}
1336
+ const sourceContainer = sourceButton.locator('xpath=ancestor::*[@draggable="true"]').first();
1337
+ const moreButton = sourceContainer.locator('[id^="source-item-more-button"], button[aria-label*="More"], button[aria-label*="more"], [aria-haspopup="menu"]').first();
1338
+ if (await moreButton.isVisible({ timeout: 3000 })) {
1339
+ await moreButton.click();
1340
+ log.info(` ✅ Clicked more button for: ${sourceTitle}`);
1341
+ await randomDelay(500, 800);
1342
+ }
1343
+ else {
1344
+ // Fallback: hover to reveal more button
1345
+ await sourceButton.hover();
1346
+ await randomDelay(500, 800);
1347
+ const hoverMoreBtn = sourceContainer.locator('[id^="source-item-more-button"], button[aria-label*="More"]').first();
1348
+ if (await hoverMoreBtn.isVisible({ timeout: 2000 })) {
1349
+ await hoverMoreBtn.click();
1350
+ await randomDelay(500, 800);
1351
+ }
1352
+ else {
1353
+ log.error(`❌ [${this.sessionId}] More button not found`);
1354
+ return false;
1355
+ }
1356
+ }
1357
+ // Click "Delete" from the menu
1358
+ const deleteMenuItem = page.locator('[role="menuitem"]:has-text("Delete"), button:has-text("Delete")').first();
1359
+ if (await deleteMenuItem.isVisible({ timeout: 3000 })) {
1360
+ await deleteMenuItem.click();
1361
+ log.info(` ✅ Clicked Delete menu item`);
1362
+ await randomDelay(500, 800);
1363
+ // Confirm deletion if dialog appears
1364
+ const confirmBtn = page.locator('button:has-text("Delete"):not([role="menuitem"])').first();
1365
+ if (await confirmBtn.isVisible({ timeout: 3000 })) {
1366
+ await confirmBtn.click();
1367
+ log.info(` ✅ Confirmed deletion`);
1368
+ await randomDelay(1000, 1500);
1369
+ }
1370
+ log.success(`✅ [${this.sessionId}] Source deleted: ${sourceTitle}`);
1371
+ if (sendProgress)
1372
+ await sendProgress(`✅ Source deleted: ${sourceTitle}`);
1373
+ this.updateActivity();
1374
+ return true;
1375
+ }
1376
+ log.error(`❌ [${this.sessionId}] Delete menu item not found`);
1377
+ return false;
1378
+ }
1379
+ catch (error) {
1380
+ log.error(`❌ [${this.sessionId}] Failed to delete source via UI: ${error}`);
1381
+ return false;
1382
+ }
1383
+ }
1384
+ /**
1385
+ * Get source details by clicking on it via browser UI
1386
+ *
1387
+ * @param sourceTitle - The title/name of the source
1388
+ * @returns Source details object or null if not found
1389
+ */
1390
+ async getSourceDetailsViaUI(sourceTitle) {
1391
+ if (!this.page || !this.initialized) {
1392
+ throw new Error("Session not initialized");
1393
+ }
1394
+ log.info(`📄 [${this.sessionId}] Getting source details via UI: ${sourceTitle}`);
1395
+ try {
1396
+ const page = this.page;
1397
+ // Click on Sources tab first
1398
+ const sourcesTab = page.locator('[role="tab"]:has-text("Sources")').first();
1399
+ if (await sourcesTab.isVisible({ timeout: 5000 })) {
1400
+ await sourcesTab.click();
1401
+ await randomDelay(1000, 1500);
1402
+ }
1403
+ // Find and click the source to open details panel
1404
+ const sourceButton = page.locator(`button.source-stretched-button[aria-label="${sourceTitle}"]`).first();
1405
+ if (!await sourceButton.isVisible({ timeout: 5000 })) {
1406
+ log.error(`❌ [${this.sessionId}] Source not found: ${sourceTitle}`);
1407
+ return null;
1408
+ }
1409
+ await sourceButton.click();
1410
+ await randomDelay(1500, 2000);
1411
+ // Scrape details from the source panel
1412
+ const details = await page.evaluate(`
1413
+ (() => {
1414
+ // Look for source detail panel elements
1415
+ const titleEl = document.querySelector('[class*="source-title"], [class*="sourceTitle"], h2, h3');
1416
+ const typeEl = document.querySelector('[class*="source-type"], [class*="sourceType"]');
1417
+ const dateEl = document.querySelector('[class*="date"], time');
1418
+ const summaryEl = document.querySelector('[class*="summary"], [class*="description"], p');
1419
+
1420
+ return {
1421
+ title: titleEl?.textContent?.trim() || '${sourceTitle}',
1422
+ type: typeEl?.textContent?.trim() || undefined,
1423
+ addedDate: dateEl?.textContent?.trim() || undefined,
1424
+ summary: summaryEl?.textContent?.trim() || undefined
1425
+ };
1426
+ })()
1427
+ `);
1428
+ log.success(`✅ [${this.sessionId}] Got source details: ${sourceTitle}`);
1429
+ this.updateActivity();
1430
+ return details;
1431
+ }
1432
+ catch (error) {
1433
+ log.error(`❌ [${this.sessionId}] Failed to get source details via UI: ${error}`);
1434
+ return null;
1435
+ }
1436
+ }
602
1437
  /**
603
1438
  * Close the session
604
1439
  */