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