@roomi-fields/notebooklm-mcp 1.3.6 → 1.5.0

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 (138) hide show
  1. package/README.md +69 -34
  2. package/dist/accounts/account-manager.d.ts +163 -0
  3. package/dist/accounts/account-manager.d.ts.map +1 -0
  4. package/dist/accounts/account-manager.js +614 -0
  5. package/dist/accounts/account-manager.js.map +1 -0
  6. package/dist/accounts/auto-login-manager.d.ts +62 -0
  7. package/dist/accounts/auto-login-manager.d.ts.map +1 -0
  8. package/dist/accounts/auto-login-manager.js +537 -0
  9. package/dist/accounts/auto-login-manager.js.map +1 -0
  10. package/dist/accounts/crypto.d.ts +45 -0
  11. package/dist/accounts/crypto.d.ts.map +1 -0
  12. package/dist/accounts/crypto.js +138 -0
  13. package/dist/accounts/crypto.js.map +1 -0
  14. package/dist/accounts/index.d.ts +14 -0
  15. package/dist/accounts/index.d.ts.map +1 -0
  16. package/dist/accounts/index.js +14 -0
  17. package/dist/accounts/index.js.map +1 -0
  18. package/dist/accounts/types.d.ts +103 -0
  19. package/dist/accounts/types.d.ts.map +1 -0
  20. package/dist/accounts/types.js +7 -0
  21. package/dist/accounts/types.js.map +1 -0
  22. package/dist/auth/auth-manager.d.ts +9 -2
  23. package/dist/auth/auth-manager.d.ts.map +1 -1
  24. package/dist/auth/auth-manager.js +60 -6
  25. package/dist/auth/auth-manager.js.map +1 -1
  26. package/dist/auto-discovery/auto-discovery.d.ts.map +1 -1
  27. package/dist/auto-discovery/auto-discovery.js +2 -1
  28. package/dist/auto-discovery/auto-discovery.js.map +1 -1
  29. package/dist/cli/accounts.d.ts +13 -0
  30. package/dist/cli/accounts.d.ts.map +1 -0
  31. package/dist/cli/accounts.js +195 -0
  32. package/dist/cli/accounts.js.map +1 -0
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +9 -0
  35. package/dist/config.js.map +1 -1
  36. package/dist/content/content-generator.d.ts +153 -0
  37. package/dist/content/content-generator.d.ts.map +1 -0
  38. package/dist/content/content-generator.js +637 -0
  39. package/dist/content/content-generator.js.map +1 -0
  40. package/dist/content/content-manager.d.ts +364 -0
  41. package/dist/content/content-manager.d.ts.map +1 -0
  42. package/dist/content/content-manager.js +3846 -0
  43. package/dist/content/content-manager.js.map +1 -0
  44. package/dist/content/content-templates.d.ts +183 -0
  45. package/dist/content/content-templates.d.ts.map +1 -0
  46. package/dist/content/content-templates.js +719 -0
  47. package/dist/content/content-templates.js.map +1 -0
  48. package/dist/content/index.d.ts +14 -0
  49. package/dist/content/index.d.ts.map +1 -0
  50. package/dist/content/index.js +14 -0
  51. package/dist/content/index.js.map +1 -0
  52. package/dist/content/types.d.ts +285 -0
  53. package/dist/content/types.d.ts.map +1 -0
  54. package/dist/content/types.js +10 -0
  55. package/dist/content/types.js.map +1 -0
  56. package/dist/errors.d.ts +1 -1
  57. package/dist/errors.d.ts.map +1 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/http-wrapper.d.ts +7 -0
  60. package/dist/http-wrapper.d.ts.map +1 -1
  61. package/dist/http-wrapper.js +449 -29
  62. package/dist/http-wrapper.js.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/library/notebook-library.d.ts +4 -0
  66. package/dist/library/notebook-library.d.ts.map +1 -1
  67. package/dist/library/notebook-library.js +20 -3
  68. package/dist/library/notebook-library.js.map +1 -1
  69. package/dist/session/browser-session.d.ts +35 -8
  70. package/dist/session/browser-session.d.ts.map +1 -1
  71. package/dist/session/browser-session.js +242 -28
  72. package/dist/session/browser-session.js.map +1 -1
  73. package/dist/session/session-manager.d.ts +6 -0
  74. package/dist/session/session-manager.d.ts.map +1 -1
  75. package/dist/session/session-manager.js +46 -14
  76. package/dist/session/session-manager.js.map +1 -1
  77. package/dist/session/shared-context-manager.d.ts +3 -3
  78. package/dist/session/shared-context-manager.d.ts.map +1 -1
  79. package/dist/session/shared-context-manager.js +8 -7
  80. package/dist/session/shared-context-manager.js.map +1 -1
  81. package/dist/stdio-http-proxy.d.ts +24 -0
  82. package/dist/stdio-http-proxy.d.ts.map +1 -0
  83. package/dist/stdio-http-proxy.js +592 -0
  84. package/dist/stdio-http-proxy.js.map +1 -0
  85. package/dist/tools/index.d.ts +106 -1
  86. package/dist/tools/index.d.ts.map +1 -1
  87. package/dist/tools/index.js +1028 -7
  88. package/dist/tools/index.js.map +1 -1
  89. package/dist/types.d.ts +81 -17
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/utils/citation-extractor.d.ts +66 -0
  92. package/dist/utils/citation-extractor.d.ts.map +1 -0
  93. package/dist/utils/citation-extractor.js +492 -0
  94. package/dist/utils/citation-extractor.js.map +1 -0
  95. package/dist/utils/page-utils.d.ts +8 -0
  96. package/dist/utils/page-utils.d.ts.map +1 -1
  97. package/dist/utils/page-utils.js +112 -8
  98. package/dist/utils/page-utils.js.map +1 -1
  99. package/docs/ARCHITECTURE_MIGRATION_STUDY.md +894 -0
  100. package/docs/CHROME_PROFILE_LIMITATION.md +15 -1
  101. package/docs/MULTI_ACCOUNT_SYSTEM.md +304 -0
  102. package/package.json +10 -10
  103. package/dist/__tests__/cleanup-manager.test.d.ts +0 -2
  104. package/dist/__tests__/cleanup-manager.test.d.ts.map +0 -1
  105. package/dist/__tests__/cleanup-manager.test.js +0 -341
  106. package/dist/__tests__/cleanup-manager.test.js.map +0 -1
  107. package/dist/__tests__/config-parsing.test.d.ts +0 -2
  108. package/dist/__tests__/config-parsing.test.d.ts.map +0 -1
  109. package/dist/__tests__/config-parsing.test.js +0 -338
  110. package/dist/__tests__/config-parsing.test.js.map +0 -1
  111. package/dist/__tests__/config.test.d.ts +0 -2
  112. package/dist/__tests__/config.test.d.ts.map +0 -1
  113. package/dist/__tests__/config.test.js +0 -267
  114. package/dist/__tests__/config.test.js.map +0 -1
  115. package/dist/__tests__/errors.test.d.ts +0 -2
  116. package/dist/__tests__/errors.test.d.ts.map +0 -1
  117. package/dist/__tests__/errors.test.js +0 -166
  118. package/dist/__tests__/errors.test.js.map +0 -1
  119. package/dist/__tests__/logger.test.d.ts +0 -2
  120. package/dist/__tests__/logger.test.d.ts.map +0 -1
  121. package/dist/__tests__/logger.test.js +0 -324
  122. package/dist/__tests__/logger.test.js.map +0 -1
  123. package/dist/__tests__/page-utils.test.d.ts +0 -2
  124. package/dist/__tests__/page-utils.test.d.ts.map +0 -1
  125. package/dist/__tests__/page-utils.test.js +0 -349
  126. package/dist/__tests__/page-utils.test.js.map +0 -1
  127. package/dist/__tests__/setup-verification.test.d.ts +0 -2
  128. package/dist/__tests__/setup-verification.test.d.ts.map +0 -1
  129. package/dist/__tests__/setup-verification.test.js +0 -15
  130. package/dist/__tests__/setup-verification.test.js.map +0 -1
  131. package/dist/__tests__/stealth-utils.test.d.ts +0 -2
  132. package/dist/__tests__/stealth-utils.test.d.ts.map +0 -1
  133. package/dist/__tests__/stealth-utils.test.js +0 -413
  134. package/dist/__tests__/stealth-utils.test.js.map +0 -1
  135. package/dist/__tests__/types.test.d.ts +0 -2
  136. package/dist/__tests__/types.test.d.ts.map +0 -1
  137. package/dist/__tests__/types.test.js +0 -461
  138. package/dist/__tests__/types.test.js.map +0 -1
@@ -0,0 +1,3846 @@
1
+ /**
2
+ * Content Manager
3
+ *
4
+ * Handles NotebookLM content operations:
5
+ * - Source/document upload
6
+ * - Content generation (audio, briefing, study guides, etc.)
7
+ * - Content listing and download
8
+ *
9
+ * Uses Playwright to interact with NotebookLM's web interface.
10
+ */
11
+ import path from 'path';
12
+ import { existsSync } from 'fs';
13
+ import { randomDelay, realisticClick, humanType } from '../utils/stealth-utils.js';
14
+ import { log } from '../utils/logger.js';
15
+ import { CONFIG } from '../config.js';
16
+ import { waitForLatestAnswer, snapshotAllResponses, isErrorMessage } from '../utils/page-utils.js';
17
+ import { ContentGenerator } from './content-generator.js';
18
+ // Note: UI selectors are defined inline in methods for better maintainability
19
+ // as NotebookLM's UI may change frequently
20
+ export class ContentManager {
21
+ page;
22
+ constructor(page) {
23
+ this.page = page;
24
+ }
25
+ // ============================================================================
26
+ // Source/Document Upload
27
+ // ============================================================================
28
+ /**
29
+ * Add a source to the current notebook
30
+ */
31
+ async addSource(input) {
32
+ log.info(`📄 Adding source: ${input.type}`);
33
+ // CRITICAL: Capture initial URL BEFORE any action
34
+ // NotebookLM may redirect when clicking "Add source" button!
35
+ const initialUrl = this.page.url();
36
+ const expectedNotebookUuid = initialUrl.match(/notebook\/([a-f0-9-]+)/)?.[1];
37
+ log.info(` 🎯 Target notebook UUID: ${expectedNotebookUuid || 'NOT FOUND'}`);
38
+ try {
39
+ // Click "Add source" button
40
+ await this.clickAddSource();
41
+ // Wait for upload dialog
42
+ await this.waitForUploadDialog();
43
+ // Select upload type and upload (pass expectedNotebookUuid for redirect detection)
44
+ switch (input.type) {
45
+ case 'file':
46
+ return await this.uploadFile(input, expectedNotebookUuid);
47
+ case 'url':
48
+ return await this.uploadUrl(input, expectedNotebookUuid);
49
+ case 'text':
50
+ return await this.uploadText(input, expectedNotebookUuid);
51
+ case 'google_drive':
52
+ return await this.uploadGoogleDrive(input, expectedNotebookUuid);
53
+ case 'youtube':
54
+ return await this.uploadYouTube(input, expectedNotebookUuid);
55
+ default:
56
+ return { success: false, error: `Unsupported source type: ${input.type}` };
57
+ }
58
+ }
59
+ catch (error) {
60
+ const errorMsg = error instanceof Error ? error.message : String(error);
61
+ log.error(`❌ Failed to add source: ${errorMsg}`);
62
+ return { success: false, error: errorMsg };
63
+ }
64
+ }
65
+ /**
66
+ * Click the "Add source" button
67
+ */
68
+ async clickAddSource() {
69
+ // First, ensure we're on the Sources panel (left panel)
70
+ await this.ensureSourcesPanel();
71
+ // Wait for panel to be ready (increased for reliability)
72
+ await randomDelay(800, 1200);
73
+ // Check if a dialog is already open and close it first
74
+ try {
75
+ const existingDialog = this.page.locator('[role="dialog"]');
76
+ if (await existingDialog.isVisible({ timeout: 500 })) {
77
+ log.info(' ⚠️ Dialog already open, closing first...');
78
+ await this.page.keyboard.press('Escape');
79
+ await randomDelay(500, 800);
80
+ }
81
+ }
82
+ catch {
83
+ // No existing dialog, continue
84
+ }
85
+ const addSourceSelectors = [
86
+ // NotebookLM current UI (Dec 2024) - aria-label based (most reliable)
87
+ 'button[aria-label="Add source"]',
88
+ 'button[aria-label="Ajouter une source"]', // French
89
+ 'button[aria-label*="Add source"]',
90
+ 'button[aria-label*="Ajouter une source"]',
91
+ 'button[aria-label*="add source" i]',
92
+ // Icon button with "add" icon specifically
93
+ 'button:has(mat-icon:has-text("add"))',
94
+ 'button:has(mat-icon:has-text("add_circle"))',
95
+ // Text-based patterns
96
+ 'button:has-text("Add source")',
97
+ 'button:has-text("Ajouter une source")',
98
+ // FAB buttons (floating action button for adding)
99
+ 'button.mat-fab',
100
+ 'button.mat-mini-fab',
101
+ // REMOVED generic selectors that match ANY icon button
102
+ ];
103
+ log.info(` 🔍 Looking for Add source button...`);
104
+ for (const selector of addSourceSelectors) {
105
+ try {
106
+ const button = this.page.locator(selector).first();
107
+ if (await button.isVisible({ timeout: 1000 })) {
108
+ log.info(` ✅ Found add source button: ${selector}`);
109
+ await realisticClick(this.page, selector, true);
110
+ await randomDelay(500, 1000);
111
+ return;
112
+ }
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ }
118
+ // Fallback: Try to find any button with "add" aria-label
119
+ log.info(` 🔍 Trying fallback: buttons with add-related aria-label...`);
120
+ try {
121
+ const addButtons = await this.page.locator('button[aria-label]').all();
122
+ for (const btn of addButtons) {
123
+ const ariaLabel = await btn.getAttribute('aria-label');
124
+ if (ariaLabel && /add|ajouter|upload|source/i.test(ariaLabel)) {
125
+ if (await btn.isVisible()) {
126
+ log.info(` ✅ Found add button via fallback: aria-label="${ariaLabel}"`);
127
+ await btn.click();
128
+ await randomDelay(500, 1000);
129
+ return;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ // Continue to debug
136
+ }
137
+ // Debug: log page content to help identify the correct selector
138
+ await this.debugPageContent();
139
+ throw new Error('Could not find "Add source" button');
140
+ }
141
+ /**
142
+ * Ensure we're on the Sources panel
143
+ */
144
+ async ensureSourcesPanel() {
145
+ log.info(` 📑 Ensuring Sources panel is active...`);
146
+ // First, close any open dialogs that might be blocking
147
+ try {
148
+ const openDialog = this.page.locator('[role="dialog"]');
149
+ if (await openDialog.isVisible({ timeout: 500 })) {
150
+ log.info(` ⚠️ Closing blocking dialog first...`);
151
+ await this.page.keyboard.press('Escape');
152
+ await randomDelay(300, 500);
153
+ }
154
+ }
155
+ catch {
156
+ /* no dialog */
157
+ }
158
+ const sourcesTabSelectors = [
159
+ // NotebookLM current UI (Dec 2024) - MDC tabs
160
+ 'div.mdc-tab:has-text("Sources")',
161
+ '.mat-mdc-tab:has-text("Sources")',
162
+ '[role="tab"]:has-text("Sources")',
163
+ // First tab in the tab list (Sources is typically first)
164
+ '.mat-mdc-tab-list .mdc-tab:first-child',
165
+ ];
166
+ for (const selector of sourcesTabSelectors) {
167
+ try {
168
+ const tab = this.page.locator(selector).first();
169
+ if (await tab.isVisible({ timeout: 2000 })) {
170
+ // Check if already selected using multiple methods
171
+ const isSelected = await tab.getAttribute('aria-selected');
172
+ const hasActiveClass = (await tab.getAttribute('class'))?.includes('mdc-tab--active');
173
+ if (isSelected !== 'true' && !hasActiveClass) {
174
+ log.info(` 📑 Clicking Sources tab: ${selector}`);
175
+ // Use shorter timeout and force click if needed
176
+ await tab.click({ timeout: 5000 });
177
+ await randomDelay(500, 1000);
178
+ }
179
+ else {
180
+ log.info(` ✅ Sources tab already active`);
181
+ }
182
+ return;
183
+ }
184
+ }
185
+ catch (e) {
186
+ log.warning(` ⚠️ Selector failed: ${selector} - ${e}`);
187
+ continue;
188
+ }
189
+ }
190
+ // Sources panel might already be visible or not use tabs
191
+ log.info(` ℹ️ No Sources tab found, assuming already on sources panel`);
192
+ // Take debug screenshot to help identify the correct selectors
193
+ try {
194
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-sources-panel.png');
195
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
196
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
197
+ // Log all tab elements to help find the correct selector
198
+ const allTabs = await this.page.locator('[role="tab"], .mdc-tab, .mat-tab-label').all();
199
+ log.info(` 🔍 Found ${allTabs.length} tab-like elements:`);
200
+ for (let i = 0; i < Math.min(allTabs.length, 10); i++) {
201
+ const tab = allTabs[i];
202
+ const text = await tab.textContent();
203
+ const ariaLabel = await tab.getAttribute('aria-label');
204
+ const classes = await tab.getAttribute('class');
205
+ log.info(` Tab[${i}]: text="${text?.trim()}", aria="${ariaLabel}", class="${classes?.substring(0, 50)}..."`);
206
+ }
207
+ }
208
+ catch (e) {
209
+ log.warning(` ⚠️ Could not capture debug info: ${e}`);
210
+ }
211
+ }
212
+ /**
213
+ * Debug helper to log page content for selector debugging
214
+ */
215
+ async debugPageContent() {
216
+ try {
217
+ // Log all buttons on the page
218
+ const buttons = await this.page.locator('button').all();
219
+ log.info(` 🔍 DEBUG: Found ${buttons.length} buttons on page`);
220
+ for (let i = 0; i < Math.min(buttons.length, 10); i++) {
221
+ const btn = buttons[i];
222
+ const ariaLabel = await btn.getAttribute('aria-label');
223
+ const text = await btn.textContent();
224
+ const classes = await btn.getAttribute('class');
225
+ log.info(` 🔍 Button[${i}]: aria="${ariaLabel}", text="${text?.trim()}", class="${classes}"`);
226
+ }
227
+ // Take a screenshot for debugging
228
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-add-source.png');
229
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
230
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
231
+ }
232
+ catch (e) {
233
+ log.warning(` ⚠️ Debug failed: ${e}`);
234
+ }
235
+ }
236
+ /**
237
+ * Wait for upload dialog to appear
238
+ */
239
+ async waitForUploadDialog() {
240
+ const dialogSelectors = [
241
+ '[role="dialog"]',
242
+ '.upload-dialog',
243
+ '.modal',
244
+ '[data-dialog="upload"]',
245
+ ];
246
+ for (const selector of dialogSelectors) {
247
+ try {
248
+ await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
249
+ log.info(` ✅ Upload dialog appeared`);
250
+ return;
251
+ }
252
+ catch {
253
+ continue;
254
+ }
255
+ }
256
+ // Dialog might not be a separate element - continue anyway
257
+ log.info(` ℹ️ No explicit dialog, continuing with upload...`);
258
+ }
259
+ /**
260
+ * Upload a local file
261
+ */
262
+ async uploadFile(input, expectedNotebookUuid) {
263
+ if (!input.filePath) {
264
+ return { success: false, error: 'File path is required' };
265
+ }
266
+ // Path traversal protection: resolve and validate the path
267
+ const resolvedPath = path.resolve(input.filePath);
268
+ const allowedDir = path.resolve(CONFIG.dataDir);
269
+ // Allow files from dataDir or current working directory
270
+ const cwd = path.resolve(process.cwd());
271
+ const isAllowed = resolvedPath.startsWith(allowedDir) || resolvedPath.startsWith(cwd);
272
+ if (!isAllowed) {
273
+ log.warning(` ⚠️ Path traversal attempt blocked: ${input.filePath}`);
274
+ return {
275
+ success: false,
276
+ error: 'File path not allowed: must be within data directory or current working directory',
277
+ };
278
+ }
279
+ if (!existsSync(resolvedPath)) {
280
+ return { success: false, error: `File not found: ${input.filePath}` };
281
+ }
282
+ log.info(` 📁 Uploading file: ${path.basename(resolvedPath)}`);
283
+ try {
284
+ // Click on file upload option
285
+ const fileTypeSelectors = [
286
+ 'button:has-text("Upload files")',
287
+ 'button:has-text("Importer des fichiers")',
288
+ 'button:has-text("Upload")',
289
+ '[data-type="file"]',
290
+ ];
291
+ for (const selector of fileTypeSelectors) {
292
+ try {
293
+ const btn = this.page.locator(selector).first();
294
+ if (await btn.isVisible({ timeout: 1000 })) {
295
+ await btn.click();
296
+ await randomDelay(300, 500);
297
+ break;
298
+ }
299
+ }
300
+ catch {
301
+ continue;
302
+ }
303
+ }
304
+ // Find file input and upload
305
+ const fileInput = await this.page.waitForSelector('input[type="file"]', {
306
+ state: 'attached',
307
+ timeout: 5000,
308
+ });
309
+ if (!fileInput) {
310
+ throw new Error('File input not found');
311
+ }
312
+ await fileInput.setInputFiles(input.filePath);
313
+ log.info(` ✅ File selected`);
314
+ // Wait for upload to start
315
+ await randomDelay(1000, 2000);
316
+ // Click upload/confirm button
317
+ await this.clickUploadButton();
318
+ // Wait for processing
319
+ const result = await this.waitForSourceProcessing(input.title || path.basename(input.filePath), undefined, expectedNotebookUuid);
320
+ return result;
321
+ }
322
+ catch (error) {
323
+ const errorMsg = error instanceof Error ? error.message : String(error);
324
+ return { success: false, error: `File upload failed: ${errorMsg}` };
325
+ }
326
+ }
327
+ /**
328
+ * Upload from URL
329
+ */
330
+ async uploadUrl(input, expectedNotebookUuid) {
331
+ if (!input.url) {
332
+ return { success: false, error: 'URL is required' };
333
+ }
334
+ log.info(` 🌐 Adding URL: ${input.url}`);
335
+ try {
336
+ // Click on URL/Website option
337
+ const urlTypeSelectors = [
338
+ 'button:has-text("Website")',
339
+ 'button:has-text("Site web")',
340
+ 'button:has-text("Link")',
341
+ 'button:has-text("URL")',
342
+ 'button:has-text("Web")',
343
+ '[data-type="url"]',
344
+ '[aria-label*="website"]',
345
+ '[aria-label*="URL"]',
346
+ ];
347
+ log.info(` 🔍 Looking for URL option...`);
348
+ let foundUrlOption = false;
349
+ for (const selector of urlTypeSelectors) {
350
+ try {
351
+ const btn = this.page.locator(selector).first();
352
+ if (await btn.isVisible({ timeout: 500 })) {
353
+ log.info(` ✅ Found URL option: ${selector}`);
354
+ await btn.click();
355
+ await randomDelay(300, 500);
356
+ foundUrlOption = true;
357
+ break;
358
+ }
359
+ }
360
+ catch {
361
+ continue;
362
+ }
363
+ }
364
+ if (!foundUrlOption) {
365
+ log.info(` ℹ️ No URL option button found, looking for input directly`);
366
+ }
367
+ // Wait for input to appear after clicking option
368
+ await randomDelay(500, 1000);
369
+ // Find URL input (can be input OR textarea)
370
+ log.info(` 🔍 Looking for URL input...`);
371
+ const urlInputSelectors = [
372
+ // French placeholders - input AND textarea
373
+ 'input[placeholder*="Collez"]',
374
+ 'textarea[placeholder*="Collez"]',
375
+ 'input[placeholder*="liens"]',
376
+ 'textarea[placeholder*="liens"]',
377
+ // English placeholders
378
+ 'input[placeholder*="URL"]',
379
+ 'textarea[placeholder*="URL"]',
380
+ 'input[placeholder*="url"]',
381
+ 'textarea[placeholder*="url"]',
382
+ 'input[placeholder*="http"]',
383
+ 'textarea[placeholder*="http"]',
384
+ 'input[placeholder*="Paste"]',
385
+ 'textarea[placeholder*="Paste"]',
386
+ 'input[placeholder*="Enter"]',
387
+ 'textarea[placeholder*="Enter"]',
388
+ 'input[placeholder*="Coller"]',
389
+ 'textarea[placeholder*="Coller"]',
390
+ 'input[placeholder*="link"]',
391
+ 'textarea[placeholder*="link"]',
392
+ 'input[placeholder*="Link"]',
393
+ 'textarea[placeholder*="Link"]',
394
+ 'input[name="url"]',
395
+ 'input[type="url"]',
396
+ '[role="dialog"] input[type="text"]',
397
+ '[role="dialog"] input:not([type="hidden"])',
398
+ '[role="dialog"] textarea',
399
+ '.mat-dialog-content input',
400
+ '.mat-dialog-content textarea',
401
+ '.mdc-dialog__content input',
402
+ '.mdc-dialog__content textarea',
403
+ ];
404
+ let urlInput = null;
405
+ for (const selector of urlInputSelectors) {
406
+ try {
407
+ const input = this.page.locator(selector).first();
408
+ if (await input.isVisible({ timeout: 500 })) {
409
+ urlInput = input;
410
+ log.info(` ✅ Found URL input: ${selector}`);
411
+ break;
412
+ }
413
+ }
414
+ catch {
415
+ continue;
416
+ }
417
+ }
418
+ // Fallback: find any visible input or textarea in the dialog
419
+ if (!urlInput) {
420
+ log.info(` 🔍 Trying fallback: any visible input/textarea in dialog...`);
421
+ try {
422
+ // Try inputs first
423
+ const allInputs = await this.page.locator('[role="dialog"] input').all();
424
+ for (const input of allInputs) {
425
+ if (await input.isVisible()) {
426
+ urlInput = input;
427
+ const placeholder = await input.getAttribute('placeholder');
428
+ log.info(` ✅ Found input via fallback: placeholder="${placeholder}"`);
429
+ break;
430
+ }
431
+ }
432
+ // Try textareas if no input found
433
+ if (!urlInput) {
434
+ const allTextareas = await this.page.locator('[role="dialog"] textarea').all();
435
+ for (const textarea of allTextareas) {
436
+ if (await textarea.isVisible()) {
437
+ urlInput = textarea;
438
+ const placeholder = await textarea.getAttribute('placeholder');
439
+ log.info(` ✅ Found textarea via fallback: placeholder="${placeholder}"`);
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ }
445
+ catch {
446
+ /* ignore */
447
+ }
448
+ }
449
+ // Debug: list all inputs/textareas if still not found
450
+ if (!urlInput) {
451
+ log.warning(` ⚠️ URL input not found, listing dialog elements...`);
452
+ try {
453
+ const inputs = await this.page
454
+ .locator('[role="dialog"] input, [role="dialog"] textarea')
455
+ .all();
456
+ for (let i = 0; i < inputs.length; i++) {
457
+ const el = inputs[i];
458
+ const tag = await el.evaluate((e) => e.tagName?.toLowerCase() || 'unknown');
459
+ const type = await el.getAttribute('type');
460
+ const placeholder = await el.getAttribute('placeholder');
461
+ const visible = await el.isVisible();
462
+ log.info(` 🔍 Element[${i}]: tag=${tag}, type="${type}", placeholder="${placeholder}", visible=${visible}`);
463
+ }
464
+ }
465
+ catch (e) {
466
+ log.warning(` ⚠️ Could not list dialog elements: ${e}`);
467
+ }
468
+ throw new Error('URL input not found');
469
+ }
470
+ await urlInput.fill(input.url);
471
+ log.info(` ✅ URL entered`);
472
+ await randomDelay(300, 500);
473
+ // Click add/upload button
474
+ log.info(` 🔍 Looking for upload button...`);
475
+ await this.clickUploadButton();
476
+ // Wait for processing
477
+ const result = await this.waitForSourceProcessing(input.title || input.url, undefined, expectedNotebookUuid);
478
+ return result;
479
+ }
480
+ catch (error) {
481
+ const errorMsg = error instanceof Error ? error.message : String(error);
482
+ return { success: false, error: `URL upload failed: ${errorMsg}` };
483
+ }
484
+ }
485
+ /**
486
+ * Upload text content
487
+ */
488
+ async uploadText(input, expectedNotebookUuid) {
489
+ if (!input.text) {
490
+ return { success: false, error: 'Text content is required' };
491
+ }
492
+ log.info(` 📝 Adding text content (${input.text.length} chars)`);
493
+ try {
494
+ // Click on paste text option
495
+ // NotebookLM dialog shows: "Coller du texte" (French) with "Texte copié" span inside a clickable element
496
+ const textTypeSelectors = [
497
+ // French UI (current NotebookLM Dec 2024) - span element
498
+ 'span:has-text("Texte copié")',
499
+ ':has-text("Texte copié")',
500
+ // Parent of the span (clickable area)
501
+ '*:has(> span:has-text("Texte copié"))',
502
+ // English UI
503
+ 'span:has-text("Copied text")',
504
+ ':has-text("Copied text")',
505
+ '*:has(> span:has-text("Copied text"))',
506
+ // Generic fallbacks
507
+ 'span:has-text("Paste text")',
508
+ ':has-text("Paste text")',
509
+ '[data-type="text"]',
510
+ ];
511
+ log.info(` 🔍 Looking for paste text option...`);
512
+ let foundTextOption = false;
513
+ // Debug: Log all clickable elements in the dialog
514
+ try {
515
+ const dialogButtons = await this.page
516
+ .locator('[role="dialog"] button, [role="dialog"] [role="button"], [role="dialog"] a')
517
+ .all();
518
+ log.info(` 🔍 DEBUG: Found ${dialogButtons.length} clickable elements in dialog`);
519
+ for (let i = 0; i < Math.min(dialogButtons.length, 15); i++) {
520
+ const btn = dialogButtons[i];
521
+ const text = await btn.textContent();
522
+ log.info(` Element[${i}]: "${text?.trim()}"`);
523
+ }
524
+ }
525
+ catch (e) {
526
+ log.warning(` ⚠️ Could not debug dialog elements: ${e}`);
527
+ }
528
+ for (const selector of textTypeSelectors) {
529
+ try {
530
+ log.info(` 🔍 Trying selector: ${selector}`);
531
+ const btn = this.page.locator(selector).first();
532
+ if (await btn.isVisible({ timeout: 2000 })) {
533
+ log.info(` ✅ Found text option: ${selector}`);
534
+ await btn.click();
535
+ await randomDelay(500, 1000);
536
+ foundTextOption = true;
537
+ break;
538
+ }
539
+ }
540
+ catch {
541
+ continue;
542
+ }
543
+ }
544
+ if (!foundTextOption) {
545
+ log.warning(` ⚠️ No text option found - this will likely fail!`);
546
+ // Take screenshot for debugging
547
+ try {
548
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-text-option-not-found.png');
549
+ await this.page.screenshot({ path: screenshotPath });
550
+ log.info(` 📸 Debug screenshot: ${screenshotPath}`);
551
+ }
552
+ catch {
553
+ /* ignore */
554
+ }
555
+ }
556
+ // Find text input - must be in the dialog, not the chat input
557
+ log.info(` 🔍 Looking for text input in dialog...`);
558
+ // Wait for the paste dialog to fully appear
559
+ await randomDelay(500, 800);
560
+ // Try to find textarea specifically in the dialog context
561
+ const textInputSelectors = [
562
+ '[role="dialog"] textarea',
563
+ '.mat-dialog-container textarea',
564
+ '.mdc-dialog textarea',
565
+ // Fallback to any visible textarea that's not the chat input
566
+ 'textarea:not(.query-box-input)',
567
+ ];
568
+ let textInput = null;
569
+ for (const selector of textInputSelectors) {
570
+ try {
571
+ const el = await this.page.waitForSelector(selector, {
572
+ state: 'visible',
573
+ timeout: 3000,
574
+ });
575
+ if (el) {
576
+ log.info(` ✅ Found text input with: ${selector}`);
577
+ textInput = el;
578
+ break;
579
+ }
580
+ }
581
+ catch {
582
+ continue;
583
+ }
584
+ }
585
+ if (!textInput) {
586
+ // Debug: list all textareas on page
587
+ const allTextareas = await this.page.locator('textarea').all();
588
+ log.warning(` ⚠️ Found ${allTextareas.length} textareas on page`);
589
+ for (let i = 0; i < Math.min(allTextareas.length, 3); i++) {
590
+ const cls = await allTextareas[i].getAttribute('class');
591
+ const placeholder = await allTextareas[i].getAttribute('placeholder');
592
+ log.info(` textarea[${i}]: class="${cls}", placeholder="${placeholder}"`);
593
+ }
594
+ throw new Error('Text input not found in dialog');
595
+ }
596
+ await textInput.fill(input.text);
597
+ log.info(` ✅ Text entered (${input.text.length} chars)`);
598
+ // Set title if provided
599
+ log.info(` 🔍 Looking for title input...`);
600
+ if (input.title) {
601
+ const titleSelectors = [
602
+ 'input[placeholder*="title"]',
603
+ 'input[placeholder*="Title"]',
604
+ 'input[placeholder*="name"]',
605
+ 'input[placeholder*="Name"]',
606
+ 'input[name="title"]',
607
+ '[role="dialog"] input[type="text"]:not([readonly])',
608
+ ];
609
+ let titleSet = false;
610
+ for (const selector of titleSelectors) {
611
+ try {
612
+ const titleInput = this.page.locator(selector).first();
613
+ if (await titleInput.isVisible({ timeout: 500 })) {
614
+ await titleInput.fill(input.title);
615
+ log.info(` ✅ Title set: ${input.title} (via ${selector})`);
616
+ titleSet = true;
617
+ break;
618
+ }
619
+ }
620
+ catch {
621
+ continue;
622
+ }
623
+ }
624
+ if (!titleSet) {
625
+ log.warning(` ⚠️ Title input NOT found - source will have default name`);
626
+ // Debug: list all inputs in dialog
627
+ try {
628
+ const allInputs = await this.page.locator('[role="dialog"] input').all();
629
+ log.info(` 🔍 DEBUG: Found ${allInputs.length} inputs in dialog`);
630
+ for (let i = 0; i < Math.min(allInputs.length, 5); i++) {
631
+ const inp = allInputs[i];
632
+ const type = await inp.getAttribute('type');
633
+ const placeholder = await inp.getAttribute('placeholder');
634
+ log.info(` input[${i}]: type="${type}", placeholder="${placeholder}"`);
635
+ }
636
+ }
637
+ catch {
638
+ /* ignore */
639
+ }
640
+ }
641
+ }
642
+ await randomDelay(300, 500);
643
+ // DEBUG: Take screenshot before clicking upload button
644
+ try {
645
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-before-insert.png');
646
+ await this.page.screenshot({ path: screenshotPath });
647
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
648
+ }
649
+ catch (e) {
650
+ log.warning(` ⚠️ Could not take debug screenshot: ${e}`);
651
+ }
652
+ // DEBUG: Check if the "Insérer" button is enabled
653
+ try {
654
+ const insertBtnSelectors = ['button:has-text("Insérer")', 'button:has-text("Insert")'];
655
+ for (const sel of insertBtnSelectors) {
656
+ const btn = this.page.locator(sel).first();
657
+ if (await btn.isVisible({ timeout: 500 })) {
658
+ const isDisabled = await btn.isDisabled();
659
+ const ariaDisabled = await btn.getAttribute('aria-disabled');
660
+ const classList = await btn.getAttribute('class');
661
+ log.info(` 🔍 Button "${sel}" - disabled: ${isDisabled}, aria-disabled: ${ariaDisabled}`);
662
+ log.info(` 🔍 Button classes: ${classList}`);
663
+ break;
664
+ }
665
+ }
666
+ }
667
+ catch (e) {
668
+ log.warning(` ⚠️ Could not check button state: ${e}`);
669
+ }
670
+ // Get first few words of text for later verification (NotebookLM uses text content as title)
671
+ const textPreview = input.text.slice(0, 30).trim();
672
+ log.info(` 📝 Text preview for verification: "${textPreview}..."`);
673
+ // Click add button
674
+ log.info(` 🔍 Looking for upload button...`);
675
+ await this.clickUploadButton();
676
+ // Wait for processing - NotebookLM names pasted text sources "Texte collé" in French or "Pasted text"
677
+ // We'll look for either the expected name or "Texte collé"
678
+ // Pass initialUuid to detect notebook redirection
679
+ const result = await this.waitForSourceProcessing(input.title || 'Texte collé', textPreview, expectedNotebookUuid);
680
+ return result;
681
+ }
682
+ catch (error) {
683
+ const errorMsg = error instanceof Error ? error.message : String(error);
684
+ return { success: false, error: `Text upload failed: ${errorMsg}` };
685
+ }
686
+ }
687
+ /**
688
+ * Upload from Google Drive
689
+ */
690
+ async uploadGoogleDrive(input, expectedNotebookUuid) {
691
+ if (!input.url) {
692
+ return { success: false, error: 'Google Drive URL is required' };
693
+ }
694
+ log.info(` 📂 Adding Google Drive source: ${input.url}`);
695
+ // Similar to URL upload but with Google Drive specific handling
696
+ return await this.uploadUrl({ ...input, type: 'url' }, expectedNotebookUuid);
697
+ }
698
+ /**
699
+ * Upload YouTube video
700
+ */
701
+ async uploadYouTube(input, expectedNotebookUuid) {
702
+ if (!input.url) {
703
+ return { success: false, error: 'YouTube URL is required' };
704
+ }
705
+ log.info(` 🎬 Adding YouTube video: ${input.url}`);
706
+ try {
707
+ // Wait for dialog to be fully ready
708
+ await randomDelay(500, 800);
709
+ // Click on YouTube option with expanded selectors
710
+ const ytSelectors = [
711
+ 'button:has-text("YouTube")',
712
+ '[data-type="youtube"]',
713
+ 'button[aria-label*="YouTube"]',
714
+ '[role="button"]:has-text("YouTube")',
715
+ // Material design buttons
716
+ '.mat-button:has-text("YouTube")',
717
+ '.mdc-button:has-text("YouTube")',
718
+ // List items that might be clickable
719
+ '[role="listitem"]:has-text("YouTube")',
720
+ '[role="option"]:has-text("YouTube")',
721
+ // Generic clickable elements with YouTube text
722
+ '[class*="option"]:has-text("YouTube")',
723
+ '[class*="source-type"]:has-text("YouTube")',
724
+ ];
725
+ let youtubeClicked = false;
726
+ for (const selector of ytSelectors) {
727
+ try {
728
+ const btn = this.page.locator(selector).first();
729
+ if (await btn.isVisible({ timeout: 1500 })) {
730
+ log.info(` ✅ Found YouTube option: ${selector}`);
731
+ await btn.click();
732
+ await randomDelay(500, 800);
733
+ youtubeClicked = true;
734
+ break;
735
+ }
736
+ }
737
+ catch {
738
+ continue;
739
+ }
740
+ }
741
+ if (!youtubeClicked) {
742
+ log.warning(' ⚠️ Could not click YouTube option, trying to proceed anyway...');
743
+ }
744
+ // Enter YouTube URL (can be input or textarea)
745
+ await randomDelay(500, 1000);
746
+ const ytInputSelectors = [
747
+ // French placeholders
748
+ 'input[placeholder*="Collez"]',
749
+ 'textarea[placeholder*="Collez"]',
750
+ 'input[placeholder*="YouTube"]',
751
+ 'textarea[placeholder*="YouTube"]',
752
+ // English placeholders
753
+ 'input[placeholder*="youtube" i]',
754
+ 'textarea[placeholder*="youtube" i]',
755
+ 'input[placeholder*="URL"]',
756
+ 'textarea[placeholder*="URL"]',
757
+ 'input[placeholder*="Paste"]',
758
+ 'textarea[placeholder*="Paste"]',
759
+ '[role="dialog"] input[type="text"]',
760
+ '[role="dialog"] textarea',
761
+ ];
762
+ let urlInput = null;
763
+ log.info(` 🔍 Looking for YouTube URL input...`);
764
+ for (const selector of ytInputSelectors) {
765
+ try {
766
+ const input = this.page.locator(selector).first();
767
+ if (await input.isVisible({ timeout: 500 })) {
768
+ urlInput = input;
769
+ log.info(` ✅ Found YouTube input: ${selector}`);
770
+ break;
771
+ }
772
+ }
773
+ catch {
774
+ continue;
775
+ }
776
+ }
777
+ // Fallback: any visible input/textarea in dialog
778
+ if (!urlInput) {
779
+ log.info(` 🔍 Trying fallback for YouTube input...`);
780
+ try {
781
+ const allInputs = await this.page
782
+ .locator('[role="dialog"] input, [role="dialog"] textarea')
783
+ .all();
784
+ for (const input of allInputs) {
785
+ if (await input.isVisible()) {
786
+ urlInput = input;
787
+ const placeholder = await input.getAttribute('placeholder');
788
+ log.info(` ✅ Found via fallback: placeholder="${placeholder}"`);
789
+ break;
790
+ }
791
+ }
792
+ }
793
+ catch {
794
+ /* ignore */
795
+ }
796
+ }
797
+ if (!urlInput) {
798
+ throw new Error('YouTube URL input not found');
799
+ }
800
+ await urlInput.fill(input.url);
801
+ log.info(` ✅ YouTube URL entered`);
802
+ await randomDelay(500, 1000);
803
+ await this.clickUploadButton();
804
+ const result = await this.waitForSourceProcessing(input.title || 'YouTube video', undefined, expectedNotebookUuid);
805
+ return result;
806
+ }
807
+ catch (error) {
808
+ const errorMsg = error instanceof Error ? error.message : String(error);
809
+ return { success: false, error: `YouTube upload failed: ${errorMsg}` };
810
+ }
811
+ }
812
+ /**
813
+ * Click the upload/add button
814
+ */
815
+ async clickUploadButton() {
816
+ const uploadBtnSelectors = [
817
+ // Primary action buttons (most likely)
818
+ 'button.mdc-button--raised:has-text("Insert")',
819
+ 'button.mat-flat-button:has-text("Insert")',
820
+ 'button[color="primary"]:has-text("Insert")',
821
+ // Generic text patterns
822
+ 'button:has-text("Insert")',
823
+ 'button:has-text("Insérer")',
824
+ 'button:has-text("Add")',
825
+ 'button:has-text("Ajouter")',
826
+ 'button:has-text("Upload")',
827
+ 'button:has-text("Import")',
828
+ 'button:has-text("Save")',
829
+ 'button:has-text("Submit")',
830
+ // Form submit
831
+ 'button[type="submit"]',
832
+ // Dialog actions
833
+ '[role="dialog"] button:not(:has-text("Cancel")):not(:has-text("Close"))',
834
+ '.mat-dialog-actions button:not(:has-text("Cancel"))',
835
+ '.mdc-dialog__actions button:not(:has-text("Cancel"))',
836
+ ];
837
+ for (const selector of uploadBtnSelectors) {
838
+ try {
839
+ const btn = this.page.locator(selector).first();
840
+ if (await btn.isVisible({ timeout: 500 })) {
841
+ log.info(` ✅ Found upload button: ${selector}`);
842
+ await btn.click();
843
+ log.info(` ✅ Clicked upload button`);
844
+ return;
845
+ }
846
+ }
847
+ catch {
848
+ continue;
849
+ }
850
+ }
851
+ // Debug: list all buttons in dialog
852
+ log.warning(` ⚠️ No upload button found, listing dialog buttons...`);
853
+ try {
854
+ const dialogButtons = await this.page.locator('[role="dialog"] button').all();
855
+ for (let i = 0; i < Math.min(dialogButtons.length, 5); i++) {
856
+ const text = await dialogButtons[i].textContent();
857
+ log.info(` 🔍 Dialog button[${i}]: "${text?.trim()}"`);
858
+ }
859
+ }
860
+ catch {
861
+ // ignore
862
+ }
863
+ // Try pressing Enter as fallback
864
+ log.info(` ⌨️ Pressing Enter as fallback`);
865
+ await this.page.keyboard.press('Enter');
866
+ }
867
+ /**
868
+ * Wait for source to finish processing
869
+ * @param sourceName The name we expect the source to have
870
+ * @param _textPreview Optional first words of text (for text sources - NotebookLM may use this as name)
871
+ * @param expectedNotebookUuid Optional UUID of the notebook we expect to be on (to detect redirects)
872
+ */
873
+ async waitForSourceProcessing(sourceName, _textPreview, expectedNotebookUuid) {
874
+ log.info(` ⏳ Waiting for source processing: ${sourceName}`);
875
+ const timeout = 90000; // 1.5 minutes (sources can take time)
876
+ const startTime = Date.now();
877
+ // First, wait a bit for the dialog to close (indicates upload started)
878
+ await randomDelay(2000, 3000);
879
+ while (Date.now() - startTime < timeout) {
880
+ // Check for errors in the dialog or page
881
+ const errorSelectors = [
882
+ '.error-message',
883
+ '[role="alert"]:has-text("error")',
884
+ '[role="alert"]:has-text("Error")',
885
+ '.mdc-snackbar--error',
886
+ '[class*="error"]',
887
+ ];
888
+ for (const errorSelector of errorSelectors) {
889
+ try {
890
+ const errorEl = this.page.locator(errorSelector).first();
891
+ if (await errorEl.isVisible({ timeout: 500 })) {
892
+ const errorText = await errorEl.textContent();
893
+ return { success: false, error: errorText || 'Upload failed', status: 'failed' };
894
+ }
895
+ }
896
+ catch {
897
+ continue;
898
+ }
899
+ }
900
+ // Check if dialog is still open (might mean still processing)
901
+ const dialogSelectors = ['[role="dialog"]', '.mat-dialog-container', '.mdc-dialog'];
902
+ let dialogVisible = false;
903
+ for (const dialogSelector of dialogSelectors) {
904
+ try {
905
+ const dialog = this.page.locator(dialogSelector).first();
906
+ if (await dialog.isVisible({ timeout: 500 })) {
907
+ dialogVisible = true;
908
+ break;
909
+ }
910
+ }
911
+ catch {
912
+ continue;
913
+ }
914
+ }
915
+ // If dialog closed, check if source appears in the sources list
916
+ if (!dialogVisible) {
917
+ log.info(` ℹ️ Dialog closed, checking for source in list...`);
918
+ // CRITICAL: Verify we're still on the correct notebook after dialog closes
919
+ // NotebookLM sometimes redirects to a NEW notebook when adding text sources!
920
+ const currentUrl = this.page.url();
921
+ log.info(` 🔍 Current URL: ${currentUrl}`);
922
+ // Check if URL changed (different notebook UUID)
923
+ const currentUuid = currentUrl.match(/notebook\/([a-f0-9-]+)/)?.[1];
924
+ log.info(` 🆔 Current UUID: ${currentUuid || 'NOT FOUND'}`);
925
+ log.info(` 🆔 Expected UUID: ${expectedNotebookUuid || 'NOT PROVIDED'}`);
926
+ if (currentUuid && expectedNotebookUuid && currentUuid !== expectedNotebookUuid) {
927
+ log.error(` ❌ NOTEBOOK MISMATCH! NotebookLM redirected to a different notebook!`);
928
+ log.error(` ❌ Expected: ${expectedNotebookUuid}`);
929
+ log.error(` ❌ Got: ${currentUuid}`);
930
+ // Navigate back to the correct notebook and try to add source properly
931
+ log.warning(` ⚠️ This is a known NotebookLM behavior - text sources may create new notebooks`);
932
+ // Return failure with clear error message
933
+ return {
934
+ success: false,
935
+ error: `NotebookLM redirected to a different notebook (${currentUuid}) instead of the target (${expectedNotebookUuid}). This happens when NotebookLM creates a new notebook for pasted text. The source was added to an 'Untitled notebook' instead.`,
936
+ status: 'failed',
937
+ };
938
+ }
939
+ // Try to get notebook title for logging
940
+ try {
941
+ const titleSelectors = ['h1', '[class*="notebook-title"]', '[class*="title"]'];
942
+ for (const sel of titleSelectors) {
943
+ try {
944
+ const titleEl = this.page.locator(sel).first();
945
+ if (await titleEl.isVisible({ timeout: 500 })) {
946
+ const title = await titleEl.textContent();
947
+ if (title && title.length < 100) {
948
+ log.info(` 📓 Notebook title: "${title.trim()}"`);
949
+ break;
950
+ }
951
+ }
952
+ }
953
+ catch {
954
+ continue;
955
+ }
956
+ }
957
+ }
958
+ catch {
959
+ /* ignore */
960
+ }
961
+ // Take screenshot after dialog closed
962
+ try {
963
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-after-insert.png');
964
+ await this.page.screenshot({ path: screenshotPath });
965
+ log.info(` 📸 Debug screenshot (after click): ${screenshotPath}`);
966
+ }
967
+ catch {
968
+ /* ignore */
969
+ }
970
+ await randomDelay(1000, 2000);
971
+ // METHOD 1: Look for pasted text source in the SOURCES PANEL specifically (not anywhere on page)
972
+ // Use more specific selectors to avoid matching dialog content
973
+ // Support both French ("Texte collé") and English ("Pasted text") UI
974
+ const pastedTextSelectors = [
975
+ // Sources panel specific selectors - French
976
+ 'mat-checkbox:has-text("Texte collé")',
977
+ '[class*="source"]:has-text("Texte collé")',
978
+ ':has-text("Texte collé"):not([role="dialog"])',
979
+ // Sources panel specific selectors - English
980
+ 'mat-checkbox:has-text("Pasted text")',
981
+ '[class*="source"]:has-text("Pasted text")',
982
+ ':has-text("Pasted text"):not([role="dialog"])',
983
+ ];
984
+ for (const selector of pastedTextSelectors) {
985
+ try {
986
+ const el = this.page.locator(selector).first();
987
+ if (await el.isVisible({ timeout: 1000 })) {
988
+ log.success(` ✅ Found pasted text source: ${selector}`);
989
+ // Detect source name from selector (FR: "Texte collé", EN: "Pasted text")
990
+ const detectedName = selector.includes('Pasted text') ? 'Pasted text' : 'Texte collé';
991
+ return { success: true, sourceName: detectedName, status: 'ready' };
992
+ }
993
+ }
994
+ catch {
995
+ continue;
996
+ }
997
+ }
998
+ // METHOD 2: Check for source in the sources panel by name
999
+ const sourceListSelectors = [
1000
+ // Source items that might contain our source
1001
+ `[class*="source"]:has-text("${sourceName}")`,
1002
+ `[class*="Source"]:has-text("${sourceName}")`,
1003
+ // Generic list items
1004
+ '.source-list-item',
1005
+ '[class*="source-item"]',
1006
+ '[class*="SourceItem"]',
1007
+ // Material list
1008
+ 'mat-list-item',
1009
+ '.mat-list-item',
1010
+ // By count change (sources list exists)
1011
+ '[class*="sources"]',
1012
+ ];
1013
+ // Try to find the specific source by name
1014
+ for (const selector of sourceListSelectors.slice(0, 2)) {
1015
+ // Only name-based selectors
1016
+ try {
1017
+ const el = this.page.locator(selector).first();
1018
+ if (await el.isVisible({ timeout: 500 })) {
1019
+ log.success(` ✅ Source added successfully: ${sourceName}`);
1020
+ return { success: true, sourceName, status: 'ready' };
1021
+ }
1022
+ }
1023
+ catch {
1024
+ continue;
1025
+ }
1026
+ }
1027
+ // Wait a bit more and try again - sources can take time to appear
1028
+ log.info(` ⏳ Source not found yet, waiting 5 more seconds...`);
1029
+ await randomDelay(4000, 6000);
1030
+ // Try again for pasted text source with different variations
1031
+ for (const selector of pastedTextSelectors) {
1032
+ try {
1033
+ const el = this.page.locator(selector).first();
1034
+ if (await el.isVisible({ timeout: 1000 })) {
1035
+ log.success(` ✅ Found pasted text source after wait: ${selector}`);
1036
+ const detectedName = selector.includes('Pasted text') ? 'Pasted text' : 'Texte collé';
1037
+ return { success: true, sourceName: detectedName, status: 'ready' };
1038
+ }
1039
+ }
1040
+ catch {
1041
+ continue;
1042
+ }
1043
+ }
1044
+ // Try one more time to find the source by name
1045
+ try {
1046
+ const sourceByName = this.page
1047
+ .locator(`[class*="source"]:has-text("${sourceName}")`)
1048
+ .first();
1049
+ if (await sourceByName.isVisible({ timeout: 2000 })) {
1050
+ log.success(` ✅ Source found after wait: ${sourceName}`);
1051
+ return { success: true, sourceName, status: 'ready' };
1052
+ }
1053
+ }
1054
+ catch {
1055
+ /* ignore */
1056
+ }
1057
+ // If still not found, this is a failure - don't assume success
1058
+ log.warning(` ⚠️ Dialog closed but source not found in list - upload likely failed`);
1059
+ return {
1060
+ success: false,
1061
+ sourceName,
1062
+ error: 'Source not found after upload - dialog closed but source not visible in list',
1063
+ status: 'failed',
1064
+ };
1065
+ }
1066
+ // Still in dialog - check for processing indicators
1067
+ const processingSelectors = [
1068
+ '.loading',
1069
+ '.spinner',
1070
+ '[class*="loading"]',
1071
+ '[class*="processing"]',
1072
+ 'mat-progress-bar',
1073
+ 'mat-spinner',
1074
+ '.mdc-linear-progress',
1075
+ ];
1076
+ let isProcessing = false;
1077
+ for (const procSelector of processingSelectors) {
1078
+ try {
1079
+ const proc = this.page.locator(procSelector).first();
1080
+ if (await proc.isVisible({ timeout: 500 })) {
1081
+ isProcessing = true;
1082
+ break;
1083
+ }
1084
+ }
1085
+ catch {
1086
+ continue;
1087
+ }
1088
+ }
1089
+ if (isProcessing) {
1090
+ log.info(` ⏳ Still processing...`);
1091
+ }
1092
+ await this.page.waitForTimeout(2000);
1093
+ }
1094
+ return { success: false, error: 'Timeout waiting for source processing', status: 'failed' };
1095
+ }
1096
+ // ============================================================================
1097
+ // Chat-Based Content Generation (New UI - Dec 2024)
1098
+ // ============================================================================
1099
+ /**
1100
+ * Send a message in the chat interface (without waiting for response)
1101
+ * This is the new way to generate content in NotebookLM
1102
+ * Uses the same typing and submission approach as ask_question for reliability
1103
+ */
1104
+ async sendChatMessage(message) {
1105
+ log.info(` 💬 Sending chat message: "${message.substring(0, 50)}..."`);
1106
+ // Find the chat input (same approach as BrowserSession.findChatInput)
1107
+ const chatInputSelectors = [
1108
+ 'textarea.query-box-input', // PRIMARY - same as Python implementation
1109
+ 'textarea[aria-label*="query"]',
1110
+ 'textarea[aria-label*="Zone de requête"]',
1111
+ ];
1112
+ let inputSelector = null;
1113
+ for (const selector of chatInputSelectors) {
1114
+ try {
1115
+ const input = await this.page.waitForSelector(selector, {
1116
+ state: 'visible',
1117
+ timeout: 3000,
1118
+ });
1119
+ if (input) {
1120
+ inputSelector = selector;
1121
+ log.info(` ✅ Found chat input: ${selector}`);
1122
+ break;
1123
+ }
1124
+ }
1125
+ catch {
1126
+ continue;
1127
+ }
1128
+ }
1129
+ if (!inputSelector) {
1130
+ throw new Error('Chat input not found');
1131
+ }
1132
+ // Clear any existing text first
1133
+ const inputEl = await this.page.$(inputSelector);
1134
+ if (inputEl) {
1135
+ await inputEl.click();
1136
+ await this.page.keyboard.press('Control+A');
1137
+ await this.page.keyboard.press('Backspace');
1138
+ await randomDelay(200, 400);
1139
+ }
1140
+ // Type the message with human-like behavior (same as BrowserSession.askQuestion)
1141
+ log.info(` ⌨️ Typing message with human-like behavior...`);
1142
+ await humanType(this.page, inputSelector, message, {
1143
+ withTypos: false, // No typos for prompts to avoid confusion
1144
+ wpm: 150, // Faster typing for long prompts
1145
+ });
1146
+ // Small pause before submitting
1147
+ await randomDelay(500, 1000);
1148
+ // Submit with Enter key (same as BrowserSession.askQuestion)
1149
+ log.info(` 📤 Submitting message...`);
1150
+ await this.page.keyboard.press('Enter');
1151
+ // Small pause after submit
1152
+ await randomDelay(1000, 1500);
1153
+ log.info(` ✅ Message sent`);
1154
+ }
1155
+ /**
1156
+ * Wait for generated content to appear in chat
1157
+ * Uses the same proven approach as /ask endpoint (waitForLatestAnswer with full timeout)
1158
+ */
1159
+ async waitForGeneratedContent(contentType, timeoutMs = 600000) {
1160
+ log.info(` ⏳ Waiting for ${contentType} response (up to ${timeoutMs / 60000} minutes)...`);
1161
+ // Scroll to bottom to ensure we see all messages
1162
+ await this.scrollChatToBottom();
1163
+ // Snapshot existing chat responses to ignore them
1164
+ const existingChatResponses = await snapshotAllResponses(this.page);
1165
+ log.info(` 📊 Ignoring ${existingChatResponses.length} existing chat responses`);
1166
+ // Use the same proven logic as /ask endpoint - wait for new chat response
1167
+ const response = await waitForLatestAnswer(this.page, {
1168
+ question: '', // Empty question since we already sent the message
1169
+ timeoutMs: timeoutMs,
1170
+ pollIntervalMs: 2000, // Poll every 2 seconds
1171
+ ignoreTexts: existingChatResponses,
1172
+ debug: true, // Enable debug to see what's happening
1173
+ });
1174
+ // Check if response is an error message from NotebookLM
1175
+ if (response && isErrorMessage(response)) {
1176
+ log.error(` ❌ NotebookLM returned an error: "${response}"`);
1177
+ throw new Error(`NotebookLM error: ${response}`);
1178
+ }
1179
+ if (response && response.length > 50) {
1180
+ log.success(` ✅ Content received (${response.length} chars)`);
1181
+ return { source: 'chat', content: response };
1182
+ }
1183
+ throw new Error(`Timeout waiting for ${contentType} generation after ${timeoutMs / 1000}s`);
1184
+ }
1185
+ /**
1186
+ * Scroll chat container to bottom to ensure latest messages are visible
1187
+ */
1188
+ async scrollChatToBottom() {
1189
+ try {
1190
+ // Try multiple selectors for the chat container
1191
+ const chatContainerSelectors = [
1192
+ '.chat-scroll-container',
1193
+ '.messages-container',
1194
+ '[class*="scroll"]',
1195
+ '.query-container',
1196
+ ];
1197
+ for (const selector of chatContainerSelectors) {
1198
+ const container = await this.page.$(selector);
1199
+ if (container) {
1200
+ await container.evaluate((el) => {
1201
+ el.scrollTop = el.scrollHeight;
1202
+ });
1203
+ log.debug(` 📜 Scrolled chat to bottom using ${selector}`);
1204
+ return;
1205
+ }
1206
+ }
1207
+ // Fallback: scroll the whole page
1208
+ await this.page.evaluate(`window.scrollTo(0, document.body.scrollHeight)`);
1209
+ log.debug(` 📜 Scrolled page to bottom (fallback)`);
1210
+ }
1211
+ catch (error) {
1212
+ log.debug(` ⚠️ Could not scroll: ${error}`);
1213
+ }
1214
+ }
1215
+ // ============================================================================
1216
+ // Content Generation
1217
+ // ============================================================================
1218
+ /**
1219
+ * Generate content (audio overview, presentation, report)
1220
+ *
1221
+ * Supported content types:
1222
+ * - audio_overview: Uses real Studio UI buttons for audio podcast generation
1223
+ * - video: Uses generic ContentGenerator for video generation (with format, language, style options)
1224
+ * - infographic: Uses generic ContentGenerator for infographic generation (with format options)
1225
+ * - presentation: Uses generic ContentGenerator for slides generation (with format options)
1226
+ * - report: Uses generic ContentGenerator for briefing document generation (with format options)
1227
+ * - data_table: Uses generic ContentGenerator for data table generation (with format options)
1228
+ *
1229
+ * NOTE: Other content types (study_guide, faq, timeline, table_of_contents)
1230
+ * were removed because they only sent chat prompts instead of clicking actual
1231
+ * NotebookLM Studio buttons.
1232
+ */
1233
+ async generateContent(input) {
1234
+ log.info(`🎨 Generating content: ${input.type}`);
1235
+ try {
1236
+ if (input.type === 'audio_overview') {
1237
+ return await this.generateAudioOverview(input);
1238
+ }
1239
+ // Use generic ContentGenerator for all other supported types
1240
+ if (input.type === 'video' ||
1241
+ input.type === 'infographic' ||
1242
+ input.type === 'presentation' ||
1243
+ input.type === 'report' ||
1244
+ input.type === 'data_table') {
1245
+ return await this.generateGenericContent(input);
1246
+ }
1247
+ // Unsupported content type
1248
+ return {
1249
+ success: false,
1250
+ contentType: input.type,
1251
+ error: `Unsupported content type: ${input.type}. Supported types: 'audio_overview', 'video', 'infographic', 'presentation', 'report', 'data_table'.`,
1252
+ };
1253
+ }
1254
+ catch (error) {
1255
+ const errorMsg = error instanceof Error ? error.message : String(error);
1256
+ log.error(`❌ Content generation failed: ${errorMsg}`);
1257
+ return { success: false, contentType: input.type, error: errorMsg };
1258
+ }
1259
+ }
1260
+ /**
1261
+ * Generate content using the generic ContentGenerator
1262
+ *
1263
+ * This method handles all content types except audio_overview:
1264
+ * - video: With format (brief/explainer), language, and visual style options
1265
+ * - infographic: With format (horizontal/vertical) and language options
1266
+ * - presentation: With format (overview/detailed) and language options
1267
+ * - report: With format (summary/detailed) and language options
1268
+ * - data_table: With format (simple/detailed) and language options
1269
+ *
1270
+ * @param input Content generation input with all options
1271
+ * @returns Content generation result
1272
+ */
1273
+ async generateGenericContent(input) {
1274
+ log.info(`🎨 Generating ${input.type} via ContentGenerator...`);
1275
+ try {
1276
+ const generator = new ContentGenerator(this.page);
1277
+ const result = await generator.generate(input);
1278
+ if (result.success) {
1279
+ log.success(` ✅ ${input.type} generated successfully`);
1280
+ }
1281
+ else {
1282
+ log.error(` ❌ ${input.type} generation failed: ${result.error}`);
1283
+ }
1284
+ return result;
1285
+ }
1286
+ catch (error) {
1287
+ const errorMsg = error instanceof Error ? error.message : String(error);
1288
+ log.error(` ❌ ${input.type} generation failed: ${errorMsg}`);
1289
+ return {
1290
+ success: false,
1291
+ contentType: input.type,
1292
+ error: errorMsg,
1293
+ };
1294
+ }
1295
+ }
1296
+ /**
1297
+ * Generate Audio Overview (podcast)
1298
+ *
1299
+ * NOTE (Dec 2024): NotebookLM UI has changed significantly.
1300
+ * Audio generation now works via chat requests or may require specific UI interaction.
1301
+ * This method attempts both approaches.
1302
+ */
1303
+ async generateAudioOverview(input) {
1304
+ log.info(`🎙️ Generating Audio Overview...`);
1305
+ try {
1306
+ // First, check Studio for existing audio or audio generation button
1307
+ await this.navigateToStudio();
1308
+ await this.page.waitForTimeout(1000);
1309
+ // Check if audio already exists
1310
+ const existingAudio = await this.page.$('audio, .audio-player, [class*="audio-overview"]');
1311
+ if (existingAudio) {
1312
+ log.info(` ℹ️ Audio Overview already exists`);
1313
+ return {
1314
+ success: true,
1315
+ contentType: 'audio_overview',
1316
+ status: 'ready',
1317
+ };
1318
+ }
1319
+ // Try to find audio generation button in Studio
1320
+ const audioSelectors = [
1321
+ 'button:has-text("Audio")',
1322
+ 'button:has-text("Generate audio")',
1323
+ 'button:has-text("Générer")',
1324
+ 'button[aria-label*="audio" i]',
1325
+ '[class*="audio"] button',
1326
+ 'button:has(mat-icon:has-text("mic"))',
1327
+ 'button:has(mat-icon:has-text("podcast"))',
1328
+ ];
1329
+ for (const selector of audioSelectors) {
1330
+ try {
1331
+ const btn = this.page.locator(selector).first();
1332
+ if (await btn.isVisible({ timeout: 1000 })) {
1333
+ log.info(` ✅ Found audio button: ${selector}`);
1334
+ // Add custom instructions if provided
1335
+ if (input.customInstructions) {
1336
+ await this.addCustomInstructions(input.customInstructions);
1337
+ }
1338
+ await btn.click();
1339
+ log.info(` ✅ Started audio generation`);
1340
+ // Wait for generation
1341
+ return await this.waitForAudioGeneration();
1342
+ }
1343
+ }
1344
+ catch {
1345
+ continue;
1346
+ }
1347
+ }
1348
+ // Fallback: Try chat-based approach
1349
+ log.info(` ℹ️ No audio button found, trying chat-based approach...`);
1350
+ await this.navigateToDiscussion();
1351
+ let prompt = 'Create an audio overview (Deep Dive podcast) for this notebook. Generate a conversational podcast script that covers the main topics from all sources.';
1352
+ if (input.customInstructions) {
1353
+ prompt += `\n\nCustom instructions: ${input.customInstructions}`;
1354
+ }
1355
+ await this.sendChatMessage(prompt);
1356
+ const result = await this.waitForGeneratedContent('audio_overview', 600000);
1357
+ if (result.content && result.content.length > 100) {
1358
+ log.success(` ✅ Audio overview script generated via ${result.source}`);
1359
+ return {
1360
+ success: true,
1361
+ contentType: 'audio_overview',
1362
+ status: 'ready',
1363
+ textContent: result.content,
1364
+ };
1365
+ }
1366
+ throw new Error('Could not generate audio overview - button not found and chat approach failed');
1367
+ }
1368
+ catch (error) {
1369
+ const errorMsg = error instanceof Error ? error.message : String(error);
1370
+ return { success: false, contentType: 'audio_overview', error: errorMsg };
1371
+ }
1372
+ }
1373
+ // NOTE: generateBriefingDoc, generateStudyGuide, generateTimeline, generateFAQ,
1374
+ // generateTOC, and generateDocumentContent methods were removed because they
1375
+ // only sent chat prompts instead of clicking actual NotebookLM Studio buttons.
1376
+ // Only audio_overview uses real UI interaction.
1377
+ /**
1378
+ * Generate Presentation/Slides using the generic ContentGenerator
1379
+ *
1380
+ * This uses the generic content generation architecture that:
1381
+ * 1. Navigates to Studio panel
1382
+ * 2. Looks for presentation/slides button
1383
+ * 3. Falls back to chat-based generation if button not found
1384
+ *
1385
+ * @param input Content generation input with optional custom instructions
1386
+ * @returns Content generation result
1387
+ */
1388
+ async generatePresentation(input) {
1389
+ log.info(`📊 Generating Presentation/Slides...`);
1390
+ try {
1391
+ // Use the generic ContentGenerator for presentation generation
1392
+ const generator = new ContentGenerator(this.page);
1393
+ const result = await generator.generate({
1394
+ type: 'presentation',
1395
+ customInstructions: input.customInstructions,
1396
+ sources: input.sources,
1397
+ language: input.language,
1398
+ presentationStyle: input.presentationStyle,
1399
+ presentationLength: input.presentationLength,
1400
+ });
1401
+ if (result.success) {
1402
+ log.success(` ✅ Presentation generated successfully`);
1403
+ }
1404
+ else {
1405
+ log.error(` ❌ Presentation generation failed: ${result.error}`);
1406
+ }
1407
+ return result;
1408
+ }
1409
+ catch (error) {
1410
+ const errorMsg = error instanceof Error ? error.message : String(error);
1411
+ log.error(` ❌ Presentation generation failed: ${errorMsg}`);
1412
+ return {
1413
+ success: false,
1414
+ contentType: 'presentation',
1415
+ error: errorMsg,
1416
+ };
1417
+ }
1418
+ }
1419
+ /**
1420
+ * Generate Report/Briefing Document using the generic ContentGenerator
1421
+ *
1422
+ * Creates a comprehensive briefing document (2,000-3,000 words) that summarizes
1423
+ * key findings, insights, and recommendations from notebook sources.
1424
+ * Can be exported as PDF or DOCX format.
1425
+ *
1426
+ * This uses the generic content generation architecture that:
1427
+ * 1. Navigates to Studio panel
1428
+ * 2. Looks for briefing/report button
1429
+ * 3. Falls back to chat-based generation if button not found
1430
+ *
1431
+ * @param input Content generation input with optional custom instructions
1432
+ * @returns Content generation result with text content
1433
+ */
1434
+ async generateReport(input) {
1435
+ log.info(`📄 Generating Report/Briefing Document...`);
1436
+ try {
1437
+ // Use the generic ContentGenerator for report generation
1438
+ const generator = new ContentGenerator(this.page);
1439
+ const result = await generator.generate({
1440
+ type: 'report',
1441
+ customInstructions: input.customInstructions,
1442
+ sources: input.sources,
1443
+ language: input.language,
1444
+ reportFormat: input.reportFormat,
1445
+ });
1446
+ if (result.success) {
1447
+ log.success(` ✅ Report generated successfully`);
1448
+ }
1449
+ else {
1450
+ log.error(` ❌ Report generation failed: ${result.error}`);
1451
+ }
1452
+ return result;
1453
+ }
1454
+ catch (error) {
1455
+ const errorMsg = error instanceof Error ? error.message : String(error);
1456
+ log.error(` ❌ Report generation failed: ${errorMsg}`);
1457
+ return {
1458
+ success: false,
1459
+ contentType: 'report',
1460
+ error: errorMsg,
1461
+ };
1462
+ }
1463
+ }
1464
+ /**
1465
+ * Generate Infographic using the generic ContentGenerator
1466
+ *
1467
+ * Creates a visual infographic from the notebook sources.
1468
+ * Supports two formats:
1469
+ * - horizontal (16:9): Landscape format for presentations/displays
1470
+ * - vertical (9:16): Portrait format for social media/mobile
1471
+ *
1472
+ * This uses the generic content generation architecture that:
1473
+ * 1. Navigates to Studio panel
1474
+ * 2. Looks for infographic button
1475
+ * 3. Falls back to chat-based generation if button not found
1476
+ *
1477
+ * @param input Content generation input with optional custom instructions and format
1478
+ * @returns Content generation result
1479
+ */
1480
+ async generateInfographic(input) {
1481
+ log.info(`Generating Infographic...`);
1482
+ try {
1483
+ // Use the generic ContentGenerator for infographic generation
1484
+ const generator = new ContentGenerator(this.page);
1485
+ const result = await generator.generate({
1486
+ type: 'infographic',
1487
+ customInstructions: input.customInstructions,
1488
+ sources: input.sources,
1489
+ language: input.language,
1490
+ infographicFormat: input.infographicFormat,
1491
+ });
1492
+ if (result.success) {
1493
+ log.success(` Infographic generated successfully`);
1494
+ }
1495
+ else {
1496
+ log.error(` Infographic generation failed: ${result.error}`);
1497
+ }
1498
+ return result;
1499
+ }
1500
+ catch (error) {
1501
+ const errorMsg = error instanceof Error ? error.message : String(error);
1502
+ log.error(` Infographic generation failed: ${errorMsg}`);
1503
+ return {
1504
+ success: false,
1505
+ contentType: 'infographic',
1506
+ error: errorMsg,
1507
+ };
1508
+ }
1509
+ }
1510
+ /**
1511
+ * Generate a Data Table using the generic ContentGenerator
1512
+ *
1513
+ * Creates a structured table that organizes key information from notebook sources.
1514
+ * The generated table can be exported as CSV or Excel format.
1515
+ *
1516
+ * @param input Content generation input with optional custom instructions
1517
+ * @returns Content generation result with table data
1518
+ */
1519
+ async generateDataTable(input) {
1520
+ log.info(`Generating Data Table...`);
1521
+ try {
1522
+ // Use the generic ContentGenerator for data table generation
1523
+ const generator = new ContentGenerator(this.page);
1524
+ const result = await generator.generate({
1525
+ type: 'data_table',
1526
+ customInstructions: input.customInstructions,
1527
+ sources: input.sources,
1528
+ language: input.language,
1529
+ // Note: data_table has no format options - exports to Google Sheets
1530
+ });
1531
+ if (result.success) {
1532
+ log.success(` ✅ Data Table generated successfully`);
1533
+ }
1534
+ else {
1535
+ log.error(` ❌ Data Table generation failed: ${result.error}`);
1536
+ }
1537
+ return result;
1538
+ }
1539
+ catch (error) {
1540
+ const errorMsg = error instanceof Error ? error.message : String(error);
1541
+ log.error(` ❌ Data Table generation failed: ${errorMsg}`);
1542
+ return {
1543
+ success: false,
1544
+ contentType: 'data_table',
1545
+ error: errorMsg,
1546
+ };
1547
+ }
1548
+ }
1549
+ /**
1550
+ * Generate a Video using the generic ContentGenerator
1551
+ *
1552
+ * Creates a video summary that visually explains the main topics from notebook sources.
1553
+ * Video generation may take several minutes depending on content complexity.
1554
+ *
1555
+ * Supported formats:
1556
+ * - brief: Short video summary (2-3 minutes)
1557
+ * - explainer: Detailed explanation video (5-10 minutes)
1558
+ *
1559
+ * This uses the generic content generation architecture that:
1560
+ * 1. Navigates to Studio panel
1561
+ * 2. Looks for video generation button
1562
+ * 3. Falls back to chat-based generation if button not found
1563
+ *
1564
+ * @param input Content generation input with optional custom instructions and format
1565
+ * @returns Content generation result with video data
1566
+ */
1567
+ async generateVideo(input) {
1568
+ log.info(`🎬 Generating Video...`);
1569
+ try {
1570
+ // Use the generic ContentGenerator for video generation
1571
+ const generator = new ContentGenerator(this.page);
1572
+ const result = await generator.generate({
1573
+ type: 'video',
1574
+ customInstructions: input.customInstructions,
1575
+ sources: input.sources,
1576
+ language: input.language,
1577
+ videoFormat: input.videoFormat,
1578
+ });
1579
+ if (result.success) {
1580
+ log.success(` ✅ Video generated successfully`);
1581
+ }
1582
+ else {
1583
+ log.error(` ❌ Video generation failed: ${result.error}`);
1584
+ }
1585
+ return result;
1586
+ }
1587
+ catch (error) {
1588
+ const errorMsg = error instanceof Error ? error.message : String(error);
1589
+ log.error(` ❌ Video generation failed: ${errorMsg}`);
1590
+ return {
1591
+ success: false,
1592
+ contentType: 'video',
1593
+ error: errorMsg,
1594
+ };
1595
+ }
1596
+ }
1597
+ /**
1598
+ * Navigate to Discussion panel (chat)
1599
+ */
1600
+ async navigateToDiscussion() {
1601
+ const discussionSelectors = [
1602
+ 'div.mdc-tab:has-text("Discussion")',
1603
+ '.mat-mdc-tab:has-text("Discussion")',
1604
+ '[role="tab"]:has-text("Discussion")',
1605
+ 'div.mdc-tab >> text=Discussion',
1606
+ ];
1607
+ for (const selector of discussionSelectors) {
1608
+ try {
1609
+ const el = this.page.locator(selector).first();
1610
+ if (await el.isVisible({ timeout: 2000 })) {
1611
+ // Check if already selected
1612
+ const isActive = (await el.getAttribute('aria-selected')) === 'true' ||
1613
+ (await el.getAttribute('class'))?.includes('mdc-tab--active');
1614
+ if (!isActive) {
1615
+ await el.click();
1616
+ await randomDelay(500, 800);
1617
+ log.info(` ✅ Clicked Discussion tab`);
1618
+ }
1619
+ else {
1620
+ log.info(` ✅ Discussion tab already active`);
1621
+ }
1622
+ return;
1623
+ }
1624
+ }
1625
+ catch {
1626
+ continue;
1627
+ }
1628
+ }
1629
+ // Quick check if there's any chat content visible (might already be on Discussion)
1630
+ try {
1631
+ const chatContent = this.page
1632
+ .locator('[class*="chat"], [class*="discussion"], [class*="message"]')
1633
+ .first();
1634
+ if (await chatContent.isVisible({ timeout: 1000 })) {
1635
+ log.info(` ℹ️ Discussion content appears accessible`);
1636
+ return;
1637
+ }
1638
+ }
1639
+ catch {
1640
+ // Continue to error
1641
+ }
1642
+ // Fail-fast: throw error if Discussion tab not found
1643
+ throw new Error('Discussion tab not found - notebook may not have a chat history');
1644
+ }
1645
+ /**
1646
+ * Navigate to Studio panel
1647
+ */
1648
+ async navigateToStudio() {
1649
+ // Updated selectors based on current NotebookLM UI (Dec 2024)
1650
+ // The tabs are: Sources | Discussion | Studio
1651
+ // Tab class: mdc-tab mat-mdc-tab mat-focus-indicator
1652
+ const studioSelectors = [
1653
+ 'div.mdc-tab:has-text("Studio")', // Material Design tab with text
1654
+ '.mat-mdc-tab:has-text("Studio")', // Angular Material tab
1655
+ '[role="tab"]:has-text("Studio")', // Tab role with Studio text
1656
+ 'div.mdc-tab >> text=Studio', // Playwright text selector
1657
+ '.notebook-guide', // Legacy fallback
1658
+ ];
1659
+ for (const selector of studioSelectors) {
1660
+ try {
1661
+ const el = this.page.locator(selector).first();
1662
+ if (await el.isVisible({ timeout: 2000 })) {
1663
+ // Check if already selected
1664
+ const isActive = (await el.getAttribute('aria-selected')) === 'true' ||
1665
+ (await el.getAttribute('class'))?.includes('mdc-tab--active');
1666
+ if (!isActive) {
1667
+ await el.click();
1668
+ await randomDelay(800, 1200);
1669
+ log.info(` ✅ Clicked Studio tab`);
1670
+ }
1671
+ else {
1672
+ log.info(` ✅ Studio tab already active`);
1673
+ }
1674
+ return;
1675
+ }
1676
+ }
1677
+ catch {
1678
+ continue;
1679
+ }
1680
+ }
1681
+ // Try clicking by finding the tab list and clicking the third tab
1682
+ try {
1683
+ const tabList = this.page.locator('.mat-mdc-tab-list .mdc-tab').nth(2); // Studio is 3rd tab (0-indexed)
1684
+ if (await tabList.isVisible({ timeout: 1000 })) {
1685
+ await tabList.click();
1686
+ await randomDelay(800, 1200);
1687
+ log.info(` ✅ Studio tab accessed via tab list`);
1688
+ return;
1689
+ }
1690
+ }
1691
+ catch {
1692
+ // Continue to fallback
1693
+ }
1694
+ log.warning(` ⚠️ Could not find Studio tab, content generation may fail`);
1695
+ }
1696
+ /**
1697
+ * Add custom instructions for content generation
1698
+ */
1699
+ async addCustomInstructions(instructions) {
1700
+ const instructionSelectors = [
1701
+ 'textarea[placeholder*="instruction"]',
1702
+ 'textarea[placeholder*="focus"]',
1703
+ 'textarea[placeholder*="custom"]',
1704
+ '.custom-instructions textarea',
1705
+ ];
1706
+ for (const selector of instructionSelectors) {
1707
+ try {
1708
+ const textarea = await this.page.$(selector);
1709
+ if (textarea && (await textarea.isVisible())) {
1710
+ await textarea.fill(instructions);
1711
+ log.info(` ✅ Custom instructions added`);
1712
+ return;
1713
+ }
1714
+ }
1715
+ catch {
1716
+ continue;
1717
+ }
1718
+ }
1719
+ }
1720
+ /**
1721
+ * Wait for audio generation to complete
1722
+ */
1723
+ async waitForAudioGeneration() {
1724
+ log.info(` ⏳ Waiting for audio generation (this may take several minutes)...`);
1725
+ const timeout = 600000; // 10 minutes
1726
+ const startTime = Date.now();
1727
+ while (Date.now() - startTime < timeout) {
1728
+ // Check for errors
1729
+ const errorEl = await this.page.$('.error-message, [role="alert"]:has-text("error")');
1730
+ if (errorEl) {
1731
+ const errorText = await errorEl.textContent();
1732
+ return {
1733
+ success: false,
1734
+ contentType: 'audio_overview',
1735
+ error: errorText || 'Audio generation failed',
1736
+ status: 'failed',
1737
+ };
1738
+ }
1739
+ // Check for audio player (generation complete)
1740
+ const audioPlayer = await this.page.$('audio, .audio-player, [data-component="audio-player"]');
1741
+ if (audioPlayer) {
1742
+ log.success(` ✅ Audio Overview generated!`);
1743
+ return { success: true, contentType: 'audio_overview', status: 'ready' };
1744
+ }
1745
+ // Check progress
1746
+ const progressEl = await this.page.$('[role="progressbar"], .progress-bar');
1747
+ if (progressEl) {
1748
+ const progress = await progressEl.getAttribute('aria-valuenow');
1749
+ if (progress) {
1750
+ log.info(` ⏳ Generation progress: ${progress}%`);
1751
+ }
1752
+ }
1753
+ await this.page.waitForTimeout(5000);
1754
+ }
1755
+ return {
1756
+ success: false,
1757
+ contentType: 'audio_overview',
1758
+ error: 'Timeout waiting for audio generation',
1759
+ status: 'failed',
1760
+ };
1761
+ }
1762
+ // ============================================================================
1763
+ // Content Listing & Download
1764
+ // ============================================================================
1765
+ /**
1766
+ * Get overview of notebook content (sources and generated content)
1767
+ */
1768
+ async getContentOverview() {
1769
+ log.info(`📋 Getting notebook content overview...`);
1770
+ const sources = await this.listSources();
1771
+ const generatedContent = await this.listGeneratedContent();
1772
+ const hasAudioOverview = generatedContent.some((c) => c.type === 'audio_overview');
1773
+ return {
1774
+ sources,
1775
+ generatedContent,
1776
+ sourceCount: sources.length,
1777
+ hasAudioOverview,
1778
+ };
1779
+ }
1780
+ /**
1781
+ * List all sources in the notebook
1782
+ */
1783
+ async listSources() {
1784
+ const sources = [];
1785
+ try {
1786
+ // First ensure Sources panel is active
1787
+ await this.ensureSourcesPanel();
1788
+ await randomDelay(500, 800);
1789
+ // Try to find source names by looking at the actual text content visible on page
1790
+ // NotebookLM shows source names in spans/divs - look for text that looks like file names
1791
+ const seenNames = new Set();
1792
+ // Method 1: Look for elements with PDF-like text using Playwright locators
1793
+ try {
1794
+ // Find all elements containing .pdf text
1795
+ const pdfElements = await this.page.locator('text=/\\.pdf/i').all();
1796
+ if (pdfElements.length > 0) {
1797
+ log.info(` 📚 Found ${pdfElements.length} PDF elements`);
1798
+ for (const el of pdfElements) {
1799
+ try {
1800
+ const text = await el.textContent();
1801
+ if (text && text.length > 10 && !seenNames.has(text.trim())) {
1802
+ seenNames.add(text.trim());
1803
+ sources.push({
1804
+ id: `source-${sources.length}`,
1805
+ name: text.trim(),
1806
+ type: 'document',
1807
+ status: 'ready',
1808
+ });
1809
+ }
1810
+ }
1811
+ catch {
1812
+ continue;
1813
+ }
1814
+ }
1815
+ }
1816
+ // Also look for elements with [Author] format (brackets)
1817
+ if (sources.length === 0) {
1818
+ const bracketElements = await this.page.locator('text=/\\[.+\\]/').all();
1819
+ if (bracketElements.length > 0) {
1820
+ log.info(` 📚 Found ${bracketElements.length} bracketed elements`);
1821
+ for (const el of bracketElements) {
1822
+ try {
1823
+ const text = await el.textContent();
1824
+ if (text && text.length > 10 && !seenNames.has(text.trim())) {
1825
+ // Skip UI elements
1826
+ if (/^(Sources|Discussion|Studio|Sélectionner)/i.test(text.trim()))
1827
+ continue;
1828
+ seenNames.add(text.trim());
1829
+ sources.push({
1830
+ id: `source-${sources.length}`,
1831
+ name: text.trim(),
1832
+ type: 'document',
1833
+ status: 'ready',
1834
+ });
1835
+ }
1836
+ }
1837
+ catch {
1838
+ continue;
1839
+ }
1840
+ }
1841
+ }
1842
+ }
1843
+ }
1844
+ catch (error) {
1845
+ log.warning(` ⚠️ Text scan failed: ${error}`);
1846
+ }
1847
+ // Method 2: If no sources found, try looking at specific source list selectors
1848
+ if (sources.length === 0) {
1849
+ log.info(` 🔍 Trying alternative source selectors...`);
1850
+ // Look for any element that contains source text
1851
+ const sourceTextSelectors = [
1852
+ '.source-item-name',
1853
+ '.source-name',
1854
+ '[class*="source-item"] span:not(mat-icon)',
1855
+ '[class*="source"] span.mdc-list-item__primary-text',
1856
+ 'mat-list-item span',
1857
+ ];
1858
+ for (const selector of sourceTextSelectors) {
1859
+ try {
1860
+ const elements = await this.page.$$(selector);
1861
+ if (elements.length > 0) {
1862
+ log.info(` 📄 Found ${elements.length} elements with ${selector}`);
1863
+ for (const el of elements) {
1864
+ const text = (await el.textContent())?.trim();
1865
+ if (text && text.length > 5 && !seenNames.has(text)) {
1866
+ // Skip icon text
1867
+ if (text.match(/^(drive_pdf|markdown|more_vert|check)/i))
1868
+ continue;
1869
+ seenNames.add(text);
1870
+ sources.push({
1871
+ id: `source-${sources.length}`,
1872
+ name: text,
1873
+ type: 'document',
1874
+ status: 'ready',
1875
+ });
1876
+ }
1877
+ }
1878
+ if (sources.length > 0)
1879
+ break;
1880
+ }
1881
+ }
1882
+ catch {
1883
+ continue;
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1888
+ catch (error) {
1889
+ log.warning(` ⚠️ Could not list sources: ${error}`);
1890
+ }
1891
+ return sources;
1892
+ }
1893
+ /**
1894
+ * Delete a source from the current notebook
1895
+ *
1896
+ * @param input - Either sourceId or sourceName to identify the source to delete
1897
+ * @returns Result indicating success or failure
1898
+ */
1899
+ async deleteSource(input) {
1900
+ const { sourceId, sourceName } = input;
1901
+ if (!sourceId && !sourceName) {
1902
+ return { success: false, error: 'Either sourceId or sourceName is required' };
1903
+ }
1904
+ log.info(`🗑️ Deleting source: ${sourceId || sourceName}`);
1905
+ try {
1906
+ // First, ensure we're on the Sources panel
1907
+ await this.ensureSourcesPanel();
1908
+ await randomDelay(500, 800);
1909
+ // Find the source element
1910
+ const sourceElement = await this.findSourceElement(sourceId, sourceName);
1911
+ if (!sourceElement) {
1912
+ return {
1913
+ success: false,
1914
+ error: `Source not found: ${sourceId || sourceName}`,
1915
+ };
1916
+ }
1917
+ // Get the source name for logging before deletion
1918
+ let deletedSourceName = sourceName;
1919
+ if (!deletedSourceName) {
1920
+ try {
1921
+ deletedSourceName = await sourceElement.$eval('.source-name, .title, [class*="name"], [class*="title"]', (e) => e.textContent?.trim() || 'Unknown');
1922
+ }
1923
+ catch {
1924
+ deletedSourceName = sourceId || 'Unknown';
1925
+ }
1926
+ }
1927
+ // Click on the source to select it
1928
+ await sourceElement.click();
1929
+ await randomDelay(300, 500);
1930
+ // Open the source menu (3-dot menu or right-click)
1931
+ const menuOpened = await this.openSourceMenu(sourceElement);
1932
+ if (!menuOpened) {
1933
+ // Try right-click as fallback
1934
+ log.info(` 🔍 Trying right-click on source...`);
1935
+ await sourceElement.click({ button: 'right' });
1936
+ await randomDelay(300, 500);
1937
+ }
1938
+ // Click delete option
1939
+ const deleteClicked = await this.clickDeleteOption();
1940
+ if (!deleteClicked) {
1941
+ return {
1942
+ success: false,
1943
+ error: 'Could not find delete option in menu',
1944
+ };
1945
+ }
1946
+ // Confirm deletion if prompted
1947
+ await this.confirmDeletion();
1948
+ // Wait for source to be removed
1949
+ await randomDelay(1000, 2000);
1950
+ // Verify deletion by checking if source is still present
1951
+ const stillExists = await this.findSourceElement(sourceId, sourceName);
1952
+ if (stillExists) {
1953
+ return {
1954
+ success: false,
1955
+ error: 'Source deletion may have failed - source still appears in list',
1956
+ };
1957
+ }
1958
+ log.success(` ✅ Source deleted: ${deletedSourceName}`);
1959
+ return {
1960
+ success: true,
1961
+ sourceId: sourceId,
1962
+ sourceName: deletedSourceName,
1963
+ };
1964
+ }
1965
+ catch (error) {
1966
+ const errorMsg = error instanceof Error ? error.message : String(error);
1967
+ log.error(`❌ Failed to delete source: ${errorMsg}`);
1968
+ return { success: false, error: errorMsg };
1969
+ }
1970
+ }
1971
+ /**
1972
+ * Find a source element by ID or name
1973
+ */
1974
+ async findSourceElement(sourceId, sourceName
1975
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1976
+ ) {
1977
+ log.info(` 🔍 Finding source: id="${sourceId}", name="${sourceName}"`);
1978
+ // METHOD 1: Direct text search (most reliable for NotebookLM)
1979
+ if (sourceName) {
1980
+ const directSelectors = [
1981
+ // Try to find element containing the source name text
1982
+ `:has-text("${sourceName}")`,
1983
+ `text="${sourceName}"`,
1984
+ `text=/.*${sourceName}.*/i`,
1985
+ ];
1986
+ for (const selector of directSelectors) {
1987
+ try {
1988
+ const el = this.page.locator(selector).first();
1989
+ if (await el.isVisible({ timeout: 1000 })) {
1990
+ log.info(` ✅ Found source via direct text: ${selector}`);
1991
+ return await el.elementHandle();
1992
+ }
1993
+ }
1994
+ catch {
1995
+ continue;
1996
+ }
1997
+ }
1998
+ }
1999
+ // METHOD 2: Look within sources panel structure
2000
+ const sourceItemSelectors = [
2001
+ // NotebookLM current UI structure (checkboxes with labels)
2002
+ 'mat-checkbox',
2003
+ '[class*="checkbox"]',
2004
+ // Standard list items
2005
+ '.source-item',
2006
+ '[data-item="source"]',
2007
+ '.sources-list-item',
2008
+ '[class*="source-list"] > div',
2009
+ '[class*="source-list"] > li',
2010
+ 'mat-list-item',
2011
+ '.mat-list-item',
2012
+ '[role="listitem"]',
2013
+ ];
2014
+ for (const selector of sourceItemSelectors) {
2015
+ try {
2016
+ const elements = await this.page.$$(selector);
2017
+ log.info(` 🔍 Checking ${elements.length} elements with selector: ${selector}`);
2018
+ for (const el of elements) {
2019
+ // Check by data-id attribute
2020
+ if (sourceId) {
2021
+ const dataId = await el.getAttribute('data-id');
2022
+ if (dataId === sourceId) {
2023
+ log.info(` ✅ Found source by data-id`);
2024
+ return el;
2025
+ }
2026
+ }
2027
+ // Check by name/title text content
2028
+ if (sourceName) {
2029
+ const textContent = await el.textContent();
2030
+ if (textContent && textContent.toLowerCase().includes(sourceName.toLowerCase())) {
2031
+ log.info(` ✅ Found source by text content: "${textContent.slice(0, 50)}..."`);
2032
+ return el;
2033
+ }
2034
+ // Also check specific name/title elements
2035
+ try {
2036
+ const nameText = await el.$eval('.source-name, .title, [class*="name"], [class*="title"], label, span', (e) => e.textContent?.trim() || '');
2037
+ if (nameText.toLowerCase().includes(sourceName.toLowerCase())) {
2038
+ log.info(` ✅ Found source by inner text: "${nameText}"`);
2039
+ return el;
2040
+ }
2041
+ }
2042
+ catch {
2043
+ // Element doesn't have a name/title child
2044
+ }
2045
+ }
2046
+ }
2047
+ }
2048
+ catch {
2049
+ continue;
2050
+ }
2051
+ }
2052
+ // Take debug screenshot
2053
+ try {
2054
+ const screenshotPath = path.join(CONFIG.dataDir, 'debug-find-source-failed.png');
2055
+ await this.page.screenshot({ path: screenshotPath });
2056
+ log.info(` 📸 Debug screenshot saved: ${screenshotPath}`);
2057
+ }
2058
+ catch {
2059
+ /* ignore */
2060
+ }
2061
+ log.warning(` ⚠️ Source not found: ${sourceId || sourceName}`);
2062
+ return null;
2063
+ }
2064
+ /**
2065
+ * Open the source menu (3-dot menu)
2066
+ */
2067
+ async openSourceMenu(
2068
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2069
+ sourceElement) {
2070
+ const menuButtonSelectors = [
2071
+ // Material Design 3-dot menu button
2072
+ 'button:has(mat-icon:has-text("more_vert"))',
2073
+ 'button:has(mat-icon:has-text("more_horiz"))',
2074
+ 'button[aria-label*="menu" i]',
2075
+ 'button[aria-label*="options" i]',
2076
+ 'button[aria-label*="actions" i]',
2077
+ 'button[aria-label*="More" i]',
2078
+ 'button[aria-label*="Plus" i]',
2079
+ '.mat-mdc-icon-button:has(mat-icon)',
2080
+ '[class*="menu-button"]',
2081
+ '[class*="more-button"]',
2082
+ '[data-action="menu"]',
2083
+ // Generic icon buttons that might be the menu
2084
+ 'button.mat-icon-button',
2085
+ 'button.mdc-icon-button',
2086
+ ];
2087
+ // First, try to find the menu button within the source element
2088
+ for (const selector of menuButtonSelectors) {
2089
+ try {
2090
+ const menuBtn = await sourceElement.$(selector);
2091
+ if (menuBtn) {
2092
+ const isVisible = await menuBtn.isVisible();
2093
+ if (isVisible) {
2094
+ log.info(` ✅ Found menu button: ${selector}`);
2095
+ await menuBtn.click();
2096
+ await randomDelay(300, 500);
2097
+ return true;
2098
+ }
2099
+ }
2100
+ }
2101
+ catch {
2102
+ continue;
2103
+ }
2104
+ }
2105
+ // Hover over the source to reveal hidden menu button
2106
+ log.info(` 🔍 Hovering to reveal menu button...`);
2107
+ await sourceElement.hover();
2108
+ await randomDelay(500, 800);
2109
+ // Try again after hover
2110
+ for (const selector of menuButtonSelectors) {
2111
+ try {
2112
+ const menuBtn = await sourceElement.$(selector);
2113
+ if (menuBtn) {
2114
+ const isVisible = await menuBtn.isVisible();
2115
+ if (isVisible) {
2116
+ log.info(` ✅ Found menu button after hover: ${selector}`);
2117
+ await menuBtn.click();
2118
+ await randomDelay(300, 500);
2119
+ return true;
2120
+ }
2121
+ }
2122
+ }
2123
+ catch {
2124
+ continue;
2125
+ }
2126
+ }
2127
+ return false;
2128
+ }
2129
+ /**
2130
+ * Click the delete option in the menu
2131
+ */
2132
+ async clickDeleteOption() {
2133
+ const deleteSelectors = [
2134
+ // Menu item selectors
2135
+ 'button:has-text("Delete")',
2136
+ 'button:has-text("Supprimer")',
2137
+ 'button:has-text("Remove")',
2138
+ 'button:has-text("Retirer")',
2139
+ '[role="menuitem"]:has-text("Delete")',
2140
+ '[role="menuitem"]:has-text("Supprimer")',
2141
+ '[role="menuitem"]:has-text("Remove")',
2142
+ '[role="menuitem"]:has-text("Retirer")',
2143
+ 'mat-menu-item:has-text("Delete")',
2144
+ 'mat-menu-item:has-text("Remove")',
2145
+ '.mat-menu-item:has-text("Delete")',
2146
+ '.mat-menu-item:has-text("Remove")',
2147
+ // With icons
2148
+ 'button:has(mat-icon:has-text("delete"))',
2149
+ '[role="menuitem"]:has(mat-icon:has-text("delete"))',
2150
+ 'mat-menu-item:has(mat-icon:has-text("delete"))',
2151
+ // Aria labels
2152
+ 'button[aria-label*="Delete" i]',
2153
+ 'button[aria-label*="Remove" i]',
2154
+ 'button[aria-label*="Supprimer" i]',
2155
+ // Generic patterns
2156
+ '[data-action="delete"]',
2157
+ '[class*="delete"]',
2158
+ ];
2159
+ // Wait for menu to appear
2160
+ await randomDelay(300, 500);
2161
+ for (const selector of deleteSelectors) {
2162
+ try {
2163
+ const deleteBtn = this.page.locator(selector).first();
2164
+ if (await deleteBtn.isVisible({ timeout: 500 })) {
2165
+ log.info(` ✅ Found delete option: ${selector}`);
2166
+ await deleteBtn.click();
2167
+ await randomDelay(300, 500);
2168
+ return true;
2169
+ }
2170
+ }
2171
+ catch {
2172
+ continue;
2173
+ }
2174
+ }
2175
+ // Debug: list menu items
2176
+ log.warning(` ⚠️ Delete option not found, listing menu items...`);
2177
+ try {
2178
+ const menuItems = await this.page.$$('[role="menuitem"], .mat-menu-item, mat-menu-item');
2179
+ for (let i = 0; i < Math.min(menuItems.length, 5); i++) {
2180
+ const text = await menuItems[i].textContent();
2181
+ log.info(` 🔍 Menu item[${i}]: "${text?.trim()}"`);
2182
+ }
2183
+ }
2184
+ catch {
2185
+ // ignore
2186
+ }
2187
+ return false;
2188
+ }
2189
+ /**
2190
+ * Confirm deletion if a confirmation dialog appears
2191
+ */
2192
+ async confirmDeletion() {
2193
+ const confirmSelectors = [
2194
+ // Confirmation buttons
2195
+ 'button:has-text("Confirm")',
2196
+ 'button:has-text("Confirmer")',
2197
+ 'button:has-text("Yes")',
2198
+ 'button:has-text("Oui")',
2199
+ 'button:has-text("Delete")',
2200
+ 'button:has-text("Supprimer")',
2201
+ 'button:has-text("OK")',
2202
+ // Dialog confirm buttons
2203
+ '[role="dialog"] button.mat-primary',
2204
+ '[role="dialog"] button[color="primary"]',
2205
+ '[role="dialog"] button.mdc-button--raised',
2206
+ '.mat-dialog-actions button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
2207
+ '.mdc-dialog__actions button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
2208
+ // Aria patterns
2209
+ 'button[aria-label*="Confirm" i]',
2210
+ 'button[aria-label*="Delete" i]',
2211
+ ];
2212
+ // Wait a moment for dialog to appear
2213
+ await randomDelay(500, 800);
2214
+ // Check if a confirmation dialog is visible
2215
+ const dialogSelectors = ['[role="dialog"]', '.mat-dialog-container', '.mdc-dialog'];
2216
+ let dialogVisible = false;
2217
+ for (const dialogSelector of dialogSelectors) {
2218
+ try {
2219
+ const dialog = this.page.locator(dialogSelector).first();
2220
+ if (await dialog.isVisible({ timeout: 500 })) {
2221
+ dialogVisible = true;
2222
+ log.info(` 📋 Confirmation dialog detected`);
2223
+ break;
2224
+ }
2225
+ }
2226
+ catch {
2227
+ continue;
2228
+ }
2229
+ }
2230
+ if (!dialogVisible) {
2231
+ log.info(` ℹ️ No confirmation dialog detected`);
2232
+ return;
2233
+ }
2234
+ // Click confirm button
2235
+ for (const selector of confirmSelectors) {
2236
+ try {
2237
+ const confirmBtn = this.page.locator(selector).first();
2238
+ if (await confirmBtn.isVisible({ timeout: 500 })) {
2239
+ log.info(` ✅ Clicking confirm: ${selector}`);
2240
+ await confirmBtn.click();
2241
+ await randomDelay(300, 500);
2242
+ return;
2243
+ }
2244
+ }
2245
+ catch {
2246
+ continue;
2247
+ }
2248
+ }
2249
+ log.warning(` ⚠️ No confirm button found, pressing Enter as fallback`);
2250
+ await this.page.keyboard.press('Enter');
2251
+ }
2252
+ /**
2253
+ * List all generated content
2254
+ */
2255
+ async listGeneratedContent() {
2256
+ const content = [];
2257
+ try {
2258
+ // Check for audio overview
2259
+ const audioPlayer = await this.page.$('audio, .audio-player');
2260
+ if (audioPlayer) {
2261
+ content.push({
2262
+ id: 'audio-overview',
2263
+ type: 'audio_overview',
2264
+ name: 'Audio Overview',
2265
+ status: 'ready',
2266
+ createdAt: new Date().toISOString(),
2267
+ });
2268
+ }
2269
+ // Note: We only list audio_overview content now since other content types
2270
+ // (briefing_doc, study_guide, etc.) were removed as they were fake implementations.
2271
+ // Any notes in the Studio panel would have been created by the user directly in NotebookLM.
2272
+ }
2273
+ catch (error) {
2274
+ log.warning(` ⚠️ Could not list generated content: ${error}`);
2275
+ }
2276
+ return content;
2277
+ }
2278
+ /**
2279
+ * Download generated content (audio, video, infographic)
2280
+ *
2281
+ * For media content types that produce downloadable files:
2282
+ * - audio_overview: WAV audio file
2283
+ * - video: MP4 video file
2284
+ * - infographic: PNG image file
2285
+ *
2286
+ * Note: Text-based content (report, presentation, data_table) is returned
2287
+ * directly in the generation response, not as downloadable files.
2288
+ *
2289
+ * @param contentType Type of content to download
2290
+ * @param outputPath Optional path to save the file
2291
+ * @returns Download result with file path
2292
+ */
2293
+ async downloadContent(contentType, outputPath) {
2294
+ log.info(`📥 Downloading ${contentType}...`);
2295
+ // Handle Google export types (presentation -> Google Slides, data_table -> Google Sheets)
2296
+ if (contentType === 'presentation') {
2297
+ return await this.exportPresentationToGoogleSlides();
2298
+ }
2299
+ if (contentType === 'data_table') {
2300
+ return await this.exportDataTableToGoogleSheets();
2301
+ }
2302
+ // Report is truly text-based with no export option
2303
+ if (contentType === 'report') {
2304
+ return {
2305
+ success: false,
2306
+ error: `Content type 'report' is text-based and returned in the generation response. No file download available.`,
2307
+ };
2308
+ }
2309
+ try {
2310
+ // Navigate to the appropriate content panel
2311
+ const panelConfig = this.getContentPanelConfig(contentType);
2312
+ await this.navigateToContentPanel(panelConfig);
2313
+ // Find and click download button
2314
+ const downloadBtn = await this.findDownloadButton();
2315
+ if (!downloadBtn) {
2316
+ // For audio, try to get source URL directly
2317
+ if (contentType === 'audio_overview') {
2318
+ const audioSrc = await this.getAudioSourceUrl();
2319
+ if (audioSrc) {
2320
+ return { success: true, filePath: audioSrc, mimeType: 'audio/wav' };
2321
+ }
2322
+ }
2323
+ throw new Error('Download button not found');
2324
+ }
2325
+ // Set up download handling and click
2326
+ const downloadPromise = this.page.waitForEvent('download', { timeout: 60000 });
2327
+ await downloadBtn.click();
2328
+ const download = await downloadPromise;
2329
+ // Save the file
2330
+ const suggestedName = download.suggestedFilename();
2331
+ const savePath = outputPath || path.join(CONFIG.dataDir, suggestedName);
2332
+ await download.saveAs(savePath);
2333
+ // Determine MIME type
2334
+ const mimeTypes = {
2335
+ audio_overview: 'audio/wav',
2336
+ video: 'video/mp4',
2337
+ infographic: 'image/png',
2338
+ };
2339
+ log.success(` ✅ ${contentType} downloaded: ${savePath}`);
2340
+ return {
2341
+ success: true,
2342
+ filePath: savePath,
2343
+ mimeType: mimeTypes[contentType] || 'application/octet-stream',
2344
+ };
2345
+ }
2346
+ catch (error) {
2347
+ const errorMsg = error instanceof Error ? error.message : String(error);
2348
+ return { success: false, error: `Download failed: ${errorMsg}` };
2349
+ }
2350
+ }
2351
+ /**
2352
+ * Get panel configuration for a content type
2353
+ */
2354
+ getContentPanelConfig(contentType) {
2355
+ const configs = {
2356
+ audio_overview: {
2357
+ tabSelectors: [
2358
+ '[role="tab"]:has-text("Audio Overview")',
2359
+ '[role="tab"]:has-text("Audio")',
2360
+ 'button:has-text("Audio Overview")',
2361
+ '[aria-label*="Audio"]',
2362
+ ],
2363
+ cardSelectors: [
2364
+ '.audio-overview-card',
2365
+ '[data-type="audio"]',
2366
+ 'button:has-text("Deep Dive")',
2367
+ ],
2368
+ },
2369
+ video: {
2370
+ tabSelectors: [
2371
+ '[role="tab"]:has-text("Video")',
2372
+ 'button:has-text("Video")',
2373
+ '[aria-label*="Video"]',
2374
+ ],
2375
+ cardSelectors: ['.video-card', '[data-type="video"]', 'video'],
2376
+ },
2377
+ infographic: {
2378
+ tabSelectors: [
2379
+ '[role="tab"]:has-text("Infographic")',
2380
+ 'button:has-text("Infographic")',
2381
+ '[aria-label*="Infographic"]',
2382
+ ],
2383
+ cardSelectors: [
2384
+ '.infographic-card',
2385
+ '[data-type="infographic"]',
2386
+ 'img[class*="infographic"]',
2387
+ ],
2388
+ },
2389
+ presentation: {
2390
+ tabSelectors: [
2391
+ '[role="tab"]:has-text("Presentation")',
2392
+ '[role="tab"]:has-text("Slides")',
2393
+ '[role="tab"]:has-text("Diaporama")',
2394
+ 'button:has-text("Presentation")',
2395
+ 'button:has-text("Slides")',
2396
+ '[aria-label*="Presentation"]',
2397
+ '[aria-label*="Slides"]',
2398
+ ],
2399
+ cardSelectors: [
2400
+ '.presentation-card',
2401
+ '.slides-card',
2402
+ '[data-type="presentation"]',
2403
+ '[data-type="slides"]',
2404
+ ],
2405
+ },
2406
+ data_table: {
2407
+ tabSelectors: [
2408
+ '[role="tab"]:has-text("Data Table")',
2409
+ '[role="tab"]:has-text("Table")',
2410
+ '[role="tab"]:has-text("Tableau")',
2411
+ 'button:has-text("Data Table")',
2412
+ 'button:has-text("Table")',
2413
+ '[aria-label*="Table"]',
2414
+ '[aria-label*="Data"]',
2415
+ ],
2416
+ cardSelectors: [
2417
+ '.data-table-card',
2418
+ '.table-card',
2419
+ '[data-type="data_table"]',
2420
+ '[data-type="table"]',
2421
+ ],
2422
+ },
2423
+ };
2424
+ return configs[contentType] || { tabSelectors: [], cardSelectors: [] };
2425
+ }
2426
+ /**
2427
+ * Navigate to content panel
2428
+ */
2429
+ async navigateToContentPanel(config) {
2430
+ // Try to click tab
2431
+ for (const selector of config.tabSelectors) {
2432
+ try {
2433
+ const tab = this.page.locator(selector).first();
2434
+ if (await tab.isVisible({ timeout: 500 })) {
2435
+ await tab.click();
2436
+ await randomDelay(500, 1000);
2437
+ break;
2438
+ }
2439
+ }
2440
+ catch {
2441
+ continue;
2442
+ }
2443
+ }
2444
+ // Try to click card
2445
+ for (const selector of config.cardSelectors) {
2446
+ try {
2447
+ const card = this.page.locator(selector).first();
2448
+ if (await card.isVisible({ timeout: 500 })) {
2449
+ await card.click();
2450
+ await randomDelay(500, 1000);
2451
+ break;
2452
+ }
2453
+ }
2454
+ catch {
2455
+ continue;
2456
+ }
2457
+ }
2458
+ }
2459
+ /**
2460
+ * Find download button on the page
2461
+ */
2462
+ async findDownloadButton() {
2463
+ const downloadSelectors = [
2464
+ 'button:has(mat-icon:has-text("download"))',
2465
+ 'button:has(mat-icon:has-text("file_download"))',
2466
+ 'button:has(mat-icon:has-text("get_app"))',
2467
+ 'button[aria-label*="Download"]',
2468
+ 'button[aria-label*="Télécharger"]',
2469
+ 'button[aria-label*="download"]',
2470
+ 'button:has-text("Download")',
2471
+ 'button:has-text("Télécharger")',
2472
+ 'a[download]',
2473
+ '.download-button',
2474
+ '[data-action="download"]',
2475
+ ];
2476
+ for (const selector of downloadSelectors) {
2477
+ try {
2478
+ const btn = this.page.locator(selector).first();
2479
+ if (await btn.isVisible({ timeout: 500 })) {
2480
+ log.info(` ✅ Found download button: ${selector}`);
2481
+ return btn;
2482
+ }
2483
+ }
2484
+ catch {
2485
+ continue;
2486
+ }
2487
+ }
2488
+ return null;
2489
+ }
2490
+ /**
2491
+ * Get audio source URL directly from audio element
2492
+ */
2493
+ async getAudioSourceUrl() {
2494
+ try {
2495
+ const audioEl = await this.page.$('audio');
2496
+ if (audioEl) {
2497
+ const src = await audioEl.getAttribute('src');
2498
+ if (src) {
2499
+ log.info(` ✅ Audio source URL found: ${src}`);
2500
+ return src;
2501
+ }
2502
+ }
2503
+ }
2504
+ catch {
2505
+ /* ignore */
2506
+ }
2507
+ return null;
2508
+ }
2509
+ /**
2510
+ * Download audio content
2511
+ * @deprecated Use downloadContent('audio_overview', outputPath) instead
2512
+ */
2513
+ async downloadAudio(outputPath) {
2514
+ log.info(`📥 Downloading audio...`);
2515
+ try {
2516
+ // First, navigate to the Audio Overview panel/tab
2517
+ log.info(` 📑 Looking for Audio Overview panel...`);
2518
+ const audioTabSelectors = [
2519
+ '[role="tab"]:has-text("Audio Overview")',
2520
+ '[role="tab"]:has-text("Audio")',
2521
+ 'button:has-text("Audio Overview")',
2522
+ 'button:has-text("Audio")',
2523
+ '[aria-label*="Audio"]',
2524
+ ];
2525
+ for (const selector of audioTabSelectors) {
2526
+ try {
2527
+ const tab = this.page.locator(selector).first();
2528
+ if (await tab.isVisible({ timeout: 500 })) {
2529
+ log.info(` ✅ Found Audio tab: ${selector}`);
2530
+ await tab.click();
2531
+ await randomDelay(500, 1000);
2532
+ break;
2533
+ }
2534
+ }
2535
+ catch {
2536
+ continue;
2537
+ }
2538
+ }
2539
+ // Look for Audio Overview card/section and click it if needed
2540
+ const audioCardSelectors = [
2541
+ '.audio-overview-card',
2542
+ '[data-type="audio"]',
2543
+ 'button:has-text("Deep Dive")',
2544
+ 'button:has-text("Conversation")',
2545
+ ];
2546
+ for (const selector of audioCardSelectors) {
2547
+ try {
2548
+ const card = this.page.locator(selector).first();
2549
+ if (await card.isVisible({ timeout: 500 })) {
2550
+ log.info(` ✅ Found Audio card: ${selector}`);
2551
+ await card.click();
2552
+ await randomDelay(500, 1000);
2553
+ break;
2554
+ }
2555
+ }
2556
+ catch {
2557
+ continue;
2558
+ }
2559
+ }
2560
+ // First try to open a menu (NotebookLM often has download in a three-dot menu)
2561
+ const menuTriggerSelectors = [
2562
+ 'button:has(mat-icon:has-text("more_vert"))',
2563
+ 'button:has(mat-icon:has-text("more_horiz"))',
2564
+ 'button[aria-label*="More"]',
2565
+ 'button[aria-label*="Options"]',
2566
+ 'button[aria-label*="Menu"]',
2567
+ 'button[aria-label*="plus"]',
2568
+ '.mat-mdc-menu-trigger',
2569
+ '[aria-haspopup="menu"]',
2570
+ ];
2571
+ for (const menuSelector of menuTriggerSelectors) {
2572
+ try {
2573
+ const menuBtn = this.page.locator(menuSelector).first();
2574
+ if (await menuBtn.isVisible({ timeout: 300 })) {
2575
+ log.info(` 🔍 Opening menu: ${menuSelector}`);
2576
+ await menuBtn.click();
2577
+ await randomDelay(300, 500);
2578
+ break;
2579
+ }
2580
+ }
2581
+ catch {
2582
+ continue;
2583
+ }
2584
+ }
2585
+ // Find download button (either direct or in menu)
2586
+ const downloadSelectors = [
2587
+ // Menu item patterns (if menu was opened)
2588
+ '[role="menuitem"]:has-text("Download")',
2589
+ '[role="menuitem"]:has-text("Télécharger")',
2590
+ 'mat-menu-item:has-text("Download")',
2591
+ 'mat-menu-item:has-text("Télécharger")',
2592
+ '.mat-mdc-menu-item:has-text("Download")',
2593
+ // Material Design icon buttons
2594
+ 'button:has(mat-icon:has-text("download"))',
2595
+ 'button:has(mat-icon:has-text("file_download"))',
2596
+ 'button:has(mat-icon:has-text("get_app"))',
2597
+ // Aria labels
2598
+ 'button[aria-label*="Download"]',
2599
+ 'button[aria-label*="Télécharger"]',
2600
+ 'button[aria-label*="download"]',
2601
+ // Text patterns
2602
+ 'button:has-text("Download")',
2603
+ 'button:has-text("Télécharger")',
2604
+ // Icon buttons near audio
2605
+ '.audio-controls button:has(mat-icon)',
2606
+ '.audio-player button:has(mat-icon)',
2607
+ // Generic download patterns
2608
+ 'a[download]',
2609
+ '.download-button',
2610
+ '[data-action="download"]',
2611
+ ];
2612
+ let downloadBtn = null;
2613
+ for (const selector of downloadSelectors) {
2614
+ try {
2615
+ const btn = this.page.locator(selector).first();
2616
+ if (await btn.isVisible({ timeout: 500 })) {
2617
+ downloadBtn = btn;
2618
+ log.info(` ✅ Found download button: ${selector}`);
2619
+ break;
2620
+ }
2621
+ }
2622
+ catch {
2623
+ continue;
2624
+ }
2625
+ }
2626
+ if (!downloadBtn) {
2627
+ // Try to get audio source directly from audio element
2628
+ log.info(` 🔍 No download button, looking for audio element...`);
2629
+ const audioEl = await this.page.$('audio');
2630
+ if (audioEl) {
2631
+ const src = await audioEl.getAttribute('src');
2632
+ if (src) {
2633
+ log.info(` ✅ Audio source URL found: ${src}`);
2634
+ return {
2635
+ success: true,
2636
+ filePath: src,
2637
+ mimeType: 'audio/wav',
2638
+ };
2639
+ }
2640
+ }
2641
+ // Debug: list all buttons in the panel
2642
+ log.warning(` ⚠️ Download button not found, listing panel buttons...`);
2643
+ try {
2644
+ const buttons = await this.page.locator('button').all();
2645
+ for (let i = 0; i < Math.min(buttons.length, 10); i++) {
2646
+ const text = await buttons[i].textContent();
2647
+ const aria = await buttons[i].getAttribute('aria-label');
2648
+ log.info(` 🔍 Button[${i}]: text="${text?.trim()}", aria="${aria}"`);
2649
+ }
2650
+ }
2651
+ catch {
2652
+ /* ignore */
2653
+ }
2654
+ throw new Error('Download button not found');
2655
+ }
2656
+ // Set up download handling
2657
+ const downloadPromise = this.page.waitForEvent('download', { timeout: 30000 });
2658
+ await downloadBtn.click();
2659
+ const download = await downloadPromise;
2660
+ const suggestedName = download.suggestedFilename();
2661
+ const savePath = outputPath || path.join(CONFIG.dataDir, suggestedName);
2662
+ await download.saveAs(savePath);
2663
+ log.success(` ✅ Audio downloaded: ${savePath}`);
2664
+ return {
2665
+ success: true,
2666
+ filePath: savePath,
2667
+ mimeType: 'audio/wav',
2668
+ };
2669
+ }
2670
+ catch (error) {
2671
+ const errorMsg = error instanceof Error ? error.message : String(error);
2672
+ return { success: false, error: `Download failed: ${errorMsg}` };
2673
+ }
2674
+ }
2675
+ /**
2676
+ * Export presentation to Google Slides
2677
+ * Finds and clicks the "Open in Slides" button to get the Google Slides URL
2678
+ */
2679
+ async exportPresentationToGoogleSlides() {
2680
+ log.info(` 📤 Exporting presentation to Google Slides...`);
2681
+ try {
2682
+ // Navigate to presentation panel
2683
+ const panelConfig = this.getContentPanelConfig('presentation');
2684
+ await this.navigateToContentPanel(panelConfig);
2685
+ // Look for "Open in Slides" or similar export button
2686
+ const exportSelectors = [
2687
+ 'button:has-text("Open in Slides")',
2688
+ 'button:has-text("Ouvrir dans Slides")',
2689
+ 'button:has-text("Export to Slides")',
2690
+ 'button:has-text("Google Slides")',
2691
+ 'a[href*="docs.google.com/presentation"]',
2692
+ 'button[aria-label*="Slides"]',
2693
+ 'button[aria-label*="slides"]',
2694
+ 'button:has(mat-icon:has-text("slideshow"))',
2695
+ // Also look for download as PDF option
2696
+ 'button:has-text("Download PDF")',
2697
+ 'button:has-text("Télécharger PDF")',
2698
+ ];
2699
+ for (const selector of exportSelectors) {
2700
+ try {
2701
+ const btn = this.page.locator(selector).first();
2702
+ if (await btn.isVisible({ timeout: 1000 })) {
2703
+ log.info(` ✅ Found export button: ${selector}`);
2704
+ // Check if it's a direct link
2705
+ const href = await btn.getAttribute('href');
2706
+ if (href && href.includes('docs.google.com/presentation')) {
2707
+ log.success(` ✅ Google Slides URL found: ${href}`);
2708
+ return {
2709
+ success: true,
2710
+ googleSlidesUrl: href,
2711
+ mimeType: 'application/vnd.google-apps.presentation',
2712
+ };
2713
+ }
2714
+ // Click the button and wait for navigation or new tab
2715
+ const [newPage] = await Promise.all([
2716
+ this.page
2717
+ .context()
2718
+ .waitForEvent('page', { timeout: 10000 })
2719
+ .catch(() => null),
2720
+ btn.click(),
2721
+ ]);
2722
+ if (newPage) {
2723
+ const newUrl = newPage.url();
2724
+ await newPage.close();
2725
+ if (newUrl.includes('docs.google.com/presentation')) {
2726
+ log.success(` ✅ Google Slides URL: ${newUrl}`);
2727
+ return {
2728
+ success: true,
2729
+ googleSlidesUrl: newUrl,
2730
+ mimeType: 'application/vnd.google-apps.presentation',
2731
+ };
2732
+ }
2733
+ }
2734
+ // Check current page URL
2735
+ await randomDelay(2000, 3000);
2736
+ const currentUrl = this.page.url();
2737
+ if (currentUrl.includes('docs.google.com/presentation')) {
2738
+ log.success(` ✅ Navigated to Google Slides: ${currentUrl}`);
2739
+ return {
2740
+ success: true,
2741
+ googleSlidesUrl: currentUrl,
2742
+ mimeType: 'application/vnd.google-apps.presentation',
2743
+ };
2744
+ }
2745
+ }
2746
+ }
2747
+ catch {
2748
+ continue;
2749
+ }
2750
+ }
2751
+ return {
2752
+ success: false,
2753
+ error: 'Could not find Google Slides export button. The presentation may not be ready or the export feature is not available.',
2754
+ };
2755
+ }
2756
+ catch (error) {
2757
+ const errorMsg = error instanceof Error ? error.message : String(error);
2758
+ return { success: false, error: `Export to Google Slides failed: ${errorMsg}` };
2759
+ }
2760
+ }
2761
+ /**
2762
+ * Export data table to Google Sheets
2763
+ * Finds and clicks the "Open in Sheets" button to get the Google Sheets URL
2764
+ */
2765
+ async exportDataTableToGoogleSheets() {
2766
+ log.info(` 📤 Exporting data table to Google Sheets...`);
2767
+ try {
2768
+ // Navigate to data table panel
2769
+ const panelConfig = this.getContentPanelConfig('data_table');
2770
+ await this.navigateToContentPanel(panelConfig);
2771
+ // Look for "Open in Sheets" or similar export button
2772
+ const exportSelectors = [
2773
+ 'button:has-text("Open in Sheets")',
2774
+ 'button:has-text("Ouvrir dans Sheets")',
2775
+ 'button:has-text("Export to Sheets")',
2776
+ 'button:has-text("Google Sheets")',
2777
+ 'a[href*="docs.google.com/spreadsheets"]',
2778
+ 'button[aria-label*="Sheets"]',
2779
+ 'button[aria-label*="sheets"]',
2780
+ 'button:has(mat-icon:has-text("table_chart"))',
2781
+ 'button:has(mat-icon:has-text("grid_on"))',
2782
+ ];
2783
+ for (const selector of exportSelectors) {
2784
+ try {
2785
+ const btn = this.page.locator(selector).first();
2786
+ if (await btn.isVisible({ timeout: 1000 })) {
2787
+ log.info(` ✅ Found export button: ${selector}`);
2788
+ // Check if it's a direct link
2789
+ const href = await btn.getAttribute('href');
2790
+ if (href && href.includes('docs.google.com/spreadsheets')) {
2791
+ log.success(` ✅ Google Sheets URL found: ${href}`);
2792
+ return {
2793
+ success: true,
2794
+ googleSheetsUrl: href,
2795
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2796
+ };
2797
+ }
2798
+ // Click the button and wait for navigation or new tab
2799
+ const [newPage] = await Promise.all([
2800
+ this.page
2801
+ .context()
2802
+ .waitForEvent('page', { timeout: 10000 })
2803
+ .catch(() => null),
2804
+ btn.click(),
2805
+ ]);
2806
+ if (newPage) {
2807
+ const newUrl = newPage.url();
2808
+ await newPage.close();
2809
+ if (newUrl.includes('docs.google.com/spreadsheets')) {
2810
+ log.success(` ✅ Google Sheets URL: ${newUrl}`);
2811
+ return {
2812
+ success: true,
2813
+ googleSheetsUrl: newUrl,
2814
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2815
+ };
2816
+ }
2817
+ }
2818
+ // Check current page URL
2819
+ await randomDelay(2000, 3000);
2820
+ const currentUrl = this.page.url();
2821
+ if (currentUrl.includes('docs.google.com/spreadsheets')) {
2822
+ log.success(` ✅ Navigated to Google Sheets: ${currentUrl}`);
2823
+ return {
2824
+ success: true,
2825
+ googleSheetsUrl: currentUrl,
2826
+ mimeType: 'application/vnd.google-apps.spreadsheet',
2827
+ };
2828
+ }
2829
+ }
2830
+ }
2831
+ catch {
2832
+ continue;
2833
+ }
2834
+ }
2835
+ return {
2836
+ success: false,
2837
+ error: 'Could not find Google Sheets export button. The data table may not be ready or the export feature is not available.',
2838
+ };
2839
+ }
2840
+ catch (error) {
2841
+ const errorMsg = error instanceof Error ? error.message : String(error);
2842
+ return { success: false, error: `Export to Google Sheets failed: ${errorMsg}` };
2843
+ }
2844
+ }
2845
+ // ============================================================================
2846
+ // Notes Management
2847
+ // ============================================================================
2848
+ /**
2849
+ * Create a note in the NotebookLM Studio panel
2850
+ *
2851
+ * Notes are user-created annotations that appear in the notebook's Studio panel.
2852
+ * They allow you to save research findings, summaries, or key insights.
2853
+ *
2854
+ * @param input Note input with title and content
2855
+ * @returns NoteResult with success status
2856
+ */
2857
+ async createNote(input) {
2858
+ log.info(`📝 Creating note: "${input.title}"`);
2859
+ try {
2860
+ // Step 1: Navigate to Studio panel where notes are managed
2861
+ await this.navigateToStudio();
2862
+ await randomDelay(500, 1000);
2863
+ // Step 2: Look for "Add note" or "+" button in the Studio panel
2864
+ const addNoteSelectors = [
2865
+ // Primary selectors for Add Note button
2866
+ 'button:has-text("Add note")',
2867
+ 'button:has-text("Ajouter une note")',
2868
+ 'button:has-text("New note")',
2869
+ 'button:has-text("Nouvelle note")',
2870
+ // Icon button patterns
2871
+ 'button[aria-label*="Add note"]',
2872
+ 'button[aria-label*="add note" i]',
2873
+ 'button[aria-label*="Ajouter"]',
2874
+ 'button[aria-label*="New note"]',
2875
+ // Material Design patterns
2876
+ 'button:has(mat-icon:has-text("add"))',
2877
+ 'button:has(mat-icon:has-text("note_add"))',
2878
+ 'button:has(mat-icon:has-text("post_add"))',
2879
+ '.mat-mdc-icon-button[aria-label*="note" i]',
2880
+ '.mat-mdc-icon-button[aria-label*="add" i]',
2881
+ // Studio panel specific
2882
+ '[class*="studio"] button:has(mat-icon:has-text("add"))',
2883
+ '[class*="notes"] button:has(mat-icon:has-text("add"))',
2884
+ // Generic add patterns in notes section
2885
+ '.notes-section button',
2886
+ '.note-list button.add',
2887
+ '[data-testid*="add-note"]',
2888
+ '[data-action="add-note"]',
2889
+ ];
2890
+ let addButtonFound = false;
2891
+ for (const selector of addNoteSelectors) {
2892
+ try {
2893
+ const btn = this.page.locator(selector).first();
2894
+ if (await btn.isVisible({ timeout: 1000 })) {
2895
+ log.info(` ✅ Found Add note button: ${selector}`);
2896
+ await realisticClick(this.page, selector, true);
2897
+ addButtonFound = true;
2898
+ await randomDelay(500, 1000);
2899
+ break;
2900
+ }
2901
+ }
2902
+ catch {
2903
+ continue;
2904
+ }
2905
+ }
2906
+ if (!addButtonFound) {
2907
+ // Debug: log available buttons
2908
+ log.warning(` ⚠️ Add note button not found, checking available elements...`);
2909
+ await this.debugStudioElements();
2910
+ return {
2911
+ success: false,
2912
+ error: 'Could not find Add note button in Studio panel',
2913
+ status: 'failed',
2914
+ };
2915
+ }
2916
+ // Step 3: Wait for note editor dialog/panel to appear
2917
+ await randomDelay(500, 1000);
2918
+ // Step 4: Find and fill the title input
2919
+ const titleSelectors = [
2920
+ // Common title input patterns
2921
+ 'input[placeholder*="Title"]',
2922
+ 'input[placeholder*="title"]',
2923
+ 'input[placeholder*="Titre"]',
2924
+ 'input[placeholder*="titre"]',
2925
+ 'input[placeholder*="Note title"]',
2926
+ 'input[name="title"]',
2927
+ 'input[aria-label*="title" i]',
2928
+ // Material Design inputs
2929
+ '.mat-form-field input',
2930
+ 'mat-form-field input',
2931
+ // Dialog/modal specific
2932
+ '[role="dialog"] input[type="text"]:first-of-type',
2933
+ '.note-editor input:first-of-type',
2934
+ '.note-form input:first-of-type',
2935
+ // Generic text input in note context
2936
+ '[class*="note"] input[type="text"]',
2937
+ ];
2938
+ let titleInput = null;
2939
+ for (const selector of titleSelectors) {
2940
+ try {
2941
+ const input = this.page.locator(selector).first();
2942
+ if (await input.isVisible({ timeout: 1000 })) {
2943
+ titleInput = input;
2944
+ log.info(` ✅ Found title input: ${selector}`);
2945
+ break;
2946
+ }
2947
+ }
2948
+ catch {
2949
+ continue;
2950
+ }
2951
+ }
2952
+ if (titleInput) {
2953
+ await titleInput.fill(input.title);
2954
+ log.info(` ✅ Title entered: "${input.title}"`);
2955
+ await randomDelay(300, 500);
2956
+ }
2957
+ else {
2958
+ log.warning(` ⚠️ Title input not found, proceeding without title`);
2959
+ }
2960
+ // Step 5: Find and fill the content textarea
2961
+ const contentSelectors = [
2962
+ // Textarea patterns
2963
+ 'textarea[placeholder*="content"]',
2964
+ 'textarea[placeholder*="Content"]',
2965
+ 'textarea[placeholder*="note"]',
2966
+ 'textarea[placeholder*="Note"]',
2967
+ 'textarea[placeholder*="Contenu"]',
2968
+ 'textarea[placeholder*="Write"]',
2969
+ 'textarea[placeholder*="write"]',
2970
+ 'textarea[name="content"]',
2971
+ 'textarea[aria-label*="content" i]',
2972
+ 'textarea[aria-label*="note" i]',
2973
+ // Material Design
2974
+ '.mat-form-field textarea',
2975
+ 'mat-form-field textarea',
2976
+ // Dialog/modal specific
2977
+ '[role="dialog"] textarea',
2978
+ '.note-editor textarea',
2979
+ '.note-form textarea',
2980
+ // Rich text editor patterns
2981
+ '[contenteditable="true"]',
2982
+ '.ProseMirror',
2983
+ '.ql-editor',
2984
+ // Generic textarea in note context
2985
+ '[class*="note"] textarea',
2986
+ ];
2987
+ let contentInput = null;
2988
+ for (const selector of contentSelectors) {
2989
+ try {
2990
+ const input = this.page.locator(selector).first();
2991
+ if (await input.isVisible({ timeout: 1000 })) {
2992
+ contentInput = input;
2993
+ log.info(` ✅ Found content input: ${selector}`);
2994
+ break;
2995
+ }
2996
+ }
2997
+ catch {
2998
+ continue;
2999
+ }
3000
+ }
3001
+ if (contentInput) {
3002
+ // Check if it's a contenteditable element
3003
+ const isContentEditable = await contentInput.getAttribute('contenteditable');
3004
+ if (isContentEditable === 'true') {
3005
+ await contentInput.click();
3006
+ await this.page.keyboard.type(input.content);
3007
+ }
3008
+ else {
3009
+ await contentInput.fill(input.content);
3010
+ }
3011
+ log.info(` ✅ Content entered (${input.content.length} chars)`);
3012
+ await randomDelay(300, 500);
3013
+ }
3014
+ else {
3015
+ log.warning(` ⚠️ Content input not found`);
3016
+ return {
3017
+ success: false,
3018
+ error: 'Could not find content input field',
3019
+ status: 'failed',
3020
+ };
3021
+ }
3022
+ // Step 6: Save the note by clicking Save/Done button
3023
+ const saveSelectors = [
3024
+ // Primary save buttons
3025
+ 'button:has-text("Save")',
3026
+ 'button:has-text("Enregistrer")',
3027
+ 'button:has-text("Done")',
3028
+ 'button:has-text("Terminé")',
3029
+ 'button:has-text("Create")',
3030
+ 'button:has-text("Créer")',
3031
+ 'button:has-text("Add")',
3032
+ 'button:has-text("Ajouter")',
3033
+ // Icon buttons
3034
+ 'button:has(mat-icon:has-text("check"))',
3035
+ 'button:has(mat-icon:has-text("save"))',
3036
+ 'button:has(mat-icon:has-text("done"))',
3037
+ // Submit button
3038
+ 'button[type="submit"]',
3039
+ // Material Design primary button
3040
+ 'button.mat-flat-button',
3041
+ 'button.mdc-button--raised',
3042
+ 'button[color="primary"]',
3043
+ // Dialog actions
3044
+ '[role="dialog"] button:not(:has-text("Cancel")):not(:has-text("Annuler"))',
3045
+ '.mat-dialog-actions button:not(:has-text("Cancel"))',
3046
+ '.mdc-dialog__actions button:not(:has-text("Cancel"))',
3047
+ ];
3048
+ let saveButtonFound = false;
3049
+ for (const selector of saveSelectors) {
3050
+ try {
3051
+ const btn = this.page.locator(selector).first();
3052
+ if (await btn.isVisible({ timeout: 500 })) {
3053
+ log.info(` ✅ Found save button: ${selector}`);
3054
+ // Check if button is enabled before clicking
3055
+ const isEnabled = await btn.isEnabled();
3056
+ log.info(` ℹ️ Button enabled: ${isEnabled}`);
3057
+ // Use force click with short timeout to avoid blocking
3058
+ await btn.click({ force: true, timeout: 5000 });
3059
+ log.info(` ✅ Clicked save button`);
3060
+ saveButtonFound = true;
3061
+ // Wait for dialog to close (max 5 seconds - faster feedback)
3062
+ try {
3063
+ await this.page.waitForSelector('[role="dialog"]', {
3064
+ state: 'hidden',
3065
+ timeout: 5000,
3066
+ });
3067
+ log.info(` ✅ Dialog closed after save`);
3068
+ }
3069
+ catch {
3070
+ // Dialog might not close - that's okay, we clicked
3071
+ log.info(` ⚠️ Dialog still visible after click`);
3072
+ }
3073
+ await randomDelay(300, 500);
3074
+ break;
3075
+ }
3076
+ }
3077
+ catch (e) {
3078
+ log.debug(` ℹ️ Selector ${selector} failed: ${e}`);
3079
+ continue;
3080
+ }
3081
+ }
3082
+ if (!saveButtonFound) {
3083
+ // Try pressing Enter as fallback
3084
+ log.info(` ⌨️ No save button found, pressing Enter as fallback`);
3085
+ await this.page.keyboard.press('Enter');
3086
+ // Wait for dialog to close
3087
+ try {
3088
+ await this.page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 10000 });
3089
+ }
3090
+ catch {
3091
+ // Continue anyway
3092
+ }
3093
+ await randomDelay(500, 1000);
3094
+ }
3095
+ // Step 7: Verify note was created by checking for its presence
3096
+ await randomDelay(1000, 2000);
3097
+ // Look for the note in the list
3098
+ const noteVerifySelectors = [
3099
+ `[class*="note"]:has-text("${input.title.substring(0, 20)}")`,
3100
+ `.note-item:has-text("${input.title.substring(0, 20)}")`,
3101
+ `[data-note-title="${input.title}"]`,
3102
+ ];
3103
+ for (const selector of noteVerifySelectors) {
3104
+ try {
3105
+ const note = this.page.locator(selector).first();
3106
+ if (await note.isVisible({ timeout: 2000 })) {
3107
+ log.success(` ✅ Note created successfully: "${input.title}"`);
3108
+ return {
3109
+ success: true,
3110
+ noteTitle: input.title,
3111
+ status: 'created',
3112
+ };
3113
+ }
3114
+ }
3115
+ catch {
3116
+ continue;
3117
+ }
3118
+ }
3119
+ // If we can't verify but no errors occurred, assume success
3120
+ log.success(` ✅ Note creation completed: "${input.title}"`);
3121
+ return {
3122
+ success: true,
3123
+ noteTitle: input.title,
3124
+ status: 'created',
3125
+ };
3126
+ }
3127
+ catch (error) {
3128
+ const errorMsg = error instanceof Error ? error.message : String(error);
3129
+ log.error(` ❌ Note creation failed: ${errorMsg}`);
3130
+ return {
3131
+ success: false,
3132
+ error: errorMsg,
3133
+ status: 'failed',
3134
+ };
3135
+ }
3136
+ }
3137
+ /**
3138
+ * Debug helper to log Studio panel elements
3139
+ */
3140
+ async debugStudioElements() {
3141
+ try {
3142
+ // Log all buttons in the Studio panel
3143
+ const studioButtons = await this.page
3144
+ .locator('[class*="studio"] button, [class*="Studio"] button')
3145
+ .all();
3146
+ log.info(` 🔍 DEBUG: Found ${studioButtons.length} buttons in Studio panel`);
3147
+ for (let i = 0; i < Math.min(studioButtons.length, 10); i++) {
3148
+ const btn = studioButtons[i];
3149
+ const ariaLabel = await btn.getAttribute('aria-label');
3150
+ const text = await btn.textContent();
3151
+ const classes = await btn.getAttribute('class');
3152
+ log.info(` 🔍 Button[${i}]: aria="${ariaLabel}", text="${text?.trim()}", class="${classes?.substring(0, 50)}"`);
3153
+ }
3154
+ // Also check for any visible buttons on the page
3155
+ const allButtons = await this.page.locator('button').all();
3156
+ log.info(` 🔍 Total buttons on page: ${allButtons.length}`);
3157
+ }
3158
+ catch (e) {
3159
+ log.warning(` ⚠️ Debug failed: ${e}`);
3160
+ }
3161
+ }
3162
+ // ============================================================================
3163
+ // Save Chat to Note
3164
+ // ============================================================================
3165
+ /**
3166
+ * Save the current chat/discussion to a note
3167
+ *
3168
+ * This method extracts all messages from the NotebookLM chat/discussion panel
3169
+ * and creates a note with the chat summary.
3170
+ *
3171
+ * @param input Optional input with custom title
3172
+ * @returns SaveChatToNoteResult with success status and message count
3173
+ */
3174
+ async saveChatToNote(input = {}) {
3175
+ const title = input.title || 'Chat Summary';
3176
+ log.info(`💬 Saving chat to note: "${title}"`);
3177
+ // Overall timeout for the entire operation (60 seconds)
3178
+ const OPERATION_TIMEOUT = 60000;
3179
+ const timeoutPromise = new Promise((_, reject) => {
3180
+ setTimeout(() => reject(new Error('Save chat to note timed out after 60 seconds')), OPERATION_TIMEOUT);
3181
+ });
3182
+ try {
3183
+ // Wrap the operation with a timeout
3184
+ return await Promise.race([this.performSaveChatToNote(title), timeoutPromise]);
3185
+ }
3186
+ catch (error) {
3187
+ const errorMsg = error instanceof Error ? error.message : String(error);
3188
+ log.error(` ❌ Failed to save chat to note: ${errorMsg}`);
3189
+ return {
3190
+ success: false,
3191
+ noteTitle: title,
3192
+ status: 'failed',
3193
+ error: errorMsg,
3194
+ };
3195
+ }
3196
+ }
3197
+ /**
3198
+ * Internal method to perform the save chat to note operation
3199
+ */
3200
+ async performSaveChatToNote(title) {
3201
+ try {
3202
+ // Step 1: Navigate to Discussion panel to access chat messages
3203
+ await this.navigateToDiscussion();
3204
+ await randomDelay(500, 1000);
3205
+ // Step 2: Extract chat messages (with shorter timeout for individual ops)
3206
+ const chatMessages = await this.extractChatMessages();
3207
+ if (chatMessages.length === 0) {
3208
+ return {
3209
+ success: false,
3210
+ noteTitle: title,
3211
+ status: 'failed',
3212
+ error: 'No chat messages found to save',
3213
+ };
3214
+ }
3215
+ log.info(` 📊 Extracted ${chatMessages.length} messages from chat`);
3216
+ // Step 3: Format messages into note content
3217
+ const noteContent = this.formatChatAsNote(chatMessages, title);
3218
+ // Step 4: Create the note using existing createNote method
3219
+ const noteResult = await this.createNote({
3220
+ title,
3221
+ content: noteContent,
3222
+ });
3223
+ if (noteResult.success) {
3224
+ log.success(` ✅ Chat saved to note: "${title}" (${chatMessages.length} messages)`);
3225
+ return {
3226
+ success: true,
3227
+ noteTitle: title,
3228
+ status: 'created',
3229
+ messageCount: chatMessages.length,
3230
+ };
3231
+ }
3232
+ else {
3233
+ return {
3234
+ success: false,
3235
+ noteTitle: title,
3236
+ status: 'failed',
3237
+ error: noteResult.error || 'Failed to create note',
3238
+ };
3239
+ }
3240
+ }
3241
+ catch (error) {
3242
+ const errorMsg = error instanceof Error ? error.message : String(error);
3243
+ log.error(` ❌ Failed to save chat to note: ${errorMsg}`);
3244
+ return {
3245
+ success: false,
3246
+ noteTitle: title,
3247
+ status: 'failed',
3248
+ error: errorMsg,
3249
+ };
3250
+ }
3251
+ }
3252
+ /**
3253
+ * Extract chat messages from the Discussion panel
3254
+ *
3255
+ * @returns Array of message objects with role (user/assistant) and content
3256
+ */
3257
+ async extractChatMessages() {
3258
+ const messages = [];
3259
+ try {
3260
+ // Selectors for user messages (questions)
3261
+ const userMessageSelectors = [
3262
+ // NotebookLM user message patterns
3263
+ '.user-message',
3264
+ '[data-role="user"]',
3265
+ '[class*="user-message"]',
3266
+ '[class*="user_message"]',
3267
+ '.chat-message.user',
3268
+ '.message.user',
3269
+ // Material Design chat patterns
3270
+ '.mat-chat-user-message',
3271
+ '.mdc-chat-user-message',
3272
+ // Generic patterns
3273
+ '[data-message-role="user"]',
3274
+ '[data-sender="user"]',
3275
+ ];
3276
+ // Selectors for AI messages (responses)
3277
+ const aiMessageSelectors = [
3278
+ // NotebookLM AI response patterns
3279
+ '.ai-message',
3280
+ '.assistant-message',
3281
+ '[data-role="assistant"]',
3282
+ '[data-role="model"]',
3283
+ '[class*="ai-message"]',
3284
+ '[class*="assistant-message"]',
3285
+ '[class*="model-message"]',
3286
+ '.chat-message.assistant',
3287
+ '.chat-message.ai',
3288
+ '.message.assistant',
3289
+ // Material Design patterns
3290
+ '.mat-chat-ai-message',
3291
+ '.mdc-chat-ai-message',
3292
+ // Generic patterns
3293
+ '[data-message-role="assistant"]',
3294
+ '[data-message-role="model"]',
3295
+ '[data-sender="assistant"]',
3296
+ '[data-sender="model"]',
3297
+ // Markdown response container (common in NotebookLM)
3298
+ '.response-container',
3299
+ '.markdown-body',
3300
+ '[class*="response"]',
3301
+ ];
3302
+ // Try to find all message containers in order
3303
+ const allMessageSelectors = [
3304
+ // Combined message container patterns
3305
+ '.chat-message',
3306
+ '.message-item',
3307
+ '[class*="message-container"]',
3308
+ '.messages-list > div',
3309
+ '.chat-scroll-container > div',
3310
+ '[role="listitem"]',
3311
+ // NotebookLM specific patterns
3312
+ '[class*="chat"] [class*="message"]',
3313
+ '[class*="discussion"] [class*="message"]',
3314
+ ];
3315
+ // First, try to find all messages in sequence
3316
+ for (const containerSelector of allMessageSelectors) {
3317
+ try {
3318
+ const messageContainers = await this.page.locator(containerSelector).all();
3319
+ if (messageContainers.length > 0) {
3320
+ log.info(` 🔍 Found ${messageContainers.length} message containers with: ${containerSelector}`);
3321
+ for (const container of messageContainers) {
3322
+ if (!(await container.isVisible()))
3323
+ continue;
3324
+ const text = await container.textContent();
3325
+ if (!text || text.trim().length === 0)
3326
+ continue;
3327
+ // Determine if user or AI message based on class/attributes
3328
+ const classes = (await container.getAttribute('class')) || '';
3329
+ const role = (await container.getAttribute('data-role')) ||
3330
+ (await container.getAttribute('data-sender')) ||
3331
+ '';
3332
+ const isUser = /user|human|question/i.test(classes) || /user|human/i.test(role);
3333
+ const isAI = /ai|assistant|model|response|answer/i.test(classes) ||
3334
+ /assistant|model/i.test(role);
3335
+ if (isUser) {
3336
+ messages.push({ role: 'user', content: text.trim() });
3337
+ }
3338
+ else if (isAI) {
3339
+ messages.push({ role: 'assistant', content: text.trim() });
3340
+ }
3341
+ }
3342
+ if (messages.length > 0) {
3343
+ break;
3344
+ }
3345
+ }
3346
+ }
3347
+ catch {
3348
+ continue;
3349
+ }
3350
+ }
3351
+ // Fallback: Try to extract user and AI messages separately
3352
+ if (messages.length === 0) {
3353
+ log.info(` 🔍 Trying fallback: separate user/AI message extraction...`);
3354
+ // Extract user messages
3355
+ for (const selector of userMessageSelectors) {
3356
+ try {
3357
+ const userMsgs = await this.page.locator(selector).all();
3358
+ for (const msg of userMsgs) {
3359
+ if (await msg.isVisible()) {
3360
+ const text = await msg.textContent();
3361
+ if (text && text.trim().length > 0) {
3362
+ messages.push({ role: 'user', content: text.trim() });
3363
+ }
3364
+ }
3365
+ }
3366
+ if (messages.length > 0)
3367
+ break;
3368
+ }
3369
+ catch {
3370
+ continue;
3371
+ }
3372
+ }
3373
+ // Extract AI messages
3374
+ for (const selector of aiMessageSelectors) {
3375
+ try {
3376
+ const aiMsgs = await this.page.locator(selector).all();
3377
+ for (const msg of aiMsgs) {
3378
+ if (await msg.isVisible()) {
3379
+ const text = await msg.textContent();
3380
+ if (text && text.trim().length > 0) {
3381
+ // Check if we already have this message (to avoid duplicates)
3382
+ const exists = messages.some((m) => m.content === text.trim());
3383
+ if (!exists) {
3384
+ messages.push({ role: 'assistant', content: text.trim() });
3385
+ }
3386
+ }
3387
+ }
3388
+ }
3389
+ if (messages.filter((m) => m.role === 'assistant').length > 0)
3390
+ break;
3391
+ }
3392
+ catch {
3393
+ continue;
3394
+ }
3395
+ }
3396
+ }
3397
+ // Last resort: Use snapshotAllResponses utility for AI responses
3398
+ if (messages.filter((m) => m.role === 'assistant').length === 0) {
3399
+ log.info(` 🔍 Using snapshotAllResponses for AI messages...`);
3400
+ const aiResponses = await snapshotAllResponses(this.page);
3401
+ for (const response of aiResponses) {
3402
+ if (response && response.trim().length > 0) {
3403
+ messages.push({ role: 'assistant', content: response.trim() });
3404
+ }
3405
+ }
3406
+ }
3407
+ log.info(` 📊 Total extracted: ${messages.filter((m) => m.role === 'user').length} user, ${messages.filter((m) => m.role === 'assistant').length} AI messages`);
3408
+ }
3409
+ catch (error) {
3410
+ log.warning(` ⚠️ Error extracting chat messages: ${error}`);
3411
+ }
3412
+ return messages;
3413
+ }
3414
+ /**
3415
+ * Format extracted chat messages as a note
3416
+ *
3417
+ * @param messages Array of chat messages
3418
+ * @param title Note title
3419
+ * @returns Formatted note content
3420
+ */
3421
+ formatChatAsNote(messages, title) {
3422
+ const lines = [];
3423
+ lines.push(`# ${title}`);
3424
+ lines.push('');
3425
+ lines.push(`*Saved from NotebookLM chat on ${new Date().toLocaleString()}*`);
3426
+ lines.push('');
3427
+ lines.push('---');
3428
+ lines.push('');
3429
+ for (const msg of messages) {
3430
+ if (msg.role === 'user') {
3431
+ lines.push(`**User:**`);
3432
+ lines.push(msg.content);
3433
+ lines.push('');
3434
+ }
3435
+ else {
3436
+ lines.push(`**NotebookLM:**`);
3437
+ lines.push(msg.content);
3438
+ lines.push('');
3439
+ }
3440
+ }
3441
+ lines.push('---');
3442
+ lines.push(`*${messages.length} messages total*`);
3443
+ return lines.join('\n');
3444
+ }
3445
+ // ============================================================================
3446
+ // Note to Source Conversion
3447
+ // ============================================================================
3448
+ /**
3449
+ * Convert a note to a source document in NotebookLM
3450
+ *
3451
+ * This feature allows users to convert an existing note into a source,
3452
+ * making the note content available for RAG queries. The method:
3453
+ * 1. Finds the note by title or ID in the Studio panel
3454
+ * 2. Attempts to use NotebookLM's native "Convert to source" feature if available
3455
+ * 3. Falls back to extracting note content and creating a text source if not
3456
+ *
3457
+ * @param input Note identification (title or ID)
3458
+ * @returns NoteToSourceResult with source information
3459
+ */
3460
+ async convertNoteToSource(input) {
3461
+ const { noteTitle, noteId } = input;
3462
+ log.info(`📄 Converting note to source: "${noteTitle || noteId}"`);
3463
+ try {
3464
+ // Step 1: Navigate to Studio panel where notes are located
3465
+ await this.navigateToStudio();
3466
+ await randomDelay(500, 1000);
3467
+ // Step 2: Find the note in the Studio panel
3468
+ const noteElement = await this.findNoteElement(noteTitle, noteId);
3469
+ if (!noteElement) {
3470
+ return {
3471
+ success: false,
3472
+ error: `Note not found: "${noteTitle || noteId}"`,
3473
+ };
3474
+ }
3475
+ log.info(` ✅ Found note: "${noteTitle || noteId}"`);
3476
+ // Step 3: Click on the note to select it
3477
+ await noteElement.click();
3478
+ await randomDelay(300, 500);
3479
+ // Step 4: Try to find and use native "Convert to source" or "Add to sources" option
3480
+ const nativeConversionSuccess = await this.tryNativeNoteToSourceConversion(noteElement);
3481
+ if (nativeConversionSuccess) {
3482
+ log.success(` ✅ Note converted to source using native feature`);
3483
+ return {
3484
+ success: true,
3485
+ sourceName: noteTitle || noteId,
3486
+ };
3487
+ }
3488
+ // Step 5: Fallback - Extract note content and create a text source
3489
+ log.info(` ℹ️ Native conversion not available, using fallback (extract + add as text source)`);
3490
+ const noteContent = await this.extractNoteContent(noteElement, noteTitle);
3491
+ if (!noteContent) {
3492
+ return {
3493
+ success: false,
3494
+ error: 'Could not extract note content for conversion',
3495
+ };
3496
+ }
3497
+ // Create a text source with the note content
3498
+ const sourceTitle = `[Note] ${noteTitle || 'Converted Note'}`;
3499
+ const sourceResult = await this.addSource({
3500
+ type: 'text',
3501
+ text: noteContent,
3502
+ title: sourceTitle,
3503
+ });
3504
+ if (sourceResult.success) {
3505
+ log.success(` ✅ Note converted to source: "${sourceTitle}"`);
3506
+ return {
3507
+ success: true,
3508
+ sourceId: sourceResult.sourceId,
3509
+ sourceName: sourceResult.sourceName || sourceTitle,
3510
+ };
3511
+ }
3512
+ else {
3513
+ return {
3514
+ success: false,
3515
+ error: sourceResult.error || 'Failed to create source from note content',
3516
+ };
3517
+ }
3518
+ }
3519
+ catch (error) {
3520
+ const errorMsg = error instanceof Error ? error.message : String(error);
3521
+ log.error(` ❌ Note to source conversion failed: ${errorMsg}`);
3522
+ return {
3523
+ success: false,
3524
+ error: errorMsg,
3525
+ };
3526
+ }
3527
+ }
3528
+ /**
3529
+ * Find a note element in the Studio panel by title or ID
3530
+ */
3531
+ async findNoteElement(noteTitle, noteId
3532
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3533
+ ) {
3534
+ // Selectors for note items in the Studio panel
3535
+ const noteItemSelectors = [
3536
+ '.note-item',
3537
+ '[data-item="note"]',
3538
+ '.notes-list-item',
3539
+ '[class*="note-list"] > div',
3540
+ '[class*="note-list"] > li',
3541
+ '[class*="Note"] > div',
3542
+ '[class*="notes"] > div',
3543
+ 'mat-list-item:has([class*="note"])',
3544
+ '.mat-list-item:has([class*="note"])',
3545
+ '[role="listitem"]:has([class*="note"])',
3546
+ // Studio panel specific
3547
+ '[class*="studio"] [class*="card"]',
3548
+ '[class*="Studio"] [class*="card"]',
3549
+ ];
3550
+ for (const selector of noteItemSelectors) {
3551
+ try {
3552
+ const elements = await this.page.$$(selector);
3553
+ for (const el of elements) {
3554
+ // Check by data-id attribute
3555
+ if (noteId) {
3556
+ const dataId = await el.getAttribute('data-id');
3557
+ if (dataId === noteId) {
3558
+ return el;
3559
+ }
3560
+ const dataNote = await el.getAttribute('data-note-id');
3561
+ if (dataNote === noteId) {
3562
+ return el;
3563
+ }
3564
+ }
3565
+ // Check by title text content
3566
+ if (noteTitle) {
3567
+ const textContent = await el.textContent();
3568
+ if (textContent && textContent.toLowerCase().includes(noteTitle.toLowerCase())) {
3569
+ return el;
3570
+ }
3571
+ // Also check specific title elements
3572
+ try {
3573
+ const titleText = await el.$eval('.note-title, .title, [class*="title"], [class*="name"], h3, h4', (e) => e.textContent?.trim() || '');
3574
+ if (titleText.toLowerCase().includes(noteTitle.toLowerCase())) {
3575
+ return el;
3576
+ }
3577
+ }
3578
+ catch {
3579
+ // Element doesn't have a title child
3580
+ }
3581
+ }
3582
+ }
3583
+ }
3584
+ catch {
3585
+ continue;
3586
+ }
3587
+ }
3588
+ // Fallback: look for any card or item with matching text
3589
+ try {
3590
+ const searchText = noteTitle || noteId || '';
3591
+ const fallbackSelectors = [
3592
+ `[class*="note"]:has-text("${searchText.substring(0, 30)}")`,
3593
+ `[class*="card"]:has-text("${searchText.substring(0, 30)}")`,
3594
+ `:has-text("${searchText.substring(0, 30)}")`,
3595
+ ];
3596
+ for (const selector of fallbackSelectors) {
3597
+ try {
3598
+ const el = await this.page.$(selector);
3599
+ if (el) {
3600
+ return el;
3601
+ }
3602
+ }
3603
+ catch {
3604
+ continue;
3605
+ }
3606
+ }
3607
+ }
3608
+ catch {
3609
+ // Ignore fallback errors
3610
+ }
3611
+ return null;
3612
+ }
3613
+ /**
3614
+ * Try to use NotebookLM's native "Convert to source" or "Add to sources" feature
3615
+ */
3616
+ async tryNativeNoteToSourceConversion(
3617
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3618
+ noteElement) {
3619
+ // First, try to find a menu button on the note
3620
+ const menuOpened = await this.openNoteMenu(noteElement);
3621
+ if (menuOpened) {
3622
+ // Look for "Convert to source", "Add to sources", or similar options
3623
+ const convertSelectors = [
3624
+ // English patterns
3625
+ 'button:has-text("Convert to source")',
3626
+ 'button:has-text("Add to sources")',
3627
+ 'button:has-text("Make source")',
3628
+ 'button:has-text("Save as source")',
3629
+ '[role="menuitem"]:has-text("Convert to source")',
3630
+ '[role="menuitem"]:has-text("Add to sources")',
3631
+ '[role="menuitem"]:has-text("source")',
3632
+ 'mat-menu-item:has-text("source")',
3633
+ // French patterns
3634
+ 'button:has-text("Convertir en source")',
3635
+ 'button:has-text("Ajouter aux sources")',
3636
+ '[role="menuitem"]:has-text("source")',
3637
+ // Icon patterns
3638
+ 'button:has(mat-icon:has-text("add_to_drive"))',
3639
+ 'button:has(mat-icon:has-text("file_copy"))',
3640
+ '[role="menuitem"]:has(mat-icon:has-text("add"))',
3641
+ ];
3642
+ for (const selector of convertSelectors) {
3643
+ try {
3644
+ const btn = this.page.locator(selector).first();
3645
+ if (await btn.isVisible({ timeout: 500 })) {
3646
+ log.info(` ✅ Found native convert option: ${selector}`);
3647
+ await btn.click();
3648
+ await randomDelay(1000, 2000);
3649
+ // Wait for potential confirmation or processing
3650
+ await this.waitForSourceProcessing('Note');
3651
+ return true;
3652
+ }
3653
+ }
3654
+ catch {
3655
+ continue;
3656
+ }
3657
+ }
3658
+ // Close menu if convert option wasn't found
3659
+ await this.page.keyboard.press('Escape');
3660
+ await randomDelay(200, 400);
3661
+ }
3662
+ // Also check for a direct "Convert" or "Add to sources" button on the note itself
3663
+ const directButtonSelectors = [
3664
+ 'button:has-text("Convert")',
3665
+ 'button:has-text("Add to sources")',
3666
+ 'button[aria-label*="source" i]',
3667
+ 'button[aria-label*="convert" i]',
3668
+ ];
3669
+ for (const selector of directButtonSelectors) {
3670
+ try {
3671
+ const btn = await noteElement.$(selector);
3672
+ if (btn && (await btn.isVisible())) {
3673
+ log.info(` ✅ Found direct convert button on note: ${selector}`);
3674
+ await btn.click();
3675
+ await randomDelay(1000, 2000);
3676
+ await this.waitForSourceProcessing('Note');
3677
+ return true;
3678
+ }
3679
+ }
3680
+ catch {
3681
+ continue;
3682
+ }
3683
+ }
3684
+ return false;
3685
+ }
3686
+ /**
3687
+ * Open the menu for a note element
3688
+ */
3689
+ async openNoteMenu(
3690
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3691
+ noteElement) {
3692
+ const menuButtonSelectors = [
3693
+ // Material Design 3-dot menu button
3694
+ 'button:has(mat-icon:has-text("more_vert"))',
3695
+ 'button:has(mat-icon:has-text("more_horiz"))',
3696
+ 'button[aria-label*="menu" i]',
3697
+ 'button[aria-label*="options" i]',
3698
+ 'button[aria-label*="actions" i]',
3699
+ 'button[aria-label*="More" i]',
3700
+ 'button[aria-label*="Plus" i]',
3701
+ '.mat-mdc-icon-button:has(mat-icon)',
3702
+ '[class*="menu-button"]',
3703
+ '[class*="more-button"]',
3704
+ '[data-action="menu"]',
3705
+ // Generic icon buttons
3706
+ 'button.mat-icon-button',
3707
+ 'button.mdc-icon-button',
3708
+ ];
3709
+ // First, try to find the menu button within the note element
3710
+ for (const selector of menuButtonSelectors) {
3711
+ try {
3712
+ const menuBtn = await noteElement.$(selector);
3713
+ if (menuBtn) {
3714
+ const isVisible = await menuBtn.isVisible();
3715
+ if (isVisible) {
3716
+ log.info(` ✅ Found note menu button: ${selector}`);
3717
+ await menuBtn.click();
3718
+ await randomDelay(300, 500);
3719
+ return true;
3720
+ }
3721
+ }
3722
+ }
3723
+ catch {
3724
+ continue;
3725
+ }
3726
+ }
3727
+ // Hover over the note to reveal hidden menu button
3728
+ log.info(` 🔍 Hovering to reveal note menu button...`);
3729
+ await noteElement.hover();
3730
+ await randomDelay(500, 800);
3731
+ // Try again after hover
3732
+ for (const selector of menuButtonSelectors) {
3733
+ try {
3734
+ const menuBtn = await noteElement.$(selector);
3735
+ if (menuBtn) {
3736
+ const isVisible = await menuBtn.isVisible();
3737
+ if (isVisible) {
3738
+ log.info(` ✅ Found note menu button after hover: ${selector}`);
3739
+ await menuBtn.click();
3740
+ await randomDelay(300, 500);
3741
+ return true;
3742
+ }
3743
+ }
3744
+ }
3745
+ catch {
3746
+ continue;
3747
+ }
3748
+ }
3749
+ // Try right-click as last resort
3750
+ try {
3751
+ await noteElement.click({ button: 'right' });
3752
+ await randomDelay(300, 500);
3753
+ // Check if a context menu appeared
3754
+ const contextMenu = await this.page.$('[role="menu"], .mat-menu-panel, .mdc-menu');
3755
+ if (contextMenu) {
3756
+ log.info(` ✅ Opened context menu via right-click`);
3757
+ return true;
3758
+ }
3759
+ }
3760
+ catch {
3761
+ // Ignore right-click errors
3762
+ }
3763
+ return false;
3764
+ }
3765
+ /**
3766
+ * Extract the content of a note for fallback conversion
3767
+ */
3768
+ async extractNoteContent(
3769
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3770
+ noteElement, noteTitle) {
3771
+ try {
3772
+ // Method 1: Click the note to open it and extract content
3773
+ await noteElement.click();
3774
+ await randomDelay(500, 1000);
3775
+ // Look for expanded note content
3776
+ const contentSelectors = [
3777
+ // Note body/content areas
3778
+ '.note-content',
3779
+ '.note-body',
3780
+ '[class*="note-content"]',
3781
+ '[class*="noteContent"]',
3782
+ '[class*="note-body"]',
3783
+ // Expanded card content
3784
+ '[class*="expanded"] [class*="content"]',
3785
+ '[class*="detail"] [class*="content"]',
3786
+ // Text areas in note view
3787
+ '.note-text',
3788
+ '[class*="text-content"]',
3789
+ // ProseMirror or other rich text editors
3790
+ '.ProseMirror',
3791
+ '[contenteditable="true"]',
3792
+ // Generic content areas
3793
+ 'article',
3794
+ '.content',
3795
+ '[role="article"]',
3796
+ ];
3797
+ for (const selector of contentSelectors) {
3798
+ try {
3799
+ const contentEl = await this.page.$(selector);
3800
+ if (contentEl && (await contentEl.isVisible())) {
3801
+ const content = await contentEl.textContent();
3802
+ if (content && content.trim().length > 10) {
3803
+ log.info(` ✅ Extracted note content from: ${selector}`);
3804
+ // Format the content with the note title
3805
+ const formattedContent = noteTitle
3806
+ ? `# ${noteTitle}\n\n${content.trim()}`
3807
+ : content.trim();
3808
+ return formattedContent;
3809
+ }
3810
+ }
3811
+ }
3812
+ catch {
3813
+ continue;
3814
+ }
3815
+ }
3816
+ // Method 2: Try to extract from the note element itself
3817
+ const elementContent = await noteElement.textContent();
3818
+ if (elementContent && elementContent.trim().length > 20) {
3819
+ log.info(` ✅ Extracted note content from element`);
3820
+ const formattedContent = noteTitle
3821
+ ? `# ${noteTitle}\n\n${elementContent.trim()}`
3822
+ : elementContent.trim();
3823
+ return formattedContent;
3824
+ }
3825
+ // Method 3: Look for inner HTML content
3826
+ try {
3827
+ const innerContent = await noteElement.$eval('div, p, span', (el) => el.textContent?.trim() || '');
3828
+ if (innerContent && innerContent.length > 20) {
3829
+ log.info(` ✅ Extracted note content from inner elements`);
3830
+ const formattedContent = noteTitle ? `# ${noteTitle}\n\n${innerContent}` : innerContent;
3831
+ return formattedContent;
3832
+ }
3833
+ }
3834
+ catch {
3835
+ // Ignore extraction errors
3836
+ }
3837
+ log.warning(` ⚠️ Could not extract note content`);
3838
+ return null;
3839
+ }
3840
+ catch (error) {
3841
+ log.warning(` ⚠️ Error extracting note content: ${error}`);
3842
+ return null;
3843
+ }
3844
+ }
3845
+ }
3846
+ //# sourceMappingURL=content-manager.js.map