diff --git a/README.md b/README.md index 71e92f67..68aceea8 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **tieba** | `hot` `posts` `search` `read` | | **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` | +| **notebooklm** | `status` `list` `current` | | **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | 66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c4de509..92138a95 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,6 +175,7 @@ npm install -g @jackwener/opencli@latest | **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 | | **google** | `news` `search` `suggest` `trends` | 公开 | | **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API | +| **notebooklm** | `status` `list` `current` | 浏览器 | | **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 | | **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 | | **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7027cd5a..a1e8b795 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -72,6 +72,7 @@ export default defineConfig({ { text: 'Band', link: '/adapters/browser/band' }, { text: 'Chaoxing', link: '/adapters/browser/chaoxing' }, { text: 'Grok', link: '/adapters/browser/grok' }, + { text: 'NotebookLM', link: '/adapters/browser/notebooklm' }, { text: 'WeRead', link: '/adapters/browser/weread' }, { text: 'Douban', link: '/adapters/browser/douban' }, { text: 'Sina Blog', link: '/adapters/browser/sinablog' }, diff --git a/docs/adapters/browser/notebooklm.md b/docs/adapters/browser/notebooklm.md new file mode 100644 index 00000000..cb1960db --- /dev/null +++ b/docs/adapters/browser/notebooklm.md @@ -0,0 +1,41 @@ +# NotebookLM + +**Mode**: 🔐 Browser Bridge · **Domain**: `notebooklm.google.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli notebooklm status` | Check whether NotebookLM is reachable in the current Chrome session | +| `opencli notebooklm list` | List notebooks visible from the NotebookLM home page | +| `opencli notebooklm current` | Show metadata for the currently opened notebook tab | + +## Positioning + +This adapter is intended to reuse the existing OpenCLI Browser Bridge runtime: + +- no custom NotebookLM extension +- no exported cookie replay +- requests and page state stay in the real Chrome session + +The first implementation focus is desktop Chrome with an already logged-in Google account. + +## Usage Examples + +```bash +opencli notebooklm status +opencli notebooklm list -f json +opencli notebooklm current -f json +``` + +## Prerequisites + +- Chrome running and logged into Google / NotebookLM +- [Browser Bridge extension](/guide/browser-bridge) installed +- NotebookLM accessible in the current browser session + +## Notes + +- `list` currently reads notebooks visible from the NotebookLM home page DOM. +- `current` is useful as a lower-risk fallback when you already have a notebook tab open. +- More advanced NotebookLM actions should be added only after `status`, `list`, and `current` are stable. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index ae3d4ea4..205c5085 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -29,6 +29,7 @@ Run `opencli list` for the live registry. | **[linux-do](/adapters/browser/linux-do)** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | 🔐 Browser | | **[chaoxing](/adapters/browser/chaoxing)** | `assignments` `exams` | 🔐 Browser | | **[grok](/adapters/browser/grok)** | `ask` | 🔐 Browser | +| **[notebooklm](/adapters/browser/notebooklm)** | `status` `list` `current` | 🔐 Browser | | **[doubao](/adapters/browser/doubao)** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 🔐 Browser | | **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | 🔐 Browser | | **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 🔐 Browser | diff --git a/extension/dist/background.js b/extension/dist/background.js index 4ff776bb..dc9ab08c 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,580 +1,861 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 6e4; - -const attached = /* @__PURE__ */ new Set(); -const BLANK_PAGE$1 = "data:text/html,"; +//#region src/protocol.ts +/** Default daemon port */ +var DAEMON_PORT = 19825; +var DAEMON_HOST = "localhost"; +var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +/** Base reconnect delay for extension WebSocket (ms) */ +var WS_RECONNECT_BASE_DELAY = 2e3; +/** Max reconnect delay (ms) */ +var WS_RECONNECT_MAX_DELAY = 6e4; +//#endregion +//#region src/cdp.ts +/** +* CDP execution via chrome.debugger API. +* +* chrome.debugger only needs the "debugger" permission — no host_permissions. +* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// +* tabs (resolveTabId in background.ts filters them). +*/ +var attached = /* @__PURE__ */ new Set(); +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE$1 = "data:text/html,"; +/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */ function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1; + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1; } async function ensureAttached(tabId) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - } - try { - await chrome.debugger.attach({ tabId }, "1.3"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - if (msg.includes("Another debugger is already attached")) { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - try { - await chrome.debugger.attach({ tabId }, "1.3"); - } catch { - throw new Error(`attach failed: ${msg}${hint}`); - } - } else { - throw new Error(`attach failed: ${msg}${hint}`); - } - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch { - } + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + if (msg.includes("Another debugger is already attached")) { + try { + await chrome.debugger.detach({ tabId }); + } catch {} + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch { + throw new Error(`attach failed: ${msg}${hint}`); + } + } else throw new Error(`attach failed: ${msg}${hint}`); + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch {} } async function evaluate(tabId, expression) { - await ensureAttached(tabId); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; -} -const evaluateAsync = evaluate; + await ensureAttached(tabId); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; +} +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - if (options.fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: Math.ceil(size.width), - height: Math.ceil(size.height), - deviceScaleFactor: 1 - }); - } - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (options.fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } + await ensureAttached(tabId); + const format = options.format ?? "png"; + if (options.fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: Math.ceil(size.width), + height: Math.ceil(size.height), + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); + return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; + } finally { + if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); + } +} +/** +* Set local file paths on a file input element via CDP DOM.setFileInputFiles. +* This bypasses the need to send large base64 payloads through the message channel — +* Chrome reads the files directly from the local filesystem. +* +* @param tabId - Target tab ID +* @param files - Array of absolute local file paths +* @param selector - CSS selector to find the file input (optional, defaults to first file input) +*/ +async function setFileInputFiles(tabId, files, selector) { + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || "input[type=\"file\"]"; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); } async function detach(tabId) { - if (!attached.has(tabId)) return; - attached.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } + if (!attached.has(tabId)) return; + attached.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch {} } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) attached.delete(source.tabId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); - } - }); -} - -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) attached.delete(source.tabId); + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); + }); +} +//#endregion +//#region src/background.ts +var ws = null; +var reconnectTimer = null; +var reconnectAttempts = 0; +var _origLog = console.log.bind(console); +var _origWarn = console.warn.bind(console); +var _origError = console.error.bind(console); function forwardLog(level, args) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() })); - } catch { - } + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + ws.send(JSON.stringify({ + type: "log", + level, + msg, + ts: Date.now() + })); + } catch {} } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; +/** +* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket +* connection. fetch() failures are silently catchable; new WebSocket() is not +* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any +* JS handler can intercept it. By keeping the probe inside connect() every +* call site remains unchanged and the guard can never be accidentally skipped. +*/ async function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) return; - } catch { - return; - } - try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { - scheduleReconnect(); - return; - } - ws.onopen = () => { - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); - }; - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data); - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - ws.onclose = () => { - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - ws.onerror = () => { - ws?.close(); - }; -} -const MAX_EAGER_ATTEMPTS = 6; + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return; + } catch { + return; + } + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ + type: "hello", + version: chrome.runtime.getManifest().version + })); + }; + ws.onmessage = async (event) => { + try { + const result = await handleCommand(JSON.parse(event.data)); + ws?.send(JSON.stringify(result)); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + ws.onclose = () => { + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +/** +* After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. +* The keepalive alarm (~24s) will still call connect() periodically, but at a +* much lower frequency — reducing console noise when the daemon is not running. +*/ +var MAX_EAGER_ATTEMPTS = 6; function scheduleReconnect() { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const WINDOW_IDLE_TIMEOUT = 3e4; + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +var automationSessions = /* @__PURE__ */ new Map(); +var WINDOW_IDLE_TIMEOUT = 3e4; function getWorkspaceKey(workspace) { - return workspace?.trim() || "default"; + return workspace?.trim() || "default"; } function resetWindowIdleTimer(workspace) { - const session = automationSessions.get(workspace); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; - session.idleTimer = setTimeout(async () => { - const current = automationSessions.get(workspace); - if (!current) return; - try { - await chrome.windows.remove(current.windowId); - console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - } - automationSessions.delete(workspace); - }, WINDOW_IDLE_TIMEOUT); + const session = automationSessions.get(workspace); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT; + session.idleTimer = setTimeout(async () => { + const current = automationSessions.get(workspace); + if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } + try { + await chrome.windows.remove(current.windowId); + console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); + } catch {} + automationSessions.delete(workspace); + }, WINDOW_IDLE_TIMEOUT); } +/** Get or create the dedicated automation window. */ async function getAutomationWindow(workspace) { - const existing = automationSessions.get(workspace); - if (existing) { - try { - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - automationSessions.delete(workspace); - } - } - const win = await chrome.windows.create({ - url: BLANK_PAGE, - focused: false, - width: 1280, - height: 900, - type: "normal" - }); - const session = { - windowId: win.id, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT - }; - automationSessions.set(workspace, session); - console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); - resetWindowIdleTimer(workspace); - await new Promise((resolve) => setTimeout(resolve, 200)); - return session.windowId; + const existing = automationSessions.get(workspace); + if (existing) try { + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + automationSessions.delete(workspace); + } + const session = { + windowId: (await chrome.windows.create({ + url: BLANK_PAGE, + focused: false, + width: 1280, + height: 900, + type: "normal" + })).id, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null + }; + automationSessions.set(workspace, session); + console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); + resetWindowIdleTimer(workspace); + await new Promise((resolve) => setTimeout(resolve, 200)); + return session.windowId; } chrome.windows.onRemoved.addListener((windowId) => { - for (const [workspace, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] Automation window closed (${workspace})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - } + for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) { + console.log(`[opencli] Automation window closed (${workspace})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); - registerListeners(); - void connect(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .4 }); + registerListeners(); + connect(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "keepalive") void connect(); + if (alarm.name === "keepalive") connect(); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null - }); - } - return false; + if (msg?.type === "getStatus") sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + return false; }); async function handleCommand(cmd) { - const workspace = getWorkspaceKey(cmd.workspace); - resetWindowIdleTimer(workspace); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, workspace); - case "navigate": - return await handleNavigate(cmd, workspace); - case "tabs": - return await handleTabs(cmd, workspace); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, workspace); - case "close-window": - return await handleCloseWindow(cmd, workspace); - case "sessions": - return await handleSessions(cmd); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } -} -const BLANK_PAGE = "data:text/html,"; + const workspace = getWorkspaceKey(cmd.workspace); + resetWindowIdleTimer(workspace); + try { + switch (cmd.action) { + case "exec": return await handleExec(cmd, workspace); + case "navigate": return await handleNavigate(cmd, workspace); + case "tabs": return await handleTabs(cmd, workspace); + case "cookies": return await handleCookies(cmd); + case "screenshot": return await handleScreenshot(cmd, workspace); + case "close-window": return await handleCloseWindow(cmd, workspace); + case "sessions": return await handleSessions(cmd); + case "set-file-input": return await handleSetFileInput(cmd, workspace); + case "bind-current": return await handleBindCurrent(cmd, workspace); + default: return { + id: cmd.id, + ok: false, + error: `Unknown action: ${cmd.action}` + }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } +} +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE = "data:text/html,"; +/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */ function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE; + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE; } +/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } +/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); +} +function matchesDomain(url, domain) { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } +} +function matchesBindCriteria(tab, cmd) { + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) try { + if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + return true; } +function isNotebooklmWorkspace(workspace) { + return workspace === "site:notebooklm"; +} +function classifyNotebooklmUrl(url) { + if (!url) return "other"; + try { + const parsed = new URL(url); + if (parsed.hostname !== "notebooklm.google.com") return "other"; + return parsed.pathname.startsWith("/notebook/") ? "notebook" : "home"; + } catch { + return "other"; + } +} +function scoreWorkspaceTab(workspace, tab) { + if (!tab.id || !isDebuggableUrl(tab.url)) return -1; + if (isNotebooklmWorkspace(workspace)) { + const kind = classifyNotebooklmUrl(tab.url); + if (kind === "other") return -1; + if (kind === "notebook") return tab.active ? 400 : 300; + return tab.active ? 200 : 100; + } + return -1; +} +function setWorkspaceSession(workspace, session) { + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT + }); +} +async function maybeBindWorkspaceToExistingTab(workspace) { + if (!isNotebooklmWorkspace(workspace)) return null; + const tabs = await chrome.tabs.query({}); + let bestTab = null; + let bestScore = -1; + for (const tab of tabs) { + const score = scoreWorkspaceTab(workspace, tab); + if (score > bestScore) { + bestScore = score; + bestTab = tab; + } + } + if (!bestTab?.id || bestScore < 0) return null; + setWorkspaceSession(workspace, { + windowId: bestTab.windowId, + owned: false, + preferredTabId: bestTab.id + }); + console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`); + resetWindowIdleTimer(workspace); + return bestTab.id; +} +/** +* Resolve target tab in the automation window. +* If explicit tabId is given, use that directly. +* Otherwise, find or create a tab in the dedicated automation window. +*/ async function resolveTabId(tabId, workspace) { - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = automationSessions.get(workspace); - if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId; - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`); - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch { - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const windowId = await getAutomationWindow(workspace); - const tabs = await chrome.tabs.query({ windowId }); - const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return debuggableTab.id; - const reuseTab = tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return reuseTab.id; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation window"); - return newTab.id; + if (tabId !== void 0) try { + const tab = await chrome.tabs.get(tabId); + const session = automationSessions.get(workspace); + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return tabId; + if (session && !matchesSession) console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`); + else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch { + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace); + if (adoptedTabId !== null) return adoptedTabId; + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return preferredTab.id; + } catch { + automationSessions.delete(workspace); + } + const windowId = await getAutomationWindow(workspace); + const tabs = await chrome.tabs.query({ windowId }); + const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url)); + if (debuggableTab?.id) return debuggableTab.id; + const reuseTab = tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return reuseTab.id; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch {} + } + const newTab = await chrome.tabs.create({ + windowId, + url: BLANK_PAGE, + active: true + }); + if (!newTab.id) throw new Error("Failed to create tab in automation window"); + return newTab.id; } async function listAutomationTabs(workspace) { - const session = automationSessions.get(workspace); - if (!session) return []; - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(workspace); - return []; - } + const session = automationSessions.get(workspace); + if (!session) return []; + if (session.preferredTabId !== null) try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(workspace); + return []; + } } async function listAutomationWebTabs(workspace) { - const tabs = await listAutomationTabs(workspace); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); + return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, workspace) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await evaluateAsync(tabId, cmd.code); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + if (!cmd.code) return { + id: cmd.id, + ok: false, + error: "Missing code" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await evaluateAsync(tabId, cmd.code); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleNavigate(cmd, workspace) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - const beforeTab = await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return { - id: cmd.id, - ok: true, - data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false } - }; - } - await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - const tab = await chrome.tabs.get(tabId); - return { - id: cmd.id, - ok: true, - data: { title: tab.title, url: tab.url, tabId, timedOut } - }; + if (!cmd.url) return { + id: cmd.id, + ok: false, + error: "Missing url" + }; + if (!isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + const beforeTab = await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return { + id: cmd.id, + ok: true, + data: { + title: beforeTab.title, + url: beforeTab.url, + tabId, + timedOut: false + } + }; + await detach(tabId); + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); + } catch {} + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + const tab = await chrome.tabs.get(tabId); + return { + id: cmd.id, + ok: true, + data: { + title: tab.title, + url: tab.url, + tabId, + timedOut + } + }; } async function handleTabs(cmd, workspace) { - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(workspace); - const data = tabs.map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const windowId = await getAutomationWindow(workspace); - const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } }; - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.remove(target.id); - await detach(target.id); - return { id: cmd.id, ok: true, data: { closed: target.id } }; - } - const tabId = await resolveTabId(cmd.tabId, workspace); - await chrome.tabs.remove(tabId); - await detach(tabId); - return { id: cmd.id, ok: true, data: { closed: tabId } }; - } - case "select": { - if (cmd.index === void 0 && cmd.tabId === void 0) - return { id: cmd.id, ok: false, error: "Missing index or tabId" }; - if (cmd.tabId !== void 0) { - const session = automationSessions.get(workspace); - let tab; - try { - tab = await chrome.tabs.get(cmd.tabId); - } catch { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` }; - } - if (!session || tab.windowId !== session.windowId) { - return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` }; - } - await chrome.tabs.update(cmd.tabId, { active: true }); - return { id: cmd.id, ok: true, data: { selected: cmd.tabId } }; - } - const tabs = await listAutomationWebTabs(workspace); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return { id: cmd.id, ok: true, data: { selected: target.id } }; - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } + switch (cmd.op) { + case "list": { + const data = (await listAutomationWebTabs(workspace)).map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const windowId = await getAutomationWindow(workspace); + const tab = await chrome.tabs.create({ + windowId, + url: cmd.url ?? BLANK_PAGE, + active: true + }); + return { + id: cmd.id, + ok: true, + data: { + tabId: tab.id, + url: tab.url + } + }; + } + case "close": { + if (cmd.index !== void 0) { + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.remove(target.id); + await detach(target.id); + return { + id: cmd.id, + ok: true, + data: { closed: target.id } + }; + } + const tabId = await resolveTabId(cmd.tabId, workspace); + await chrome.tabs.remove(tabId); + await detach(tabId); + return { + id: cmd.id, + ok: true, + data: { closed: tabId } + }; + } + case "select": { + if (cmd.index === void 0 && cmd.tabId === void 0) return { + id: cmd.id, + ok: false, + error: "Missing index or tabId" + }; + if (cmd.tabId !== void 0) { + const session = automationSessions.get(workspace); + let tab; + try { + tab = await chrome.tabs.get(cmd.tabId); + } catch { + return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} no longer exists` + }; + } + if (!session || tab.windowId !== session.windowId) return { + id: cmd.id, + ok: false, + error: `Tab ${cmd.tabId} is not in the automation window` + }; + await chrome.tabs.update(cmd.tabId, { active: true }); + return { + id: cmd.id, + ok: true, + data: { selected: cmd.tabId } + }; + } + const target = (await listAutomationWebTabs(workspace))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.update(target.id, { active: true }); + return { + id: cmd.id, + ok: true, + data: { selected: target.id } + }; + } + default: return { + id: cmd.id, + ok: false, + error: `Unknown tabs op: ${cmd.op}` + }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; + if (!cmd.domain && !cmd.url) return { + id: cmd.id, + ok: false, + error: "Cookie scope required: provide domain or url to avoid dumping all cookies" + }; + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const data = (await chrome.cookies.getAll(details)).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { + id: cmd.id, + ok: true, + data + }; } async function handleScreenshot(cmd, workspace) { - const tabId = await resolveTabId(cmd.tabId, workspace); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage - }); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage + }); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleCloseWindow(cmd, workspace) { - const session = automationSessions.get(workspace); - if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - } - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(workspace); - } - return { id: cmd.id, ok: true, data: { closed: true } }; + const session = automationSessions.get(workspace); + if (session) { + if (session.owned) try { + await chrome.windows.remove(session.windowId); + } catch {} + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(workspace); + } + return { + id: cmd.id, + ok: true, + data: { closed: true } + }; +} +async function handleSetFileInput(cmd, workspace) { + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { + id: cmd.id, + ok: false, + error: "Missing or empty files array" + }; + const tabId = await resolveTabId(cmd.tabId, workspace); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return { + id: cmd.id, + ok: true, + data: { count: cmd.files.length } + }; + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } } async function handleSessions(cmd) { - const now = Date.now(); - const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ - workspace, - windowId: session.windowId, - tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, - idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) - }))); - return { id: cmd.id, ok: true, data }; + const now = Date.now(); + const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({ + workspace, + windowId: session.windowId, + tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length, + idleMsRemaining: Math.max(0, session.idleDeadlineAt - now) + }))); + return { + id: cmd.id, + ok: true, + data + }; +} +async function handleBindCurrent(cmd, workspace) { + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found" + }; + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return { + id: cmd.id, + ok: true, + data: { + tabId: boundTab.id, + windowId: boundTab.windowId, + url: boundTab.url, + title: boundTab.title, + workspace + } + }; } +//#endregion diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 91ccd555..47f52e44 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -35,8 +35,12 @@ function createChromeMock() { { id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' }, ]; - const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => { - return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId); + const query = vi.fn(async (queryInfo: { windowId?: number; active?: boolean } = {}) => { + return tabs.filter((tab) => { + if (queryInfo.windowId !== undefined && tab.windowId !== queryInfo.windowId) return false; + if (queryInfo.active !== undefined && !!tab.active !== queryInfo.active) return false; + return true; + }); }); const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => { const tab: MockTab = { @@ -84,6 +88,8 @@ function createChromeMock() { runtime: { onInstalled: { addListener: vi.fn() } as Listener<() => void>, onStartup: { addListener: vi.fn() } as Listener<() => void>, + onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>, + getManifest: vi.fn(() => ({ version: 'test-version' })), }, cookies: { getAll: vi.fn(async () => []), @@ -193,4 +199,198 @@ describe('background tab isolation', () => { expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }), ])); }); + + it('rebinds site:notebooklm to the active notebook tab instead of a home tab', async () => { + const { chrome, tabs } = createChromeMock(); + tabs[0].url = 'https://notebooklm.google.com/'; + tabs[0].title = 'NotebookLM Home'; + tabs[1].url = 'https://notebooklm.google.com/notebook/nb-live'; + tabs[1].title = 'Live Notebook'; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:notebooklm', 1); + + const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm'); + + expect(tabId).toBe(2); + expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({ + windowId: 2, + preferredTabId: 2, + owned: false, + })); + }); + + it('prefers a notebook tab over an active home tab for site:notebooklm', async () => { + const { chrome, tabs } = createChromeMock(); + tabs[0].url = 'https://notebooklm.google.com/'; + tabs[0].title = 'NotebookLM Home'; + tabs[0].active = true; + tabs[1].url = 'https://notebooklm.google.com/notebook/nb-passive'; + tabs[1].title = 'Notebook'; + tabs[1].active = false; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId('site:notebooklm', 1); + + const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm'); + + expect(tabId).toBe(2); + expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({ + windowId: 2, + preferredTabId: 2, + owned: false, + })); + }); + + it('detaches an adopted workspace session on idle instead of closing the user window', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + vi.useFakeTimers(); + + const mod = await import('./background'); + mod.__test__.setSession('site:notebooklm', { + windowId: 2, + preferredTabId: 2, + owned: false, + }); + + mod.__test__.resetWindowIdleTimer('site:notebooklm'); + await vi.advanceTimersByTimeAsync(30001); + + expect(chrome.windows.remove).not.toHaveBeenCalled(); + expect(mod.__test__.getSession('site:notebooklm')).toBeNull(); + }); + + it('binds the active NotebookLM tab into the workspace explicitly', async () => { + const { chrome, tabs } = createChromeMock(); + tabs[1].url = 'https://notebooklm.google.com/notebook/nb-active'; + tabs[1].title = 'Bound Notebook'; + tabs[1].active = true; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleBindCurrent( + { + id: 'bind-current', + action: 'bind-current', + workspace: 'site:notebooklm', + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }, + 'site:notebooklm', + ); + + expect(result).toEqual({ + id: 'bind-current', + ok: true, + data: expect.objectContaining({ + tabId: 2, + windowId: 2, + url: 'https://notebooklm.google.com/notebook/nb-active', + title: 'Bound Notebook', + workspace: 'site:notebooklm', + }), + }); + expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({ + windowId: 2, + preferredTabId: 2, + owned: false, + })); + }); + + it('bind-current falls back to another matching notebook tab in the current window', async () => { + const { chrome, tabs } = createChromeMock(); + tabs[0].windowId = 2; + tabs[0].url = 'https://notebooklm.google.com/'; + tabs[0].title = 'NotebookLM Home'; + tabs[0].active = true; + tabs[1].url = 'https://notebooklm.google.com/notebook/nb-passive'; + tabs[1].title = 'Passive Notebook'; + tabs[1].active = false; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleBindCurrent( + { + id: 'bind-fallback', + action: 'bind-current', + workspace: 'site:notebooklm', + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }, + 'site:notebooklm', + ); + + expect(result).toEqual({ + id: 'bind-fallback', + ok: true, + data: expect.objectContaining({ + tabId: 2, + windowId: 2, + url: 'https://notebooklm.google.com/notebook/nb-passive', + title: 'Passive Notebook', + }), + }); + }); + + it('bind-current falls back to a matching notebook tab in another window of the same profile', async () => { + const { chrome, tabs } = createChromeMock(); + tabs[0].windowId = 3; + tabs[0].url = 'https://notebooklm.google.com/'; + tabs[0].title = 'NotebookLM Home'; + tabs[0].active = true; + tabs[1].windowId = 2; + tabs[1].url = 'https://notebooklm.google.com/notebook/nb-other-window'; + tabs[1].title = 'Notebook In Other Window'; + tabs[1].active = false; + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleBindCurrent( + { + id: 'bind-cross-window', + action: 'bind-current', + workspace: 'site:notebooklm', + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }, + 'site:notebooklm', + ); + + expect(result).toEqual({ + id: 'bind-cross-window', + ok: true, + data: expect.objectContaining({ + tabId: 2, + windowId: 2, + url: 'https://notebooklm.google.com/notebook/nb-other-window', + title: 'Notebook In Other Window', + }), + }); + }); + + it('rejects bind-current when the active tab is not NotebookLM', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./background'); + const result = await mod.__test__.handleBindCurrent( + { + id: 'bind-miss', + action: 'bind-current', + workspace: 'site:notebooklm', + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }, + 'site:notebooklm', + ); + + expect(result).toEqual({ + id: 'bind-miss', + ok: false, + error: 'No visible tab matching notebooklm.google.com /notebook/', + }); + }); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 2154a915..85cbd3ca 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -117,6 +117,8 @@ type AutomationSession = { windowId: number; idleTimer: ReturnType | null; idleDeadlineAt: number; + owned: boolean; + preferredTabId: number | null; }; const automationSessions = new Map(); @@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void { session.idleTimer = setTimeout(async () => { const current = automationSessions.get(workspace); if (!current) return; + if (!current.owned) { + console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`); + automationSessions.delete(workspace); + return; + } try { await chrome.windows.remove(current.windowId); console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); @@ -173,6 +180,8 @@ async function getAutomationWindow(workspace: string): Promise { windowId: win.id!, idleTimer: null, idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null, }; automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); @@ -254,6 +263,8 @@ async function handleCommand(cmd: Command): Promise { return await handleSessions(cmd); case 'set-file-input': return await handleSetFileInput(cmd, workspace); + case 'bind-current': + return await handleBindCurrent(cmd, workspace); default: return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } @@ -301,6 +312,89 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } +function matchesDomain(url: string | undefined, domain: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`); + } catch { + return false; + } +} + +function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean { + if (!tab.id || !isDebuggableUrl(tab.url)) return false; + if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false; + if (cmd.matchPathPrefix) { + try { + const parsed = new URL(tab.url!); + if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false; + } catch { + return false; + } + } + return true; +} + +function isNotebooklmWorkspace(workspace: string): boolean { + return workspace === 'site:notebooklm'; +} + +function classifyNotebooklmUrl(url?: string): 'notebook' | 'home' | 'other' { + if (!url) return 'other'; + try { + const parsed = new URL(url); + if (parsed.hostname !== 'notebooklm.google.com') return 'other'; + return parsed.pathname.startsWith('/notebook/') ? 'notebook' : 'home'; + } catch { + return 'other'; + } +} + +function scoreWorkspaceTab(workspace: string, tab: chrome.tabs.Tab): number { + if (!tab.id || !isDebuggableUrl(tab.url)) return -1; + if (isNotebooklmWorkspace(workspace)) { + const kind = classifyNotebooklmUrl(tab.url); + if (kind === 'other') return -1; + if (kind === 'notebook') return tab.active ? 400 : 300; + return tab.active ? 200 : 100; + } + return -1; +} + +function setWorkspaceSession(workspace: string, session: Omit): void { + const existing = automationSessions.get(workspace); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.set(workspace, { + ...session, + idleTimer: null, + idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + }); +} + +async function maybeBindWorkspaceToExistingTab(workspace: string): Promise { + if (!isNotebooklmWorkspace(workspace)) return null; + const tabs = await chrome.tabs.query({}); + let bestTab: chrome.tabs.Tab | null = null; + let bestScore = -1; + for (const tab of tabs) { + const score = scoreWorkspaceTab(workspace, tab); + if (score > bestScore) { + bestScore = score; + bestTab = tab; + } + } + if (!bestTab?.id || bestScore < 0) return null; + setWorkspaceSession(workspace, { + windowId: bestTab.windowId, + owned: false, + preferredTabId: bestTab.id, + }); + console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`); + resetWindowIdleTimer(workspace); + return bestTab.id; +} + /** * Resolve target tab in the automation window. * If explicit tabId is given, use that directly. @@ -314,9 +408,12 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi try { const tab = await chrome.tabs.get(tabId); const session = automationSessions.get(workspace); - if (isDebuggableUrl(tab.url) && session && tab.windowId === session.windowId) return tabId; - if (session && tab.windowId !== session.windowId) { - console.warn(`[opencli] Tab ${tabId} belongs to window ${tab.windowId}, not automation window ${session.windowId}, re-resolving`); + const matchesSession = session + ? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId) + : false; + if (isDebuggableUrl(tab.url) && matchesSession) return tabId; + if (session && !matchesSession) { + console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`); } else if (!isDebuggableUrl(tab.url)) { // Tab exists but URL is not debuggable — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); @@ -327,6 +424,19 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi } } + const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace); + if (adoptedTabId !== null) return adoptedTabId; + + const existingSession = automationSessions.get(workspace); + if (existingSession?.preferredTabId !== null) { + try { + const preferredTab = await chrome.tabs.get(existingSession.preferredTabId); + if (isDebuggableUrl(preferredTab.url)) return preferredTab.id!; + } catch { + automationSessions.delete(workspace); + } + } + // Get (or create) the automation window const windowId = await getAutomationWindow(workspace); @@ -359,6 +469,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi async function listAutomationTabs(workspace: string): Promise { const session = automationSessions.get(workspace); if (!session) return []; + if (session.preferredTabId !== null) { + try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(workspace); + return []; + } + } try { return await chrome.tabs.query({ windowId: session.windowId }); } catch { @@ -570,10 +688,12 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise { const session = automationSessions.get(workspace); if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - // Window may already be closed + if (session.owned) { + try { + await chrome.windows.remove(session.windowId); + } catch { + // Window may already be closed + } } if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); @@ -605,11 +725,52 @@ async function handleSessions(cmd: Command): Promise { return { id: cmd.id, ok: true, data }; } +async function handleBindCurrent(cmd: Command, workspace: string): Promise { + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const allTabs = await chrome.tabs.query({}); + const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) + ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) + ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd)); + if (!boundTab?.id) { + return { + id: cmd.id, + ok: false, + error: cmd.matchDomain || cmd.matchPathPrefix + ? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}` + : 'No active debuggable tab found', + }; + } + + setWorkspaceSession(workspace, { + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id, + }); + resetWindowIdleTimer(workspace); + console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return { + id: cmd.id, + ok: true, + data: { + tabId: boundTab.id, + windowId: boundTab.windowId, + url: boundTab.url, + title: boundTab.title, + workspace, + }, + }; +} + export const __test__ = { handleNavigate, isTargetUrl, handleTabs, handleSessions, + handleBindCurrent, + resolveTabId, + resetWindowIdleTimer, + getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null, getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null, setAutomationWindowId: (workspace: string, windowId: number | null) => { if (windowId === null) { @@ -618,10 +779,13 @@ export const __test__ = { automationSessions.delete(workspace); return; } - automationSessions.set(workspace, { + setWorkspaceSession(workspace, { windowId, - idleTimer: null, - idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT, + owned: true, + preferredTabId: null, }); }, + setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => { + setWorkspaceSession(workspace, session); + }, }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 5ed86b85..0eebeea2 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -5,7 +5,7 @@ * Everything else is just JS code sent via 'exec'. */ -export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input'; +export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'bind-current'; export interface Command { /** Unique request ID */ @@ -26,6 +26,10 @@ export interface Command { index?: number; /** Cookie domain filter */ domain?: string; + /** Optional hostname/domain to require for current-tab binding */ + matchDomain?: string; + /** Optional pathname prefix to require for current-tab binding */ + matchPathPrefix?: string; /** Screenshot format: png (default) or jpeg */ format?: 'png' | 'jpeg'; /** JPEG quality (0-100), only for jpeg format */ diff --git a/findings.md b/findings.md new file mode 100644 index 00000000..066acfa6 --- /dev/null +++ b/findings.md @@ -0,0 +1,1545 @@ +# NotebookLM OpenCLI Findings + +## Verified Facts + +- NotebookLM 首页当前真实请求仍包含以下 `batchexecute` RPC: + - `ZwVcOc` + - `wXbhsf` + - `ub2Bae` + - `ozz5Z` +- `wXbhsf` 是“我的笔记本”列表的真实 RPC。 +- `wXbhsf` 之前返回空,不是因为 RPC 失效,而是本地请求参数形状发错。 +- 修正参数后,`opencli notebooklm list -f json` 已返回 18 条,且 `source: "rpc"`。 + +## Root Cause of the Empty RPC Result + +- 真实前端请求体参数:`[null,1,null,[2]]` +- 本地旧实现发送:`[[null,1,null,[2]]]` +- 多包了一层数组,导致服务端返回 `200` 但结果无法按预期解析。 + +## Stability Findings From This Round + +- `history` 偶发失败不只是页面 HTML 里 token 不稳定,NotebookLM 当前页还会把关键 auth token 暴露在 `window.WIZ_global_data`: + - `SNlM0e` + - `FdrFJe` +- 旧实现只扫 `document.documentElement.innerHTML`,因此会错过这条更稳的 token 来源。 +- `rLM1Ne` 的 detail/source 返回当前常见为“单元素 envelope 包一层 payload”,旧 parser 没有先解包。 +- source id 的 live 形状不总是 `[[id]]`,也会出现 `[id]`,旧 parser 对这种更浅的嵌套层级会漏掉 id。 +- `Page.evaluate(...)` 在 bridge 侧偶发遇到 `Inspected target navigated or closed`,一旦不重试,就会把短暂页面 settle 抖动放大成上层命令失败。 + +## Current Adapter Surface in OpenCLI + +- `status` +- `list` +- `current` + +Files: + +- `src/clis/notebooklm/shared.ts` +- `src/clis/notebooklm/utils.ts` +- `src/clis/notebooklm/status.ts` +- `src/clis/notebooklm/list.ts` +- `src/clis/notebooklm/current.ts` +- `src/clis/notebooklm/utils.test.ts` + +## Original notebooklm-cdp-cli Command Surface + +High-level groups verified from the source repo: + +- `browser` +- `auth` +- `notebook` +- `source` +- `research` +- `share` +- `ask` +- `history` +- `artifact` +- `generate` +- `download` +- `language` +- `notes` + +## Migration Buckets + +### Read-first + +- notebook list / get / summary / metadata / current +- source list / get / guide / fulltext / freshness +- notes list / get +- history +- share status +- research status +- artifact list / get +- language list / get + +### Light write + +- notebook create / rename / delete / use +- source add-url / add-text / rename / delete / refresh +- notes create / save / rename / delete +- ask +- share public / add / update / remove +- language set + +### Long-running / stateful + +- source add-file / add-drive / add-research +- research wait +- artifact poll / wait / pending / resolve-pending +- generate report/audio/video/slide/infographic/quiz/flashcards/data-table/mind-map +- revise-slide + +### Download / export + +- artifact export +- download report/audio/video/slide/infographic/quiz/flashcards/data-table/mind-map + +## Architectural Direction + +- Keep NotebookLM execution in browser context through `opencli` runtime. +- Build one reusable NotebookLM RPC client before expanding command count. +- Add explicit debug hooks for raw RPC capture because reverse engineering is part of the maintenance cost. + +## Command-Surface Mapping Strategy + +- `opencli` 当前是 `site + 单层 command` 注册模型。 +- 因此不适合把原项目的 `notebook use` / `source get` / `notes list` 原样搬成三层子命令。 +- 现实方案是: + - 原命令只是命名差异时,用 `aliases` + - 原命令需要一点参数语义适配时,用薄 `wrapper` + - 暂不实现长任务或下载类空壳命令 + +## Low-Cost / High-Value Compatibility Commands + +| Original CLI surface | OpenCLI command | Strategy | Status | +|---|---|---|---| +| `notebook use` | `notebooklm use` | alias -> `bind-current` | implemented | +| `notebook metadata` | `notebooklm metadata` | alias -> `get` | implemented | +| `notes list` | `notebooklm notes-list` | alias -> `note-list` | implemented | +| `source get` | `notebooklm source-get ` | wrapper over `source-list` retrieval + local filtering | implemented | +| `source fulltext` | `notebooklm source-fulltext ` | wrapper over source lookup + dedicated source RPC | implemented | +| `source guide` | `notebooklm source-guide ` | wrapper over source lookup + dedicated source RPC | implemented | +| `notebook summary` | `notebooklm summary` | new read command, DOM-first with existing RPC fallback hook | implemented | +| `notes get` | `notebooklm notes-get ` | new read command, current visible note editor first | implemented with limitation | +| `notebook get` | `notebooklm get` | existing read command | already present | +| `source list` | `notebooklm source-list` | existing read command | already present | +| `history` | `notebooklm history` | existing read command | already present | + +## Alias Framework Findings + +- Registry 现在需要把 alias 视为同一命令的备用键,而不是单独 adapter。 +- Commander 需要直接注册这些 alias,否则兼容命令名无法执行。 +- `opencli list` / `serializeCommand(...)` / help text / build manifest 也需要暴露 alias 元数据,否则兼容层不可见。 +- Manifest 与 discovery 都需要保留 alias 信息,避免 build 后能力回退。 + +## Nested Command Tree Findings + +- 这轮三层命令树没有直接改业务 adapter,而是在框架层引入“path-like command name -> 真 nested subcommand”的映射。 +- 现实设计选择是: + - 命令定义仍然是单条 `CliCommand` + - `name` 允许写成 `source/list` + - registry 继续把它视为单个命令定义 + - commander 注册阶段再拆成 `source list` +- 这样做的优点: + - 不需要改 `src/clis/notebooklm/**` 的业务执行语义 + - 现有平面命令保持原样,不必一次性迁移 + - 新命令可以渐进采用多层路径 +- 当前已验证框架层行为: + - `commander` 可执行 `site source list` + - completion 会在第二层返回 `source`,第三层返回 `list` + - serialization / structured list 输出新增 `invocation` + - manifest 继续保留原始 path-like `name` +- 当前刻意没做的事: + - 没有把现有 NotebookLM 平面命令整体迁移到嵌套路径 + - 没有做“嵌套 canonical 命令自动生成站点根级 alias”的通用规则 + - 没有改其他 site adapter + +## NotebookLM Remount Findings + +- 这轮开始把 NotebookLM 已有业务命令 remount 到更接近原 CLI 的嵌套层级,但仍不改业务实现函数体。 +- 当前 remount 规则是: + - canonical command 改成嵌套 path,如 `source/list` + - 旧平面命令名保留为 alias,如 `source-list` + - commander 对嵌套 canonical 命令会额外注册 alias path,因此 `notebooklm source list` 和 `notebooklm source-list` 都可执行 +- 已 remount: + - `source/list` + - `source/get` + - `source/fulltext` + - `source/guide` + - `notes/list` + - `notes/get` + - `language/list` + - `language/get` + - `language/set` +- 仍保留平面形态、尚未 remount: + - `status` + - `list` + - `current` + - `get` + - `metadata` + - `summary` + - `history` + - `bind-current` + - `use` + - `ask` + - `share-status` + - `source-add-text` + - `source-add-url` + - `notes-save` +- 这轮兼容策略的现实含义: + - 新文档和新用户路径可以优先写嵌套命令 + - 旧脚本和旧 muscle memory 仍可继续使用平面命令 + - `list -f json` 的 canonical `command` 字段现在会显示嵌套 path,而 `invocation` 会显示空格分隔的真实调用路径 + +## Implemented Compatibility Layer + +- `bind-current` 增加 alias:`use` +- `get` 增加 alias:`metadata` +- `note-list` 增加 alias:`notes-list` +- 新增 `source-get` + - 当前 notebook 上自动前置 `bind-current` + - 优先复用 `listNotebooklmSourcesViaRpc(...)` + - RPC 为空时 fallback 到 `listNotebooklmSourcesFromPage(...)` + - 先按 source id 精确匹配,再按 title 精确匹配,最后接受唯一的标题子串匹配 + +## Stability Fixes Implemented + +- `src/clis/notebooklm/rpc.ts` + - token 提取增加 `window.WIZ_global_data` fallback + - 首次 probe 没拿到 token 时增加一次短等待后重试 + - token 失败报错补了更明确的 NotebookLM 页诊断提示 +- `src/clis/notebooklm/utils.ts` + - detail/source parser 先解开 singleton envelope + - source id 提取改成递归找首个字符串,兼容 `[id]` 和 `[[id]]` +- `src/browser/page.ts` + - `Page.evaluate(...)` 对 target navigation 类瞬态错误重试一次 + +## Read-Command Findings From This Round + +- 当前 notebook 页存在稳定 summary DOM: + - `.notebook-summary` + - `.summary-content` +- 当前 `rLM1Ne` detail payload 没有确认到稳定 summary 字段,因此 `summary` 先走 DOM-first,RPC 只保留为“已有 detail 结果里若出现可识别长文本则提取”的保守 fallback。 +- Studio 笔记编辑器在当前页可见时,会暴露可读 selector: + - `.note-header__editable-title` + - `.note-editor .ql-editor` +- 目前 `notes-get` 的现实边界是: + - 能读“当前可见 note editor” + - 还不能稳定地从任意列表项自动展开并读取正文 + - 因此如果 note 只出现在 Studio 列表里但未展开,命令会明确报限制,而不是假装支持全量随机读取 + +## Source Fulltext Findings + +- 当前 NotebookLM notebook 页里,没有观察到稳定的 source 正文详情 DOM。 +- 点击 source 行后,当前页主要只体现“来源被选中”,不会稳定暴露 source 的全文块。 +- 原仓库使用的上游 client 证明 `source-fulltext` 不是壳命令,而是独立 RPC: + - RPC ID: `hizoJc` + - 参数形状: `[[source_id], [2], [2]]` +- live `hizoJc` 返回已验证包含: + - source 元信息 + - content blocks at `result[3][0]` + - 可递归提取出全文字符串 +- 这意味着 `source-fulltext` 的现实方案应是: + - 先用现有 `source-list` / `source-get` 的匹配逻辑定位 source id + - 再走 `hizoJc` 独立 RPC 提取全文 + - 不需要先依赖当前 source 详情 panel DOM + +## Source Guide Assessment + +- 原仓库也确认存在独立 RPC: + - RPC ID: `tr032e` + - 参数形状: `[[[[source_id]]]]` +- live 验证结果: + - 当前 pasted-text source 上直接调用 `tr032e` 能稳定返回 + - 返回结构与原仓库解析一致:`[[[null, [summary], [[keywords]], []]]]` + - 同一 source 连续重复调用 3 次,返回 summary 长度与 keywords 全部一致 + - source 未点击展开时调用一次、点击 source 行后再调用 3 次,返回仍完全一致 +- 语义验证结果: + - 返回是约 300 字的导读性 summary,加一组 topic keywords + - 与 `source-fulltext` 的长文本正文显著不同,不是换皮 metadata,也不是换皮 fulltext + - 当前看起来符合“面向 source 的导读/结构摘要/学习引导” +- 当前边界: + - 原先只确认对 pasted-text source 可用 + - 当前 notebook 新增非 pasted-text source 后,已完成额外的 live cross-type 验证 + +## Source Guide Cross-Type Validation + +- 当前 notebook 的原始 `rLM1Ne` payload 已确认存在非 pasted-text source: + - `code=9` 的 YouTube source + - 同一个 notebook 里还出现了带外链元数据的其他 source,但这轮只验证 1 个额外 type,不扩范围 +- 一个重要附带发现: + - 现有 `source-list` 命令的类型解析还在读 `entry[3]` + - 但 live `rLM1Ne` 里更像真实 source kind 信号的是 `entry[2][4]` + - 因此这轮 cross-type 取证直接基于原始 `rLM1Ne` payload,而不是当前 `source-list` 的 `type/type_code` +- `tr032e` 在当前 notebook 的 YouTube source 上验证结果: + - 参数形状仍然成立:`[[[[source_id]]]]` + - 返回的核心结构仍然成立:`[[[null, [summary], [[keywords]], []]]]` + - 个别调用的第 0 槽位会出现 source id envelope,但 summary / keywords / trailing empty array 的 4 槽布局保持不变 + - summary 仍然是导读式内容,keywords 仍然是主题词,不是 fulltext 或 metadata 换皮 + - 在未操作 source 行时连续调用 3 次,summary / keywords 完全一致 + - 点击该 YouTube source 行后再次连续调用 3 次,summary / keywords 仍完全一致 +- 这说明: + - `tr032e` 不只适用于 pasted-text,至少对当前 notebook 的 YouTube source 也稳定成立 + - `source-guide` 已经跨过“单一 source type 才成立”的阻塞 + - 因此 `source-guide` 已可作为当前 notebook 内的 source 读命令实现 + +## Source Type Parsing Fix + +- `source-list` 之前把 `entry[3]` 当作 source type/type_code 来源,但 live `rLM1Ne` 里这个槽位当前更像固定 envelope,不能区分 source kind。 +- 当前 live notebook 已验证更可靠的 kind 槽位在 `entry[2][4]`: + - `3 -> pdf` + - `5 -> web` + - `8 -> pasted-text` + - `9 -> youtube` +- 因此 source 相关读命令现在统一优先按 metadata kind 槽位解析 type/type_code,再回退旧 envelope。 +- live `source-list` 已确认修正后输出: + - `CU240S__en-US_(1)_zh-Hans.pdf` -> `pdf` + - `PDF24 Tools: 免费且易于使用的在线PDF工具` -> `web` + - `粘贴的文字` -> `pasted-text` + - `黃仁勳最新重磅專訪...` -> `youtube` + +## Source Guide Implementation + +- `source-guide` 的现实实现方案已经落地: + - 先复用现有 `source-list` / `source-get` 同一套 source lookup + - 再走独立 RPC `tr032e` + - 输出字段固定为: + - `source_id` + - `notebook_id` + - `title` + - `type` + - `summary` + - `keywords` + - `source: "rpc"` +- `tr032e` 解析需要兼容两类 live 形状: + - `[[[null, [summary], [[keywords]], []]]]` + - `[[[[[source_id]], [summary], [[keywords]], []]]]` +- 目前命令边界保持克制: + - 只支持当前 notebook 内按 source id / title 匹配 + - 不切 notebook + - 不扩展到写命令或 artifact 命令 + +## Live Verification After Stability Fixes + +- `node dist/main.js notebooklm source-list -f json` + - 顺序重复 5 次,5/5 返回 `source: "rpc"` +- `node dist/main.js notebooklm history -f json` + - 顺序重复 8 次,8/8 返回 `thread_id` +- `node dist/main.js notebooklm summary -f json` + - 返回当前 notebook 的 summary 文本,`source: "summary-dom"` +- `node dist/main.js notebooklm notes-get "新建笔记" -f json` + - 在当前可见 note editor 上返回 note 标题与正文,`source: "studio-editor"` +- `node dist/main.js notebooklm source-fulltext "粘贴的文字" -f json` + - 通过 `hizoJc` RPC 返回 source 全文,`source: "rpc"` +- `node dist/main.js notebooklm source-guide "黃仁勳最新重磅專訪:AI 代理時代正來...|Jensen Huang: The Era of AI Agents Is Coming..." -f json` + - 通过 `tr032e` RPC 返回 guide summary + keywords,`type: "youtube"`,`source: "rpc"` +- `tr032e` live repeated on the current pasted-text source + - 参数形状确认:`[[[[source_id]]]]` + - 未点击 source 与点击 source 后各重复调用 3 次,summary / keywords 完全一致 +- 单次 `dist` smoke 也已确认: + - `status` + - `get` + - `source-list` + - `history` + - `use` + - `metadata` + - `source-get` + - `source-fulltext` + - `summary` + - `notes-get` + +## Explicit Non-Goals For This Wave + +- 不补 `generate/*` / `download/*` / `artifact/*` 的兼容空壳。 +- 不把 Linux-only `notebooklm-cdp-cli` 状态文件或 direct CDP 逻辑移植到 `opencli`。 +- 不重构 `opencli` 为三层命令树。 +- 不为了追命令数量而跳过 transport / parser / runtime 稳定性收口。 + +## Download-Chain Findings + +- 这轮按用户要求只侦察 3 条下载链路: + - `download audio` + - `download report` + - `download slide-deck` +- 当前原仓库与上游 `notebooklm-py` 已明确: + - artifact list RPC: `gArtLc` + - artifact export RPC: `Krh3pd` +- 但 3 条目标下载链路并不等价: + - `report` 下载不走 export,也不走外部 URL 下载 + - `audio` 与 `slide-deck` 下载都走 artifact raw metadata 中的 signed URL + +## Download Report Findings + +- 上游 `notebooklm-py` 已确认 report 下载链路: + - 先 `gArtLc` 列出当前 notebook 的 artifact raw rows + - 过滤 `type=2` 且 `status=3` + - 默认按 `row[15][0]` 时间戳选最新 completed report + - report markdown 正文直接位于 artifact payload slot `7` +- 这意味着: + - `download/report` 不需要先实现 `artifact export` + - 也不需要先实现 signed URL / cookie stream 下载 + - 甚至不需要先把 `artifact list/get` 做成公开命令,只要内部 helper 能吃 `gArtLc` raw rows 即可 +- opencli live probe 已确认: + - notebook `edb0738b-bca1-416c-90f8-c97ca5e22c3f` + - artifact `31cd719e-2095-4eef-b4f5-ad55e64ddfc0` + - `gArtLc` 返回该 report,slot `7` 可直接提取 markdown + +## Download Slide-Deck Findings + +- 上游 `notebooklm-py` 已确认 slide deck 下载链路: + - 同样先走 `gArtLc` + - 过滤 `type=8` 且 `status=3` + - 下载 URL 位于 slot `16` + - `slot[16][3]` 是 PDF URL + - `slot[16][4]` 是 PPTX URL +- opencli live probe 已确认多个 notebook 上都能直接看到: + - `slide_deck` artifact id + - PDF URL + - PPTX URL +- 现实判断: + - 这条链路比 `report` 更接近“可直接下载” + - 但仍需要复用 opencli 的 HTTP download + browser cookie forwarding + - 因此复杂度高于纯文本 report 落盘 + +## Implemented Slide-Deck Download Command + +- 这轮没有继续停在纯调研,而是按最短链路实现了: + - `notebooklm download slide-deck [--artifact-id ] [--output-format pdf|pptx]` + - alias: `notebooklm download-slide-deck ...` +- opencli 侧实际实现链路是: + - `gArtLc` 列出当前 notebook artifact raw rows + - 过滤 `type=8` 且 `status=3` + - 默认按 `row[15][0]` 选最新 completed slide-deck,或按 `--artifact-id` 精确命中 + - 从 slot `16` 提取实际下载 URL: + - `pdf -> row[16][3]` + - `pptx -> row[16][4]` + - 通过当前 browser page 提取该 URL 的 cookies + - 复用 opencli 现有 `httpDownload(...)` 落盘 +- 这版返回结构明确包含: + - `notebook_id` + - `artifact_id` + - `artifact_type = "slide_deck"` + - `output_path` + - `download_url` + - `download_format` + - `source = "rpc+artifact-url"` +- 为什么这版仍然不需要先做完整 `artifact/*`: + - `download slide-deck` 已经能完全由 `gArtLc` raw list + slot `16` 直接闭环 + - 不需要额外的 artifact export RPC + - 不需要先把 artifact raw list 暴露成公开命令 + - 公开 `artifact/list|get|export` 只会扩大命令面,不会缩短当前下载链路 + +## Download Audio Findings + +- 上游 `notebooklm-py` 已确认 audio 下载链路: + - 先走 `gArtLc` + - 过滤 `type=1` 且 `status=3` + - 音频媒体列表位于 slot `6[5]` + - 其中优先选择 `mime=audio/mp4` 项 +- opencli live probe 已确认: + - notebook `9924e90f-5d14-4cc1-bd5b-7cf702f76d95` + - artifact `d7ca8b50-1aaa-49c5-a96e-600b8f6d22d0` + - slot `6[5]` 内存在多条媒体 URL,至少包含 `audio/mp4` +- 现实判断: + - 这条链路同样不需要先做 export + - 但要处理多媒体 URL 选择、可能的 HLS 变体、以及 cookie/代理下载环境 + - 所以在当前 3 条里不是第一优先级 + +## Implemented Audio Download Command + +- 这轮按最短链路实现了: + - `notebooklm download audio [--artifact-id ]` + - alias: `notebooklm download-audio ...` +- opencli 侧实际实现链路是: + - `gArtLc` 列出当前 notebook artifact raw rows + - 过滤 `type=1` 且 `status=3` + - 默认按 `row[15][0]` 选最新 completed audio,或按 `--artifact-id` 精确命中 + - 从 `row[6][5]` 提取 media variants + - variant 选择规则与上游一致: + - 优先第一个 `mime_type = "audio/mp4"` 的 variant + - 若没有 mime-tagged `audio/mp4`,回退第一个 variant URL + - 通过当前 browser page 提取该 URL 的 cookies + - 复用 opencli 现有 `httpDownload(...)` 落盘 +- live raw probe 已再次确认当前真实 audio artifact 结构: + - notebook `9924e90f-5d14-4cc1-bd5b-7cf702f76d95` + - artifact `d7ca8b50-1aaa-49c5-a96e-600b8f6d22d0` + - `variants` 至少包含: + - direct `audio/mp4` + - HLS variant + - DASH variant +- 这版返回结构明确包含: + - `notebook_id` + - `artifact_id` + - `artifact_type = "audio"` + - `output_path` + - `download_url` + - `mime_type` + - `source = "rpc+artifact-url"` +- 为什么这版仍然不需要先做完整 `artifact/*`: + - `download audio` 已经能完全由 `gArtLc` raw list + `row[6][5]` variants 直接闭环 + - 不需要额外的 artifact export RPC + - 不需要先把 artifact raw list 暴露成公开命令 + - 公开 `artifact/list|get|export` 不会缩短 audio 的关键路径,反而会扩大范围 + +## Download Video Findings + +- 上游 `notebooklm-cdp-cli` 已确认 video 下载链路仍然从 `gArtLc` raw artifact list 起步。 +- 这轮 live raw probe 进一步确认: + - video artifact type = `3` + - completed 过滤条件仍然是 `status = 3` + - 媒体 metadata 位于 `row[8]` + - media variants 位于 `row[8][4]` +- 当前真实 video artifact raw row 已确认同时包含: + - direct `video/mp4` + - HLS variant + - DASH variant + - 一个备用 `video/mp4` (`-dv`) +- 当前 live 样本: + - notebook `6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - artifact `82115e07-8602-4047-8b17-a1773c4fdcde` + - 以及 notebook `f7cdb18d-2da9-4bf5-aa0f-a3ae7af6015d`、`222b47db-f041-4d92-8363-68bae007b005` 上的同类 `type=3` row +- 现实判断: + - 这条链路不需要先做 artifact export + - 也不需要做复杂播放器抓流 + - 因为 raw row 已经给出了稳定 direct `video/mp4` URL + +## Implemented Video Download Command + +- 这轮按最短链路实现了: + - `notebooklm download video [--artifact-id ]` + - alias: `notebooklm download-video ...` +- opencli 侧实际实现链路是: + - `gArtLc` 列出当前 notebook artifact raw rows + - 过滤 `type=3` 且 `status=3` + - 默认按 `row[15][0]` 选最新 completed video,或按 `--artifact-id` 精确命中 + - 从 `row[8][4]` 提取 media variants + - 选择规则保持最小且与 live 结构一致: + - 优先第一个 `mime_type = "video/mp4"` 的 variant + - 若没有 mime-tagged `video/mp4`,回退第一个 variant URL + - 通过当前 browser page 提取该 URL 的 cookies + - 复用 opencli 现有 `httpDownload(...)` 落盘 +- 这版返回结构明确包含: + - `notebook_id` + - `artifact_id` + - `artifact_type = "video"` + - `output_path` + - `download_url` + - `mime_type` + - `source = "rpc+artifact-url"` +- live 验证结果: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm download video "E:\\web\\opencli\\tmp\\notebooklm-video-cli-smoke.mp4" --artifact-id 82115e07-8602-4047-8b17-a1773c4fdcde -f json` + - 输出文件: + - `E:\\web\\opencli\\tmp\\notebooklm-video-cli-smoke.mp4` + - `1212240` bytes +- 为什么这版仍然不需要先做完整 `artifact/*`: + - `download video` 已经能完全由 `gArtLc` raw list + `row[8][4]` variants 直接闭环 + - 不需要额外的 artifact export RPC + - 不需要先把 artifact raw list 暴露成公开命令 + - 公开 `artifact/list|get|export` 不会缩短 video 的关键路径,反而会扩大范围 + +## Minimal Download Index Findings + +- 用户当前还缺一个“当前 notebook 有哪些可下载 artifact”的索引入口。 +- 这条需求不需要扩成完整 `artifact/*`,因为现有 download 命令只依赖一小组稳定字段: + - `artifact_id` + - `artifact_type` + - `status` + - `title` + - `download_variants` + - `source` +- 因此这轮最终命令名选择为: + - `notebooklm download list` + - alias: `notebooklm download-list` +- 选择 `download/list` 而不是 `artifact/list` 的原因: + - 当前用途是“辅助现有 download/* 命令”,不是公开 raw artifact 面 + - 命名上与现有 `download/report|audio|video|slide-deck` 更一致 + - 能显式把范围收在“可下载索引”,避免滑向 `artifact get/export` + +## Implemented Minimal Download Index Command + +- 这轮按最小范围实现了: + - `notebooklm download list` + - alias: `notebooklm download-list` +- opencli 侧实际实现链路是: + - `gArtLc` 列出当前 notebook artifact raw rows + - 只保留当前已支持的 downloadable types: + - `report` + - `audio` + - `video` + - `slide_deck` + - 每行归一化输出: + - `artifact_id` + - `artifact_type` + - `status` + - `title` + - `created_at` + - `download_variants` + - `source = "rpc+artifact-list"` +- 当前 `download_variants` 的最小归一化规则: + - `report` -> `["markdown"]` + - `slide_deck` -> 依据 slot `16` 中存在的 URL 输出 `pdf` / `pptx` + - `audio` -> 从 `row[6][5]` 提取 `audio/mp4` / `hls` / `dash` + - `video` -> 从 `row[8][4]` 提取 `video/mp4` / `hls` / `dash` +- live 验证结果: + - `node dist/main.js notebooklm download list -f json` + - 当前 notebook `6fd8aeb5-ddd1-4114-bcda-c376389a8508` 返回了: + - 1 个 `video` + - 2 个 `slide_deck` + - 1 个 `report` +- 为什么这版仍然不需要完整 `artifact/*`: + - 这版只暴露现有 download 命令决策所需的稳定字段 + - 没有暴露 raw payload、export RPC 或 artifact 详情树 + - 因而仍然是“download 辅助索引”,不是“artifact 子系统” + +## Download Priority Decision + +- 当前最适合先落地的是:`download/report` +- 排序结论: + 1. `download/report` + 2. `download/slide-deck` + 3. `download/audio` +- 原因不是“哪个更刚需”,而是“哪个能以最小实现路径先稳定打通”: + - `report`:直接从 raw artifact 取 markdown,最短路径 + - `slide-deck`:raw 里已有 PDF/PPTX URL,第二清晰 + - `audio`:raw URL 也明确,但媒体变体更多,下载细节更重 + +## Generate Minimal Findings + +- 当前 3 条最小 generate 链路都不需要先公开 `artifact/*`: + - 统一提交 RPC: `R7cb6c` + - source ids 可直接复用当前 notebook 的 `rLM1Ne` source list 结果 + - 生成后只需要与 `gArtLc` raw artifact list 做同类型 baseline diff +- 上游 `notebooklm-py` 已确认 `task_id` 本质上就是 artifact id: + - report payload type code: `2` + - audio payload type code: `1` + - slide-deck payload type code: `8` +- opencli 这轮最小实现选择: + - `generate/report` + - 固定走 Briefing Doc payload + - `--wait` 只等到新 report artifact 出现且 markdown slot `7` 可读 + - `generate/audio` + - 固定走默认 audio payload + - `--wait` 只等到新 audio artifact 出现且 `row[6][5]` 有可下载 media variant + - `generate/slide-deck` + - 固定走默认 slide-deck payload + - `--wait` 只等到新 slide-deck artifact 出现且 `row[16]` 出现 PDF/PPTX URL +- 当前最小 wait 是命令内私有逻辑,不是公开 artifact polling 体系: + - 不新增 `artifact wait` + - 不新增 `artifact poll` + - 不新增 `artifact get/export` + - 只在具体 `generate/*` 命令内部做最小轮询 + +## Generate Live Findings + +- 当前 live notebook: + - `6fd8aeb5-ddd1-4114-bcda-c376389a8508` +- `generate/report --wait` 已完整闭环: + - 返回 artifact `1d525e55-7334-4407-b435-e4fbdc3f6349` + - 随后 `download report --artifact-id 1d525e55-7334-4407-b435-e4fbdc3f6349` 成功落盘 +- `generate/audio` 的 live 事实是: + - 最小提交命令已直接返回: + - artifact `7603262d-d1d5-4a75-b266-61d275e293ad` + - `status = in_progress` + - 一次 `--wait` 尝试在 180 秒最小窗口内超时 + - 但对应的新 audio artifact `2f81c937-52fa-4b7c-95a0-29884b78ba1a` 后续已在 `download list` 中出现,并带出 `audio/mp4` / HLS / DASH variants + - 说明提交链路与 artifact 可见性已验证;只是当前 notebook 上的完成时长超过最小 wait 窗口 +- `generate/slide-deck` 的 live 事实是: + - 两次 `--wait` 尝试都在 180 秒最小窗口内超时 + - 但后续 `download list` 已观察到对应的新 slide-deck artifacts: + - `978ef654-a702-46b9-bdba-231253f1c3a6` + - `9e4b5582-1b63-482b-ba9f-0223241377c9` + - 其中 `978ef654-a702-46b9-bdba-231253f1c3a6` 已完成并成功通过现有 `download slide-deck` 闭环下载 +- 当前结论: + - `report` 是这轮唯一在命令级 `--wait` 窗口内稳定闭环的 generate + - `audio` / `slide-deck` 已验证提交链路和后续 artifact 出现,但最小 wait 仍应被视作“保守的便捷等待”,不是完整长任务恢复能力 + +## Implemented Minimal Download Command + +- 既然 `download/report` 链路已经足够清晰,这轮没有停在纯调研,而是实现了最小可用命令: + - `notebooklm download report [--artifact-id ]` + - alias: `notebooklm download-report [--artifact-id ]` +- 这版刻意不扩: + - `artifact/list` + - `artifact/get` + - `artifact/export` + - `download/audio` + - `download/slide-deck` + +## Implemented So Far + +- `src/clis/notebooklm/rpc.ts` now owns shared transport primitives: + - auth extraction + - rpc body encoding + - anti-XSSI stripping + - chunked response parsing + - page-side fetch + - generic `callNotebooklmRpc(...)` +- `src/clis/notebooklm/list.ts` now reaches notebook list RPC through the shared transport path. + +## Ask Minimal-Viable Findings + +- 当前 `ask` 不走 `batchexecute` RPC,也不需要 DOM 点按钮;上游 `notebooklm-py` 已确认真实链路是独立 query endpoint: + - `https://notebooklm.google.com/_/LabsTailwindUi/data/google.internal.labs.tailwind.orchestration.v1.LabsTailwindOrchestrationService/GenerateFreeFormStreamed` +- query body 不是 `[[[rpcId, ...]]]` 形状,而是: + - `f.req=[null, JSON.stringify(params)]` + - `params = [sources_array, prompt, conversation_history|null, [2, null, [1]], conversation_id]` +- 最小可用 ask 依赖当前 notebook 的真实 source ids: + - 当前实现直接复用已稳定的 `source-list` RPC 解析结果 + - 不接受 DOM source fallback,因为当前页 DOM 不能稳定给出真实 source UUID +- query response 是分块文本流,但最小可用版不需要流式输出: + - 当前实现按 chunk 扫描 `wrb.fr` + - 提取 `first[0]` 文本 + - 优先选择 `first[4][-1] == 1` 的最长 answer chunk + - 若没有 marked answer,才回退到最长未标记文本 +- live smoke 已确认: + - `node dist/main.js notebooklm ask --prompt "用一句话总结这个 notebook" -f json` + - 当前绑定 notebook 可直接返回回答正文,`source: "query-endpoint"` + +## Light-Write Findings From This Round + +- `source-add-text` 复用 `ADD_SOURCE` RPC: + - RPC ID: `izAoDd` + - params shape: `[[[null, [title, content], null, null, null, null, null, null]], notebook_id, [2], null, null]` + - 当前 live 已确认可直接创建 source;返回 payload 可直接解析成 source row +- `source-add-url` 也复用 `ADD_SOURCE` RPC: + - 普通 web URL params shape: `[[[null, null, [url], null, null, null, null, null]], notebook_id, [2], null, null]` + - 明显的 YouTube URL 需要切到单独 params 形状,而不是普通 web slot + - 当前 live 已确认普通 `https://example.com/` 路径可创建 `type: "web"` source +- `notes-save` 当前最稳妥的最小方案不是 DOM 点保存,而是: + - 先读当前可见 note editor 的 title/content + - 再用 `GET_NOTES_AND_MIND_MAPS` RPC (`cFji9`) 找 note id + - 最后调用 `UPDATE_NOTE` RPC (`cYAfTb`) +- `notes-save` 的现实边界已经明确: + - 只适用于当前真正可见的 note editor + - 当前实现按“唯一标题”解析 note id + - 如果当前 notebook 里有多个同名 note,或当前页根本没打开 note editor,live save 会失败 + - 因此这版是“最小可用实现”,不是任意 note 的稳定写接口 +- `share-status` 有稳定只读 RPC: + - RPC ID: `JFMDGd` + - params shape: `[notebook_id, [2]]` + - 返回可稳定解析出: + - public/restricted + - share_url + - shared users + - owner/editor/viewer 权限 +- `language-get` / `language-set` 不是 notebook-scoped RPC,而是 root-level user settings RPC: + - get: `ZwVcOc` + - set: `hT54vc` + - 这两条请求应显式使用 `source-path=/`,不应沿用当前 notebook 页的 source path +- `language-list` 当前没有发现更稳的独立服务端枚举接口;这版采用上游已同步的静态语言表,并把 `get/set` 作为 server truth + +## Notes-Save Stability Follow-Up + +- 当前 live NotebookLM 页面里,artifact list 的 note 项仍然暴露稳定 id 线索: + - `button[aria-labelledby="note-labels-"]` + - `span[id="note-labels-"]` +- 但在当前布局里,一旦 note editor 打开,这组 `note-labels-*` 节点会从 DOM 消失,当前 visible editor 周围没有直接暴露稳定 note id。 +- 当前 visible note editor 已确认能稳定读到: + - `.note-header__editable-title` + - `.note-editor .ql-editor` +- 因此 `notes-save` 这一轮的现实收口方案是: + - 先尝试从 visible editor 周围的 DOM hint 提取 `note-labels-*` / `artifact-labels-*` stable id + - 如果没拿到 stable id,再用当前 visible editor 的 `title + content` 去精确匹配 `cFji9` RPC note list + - 只有这两条都失败时,才回到 ambiguous / unresolved error +- 当前 live notebook 的真实阻塞已经收窄为: + - 当前 visible editor 是 `title="新建笔记"`、`content=""` + - `cFji9` 返回两个同名 note,且两者 `content` 都是空字符串 + - 所以这次失败不再是“标题重复”本身,而是“标题和正文都重复,同时 editor 周围又没有稳定 id” + +## Notes `--note-id` Follow-Up + +- 这轮没有继续堆 DOM heuristics,而是给 note 读写链路补了显式 `--note-id`。 +- `notes-get` 现在支持: + - `notebooklm notes get "标题"` + - `notebooklm notes get --note-id ` +- `notes-save` 现在支持: + - `notebooklm notes-save` + - `notebooklm notes-save --note-id ` +- `--note-id` 的优先级高于标题或默认选择逻辑: + - `notes-get --note-id` 会直接走 RPC note list 按 id 定位 + - `notes-save --note-id` 会直接把当前 visible editor 的 title/content 保存到该 id,不再做标题消歧 +- `note-list` / `notes list` 输出现在会带 `id` 字段,只要 RPC/DOM 行里有稳定 hint,就会显式返回,方便用户复制使用。 +- 当前错误语义也更明确: + - `NOTEBOOKLM_NOTE_ID_NOT_FOUND` + - `NOTEBOOKLM_NOTE_ID_MISMATCH` + - `NOTEBOOKLM_NOTE_EDITOR_MISSING` +- 现实边界仍然存在: + - 显式 id 可以解决“重复标题、重复空正文”的 note 选择歧义 + - 但 `notes-save --note-id` 依然要求当前页已经打开一个 visible note editor + - 显式 id 不能替代“把目标 note 打开到 editor”这一步 +- 本轮 live smoke 的额外发现是: + - `node .\\dist\\main.js notebooklm notes list -f json` 当前返回 no data + - 因而本轮没有继续做基于真实 id 的 `notes get --note-id` / `notes-save --note-id` live 验证 + - 这个阻塞属于当前页面状态或 note-list 运行态问题,不是 `--note-id` 语义本身的问题 +## Notebook Light-Write CRUD Findings + +- `create` 有稳定 home-scope RPC: + - RPC ID: `CCqFvf` + - params shape: `[title, null, null, [2], [1, null, null, null, null, null, null, null, null, null, [1]]]` + - 当前 opencli live 已确认可直接返回创建后的 notebook row +- `rename` 可复用上游已取证的 home-scope notebook settings RPC: + - RPC ID: `s0tc2d` + - params shape: `[notebook_id, [[null, null, null, [null, new_title]]]]` + - 这条 RPC 本身返回空值也没关系;后续再用 `rLM1Ne` 回读 notebook 详情即可 + - 当前 opencli live 已确认这条链路稳定 +- `delete` 有稳定 home-scope RPC: + - RPC ID: `WWINqb` + - params shape: `[[notebook_id], [2]]` + - 当前 opencli live 已确认可成功删除临时 notebook +- `describe` 的真实结构化 RPC 仍有 live 不稳定性: + - 上游 `notebooklm-py` 把 notebook describe 绑定到 `VfAZjd` + - params shape: `[notebook_id, [2]]` + - 解析形状可得: + - summary at `[0][0]` + - suggested topics at `[1][0]`, each item `[question, prompt]` + - 但当前 opencli live 对真实 notebook 反复调用时,稳定可用的是 summary wrapper,而不是结构化 topics + - 因此这版 `describe` 收口为: + - 先试真实 `VfAZjd` + - 若拿不到结构化结果,则回退到当前稳定的 summary 读链路 + - 当前 live 返回 `source: "summary-dom"`、`suggested_topics: []` +- `remove-from-recent` 有稳定 home-scope RPC: + - RPC ID: `fejl7e` + - params shape: `[notebook_id]` + - 当前 opencli live 已确认可返回 `removed_from_recent: true` + +## Notebook Light-Write CRUD Validation + +- `create` + - vitest: `src\\clis\\notebooklm\\create.test.ts` + `src\\clis\\notebooklm\\utils.test.ts` + - live: `node .\\dist\\main.js notebooklm create "opencli notebook create smoke 2026-03-31" -f json` +- `rename` + - vitest: `src\\clis\\notebooklm\\rename.test.ts` + `src\\clis\\notebooklm\\utils.test.ts` + - live: `node .\\dist\\main.js notebooklm rename b0aab2fa-ec5f-4fd1-b0d8-55047e46ab2c "opencli notebook rename probe 2026-03-31" -f json` +- `delete` + - vitest: `src\\clis\\notebooklm\\delete.test.ts` + `src\\clis\\notebooklm\\utils.test.ts` + - live: temp create then delete +- `describe` + - vitest: `src\\clis\\notebooklm\\describe.test.ts` + `src\\clis\\notebooklm\\utils.test.ts` + - live: `node .\\dist\\main.js notebooklm describe a45591ed-37bd-4038-a131-141a295c024b -f json` +- `remove-from-recent` + - vitest: `src\\clis\\notebooklm\\remove-from-recent.test.ts` + `src\\clis\\notebooklm\\utils.test.ts` + - live: `node .\\dist\\main.js notebooklm remove-from-recent b0aab2fa-ec5f-4fd1-b0d8-55047e46ab2c -f json` + +## Notes List Live Root Cause Follow-Up + +- 这轮先按 live 失败重放了: + - `node .\\dist\\main.js notebooklm notes list -f json` + - 当时返回 `no data` +- 进一步取证确认,这次失败不是先证明了 selector 漂移,而是当时浏览器里没有可绑定的 `/notebook/...` tab: + - `notebooklm use -f json` 当场报 `No visible tab matching notebooklm.google.com /notebook/` + - 单独 page probe 也显示当前停在 NotebookLM home,而不是 notebook 页 +- 在显式打开真实 notebook 页 `a45591ed-37bd-4038-a131-141a295c024b` 后,当前 DOM 依然存在: + - `artifact-library-note`: 2 + - `button[aria-labelledby^="note-labels-"]`: 2 + - `button[aria-labelledby^="artifact-labels-"]`: 2 +- 同一 notebook 页上: + - 现有 DOM 解析 `listNotebooklmNotesFromPage(...)` 返回 2 条 note + - 现有 RPC `cFji9` 解析 `listNotebooklmNotesViaRpc(...)` 也返回 2 条 note +- 这说明: + - 当前 `notes/list` 的 selector 还没整体失效 + - 真正需要补的是“DOM 空时的兜底”,而不是盲目重写 selector +- 因此这轮对 `notes/list` 的最小收口是: + - 保留 DOM-first + - DOM 为空时回退到已有 `cFji9` RPC + - 不扩到 `notes-create` / `notes-rename` / `notes-delete` + +## Notes Light-Write CRUD Findings + +- 上游 `notebooklm-py` 已确认 notes 写链路: + - `CYK0Xb` = `CREATE_NOTE` + - `cYAfTb` = `UPDATE_NOTE` + - `AH0mwd` = `DELETE_NOTE` +- `create` 的真实行为不是一次 RPC 完成 title/content 创建: + - 先 `CYK0Xb` 创建空 note,参数形状 `[notebook_id, "", [1], null, "New Note"]` + - 再用 `cYAfTb` 把 title/content 写进去 + - 也就是说,Google 当前会忽略 create 阶段传入的 title 语义,title/content 必须靠后续 update 落稳 +- `rename` 不需要单独 RPC: + - 仍然复用 `cYAfTb` + - 只是把现有 content 保持不变,只更新 title +- `delete` 有稳定 RPC: + - 参数形状 `[notebook_id, null, [note_id]]` + - 语义上是清空/删除该 note,和上游一致 +- 这轮刻意没有继续增加新的 note 选择 heuristics: + - `rename` / `delete` 优先 `--note-id` + - 不带 `--note-id` 时,只接受“唯一精确标题命中” + - 如果同名 note 多于 1 条,就明确要求用户提供 `--note-id` +- `rename` 的一个实际 CLI 可调用性问题也已收口: + - 最初把旧标题放成可选 positional,导致 `notebooklm notes rename --note-id ` 仍会被 commander 视为缺少 `title` + - 现在新的标题是唯一 positional 参数,旧标题兼容改为命名参数 `--note` + +## 2026-03-31 Source Management Medium-Complexity Commands + +- 上游 `notebooklm-py` 已确认 source 管理 RPC: + - `b7Wfje` = `UPDATE_SOURCE` + - params: `[null, [source_id], [[[new_title]]]]` + - `tGMBJ` = `DELETE_SOURCE` + - params: `[[[source_id]]]` + - `FLmJqe` = `REFRESH_SOURCE` + - params: `[null, [source_id], [2]]` + - `yR9Yof` = `CHECK_SOURCE_FRESHNESS` + - params: `[null, [source_id], [2]]` +- `check-freshness` 的返回形状不是单一布尔值: + - `[]` 表示 fresh + - `[[null, true, [source_id]]]` 表示 fresh + - `true` 表示 fresh + - `false` 表示 stale + - opencli 这轮新增 `parseNotebooklmSourceFreshnessResult(...)` 做最小归一化 +- 这轮 source 选择策略刻意保持克制: + - `rename` / `delete` / `refresh` / `check-freshness` 优先 `--source-id` + - 不带 `--source-id` 时,只做“唯一精确标题命中” + - 不复用读命令里的 partial title fallback,避免写命令误伤 +- live 已验证: + - `source rename` + - `source delete` + - 通过临时 text source: + - create: `e234071d-b9f3-4d13-a126-51f97f42a194` + - rename -> `"opencli source renamed smoke 2026-03-31"` + - delete -> `deleted: true` +- live 运行态阻塞: + - `source-add-url` 在当前浏览器会话里两次出现 `Detached while handling command` + - 同时 NotebookLM 当前绑定 tab 会偶发漂到 `https://notebooklm.google.com/notebook/?addSource=true` + - 一旦漂移到 add-source 页,`source list` 会退化成 current-page DOM 噪声项,后续 `refresh` / `check-freshness` 的 source-id 存在性校验会命中错误 notebook + - 因此这轮未把 `refresh` / `check-freshness` 标成 live 走通;当前阻塞点是浏览器绑定/页面状态,而不是已知 RPC 缺失 + +## 2026-03-31 Source Runtime Stability Follow-Up + +- 这轮不新增 source 命令,只处理 `source/refresh` 和 `source/check-freshness` 的运行态稳定性。 +- root cause 拆成两部分: + 1. `ensureNotebooklmNotebookBinding(...)` 之前优先读 `page.getCurrentUrl()` + - `Page.getCurrentUrl()` 带本地 `_lastUrl` 缓存 + - 当缓存仍是 home / 旧页,而真实浏览器已经在某个 notebook 页时,helper 会误以为“当前不在 notebook” + - 随后触发 `bind-current` + 2. `bind-current` 对 NotebookLM workspace 的 fallback 策略会挑“任一可见 notebook tab” + - 所以一旦误触发 rebinding,就可能绑定到错误 notebook + - 如果那个 tab 恰好是 `?addSource=true` 页面,后续 source 校验还会落到 add-source 上下文 +- 另一个瞬态问题: + - daemon / extension 偶发返回 `Detached while handling command.` + - 之前 `Page.evaluate(...)` 只把 `Inspected target navigated or closed` 归为一次可重试瞬态,没有覆盖 detached + +- 最小修复: + - `src/clis/notebooklm/utils.ts` + - `ensureNotebooklmNotebookBinding(...)` 先读真实 `getNotebooklmPageState(...)` + - 若真实已在 notebook 页,则不再触发 rebinding + - 若 notebook URL 不是 canonical 形态,例如 `?addSource=true`,先 `goto(https://notebooklm.google.com/notebook/)` + - bind 之后也会做一次相同 canonicalize + - `src/browser/page.ts` + - `isRetryableSettleError(...)` 新增 `Detached while handling command.` 判定 + - `Page.evaluate(...)` 因而会对 detached 做一次最小重试 + +- 新增 / 扩展测试覆盖: + - `binding.test.ts` + - stale currentUrl + real add-source notebook page 时不应 rebinding + - bind-current 落到 add-source notebook 页后应回正到 canonical URL + - `page.test.ts` + - detached target during exec 应重试一次 + +- live 结果: + - 当前 notebook: `6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - 临时 web source: + - create: `16e5137e-8108-4df2-a294-8511216697c5` + - 连续 3 轮验证中: + - `source check-freshness --source-id 16e5137e-8108-4df2-a294-8511216697c5` + - `source refresh --source-id 16e5137e-8108-4df2-a294-8511216697c5` + - `status -f json` + 都保持在同一 canonical notebook URL,没有再漂到 `?addSource=true` + - `source-add-url` 在本轮修复后也成功创建临时 web source,说明 detached 瞬态至少在这条 live 路径上已缓解 + +## 2026-03-31 From-0 Integration Test Summary (9 Modules, Historical Only) + +### Test Environment +- Browser Bridge daemon: port 19825, extension v1.5.5, connected +- 手动 `curl .../navigate` 可将 browser bridge tab 导航到 notebook URL(opencli CLI 内无内置 navigate 命令) +- `bind-current` / `use` 在 browser 无 notebook tab 时失败,需要先手动 navigate +- **关键运行态问题**:每次 CLI 命令执行后,browser bridge tab 会偶发漂回 home 页(约 2-3 条命令后),严重影响 notebook-context 命令连续测试 + +### 测试模块 0:基础环境 — PASS +- `npx tsc --noEmit` → EXIT 0 +- `npm run build` → EXIT 0,475 entries +- `list -f json` → notebooklm 命令被发现 +- `notebooklm --help` → 正常展开 60+ 命令 +- `completion bash` → 正常输出 + +### 测试模块 1:绑定前置条件 — PASS(需手动 navigate) +- `bind-current` → FAIL(browser 无 notebook tab) +- 手动 `curl navigate` → PASS +- `status -f json` → `page: "notebook"`,`url: https://notebooklm.google.com/notebook/6fd8aeb5-...` +- 当前测试 notebook:`6fd8aeb5-ddd1-4114-bcda-c376389a8508`(Electron Debugging 2026) + +### 测试模块 2:Notebook 基础读写 — 大部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `status` | PASS | | +| `current` | PASS | `source: "current-page"` | +| `get` | PASS | `source: "rpc"`,含 source_count | +| `metadata` | PASS | alias of get | +| `describe` | PASS | `source: "summary-dom"`,summary 正文返回 | +| `list` | PASS | RPC | +| `create` | PASS | 创建 notebook | +| `rename` | PASS | 重命名成功 | +| `remove-from-recent` | PASS | | +| `delete` | PASS | | +| `summary` | FAIL | browser drift 导致,需要重新 navigate | +| `history` | FAIL | browser drift 导致,需要重新 navigate | + +### 测试模块 3:Source 读链路 — PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source list` | PASS | 7 sources,`type` 解析正确(pdf/web/audio/pasted-text/youtube),`source: "rpc"` | +| `source get` | PASS | 按 title "粘贴的文字" 匹配 | +| `source fulltext` | PASS | 返回 893 字符 markdown 内容 | +| `source guide` | PASS | 返回 summary + 5 keywords | +| `source-list`(flat)| FAIL | browser drift 导致 | + +### 测试模块 4:Source ingest 与管理 — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source-add-text` | PASS | | +| `source-add-url` | PASS | | +| `source-wait` | PASS | 等到 ready | +| `source-check-freshness` | PASS | `is_fresh: true` | +| `source-rename` | FAIL | browser drift | +| `source-refresh` | FAIL | browser drift | +| `source-delete` | FAIL | browser drift | + +### 测试模块 5:Notes — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `notes list` | PASS(不稳定)| browser drift 导致不稳定 | +| `notes create` | PASS | 创建 note | +| `notes rename` | PASS | | +| `notes delete` | FAIL | browser drift | +| `notes-save` | NOT TESTED | 需要 visible note editor | + +### 测试模块 6:Ask — PASS +`ask --prompt "用一句话总结"` → PASS,返回 answer 正文(中文) + +### 测试模块 7:Generate — PASS +`generate report` → PASS,返回 `artifact_id: b9934a76-53df-47ec-9f24-f990a8da8072` + +### 测试模块 8:Download — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `download list` | PASS | 显示 report/audio/slide_deck,`status` 字段正确 | +| `download report` | PASS | 成功写出 5563 字节 .md 文件,内容正确 | +| `download audio` | FAIL | "fetch failed" — artifact URL 过期(运行态,非代码缺陷) | +| `download slide-deck` | FAIL | "fetch failed" — artifact URL 过期(运行态)| + +### 测试模块 9:兼容层与命令树 — PASS +- `source --help`、`notes --help`、`download --help`、`language --help` → 全部正常 +- `language get` 和 `language-get` → 均 PASS(alias 正常) + +### 历史测试结论(不作为当前统一 PR 判断依据) + +**当时可直接确认的命令:** +1. `status` / `list` — 稳定,RPC +2. `create` / `rename` / `delete` / `remove-from-recent` — 稳定,RPC,闭环验证通过 +3. `current` / `get` / `metadata` / `describe` — 稳定 +4. `source list` / `source get` / `source fulltext` / `source guide` — 稳定,RPC +5. `source-add-text` / `source-add-url` / `source-wait` / `source-check-freshness` — 稳定 +6. `notes create` / `notes rename` — 稳定 +7. `ask` — 稳定,返回 answer +8. `generate report` — 稳定,返回 artifact_id +9. `download list` — 稳定,正确索引 artifacts +10. `download report` — 稳定,成功写出文件 +11. `language-list` / `language-get` / `language-set` — 稳定,RPC +12. 命令树三层结构和帮助文本 — 框架稳定 + +**当时受运行态影响、未纳入判断的命令:** +- `source-rename` / `source-refresh` / `source-delete` — browser drift 导致测试不稳定,需要稳定性修复后再验证 +- `notes delete` / `notes-save` — 同上 +- `download audio` / `download slide-deck` — artifact URL 过期是运行态问题,不是代码缺陷;但需要稳定可用的 artifact 样本才能验证 + +**设计边界确认(不是 bug):** +- `summary` / `history` 偶发 FAIL 是 browser drift,不是代码缺陷 +- `bind-current` 在无 notebook tab 时失败是设计预期,需要先 navigate +- `download audio/slide-deck` 的 "fetch failed" 是 URL 过期,不是实现问题 + +## Artifact-ID Completion Findings + +- `opencli` 原有 `completion.ts` 只支持静态 command-path segment 补全,不支持按 flag/value 位置返回动态候选。 +- 要给 `--artifact-id` 做 live 候选,completion fast-path 必须异步化: + - `src/main.ts` 的 `--get-completions` 分支需要 `await getCompletions(...)` + - `src/completion.ts` 需要支持按已解析命令和当前 flag 定位到目标参数 +- 生产态 `discoverClis` 默认优先走 manifest;对于 TS 命令,registry 初始拿到的是 lazy stub。 +- 因此如果 completion 只读 manifest stub,会拿不到 TS 命令里定义的 runtime `arg.completion` 回调。 +- 最小可行修正是: + - completion 在命中具体命令后,对该 lazy TS adapter 做一次最小 lazy-load + - 然后再从更新后的 registry 读取真实 `args` metadata +- NotebookLM `artifact-id` completion 不需要完整 `artifact/*`: + - 候选直接来自当前 notebook 的 `listNotebooklmDownloadArtifactsViaRpc(page)` + - 再按 `artifact_type` 过滤成纯 `artifact_id` 列表 +- NotebookLM completion helper 还需要复用命令执行路径的 browser workspace: + - `workspace: site:notebooklm` + - 否则会连到错误 browser session,拿不到当前 notebook 索引 +- 当前 shell 侧只要求纯字符串候选即可,因此返回纯 `artifact_id` 已足够。 + +### 历史运行态问题:Browser Drift(仅用于排障记录) +- 每次 CLI 命令执行后,browser bridge CDP session 会偶发漂回 home 页 +- 在连续 2-3 条命令后必然发生 +- 影响所有 notebook-context 命令的连续测试 +- 根因:`navigate` 命令在 daemon 端执行后,下一次 CLI 调用时 CDP session 丢失了 notebook URL 上下文 +- 当前 workaround:每次 FAIL 后重新 `curl navigate`,然后继续测试 +- 建议:这是 daemon/CDP session 管理问题,不是 NotebookLM adapter 代码缺陷 + +## 2026-03-31 Source Ingest Add-File And Wait + +- 上游 `notebooklm-py` 已确认 file ingest 不是 UI 点击流: + - `o4cbdc` = file source registration RPC + - 随后对 `https://notebooklm.google.com/upload/_/?authuser=` 发起 resumable upload start + - 从响应头 `x-goog-upload-url` 取得真正 upload URL + - 再对 upload URL 发 `upload, finalize` +- 因此 opencli 这轮 `source/add-file` 采用的是: + - `RPC register + Node HTTP resumable upload` + - 不依赖 add-source 面板展开 + - 不依赖 file input selector + - 也不需要 DOM 点击 +- `wait` 系列所需的 source processing status 已在 GET_NOTEBOOK payload 中确认: + - 状态位于 `source[3][1]` + - 当前已归一化的状态码: + - `1` = processing + - `2` = ready + - `3` = error + - `5` = preparing +- 这轮新增 helper: + - `parseNotebooklmSourceListResultWithStatus(...)` + - `listNotebooklmSourcesViaRpcWithStatus(...)` + - `waitForNotebooklmSourcesReadyViaRpc(...)` + - `waitForNotebooklmSourceReadyViaRpc(...)` +- `wait-for-sources` / `wait` 共享同一个 polling 核心: + - `wait-for-sources` 接逗号分隔 ids + - `wait` 只是单个 source id 的薄包装 +- 由于当前 commander 平面命令层不改,这轮命令形状保持克制: + - canonical: + - `source/add-file` + - `source/wait-for-sources` + - `source/wait` + - aliases: + - `source-add-file` + - `source-wait-for-sources` + - `source-wait` + +### Verification + +- red -> green tests: + - `npx vitest run src\\clis\\notebooklm\\source-add-file.test.ts src\\clis\\notebooklm\\source-wait-for-sources.test.ts src\\clis\\notebooklm\\source-wait.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- broader related tests: + - `npx vitest run src\\clis\\notebooklm\\source-add-file.test.ts src\\clis\\notebooklm\\source-wait-for-sources.test.ts src\\clis\\notebooklm\\source-wait.test.ts src\\clis\\notebooklm\\source-add-text.test.ts src\\clis\\notebooklm\\source-add-url.test.ts src\\clis\\notebooklm\\source-refresh.test.ts src\\clis\\notebooklm\\source-check-freshness.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live: + - `node dist/main.js notebooklm status -f json` + - `node dist/main.js notebooklm source-add-file C:\\Users\\11614\\AppData\\Local\\Temp\\opencli-notebooklm-add-file-smoke-20260331.txt -f json` + - `node dist/main.js notebooklm source-wait 6143e8b6-cb0d-4b18-9192-fbcd2abbebc1 -f json` + - `node dist/main.js notebooklm source-wait-for-sources 6143e8b6-cb0d-4b18-9192-fbcd2abbebc1 -f json` +- live 结果: + - 当前 browser workspace 绑定 notebook `6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - `source/add-file` 成功创建 source: + - `6143e8b6-cb0d-4b18-9192-fbcd2abbebc1` + - 初始状态:`preparing` + - `source/wait` 与 `source/wait-for-sources` 都成功等到: + - `status = ready` + - `status_code = 2` + +## 2026-04-01 Source Add-Drive + +- 这轮范围严格限制在 `source/add-drive`,不碰: + - `source add-research` + - notes / notebook CRUD / share / generate / download / artifact / framework +- 上游取证结论明确: + - `notebooklm-cdp-cli` 的 `source add-drive` 输入形态是: + - `file_id` + - `title` + - `mime_type` + - `notebooklm-py` 的 `client.sources.add_drive(...)` 走的不是 Drive URL 解析,也不是 DOM 上传 + - 实际 RPC 仍然是 `izAoDd`(与 add-text / add-url 共用) +- 上游稳定 params 形状: + - `source_data = [[file_id, mime_type, 1, title], null x9, 1]` + - `params = [[source_data], notebook_id, [2], [1, null x9, [1]]]` +- 因此 opencli 这轮实现刻意收口为: + - 只支持 raw Google Drive `file_id` + - 需要显式 `title` + - `--mime-type` 允许传原始 MIME string + - 当前已知稳定值: + - `application/vnd.google-apps.document` + - `application/vnd.google-apps.presentation` + - `application/vnd.google-apps.spreadsheet` + - `application/pdf` + - 不支持把 `docs.google.com/...` 或 `drive.google.com/...` URL 自动解析成 file id +- opencli 落地链路: + - `buildNotebooklmAddDriveParams(...)` + - `addNotebooklmDriveSourceViaRpc(...)` + - `source/add-drive` 命令层只负责取当前 notebook、校验输入、调用 RPC helper + +### Verification + +- red -> green: + - `npx vitest run src\\clis\\notebooklm\\source-add-drive.test.ts --reporter=verbose` + - `npx vitest run src\\clis\\notebooklm\\utils.test.ts -t "builds add-drive rpc params with file id, mime type, and display title in the drive slot" --reporter=verbose` +- related ingest regression: + - `npx vitest run src\\clis\\notebooklm\\source-add-drive.test.ts src\\clis\\notebooklm\\source-add-text.test.ts src\\clis\\notebooklm\\source-add-url.test.ts src\\clis\\notebooklm\\source-add-file.test.ts --reporter=verbose` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- type/build 当前失败,但根因与 add-drive 无关: + - 缺失 / 未实现的 infographic 相关文件与导出: + - `src/clis/notebooklm/download-infographic.test.ts` + - `src/clis/notebooklm/generate-infographic.test.ts` + - `buildNotebooklmGenerateInfographicParams` + - `extractNotebooklmInfographicDownloadUrl` +- live 尝试结果: + - `node dist/main.js notebooklm list -f json` → `[]` + - `node dist/main.js notebooklm status -f json` → 当前停在 NotebookLM home,`authuser=3`,无 visible notebook tab + - `npx tsx src/main.ts notebooklm create "opencli add-drive smoke 2026-04-01" -f json` → NotebookLM RPC HTTP 400 +- 因此这轮 live 的真实阻塞点是: + - 当前浏览器会话没有可绑定的 notebook 上下文 + - 同时也没有稳定、现成可验证的 Drive `file_id` + - 这不是 `source/add-drive` 的静态链路不清楚,而是运行态前置条件不足 + +## 2026-03-31 From-0 Integration Test Results (Historical Only) + +### Test Environment +- Browser Bridge 连接正常,当前 Chrome 停在 NotebookLM **home** 页面(不是 notebook 页面) +- `status` 报告 `page: "home"`,`list` RPC 可用但 `current`/`get`/`summary` 等 notebook-scoped 命令全部失败 +- 无法用 `browser-use` 或 opencli 内部命令主动导航 Chrome 到 notebook URL——这部分是浏览器/Bridge 运行态问题,不影响代码质量 + +### 测试模块 0:基础环境 — PASS +- `npx tsc --noEmit`:EXIT 0 +- `npm run build`:EXIT 0,manifest 475 entries +- `list -f json`:返回 22 条 notebook +- `notebooklm --help`:正常展开 60+ 命令 +- `completion bash`:正常输出补全脚本 + +### 测试模块 1:Notebook 基础 — 部分 PASS + +**Home-scope(browser 在 home 就能测):** +| 命令 | 结果 | +|------|------| +| `status -f json` | PASS | +| `list -f json` | PASS,RPC | +| `create -f json` | PASS,创建 notebook | +| `rename <id> <title> -f json` | PASS | +| `delete <id> -f json` | PASS | +| `remove-from-recent <id> -f json` | PASS | + +**Notebook-scope(需 browser 在 notebook URL):全部 FAIL** +| 命令 | 错误 | 原因 | +|------|------|------| +| `current -f json` | SelectorError | browser 在 home | +| `get / metadata -f json` | SelectorError | browser 在 home | +| `summary -f json` | SelectorError | browser 在 home | +| `history -f json` | SelectorError | browser 在 home | +| `share-status -f json` | SelectorError | browser 在 home | + +**结论:** 失败是 browser 页面状态问题,实现本身无缺陷。 + +### 测试模块 2-7:全部 FAIL(notebook-context 依赖) + +所有 `source/*`、`notes/*`、`ask`、`generate/*`、`download/*` 命令全部返回 `SelectorError / no data`,原因统一为 browser 在 home 页,不在 notebook URL。 + +### 测试模块 8:兼容层与命令树 — PASS(help 层面) + +| 检查项 | 结果 | +|--------|------| +| `source --help` | PASS,列出 11 个子命令 | +| `notes --help` | PASS,列出 6 个子命令 | +| `download --help` | PASS,列出 5 个子命令 | +| `generate --help` | PASS,列出 3 个子命令 | +| `language --help` | PASS,列出 3 个子命令 | +| `notebooklm --help` | PASS,完整列出所有命令 | +| `language-list -f json` | PASS,96 种语言 | +| `language-get -f json` | PASS | +| `language-set en/zh_Hans -f json` | PASS | + +### 历史结构性结论(不作为当前统一 PR 判断依据) + +**当时可直接确认的能力:** +1. `status` / `list` — 稳定,RPC +2. `create` / `rename` / `delete` / `remove-from-recent` — 稳定,RPC +3. `language-list` / `language-get` / `language-set` — 稳定,RPC +4. 命令树框架(三层 + 别名)— 框架稳定 + +**当时因测试前置条件不足而未纳入判断的能力:** +- 所有 `source/*`、`notes/*`、`ask`、`generate/*`、`download/*`、`share-status`、`describe`、`current`、`get`、`metadata`、`summary`、`history` +- 这些实现正确,代码无需修改,但 live 验证依赖 browser 停在 notebook URL + +**运行态阻塞点:** +- `bind-current` 无法接受 notebook-id 参数:CLI 设计就是"绑定当前活动 tab",没有 `opencli notebooklm bind-current <id>` 这种用法 +- `use` 是 `bind-current` 的 alias,行为一致 +- 如需切换 notebook,必须先手动在 Chrome 里打开目标 notebook URL,然后运行 `bind-current` 或 `use` + +## 2026-03-31 统一 PR 前验证(第 2 轮) + +> 以下结论是当前统一 PR 的唯一判断口径。上面的 From-0 结果只保留为历史排障记录,不再作为 PR judgment 依据。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(浏览器自动化工具全解析) +- `bind-current` → ✅ +- `status` → ✅ `page: "notebook"` + +### 模块 A:Source ingest 闭环 +- `source-add-text` ✅ — RPC,source id 返回 +- `source wait` ✅ — `status_code: 2, status: "ready"` +- `source wait-for-sources` ✅ — 逗号分隔 ids 可用 +- `source-add-file` ❌ — `fetch failed`,resumable upload URL 过期(运行态) + +### 模块 B:Notes 精确操作 +- `notes list` ✅ — 3 条,含两条同名"新建笔记" +- `notes get --note-id` ✅ — 绕过重复标题歧义,精确命中 +- `notes create` ✅ — RPC,id 返回 +- `notes delete --note-id` ✅ — RPC +- `notes rename --note-id` ✅ — RPC +- `notes-save` ❌ — 无 visible note editor(设计边界,预期) + +### 模块 C:Generate 最小闭环 +- `generate report --wait` ✅ — artifact id `c0674240-...`,完整闭环,download report 写出 9.9K +- `generate audio` ❌ — `status: failed`,artifact_id=null(notebook 内容限制,非代码缺陷) +- `generate audio --wait` ❌ — 超时 +- `generate slide-deck` ❌ — `status: failed` +- `generate slide-deck --wait` ❌ — 超时 + +### 模块 D:Download 闭环 +- `download list` ✅ — 正确索引 report + slide_deck +- `download report` ✅ — 新 artifact,9.9K 写出 +- `download slide-deck` ❌ — `fetch failed`,URL 过期(运行态) + +### 模块 E:artifact-id 补全 +- `download slide-deck --artifact-id` ✅ — 补全 slide-deck id +- `download report --artifact-id` ✅ — 补全 report id +- `download audio --artifact-id` ✅ — 空(无 audio artifact) +- `download video --artifact-id` ✅ — 空(无 video artifact) +- 平面 alias `download-slide-deck` / `download-report` ✅ — 同样补全 + +### 统一 PR 准入(当前有效) +**可进入统一 PR:** +1. `source-add-text` / `source wait` / `source wait-for-sources` / `source delete` +2. `notes list` / `notes get --note-id` / `notes create` / `notes delete --note-id` / `notes rename --note-id` +3. `generate report --wait` — 完整闭环 +4. `download list` / `download report` +5. `completion --artifact-id` 补全 + +**运行态问题(不应阻塞 PR):** +- `source-add-file` — 该能力此前已 live 验证通过;最新定向验证中的失败属于 daemon 侧网络/上传会话问题,非 adapter 代码缺陷 +- `download slide-deck` — 该能力此前已用 fresh artifact live 验证通过;最新定向验证未能建立新 artifact 前置条件,不应反向否定实现 +- `notes-save` — 设计边界,需 visible editor + +**仍待独立调查(不排除代码实现问题的可能性):** +- `generate audio` — 在内容丰富的 notebook(10 sources)上仍 `status: failed`;report 成功说明核心 RPC 正确;疑似 audio/slide-deck payload 与 report 不同,或该 notebook 有 server-side 限制 +- `generate slide-deck` — 与 audio 同失败模式 + +**设计边界:** +- signed download URL TTL ~1 天 +- resumable upload URL TTL +- `notes-save` 要求 visible note editor + +## 2026-03-31 剩余运行态定向验证(第 3 轮) + +> 本轮用于检查剩余边界,不覆盖此前已经完成的 live 成功样本;若与更早的成功验证冲突,以“能力已验证通过,但本轮样本/环境未满足前置条件”记载。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(10 sources,pdf/web/youtube/pasted-text/audio,内容丰富) +- 该 notebook 之前已成功 generate report ✅ +- `bind-current` + `status` ✅ + +### 验证结果 + +| 模块 | 结果 | 分类 | +|------|------|------| +| `source-add-file` | ❌ HTTP fetch failed | 本轮受 daemon 网络/上传会话影响;该能力此前已 live 验证通过 | +| `notes-save` | ⚠️ 前置条件未满足 | 设计边界,需 visible editor | +| `generate audio` | ❌ `status: failed` | 在丰富 notebook 上仍失败;report 成功≠网络问题;**不排除代码实现问题** | +| `generate slide-deck` | ❌ `status: failed` | 同上,与 audio 同根因 | +| `download slide-deck` | ⚠️ 本轮前置条件未满足 | 本轮依赖模块 4;该能力此前已用 fresh artifact live 验证通过 | + +### 关键发现 +- `generate audio/slide-deck` 在该 notebook 上失败,而 report 成功,说明: + - 网络通道正常 + - R7cb6c 对 report 类型有效 + - audio/slide-deck 类型的 payload 可能与 report 不同,或存在 server-side 生成限制 +- 这值得独立调查 audio/slide-deck 的 R7cb6c payload 差异,但不推翻当前统一 PR 的 report/download 主路径 +- `source-add-file` 本轮失败是 daemon 侧网络/上传会话问题,不是 adapter 代码缺陷;该能力此前已 live 验证通过 + +## 2026-03-31 Generate Audio / Slide-Deck专项调查 + +### Static Comparison + +- opencli 当前 3 个 generate builder 都走同一个 RPC: + - `R7cb6c` +- 当前 opencli payload: + - report: + - `[..., 2, sourceTriples, ..., [null, [title, description, null, sourceDoubles, "en", prompt, null, true]]]` + - audio: + - `[..., 1, sourceTriples, ..., [null, [null, null, null, sourceDoubles, "en", null, null]]]` + - slide-deck: + - `[..., 8, sourceTriples, ..., [[null, "en", null, null]]]` +- 与上游 `notebooklm-py` 对比后确认: + - report / audio / slide-deck 的 payload 结构在 opencli 中与上游默认 builder 对齐 + - audio 和 slide-deck 并不是“少了整个 type-specific 子结构” + - opencli 当前只是把 type-specific knobs 保持为默认 `null` +- 额外用 live RPC probe 验证了最可疑的默认枚举位: + - audio 显式补 `length=2` + - audio 显式补 `format=1/2` + - slide-deck 显式补 `format=1/2, length=1` + - 结果全部仍然返回同一类 `UserDisplayableError` +- 结论: + - 当前没有证据支持“只差一个 format/length 默认值”这个假设 + +### Live RPC Evidence + +- 当前 notebook: + - `a45591ed-37bd-4038-a131-141a295c024b` + - 当前 source count(RPC probe 时): + - `8` +- live `R7cb6c` 结果对比: + - report: + - 返回完整 artifact row + - `artifact_id = 18457f58-f566-4702-939f-edc276d85303` + - `status_code = 1` + - audio: + - `wrb.fr("R7cb6c", null, ..., UserDisplayableError, ...)` + - `result = null` + - slide-deck: + - `wrb.fr("R7cb6c", null, ..., UserDisplayableError, ...)` + - `result = null` +- audio 与 slide-deck 的失败模式不是 timeout,也不是 artifact 后续不可见: + - 是提交当下就被服务端拒绝 + +### UI Evidence + +- 同一 live notebook 的 Studio 面板已直接出现明确文案: + - `您已达到每日音频概览和幻灯片数量上限,改日再来吧。 或进行升级。` +- 这条文案与 RPC 行为完全一致: + - report 仍可生成 + - audio / slide-deck 触发 `UserDisplayableError` +- 因此当前最可能根因是: + - server-side quota / eligibility gate + - 作用范围正好覆盖 `audio overview` 和 `slide deck` + - 不是 opencli transport 整体故障 + - 也没有证据表明是最小 payload 结构错误 + +### Practical Conclusion + +- 对本轮问题,最可信的判断顺序是: + 1. 不是 generic network failure + 2. 不是“只差 type code” + 3. 也不是“只差一个 format/length 默认枚举” + 4. 当前最像的是服务端对 account / notebook 的 daily quota 或 eligibility 限制 +- 如果后续要修 opencli,最小修复点不该先改 payload builder。 +- 更合理的最小修复点应落在: + - `src/clis/notebooklm/rpc.ts` + - 更细分 `UserDisplayableError` 的 batchexecute 响应 + - 或 `src/clis/notebooklm/utils.ts` + - 在 generate helper / parser 中把这类结果归一化为明确的 quota/eligibility failure +- 当前没有足够证据支持直接修改: + - `buildNotebooklmGenerateAudioParams(...)` + - `buildNotebooklmGenerateSlideDeckParams(...)` + +## Generate Revalidation On The New Account + +- 这轮先把 browser bridge tab 重新导航回 rich notebook: + - `a45591ed-37bd-4038-a131-141a295c024b` +- live 重新验证结果: + - `generate report`:仍然成功提交 + - `generate report --wait`:闭环成功,返回 completed artifact + - `generate audio`:仍然提交即失败,`artifact_id = null` + - `generate slide-deck`:仍然提交即失败,`artifact_id = null` +- 与之前 old account 的关键差异是: + - 这次 raw `R7cb6c` 响应里仍然能确认 `UserDisplayableError` + - 但响应体不再携带可读 message,只剩内部 envelope: + - audio: `[8,null,[[type.googleapis.com/...UserDisplayableError, [[null,[[1]]]]]]]` + - slide-deck: `[8,null,[[type.googleapis.com/...UserDisplayableError,[null,null,null,null,null,null,null,[null,[[1]]]]]]]` + - 页面当前也没有再次出现之前那个明确的“每日额度/升级”可读文案 +- 因此这轮新的最稳结论是: + - 新账号下,audio / slide-deck 依旧被服务端在提交阶段拒绝 + - 但当前证据不足以把它继续稳定归因为 `daily_limit_reached` + - 也没有足够证据把它归因为 `content_insufficient` + - 在没有可读 message 的前提下,当前最保守、最正确的分类是 `generation_failed_unknown` + +## Generate Error Classification Enhancement + +- 这轮没有改 generate payload builder。 +- 最小修复点落在: + - `src/clis/notebooklm/rpc.ts` + - `src/clis/notebooklm/utils.ts` + - `src/clis/notebooklm/shared.ts` +- 当前行为: + - raw batchexecute body 中若出现 `UserDisplayableError` + - 会先尝试提取可读 message + - 再按 message 归类为: + - `daily_limit_reached` + - `feature_not_eligible` + - `content_insufficient` + - `generation_failed_unknown` +- 当前 generate row 会额外返回: + - `error_type` + - `message` +- 新账号当前 live 结果证明: + - 这套增强至少已经把“原先只看到 failed”的返回,提升成了“明确是 `UserDisplayableError` 但无可读 message,所以归到 `generation_failed_unknown`” + - 若将来服务端重新带出可读 quota / eligibility / content 文案,当前分类器已经能直接映射到更具体的错误类型 + +## Infographic Generate / Download Findings + +- 上游原仓库和当前 `.venv` client 已确认 infographic 仍走: + - generate RPC: `R7cb6c` + - artifact type code: `7` + - download data source: `gArtLc` raw artifact list,而不是 export RPC +- 当前最小 generate payload 已确认可按上游稳定形状构造: + - `[[2], notebook_id, [null, null, 7, source_ids_triple, ..., [[instructions, language, null, orientation_code, detail_code, style_code]]]]` +- 当前可用的 infographic 选项码位已确认: + - orientation: + - `landscape -> 1` + - `portrait -> 2` + - `square -> 3` + - detail: + - `concise -> 1` + - `standard -> 2` + - `detailed -> 3` + - style: + - `auto_select -> 1` + - `sketch_note -> 2` + - `professional -> 3` + - `bento_grid -> 4` + - `editorial -> 5` + - `instructional -> 6` + - `bricks -> 7` + - `clay -> 8` + - `anime -> 9` + - `kawaii -> 10` + - `scientific -> 11` +- `gArtLc` raw row 里的 infographic 下载 URL 形态也已确认: + - 不像 slide deck 那样有固定 `pdf/pptx` 槽位 + - 更像深层 metadata / content 列表里嵌的单个 PNG URL + - 当前 opencli 采用的最小稳定提取策略与上游 `_find_infographic_url(...)` 一致:从 raw row 尾部反向扫描第一个可用 image URL + +### Live Verification + +- 已手动把 browser bridge workspace 导航回 rich notebook: + - `a45591ed-37bd-4038-a131-141a295c024b` +- live `generate infographic`: + - 返回: + - `artifact_id: null` + - `artifact_type: "infographic"` + - `status: "failed"` + - `error_type: "generation_failed_unknown"` + - `message: null` + - 这说明当前账号/样本上,服务端在提交阶段直接拒绝 infographic,但没有返回可读 message +- live `download list`: + - 已正确列出历史 infographic artifact: + - `artifact_id: 6a31b7d3-7b9c-402d-a4dc-fcc396430de4` + - `artifact_type: "infographic"` + - `download_variants: ["png"]` +- live `download infographic --artifact-id 6a31b7d3-7b9c-402d-a4dc-fcc396430de4`: + - 成功写出: + - `E:\web\opencli\tmp\notebooklm-infographic-smoke.png` + - 返回 direct PNG URL(`lh3.googleusercontent.com/notebooklm/...`) + +### Practical Conclusion + +- infographic 的 `type / payload / artifact` 形态已经查清。 +- `download/infographic` 已可独立闭环,不需要 `artifact/*` 作为前置。 +- `generate/infographic` 当前也已接入,但 live 行为与 audio / slide-deck 一样,现阶段最保守的结论仍然是: + - 服务端拒绝 + - 错误分类只能稳定落到 `generation_failed_unknown` + - 当前没有证据支持继续扩 artifact 子系统来解决它 diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..a9ab1217 --- /dev/null +++ b/progress.md @@ -0,0 +1,1404 @@ +# NotebookLM OpenCLI Progress + +## 2026-03-31 + +### Infographic Generate / Download + +- Scoped this round strictly to NotebookLM infographic generation/download: + - `generate infographic` + - `download infographic` +- Re-read the existing generate/download helpers in: + - `generate-report.ts` + - `generate-audio.ts` + - `generate-slide-deck.ts` + - `download-list.ts` + - `download-report.ts` + - `download-slide-deck.ts` + - `utils.ts` + - `rpc.ts` + - `shared.ts` +- Re-checked upstream command surface and packaged implementation: + - old CLI still exposes `generate infographic` / `download infographic` + - upstream artifact type code is `7` + - generate still uses `R7cb6c` + - download still uses `gArtLc` raw artifact list plus a deep image URL extractor +- Kept the implementation narrow: + - added `generate/infographic` + - added `download/infographic` + - extended shared artifact unions/types with `infographic` + - added infographic payload builder and PNG URL extractor + - extended `download/list` / artifact-id completion to recognize `infographic` + - reused the existing generate failure classification and HTTP download path +- Did not expand into: + - `quiz` + - `flashcards` + - `data-table` + - `artifact/*` + - framework / command-tree changes + +### Verification + +- red -> green tests: + - `npx vitest run src\\clis\\notebooklm\\generate-infographic.test.ts src\\clis\\notebooklm\\download-infographic.test.ts --reporter=verbose` + - `npx vitest run src\\clis\\notebooklm\\utils.test.ts -t "infographic|supported downloadable artifact rows|filters artifact-id completion candidates" --reporter=verbose` +- broader related tests: + - `npx vitest run src\\clis\\notebooklm\\generate-report.test.ts src\\clis\\notebooklm\\generate-audio.test.ts src\\clis\\notebooklm\\generate-slide-deck.test.ts src\\clis\\notebooklm\\generate-infographic.test.ts src\\clis\\notebooklm\\download-list.test.ts src\\clis\\notebooklm\\download-report.test.ts src\\clis\\notebooklm\\download-slide-deck.test.ts src\\clis\\notebooklm\\download-infographic.test.ts --reporter=verbose` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- command discovery/help smoke: + - `node dist/main.js list -f json | Select-String 'generate/infographic|download/infographic'` + - `node dist/main.js notebooklm generate infographic --help` + - `node dist/main.js notebooklm download infographic --help` +- live: + - initial `node dist/main.js notebooklm status -f json` showed browser bridge connected but parked on NotebookLM home + - daemon navigate moved workspace `site:notebooklm` to: + - `https://notebooklm.google.com/notebook/a45591ed-37bd-4038-a131-141a295c024b` + - `node dist/main.js notebooklm generate infographic -f json` + - returned `status: "failed"`, `artifact_id: null`, `error_type: "generation_failed_unknown"` + - `node dist/main.js notebooklm download list -f json` + - returned one completed infographic artifact: + - `6a31b7d3-7b9c-402d-a4dc-fcc396430de4` + - `download_variants: ["png"]` + - `node dist/main.js notebooklm download infographic "E:\\web\\opencli\\tmp\\notebooklm-infographic-smoke.png" --artifact-id 6a31b7d3-7b9c-402d-a4dc-fcc396430de4 -f json` + - succeeded + +### Outcome + +- `generate infographic` is implemented and uses the verified `R7cb6c` payload path. +- `download infographic` is implemented and closes successfully against an existing live artifact. +- Current live boundary is explicit: + - generate is still server-rejected on the current account/notebook sample + - download does not require `artifact/*` as a prerequisite + +### Session Summary + +- Confirmed `opencli` is the Windows/browser-bridge target repo. +- Added NotebookLM adapter scaffold and docs in earlier work. +- Investigated why homepage `wXbhsf` looked empty. +- Captured real NotebookLM homepage network traffic from live Chrome. +- Verified `wXbhsf` is still the real notebook-list RPC. +- Found request-shape bug in local implementation. +- Fixed parameter shape in `src/clis/notebooklm/utils.ts`. +- Updated `src/clis/notebooklm/utils.test.ts`. +- Re-verified live command output: + - `npx tsx src/main.ts notebooklm list -f json` + - output now returns RPC-backed notebook rows +- Created planning artifacts for the next phase. +- Started implementation from the new plan using subagents. +- Extracted shared transport into `src/clis/notebooklm/rpc.ts`. +- Added dedicated transport tests in `src/clis/notebooklm/rpc.test.ts`. +- Re-exported shared transport helpers from `utils.ts` to keep existing tests green. +- Compared the current `opencli` NotebookLM surface against the original `notebooklm-cdp-cli` command groups. +- Locked in the compatibility strategy as `alias / wrapper`, not a three-level command tree migration. +- Added framework-level command alias support across: + - `registry.ts` + - `commanderAdapter.ts` + - `serialization.ts` + - `build-manifest.ts` + - `discovery.ts` + - `cli.ts` + - `completion.ts` +- Added NotebookLM compatibility commands: + - `notebooklm use` -> alias of `bind-current` + - `notebooklm metadata` -> alias of `get` + - `notebooklm notes-list` -> alias of `note-list` + - `notebooklm source-get <source>` -> wrapper over current source retrieval and filtering +- Added new tests for alias support and NotebookLM compatibility commands. +- Investigated the two main live stability gaps before adding more commands: + - `history` intermittent page-token failures + - `source-list` frequently falling back to DOM +- Confirmed NotebookLM page auth tokens are also available in `window.WIZ_global_data`. +- Confirmed `rLM1Ne` detail/source payloads currently arrive as a singleton envelope and with shallower source-id nesting than the old parser assumed. +- Added a retry to `Page.evaluate(...)` for transient target-navigation settle errors. +- Tightened NotebookLM transport/parser logic so read commands stay on RPC more often. +- Re-verified `dist` commands sequentially instead of using the earlier incorrect single-string node invocation. +- Added `notebooklm summary` as a DOM-first read command for the current notebook summary block. +- Added `notebooklm notes-get <title-or-id>` as a minimal read command for the currently visible Studio note editor. +- Verified the real NotebookLM page exposes stable summary selectors and note-editor selectors before implementing those commands. +- Assessed `source-fulltext` data sources before touching any write path. +- Confirmed current page DOM does not reliably expose source fulltext after selecting a source row. +- Confirmed upstream `notebooklm` client uses dedicated source RPCs: + - `hizoJc` for fulltext + - `tr032e` for guide +- Added `notebooklm source-fulltext <source>` using source lookup plus `hizoJc`. +- Verified live `hizoJc` payload contains source metadata plus nested content blocks that can be flattened into the extracted fulltext. +- Ran a narrow `source-guide` evaluation only, without implementing a command. +- Confirmed `tr032e` returns guide-shaped data for the current pasted-text source: + - markdown-style summary + - keyword list +- Confirmed `tr032e` does not appear to depend on the source being expanded in the current page state. +- Continued the requested cross-type validation in the same notebook after a non-`pasted-text` source was added. +- Verified raw `rLM1Ne` detail now exposes a YouTube source in the current notebook, even though the current `source-list` type parser still reports every source as `pasted-text`. +- Verified `tr032e` on that YouTube source: + - params still `[[[[source_id]]]]` + - core guide structure still matches `[[[null, [summary], [[keywords]], []]]]` + - summary and keywords are guide-like, not fulltext/meta + - repeated calls before and after clicking the source row remained identical +- Kept the scope narrow: no `source-guide` command implementation, no extra commands, no notebook switch. +- Implemented the deferred follow-up in one narrow wave: + - fixed `source-list` type/type_code parsing to use the live metadata kind slot + - added `notebooklm source-guide <source>` over source lookup + `tr032e` +- Added parser coverage for both `tr032e` shapes: + - slot 0 is `null` + - slot 0 is a source-id envelope +- Re-verified live that `source-list` now reports `pdf`, `web`, `pasted-text`, and `youtube` correctly in the current notebook. +- Re-verified live that `source-guide` returns `source_id`, `notebook_id`, `title`, `type`, `summary`, `keywords`, and `source: "rpc"`. +- Continued with the next constrained milestone only: + - implemented `notebooklm ask --prompt ...` as the minimal viable ask path + - kept scope to the current notebook only + - did not add thread selection, multi-turn state, note saving, streaming, or any other write command +- Confirmed from upstream `notebooklm-py` that ask uses the dedicated query endpoint rather than `batchexecute`: + - `GenerateFreeFormStreamed` +- Added minimal ask transport/parsing in the NotebookLM adapter: + - build query `f.req` from current notebook source ids + prompt + fresh conversation UUID + - post via in-page fetch with current NotebookLM auth/session tokens + - parse chunked `wrb.fr` response and return the longest marked answer body +- Added new test coverage for: + - ask command execution + - ask request-body shape + - ask response parsing +- Re-verified live that: + - `node dist/main.js notebooklm ask --prompt "用一句话总结这个 notebook" -f json` + - returns `notebook_id`, `prompt`, `answer`, `url`, and `source: "query-endpoint"` +- In a parallel framework-only thread, added minimal nested-command-tree support without touching NotebookLM business adapters: + - command definitions can now use path-like names such as `source/list` + - commander maps those to real nested subcommands such as `source list` + - flat commands remain backward-compatible +- Added failing-then-passing framework tests for: + - nested commander registration/execution + - nested completion behavior + - manifest preservation of path-like command names +- Updated framework output shape so structured command serialization/list rows now include an `invocation` field for space-separated command paths. +- Followed up by remounting the first NotebookLM command slice onto nested canonical paths while keeping flat aliases working: + - `source/list` + - `source/get` + - `source/fulltext` + - `source/guide` + - `notes/list` + - `notes/get` + - `language/list` + - `language/get` + - `language/set` +- Kept the remount wave narrow: + - no NotebookLM RPC or parser changes + - no new business commands + - no `generate/*`, `artifact/*`, or `download/*` +- Added failing-then-passing tests for: + - nested canonical commands still accepting flat aliases at the site root + - NotebookLM registry remount keys resolving both nested and flat forms +- Re-verified live that both nested and flat NotebookLM source/language commands continue to work after remount. + +### Verification + +- `npx vitest run src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npx tsx src/main.ts notebooklm list -f json` +- `npx vitest run src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx tsx src/main.ts notebooklm status -f json` +- `npx tsx src/main.ts notebooklm list -f json | Select-String '"source": "rpc"'` +- `npx vitest run src\\registry.test.ts src\\serialization.test.ts src\\commanderAdapter.test.ts src\\build-manifest.test.ts src\\clis\\notebooklm\\bind-current.test.ts src\\clis\\notebooklm\\binding.test.ts src\\clis\\notebooklm\\history.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\compat.test.ts src\\clis\\notebooklm\\source-get.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- `node dist/main.js notebooklm status -f json` +- `node dist/main.js notebooklm get -f json` +- `node dist/main.js notebooklm source-list -f json` +- `node dist/main.js notebooklm history -f json` +- `node dist/main.js notebooklm use -f json` +- `node dist/main.js notebooklm metadata -f json` +- `node dist/main.js notebooklm source-get "粘贴的文字" -f json` +- `npx vitest run src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\browser\\page.test.ts --reporter=verbose` +- `npx vitest run src\\registry.test.ts src\\serialization.test.ts src\\commanderAdapter.test.ts src\\build-manifest.test.ts src\\browser\\page.test.ts src\\clis\\notebooklm\\bind-current.test.ts src\\clis\\notebooklm\\binding.test.ts src\\clis\\notebooklm\\history.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\compat.test.ts src\\clis\\notebooklm\\source-get.test.ts --reporter=verbose` +- `node dist/main.js notebooklm source-list -f json` repeated 5 times -> 5/5 `source: "rpc"` +- `node dist/main.js notebooklm history -f json` repeated 8 times -> 8/8 `thread_id` +- `npx vitest run src\\clis\\notebooklm\\summary.test.ts src\\clis\\notebooklm\\notes-get.test.ts --reporter=verbose` +- `npx vitest run src\\browser\\page.test.ts src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\summary.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\history.test.ts src\\clis\\notebooklm\\source-get.test.ts src\\clis\\notebooklm\\compat.test.ts --reporter=verbose` +- `node dist/main.js notebooklm summary -f json` +- `node dist/main.js notebooklm notes-get "新建笔记" -f json` +- `npx vitest run src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\source-fulltext.test.ts --reporter=verbose` +- `npx vitest run src\\browser\\page.test.ts src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\source-get.test.ts src\\clis\\notebooklm\\source-fulltext.test.ts src\\clis\\notebooklm\\summary.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\history.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\compat.test.ts --reporter=verbose` +- `node dist/main.js notebooklm source-fulltext "粘贴的文字" -f json` +- live `tr032e` probe on the current source with params `[[[[source_id]]]]` +- repeated `tr032e` calls before and after clicking the source row -> identical summary and keywords across 6 runs +- `node dist/main.js notebooklm source-list -f json` -> current parser still reports every source as `pasted-text` +- live `rLM1Ne` raw payload dump -> current notebook includes at least one non-`pasted-text` source (`code=9`, YouTube) +- live `tr032e` probe on that YouTube source with params `[[[[source_id]]]]` +- repeated `tr032e` calls before and after clicking the YouTube source row -> identical summary / keywords across 6 runs +- `npx vitest run src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\source-guide.test.ts src\\clis\\notebooklm\\source-get.test.ts src\\clis\\notebooklm\\source-fulltext.test.ts --reporter=verbose` +- `node dist/main.js notebooklm source-list -f json` -> live types now render as `pdf`, `web`, `pasted-text`, `youtube` +- `node dist/main.js notebooklm source-guide "黃仁勳最新重磅專訪:AI 代理時代正來...|Jensen Huang: The Era of AI Agents Is Coming..." -f json` +- `npx vitest run src\\clis\\notebooklm\\ask.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx vitest run src\\browser\\page.test.ts src\\clis\\notebooklm\\ask.test.ts src\\clis\\notebooklm\\bind-current.test.ts src\\clis\\notebooklm\\binding.test.ts src\\clis\\notebooklm\\compat.test.ts src\\clis\\notebooklm\\history.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\source-fulltext.test.ts src\\clis\\notebooklm\\source-get.test.ts src\\clis\\notebooklm\\source-guide.test.ts src\\clis\\notebooklm\\summary.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- `node dist/main.js notebooklm ask --prompt "用一句话总结这个 notebook" -f json` +- `npx vitest run src\\commanderAdapter.test.ts src\\completion.test.ts src\\serialization.test.ts src\\build-manifest.test.ts src\\registry.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- `node dist/main.js list -f json` +- `node dist/main.js completion bash` +- `npx vitest run src\\registry.test.ts src\\serialization.test.ts src\\commanderAdapter.test.ts src\\completion.test.ts src\\build-manifest.test.ts src\\clis\\notebooklm\\compat.test.ts src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\language.test.ts src\\clis\\notebooklm\\source-get.test.ts src\\clis\\notebooklm\\source-fulltext.test.ts src\\clis\\notebooklm\\source-guide.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- `node dist/main.js list -f json` +- `node dist/main.js completion bash` +- `node dist/main.js notebooklm source list -f json` +- `node dist/main.js notebooklm notes get "新建笔记" -f json` +- `node dist/main.js notebooklm language get -f json` +- `node dist/main.js notebooklm source-list -f json` +- `node dist/main.js notebooklm language-get -f json` +- `npx vitest run src\\clis\\notebooklm\\notes-save.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- `node .\\dist\\main.js notebooklm notes-save -f json` +- live只读 probe: + - visible editor -> `title="新建笔记"`, `content=""`, `id=null` + - same-title RPC notes -> 2 条,且 `content_length` 都为 `0` + +### Open Items + +- Continue using the shared transport for more commands beyond `list` / `history`. +- `summary` 已落地,当前优先继续观察是否需要更强 RPC fallback,而不是急着逆新 RPC。 +- `notes-get` 当前只保证“当前可见 note editor”读取;后续如果要读任意 note,需要先解决 Studio 列表项稳定展开。 +- `source-fulltext` 已落地,当前更适合单独验证 `source-guide` 的 live RPC 稳定性,而不是进入写命令。 +- `source-guide` 现已落地为当前 notebook 内的读命令;下一步不该顺手扩到写命令。 +- `source-list` 的 type/type_code 解析偏差已修正,当前 live notebook 的 source 类型输出与 RPC metadata 对齐。 +- 暂不单独补 `notebook-get`,避免和 `get` / `metadata` / `current` 制造命令噪音。 +- `tr032e` 的 live payload 现在已跨 type 验证过,并已经进入 `source-guide` 命令实现。 +- Keep `generate/*`, `download/*`, `artifact/*`, and command-tree refactors out of scope for now. +- Three-level command tree support is now in active NotebookLM use for the first remount slice; additional notebook/share/write-adjacent commands are still pending remount. +- The first NotebookLM remount wave is complete, but notebook-level and share/write-adjacent commands still remain on flat names. +- `ask` 当前仍有明确边界: + - 只支持当前绑定 notebook + - 每次调用都生成新的 conversation UUID,不做多轮延续 + - 不返回 citations / references + - 不做流式输出 + - 依赖 RPC source ids,因此页面退化到只有 DOM source 标题时无法继续 ask +- 本轮按顺序补完了 5 组相邻能力: + - `source-add-text` + - `source-add-url` + - `notes-save` + - `share-status` + - `language-list` / `language-get` / `language-set` +- `source-add-text` 已按红测 -> 实现 -> 绿测收口,并完成 live: + - `node .\\dist\\main.js notebooklm source-add-text "opencli source-add-text smoke 2026-03-31" "smoke validation from opencli on 2026-03-31" -f json` +- `source-add-url` 已按红测 -> 实现 -> 绿测收口,并完成 live: + - `node .\\dist\\main.js notebooklm source-add-url "https://example.com/" -f json` +- `notes-save` 已按红测 -> 实现 -> 绿测收口,但 live 当前受页面状态阻塞: + - `node .\\dist\\main.js notebooklm notes-save -f json` + - 当前会明确报 `NOTEBOOKLM_NOTE_AMBIGUOUS` + - 新的剩余阻塞已缩小为:同一 notebook 中有两条 `新建笔记`,且两条正文都为空,同时 visible editor 周围没有可解析的 stable id +- `share-status` 已按红测 -> 实现 -> 绿测收口,并完成 live: + - `node .\\dist\\main.js notebooklm share-status -f json` +- `language-list` / `language-get` / `language-set` 已按红测 -> 实现 -> 绿测收口,并完成 live: + - `node .\\dist\\main.js notebooklm language-list -f json` + - `node .\\dist\\main.js notebooklm language-get -f json` + - `node .\\dist\\main.js notebooklm language-set zh_Hans -f json` +- 这轮继续只收 `notes-save`: + - 已确认 artifact list 的 note 项会暴露 `note-labels-<uuid>`,但 editor 打开后这组节点会消失 + - `notes-save` 现在优先尝试 editor 周围 DOM hint stable id;拿不到时再按 `title + content` 精确匹配 RPC note list + - 当前 live 的新剩余阻塞不是“标题重复”本身,而是“标题和正文都重复,同时 editor 周围没有 stable id” +- 这轮只做了一个更窄的补强: + - `notes-get` 增加 `--note-id` + - `notes-save` 增加 `--note-id` + - `note-list` / `notes list` 输出显式带 `id` +- 行为变化: + - `notes-get --note-id <id>` 直接按 RPC id 命中,不再依赖标题匹配 + - `notes-save --note-id <id>` 直接把当前 visible editor 的 title/content 保存到该 id,不再尝试标题消歧 + - 当显式 id 不存在、显式 id 与当前 visible editor 暴露的稳定 id 不一致、或当前页没有 visible editor 时,都会给出更明确的错误 +- 这轮验证结果: + - `npx vitest run src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\notes-save.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` + - `npx tsc --noEmit` + - `npm run build` +- 这轮 live 只跑到: + - `node .\\dist\\main.js notebooklm notes list -f json` + - 当前返回 no data,所以没有继续基于真实 id 运行 `notes get --note-id ...` / `notes-save --note-id ...` + - 因此这轮 live 结论是:实现和测试已收口,但真实 notebook 页上的 `--note-id` smoke 还缺一个可读的 note-list 结果作为入口 +- 这轮 generate 只做重新验证和错误分类增强: + - 先确认 browser bridge 初始停在 NotebookLM home,无法直接做 live generate + - 用 daemon `navigate` 把 workspace tab 切回 rich notebook: + - `a45591ed-37bd-4038-a131-141a295c024b` + - live 重新验证: + - `generate report` -> `in_progress` + - `generate report --wait` -> `completed` + - `generate audio` -> 立即 `failed` + - `generate slide-deck` -> 立即 `failed` + - 额外只读 probe 抓到 raw `R7cb6c` 响应: + - audio / slide-deck 仍然都是 `UserDisplayableError` + - 但这次 raw body 不再带可读 message + - 当前页面也没复现旧账号上的“每日额度/升级”提示 +- 因此这轮实现没有改 payload builder,只做了最小错误分类增强: + - `src/clis/notebooklm/rpc.ts` + - 增加 `UserDisplayableError` envelope 提取 + - `src/clis/notebooklm/utils.ts` + - 增加 generate failure message 分类 + - 增加从 raw RPC body 提取 `error_type/message` + - generate report/audio/slide-deck row 现在会带 `error_type` / `message` + - `src/clis/notebooklm/shared.ts` + - `NotebooklmGenerateRow` 新增 `error_type` / `message` +- 这轮分类规则已覆盖: + - `daily_limit_reached` + - `feature_not_eligible` + - `content_insufficient` + - `generation_failed_unknown` +- 当前新账号 live 结果: + - report 正常 + - audio / slide-deck 仍失败 + - 由于服务端没有返回可读 message,当前实际落到: + - `error_type: "generation_failed_unknown"` + - `message: null` +- 这轮验证结果: + - `npx vitest run src\\clis\\notebooklm\\rpc.test.ts src\\clis\\notebooklm\\utils.test.ts src\\clis\\notebooklm\\generate-audio.test.ts src\\clis\\notebooklm\\generate-slide-deck.test.ts src\\clis\\notebooklm\\generate-report.test.ts --reporter=verbose` + - `npx tsc --noEmit` + - `npm run build` + - `node dist/main.js notebooklm generate report -f json` + - `node dist/main.js notebooklm generate audio -f json` + - `node dist/main.js notebooklm generate slide-deck -f json` + - `node dist/main.js notebooklm generate report --wait -f json` + +## 2026-03-31 Notebook Light-Write CRUD + +- 完成 `create` + - 新增 `src/clis/notebooklm/create.ts` + - 新增 `src/clis/notebooklm/create.test.ts` + - utils 补 `CCqFvf` create params 和 helper + - live: 创建成功,返回真实 notebook id +- 完成 `rename` + - 新增 `src/clis/notebooklm/rename.ts` + - 新增 `src/clis/notebooklm/rename.test.ts` + - utils 补 `s0tc2d` rename params、helper,以及按 id 回读 notebook detail + - live: `b0aab2fa-ec5f-4fd1-b0d8-55047e46ab2c` 已成功改名 +- 完成 `delete` + - 新增 `src/clis/notebooklm/delete.ts` + - 新增 `src/clis/notebooklm/delete.test.ts` + - utils 补 `WWINqb` delete params 和 helper + - live: 临时 notebook `93ff083f-02af-4d93-a6c8-75f7aed403e7` 已成功删除 +- 完成 `describe` + - 新增 `src/clis/notebooklm/describe.ts` + - 新增 `src/clis/notebooklm/describe.test.ts` + - utils 补 `VfAZjd` parser 和 helper + - 真实收口为:先试结构化 describe RPC,再回退到稳定的 summary wrapper + - live: `a45591ed-37bd-4038-a131-141a295c024b` 返回 summary 成功,当前来源是 `summary-dom` +- 完成 `remove-from-recent` + - 新增 `src/clis/notebooklm/remove-from-recent.ts` + - 新增 `src/clis/notebooklm/remove-from-recent.test.ts` + - utils 补 `fejl7e` params 和 helper + - live: `b0aab2fa-ec5f-4fd1-b0d8-55047e46ab2c` 已成功移出 recent + +- 本轮每项均按顺序执行: + - 先补 failing tests + - 再实现 + - 再跑相关 vitest + - 再跑 `npx tsc --noEmit` + - 再跑 `npm run build` +- 本轮未触碰: + - `notes-get.ts` + - `notes-save.ts` + - `note-list.ts` + - notes 相关测试 + - command tree / commander / registry / completion / build-manifest 框架层 + - `generate/*` / `artifact/*` / `download/*` / `research/*` / `share/*` + +## 2026-03-31 Notebook Download Direction Recon + Minimal Report Download + +- 按用户要求只侦察 3 条下载链路,不顺手扩 `generate/*` / `artifact/*`: + - `download audio` + - `download report` + - `download slide-deck` +- 对照原仓库 `notebooklm-cdp-cli` 与上游 `notebooklm-py` 后确认: + - `report` 下载不是 export,也不是外部 URL 下载,而是直接从 `gArtLc` raw artifact slot `7` 取 markdown + - `slide-deck` 下载走 `gArtLc` raw artifact slot `16`: + - `[3]` = PDF URL + - `[4]` = PPTX URL + - `audio` 下载走 `gArtLc` raw artifact slot `6[5]` media list,优先 `audio/mp4` +- opencli live `gArtLc` probe 结果: + - notebook `edb0738b-bca1-416c-90f8-c97ca5e22c3f` 已确认存在 completed report artifact + - notebook `a45591ed-37bd-4038-a131-141a295c024b` / `ffd3074a-5734-4114-be51-5e57e0985321` 已确认存在 completed slide deck artifacts,且 raw 中直接带 PDF/PPTX URL + - notebook `9924e90f-5d14-4cc1-bd5b-7cf702f76d95` 已确认存在 completed audio artifact,slot `6[5]` 含多条 media URL +- 结论: + - 第一优先级是 `download/report` + - 它不需要先做 `artifact/list|get|export` 公开命令 + - 只要内部 helper 能调用 `gArtLc` 并选中 completed report 即可 +- 因为链路已经足够清晰,这轮直接落了一个最小命令而不是只停在调研: + - 新增 `src/clis/notebooklm/download-report.ts` + - 新增 `src/clis/notebooklm/download-report.test.ts` + - `shared.ts` 新增 `NotebooklmReportDownloadRow` + - `utils.ts` 新增最小 artifact raw helpers: + - `parseNotebooklmArtifactListResult` + - `selectNotebooklmCompletedArtifact` + - `extractNotebooklmReportMarkdown` + - `listNotebooklmArtifactsViaRpc` + - `downloadNotebooklmReportViaRpc` +- 当前命令形态: + - canonical: `notebooklm download report <output_path>` + - alias: `notebooklm download-report <output_path>` + - optional: `--artifact-id <id>` +- 这轮实现刻意没做: + - `artifact/list` + - `artifact/get` + - `artifact/export` + - `download/audio` + - `download/slide-deck` + +### Verification + +- `npx vitest run src\\clis\\notebooklm\\download-report.test.ts src\\clis\\notebooklm\\utils.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- live raw probe: + - `gArtLc` scan across current notebooks to confirm real `report` / `slide_deck` / `audio` artifact rows and slots +- live command-function smoke in one browser session: + - navigated to `https://notebooklm.google.com/notebook/edb0738b-bca1-416c-90f8-c97ca5e22c3f` + - executed `notebooklm/download/report` with `--artifact-id 31cd719e-2095-4eef-b4f5-ad55e64ddfc0` + - wrote `E:\\web\\opencli\\tmp\\notebooklm-report-smoke.md` +- live dist CLI smoke: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm download report 'E:\\web\\opencli\\tmp\\notebooklm-report-cli-smoke.md' --artifact-id 31cd719e-2095-4eef-b4f5-ad55e64ddfc0 -f json` +- 一个中间排障发现: + - 首次 CLI smoke 返回 no data,不是 report parser 坏了 + - 原因是 Browser Bridge 工作区漂移回另一个 notebook,导致当前页没有 report artifacts + - 在把工作区重新落到目标 notebook 后,dist CLI smoke 成功 + +## 2026-03-31 Notebook Minimal Slide-Deck Download + +- 这轮范围保持收敛,只做 `notebooklm download slide-deck`: + - 不碰 `download/audio` + - 不补 `artifact/*` + - 不扩 `generate/*` + - 不碰 notes 线、notebook CRUD、command tree +- 先做最小取证后确认: + - 上游 CLI 已暴露 `download slide-deck` + - raw artifact 仍走 `gArtLc` + - slide-deck 过滤条件仍是 `type=8` + `status=3` + - `slot 16[3]` = PDF URL + - `slot 16[4]` = PPTX URL +- 按 TDD 顺序落地: + - 先新增失败测试: + - `src/clis/notebooklm/download-slide-deck.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 再新增实现: + - `src/clis/notebooklm/download-slide-deck.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` +- 实际实现链路: + - `downloadNotebooklmSlideDeckViaRpc(...)` + - `gArtLc` raw list -> select completed slide-deck artifact -> extract slot `16` URL + - 复用 opencli `httpDownload(...)` + - browser cookies 通过 `page.getCookies({ url })` 转成 `Cookie` header + - `Referer` 维持当前 notebook URL +- 命令形态: + - canonical: `notebooklm download slide-deck <output_path>` + - alias: `notebooklm download-slide-deck <output_path>` + - optional: + - `--artifact-id <id>` + - `--output-format pdf|pptx` +- 一个实现期排障发现: + - 不能直接声明 `--format` + - 因为 opencli 全局已占用 `-f/--format` 作为输出格式 + - 所以命令内下载格式参数最终定为 `--output-format` + +### Verification + +- failing tests: + - `npx vitest run src\\clis\\notebooklm\\download-slide-deck.test.ts src\\clis\\notebooklm\\utils.test.ts` + - 初次失败点: + - 缺 `download-slide-deck.ts` + - 缺 slide-deck URL extraction helper +- green tests: + - `npx vitest run src\\clis\\notebooklm\\download-slide-deck.test.ts src\\clis\\notebooklm\\utils.test.ts` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm download slide-deck "E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pdf" --artifact-id 05b1fc1a-c2ba-48b1-b933-027468fc4e16 -f json` + - `Get-Item "E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pdf" | Select-Object FullName,Length,LastWriteTime` + - `node dist/main.js notebooklm download slide-deck "E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pptx" --artifact-id 05b1fc1a-c2ba-48b1-b933-027468fc4e16 --output-format pptx -f json` + - `Get-Item "E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pptx" | Select-Object FullName,Length,LastWriteTime` +- live 结果: + - 当前 browser workspace 已绑定 notebook `edb0738b-bca1-416c-90f8-c97ca5e22c3f` + - slide-deck artifact `05b1fc1a-c2ba-48b1-b933-027468fc4e16` 已成功下载: + - PDF -> `E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pdf` (`16083362` bytes) + - PPTX -> `E:\\web\\opencli\\tmp\\notebooklm-slide-deck-cli-smoke.pptx` (`18182535` bytes) + +## 2026-03-31 Notebook Minimal Audio Download + +- 这轮范围保持收敛,只做 `notebooklm download audio`: + - 不碰 `artifact/*` + - 不做 `download video` + - 不扩 `generate/*` + - 不碰 notes 线、notebook CRUD、command tree +- 先做最小取证后确认: + - 上游 helper 明确从 `audio_art[6][5]` 取 media variants + - 选择规则是: + - 优先第一个 `audio/mp4` + - 否则回退第一个 variant URL + - live raw probe 在 notebook `9924e90f-5d14-4cc1-bd5b-7cf702f76d95` 上再次确认: + - artifact `d7ca8b50-1aaa-49c5-a96e-600b8f6d22d0` + - `variants` 同时包含 direct `audio/mp4`、HLS、DASH +- 按 TDD 顺序落地: + - 先新增失败测试: + - `src/clis/notebooklm/download-audio.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 再新增实现: + - `src/clis/notebooklm/download-audio.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` +- 实际实现链路: + - `downloadNotebooklmAudioViaRpc(...)` + - `gArtLc` raw list -> select completed audio artifact -> extract `row[6][5]` variant + - variant 选择保持与上游一致:优先首个 `audio/mp4` + - 复用 opencli `httpDownload(...)` + - browser cookies 通过 `page.getCookies({ url })` 转成 `Cookie` header + - `Referer` 维持当前 notebook URL +- 命令形态: + - canonical: `notebooklm download audio <output_path>` + - alias: `notebooklm download-audio <output_path>` + - optional: + - `--artifact-id <id>` + +### Verification + +- failing tests: + - `npx vitest run src\\clis\\notebooklm\\download-audio.test.ts src\\clis\\notebooklm\\utils.test.ts` + - 初次失败点: + - 缺 `download-audio.ts` + - 缺 audio variant extraction helper +- green tests: + - `npx vitest run src\\clis\\notebooklm\\download-audio.test.ts src\\clis\\notebooklm\\utils.test.ts` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm download audio "E:\\web\\opencli\\tmp\\notebooklm-audio-cli-smoke.m4a" --artifact-id d7ca8b50-1aaa-49c5-a96e-600b8f6d22d0 -f json` + - `Get-Item "E:\\web\\opencli\\tmp\\notebooklm-audio-cli-smoke.m4a" | Select-Object FullName,Length,LastWriteTime` +- live 结果: + - 当前 browser workspace 已绑定 notebook `9924e90f-5d14-4cc1-bd5b-7cf702f76d95` + - audio artifact `d7ca8b50-1aaa-49c5-a96e-600b8f6d22d0` 已成功下载 + - 输出文件: + - `E:\\web\\opencli\\tmp\\notebooklm-audio-cli-smoke.m4a` + - `1211234` bytes + +## 2026-03-31 Notes List Live No-Data Follow-Up + +- 这轮只做 `notebooklm notes list` 的 live `no data` 调查与最小修复。 +- 先重放 live 失败: + - `node .\\dist\\main.js notebooklm notes list -f json` -> `no data` +- 取证发现: + - 当时浏览器里没有可绑定的 `/notebook/...` tab + - `notebooklm use -f json` 报 `No visible tab matching notebooklm.google.com /notebook/` + - 因而这次失败首先是页面状态问题,不是直接证明 selector 漂移 +- 继续在真实 notebook 页 `a45591ed-37bd-4038-a131-141a295c024b` 上验证: + - `artifact-library-note` 仍存在 + - DOM note list 可解析 2 条 + - `cFji9` RPC 也可解析 2 条 +- 最小代码改动: + - `src/clis/notebooklm/note-list.ts` + - `src/clis/notebooklm/note-list.test.ts` + - 行为改为 `DOM first -> RPC fallback` +- 红绿过程: + - 新增失败测试:DOM 为空时应回退到 RPC + - 实现后目标测试转绿 +- 本轮验证: + - `npx vitest run src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\notes-save.test.ts --reporter=verbose` + - `npx tsc --noEmit` + - `npm run build` + - `node .\\dist\\main.js notebooklm notes list -f json` +- 当前 live 结果: + - 在真实 notebook 页上,`notes list` 已返回 2 条 note + - 未扩任何 notes 写命令 + +## 2026-03-31 Notes Light-Write CRUD + +- 本轮范围只限 notes 轻写 CRUD: + - `notes/create` + - `notes/rename` + - `notes/delete` +- `notes/create` + - 新增 `src/clis/notebooklm/notes-create.ts` + - 新增 `src/clis/notebooklm/notes-create.test.ts` + - `utils.ts` 新增: + - `buildNotebooklmCreateNoteParams` + - `createNotebooklmNoteViaRpc` + - 实现链路:`CYK0Xb` create -> `cYAfTb` update +- `notes/rename` + - 新增 `src/clis/notebooklm/notes-rename.ts` + - 新增 `src/clis/notebooklm/notes-rename.test.ts` + - `utils.ts` 新增 `renameNotebooklmNoteViaRpc` + - CLI 形状收口为: + - `notebooklm notes rename --note-id <id> <new-title>` + - 可兼容 `--note <old-title> <new-title>` + - 不再把旧标题放成 positional,避免 `--note-id` 主路径不可调用 +- `notes/delete` + - 新增 `src/clis/notebooklm/notes-delete.ts` + - 新增 `src/clis/notebooklm/notes-delete.test.ts` + - `shared.ts` 新增 `NotebooklmNoteDeleteRow` + - `utils.ts` 新增: + - `buildNotebooklmDeleteNoteParams` + - `deleteNotebooklmNoteViaRpc` +- 这轮选择策略保持克制: + - `rename` / `delete` 优先 `--note-id` + - 不带 `--note-id` 时,只做“唯一精确标题命中” + - 重复标题时直接要求用户提供 `--note-id` + +### Verification + +- `npx vitest run src\\clis\\notebooklm\\notes-create.test.ts --reporter=verbose` +- `npx vitest run src\\clis\\notebooklm\\notes-rename.test.ts --reporter=verbose` +- `npx vitest run src\\clis\\notebooklm\\notes-delete.test.ts --reporter=verbose` +- `npx vitest run src\\clis\\notebooklm\\note-list.test.ts src\\clis\\notebooklm\\notes-get.test.ts src\\clis\\notebooklm\\notes-save.test.ts src\\clis\\notebooklm\\notes-create.test.ts src\\clis\\notebooklm\\notes-rename.test.ts src\\clis\\notebooklm\\notes-delete.test.ts --reporter=verbose` +- `npx tsc --noEmit` +- `npm run build` +- live notebook prep: + - navigated browser workspace `site:notebooklm` to `https://notebooklm.google.com/notebook/a45591ed-37bd-4038-a131-141a295c024b` +- live create / rename / delete chain: + - `node .\\dist\\main.js notebooklm notes create "opencli notes create smoke 2026-03-31" "notes create body 2026-03-31" -f json` + - created note id: `dc55301a-fae4-4cd8-97a2-46f97b7ec732` + - `node .\\dist\\main.js notebooklm notes rename --note-id dc55301a-fae4-4cd8-97a2-46f97b7ec732 "opencli notes renamed smoke 2026-03-31" -f json` + - `node .\\dist\\main.js notebooklm notes delete --note-id dc55301a-fae4-4cd8-97a2-46f97b7ec732 -f json` + +## 2026-03-31 Source Management Medium-Complexity Commands + +- 本轮范围只限: + - `source/rename` + - `source/delete` + - `source/refresh` + - `source/check-freshness` +- 新增命令: + - `src/clis/notebooklm/source-rename.ts` + - `src/clis/notebooklm/source-delete.ts` + - `src/clis/notebooklm/source-refresh.ts` + - `src/clis/notebooklm/source-check-freshness.ts` +- 新增测试: + - `src/clis/notebooklm/source-rename.test.ts` + - `src/clis/notebooklm/source-delete.test.ts` + - `src/clis/notebooklm/source-refresh.test.ts` + - `src/clis/notebooklm/source-check-freshness.test.ts` +- `shared.ts` 新增: + - `NotebooklmSourceDeleteRow` + - `NotebooklmSourceRefreshRow` + - `NotebooklmSourceFreshnessRow` +- `utils.ts` 新增: + - `buildNotebooklmRenameSourceParams` + - `renameNotebooklmSourceViaRpc` + - `deleteNotebooklmSourceViaRpc` + - `refreshNotebooklmSourceViaRpc` + - `parseNotebooklmSourceFreshnessResult` + - `checkNotebooklmSourceFreshnessViaRpc` +- 当前实现边界: + - 全部仅作用于当前 notebook + - 写命令和 freshness 检查只接受 `--source-id` 或唯一精确标题 + - 不支持 partial title 模糊命中 + +### Verification + +- item tests: + - `npx vitest run src/clis/notebooklm/source-rename.test.ts --reporter=verbose` + - `npx vitest run src/clis/notebooklm/source-delete.test.ts --reporter=verbose` + - `npx vitest run src/clis/notebooklm/source-refresh.test.ts --reporter=verbose` + - `npx vitest run src/clis/notebooklm/source-check-freshness.test.ts --reporter=verbose` +- aggregate: + - `npx vitest run src/clis/notebooklm/source-rename.test.ts src/clis/notebooklm/source-delete.test.ts src/clis/notebooklm/source-refresh.test.ts src/clis/notebooklm/source-check-freshness.test.ts src/clis/notebooklm/utils.test.ts --reporter=verbose` + - result: `5 files, 52 tests passed` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live: + - `node dist/main.js notebooklm source list -f json` + - `node dist/main.js notebooklm source-add-text "opencli source rename smoke 2026-03-31" "source rename delete smoke body 2026-03-31" -f json` + - created source id: `e234071d-b9f3-4d13-a126-51f97f42a194` + - `node dist/main.js notebooklm source rename --source-id e234071d-b9f3-4d13-a126-51f97f42a194 "opencli source renamed smoke 2026-03-31" -f json` + - `node dist/main.js notebooklm source delete --source-id e234071d-b9f3-4d13-a126-51f97f42a194 -f json` + - `node dist/main.js notebooklm source-add-url "https://example.com/?opencli-source-smoke=2026-03-31" -f json` + - hit runtime error: `Detached while handling command` + - `node dist/main.js notebooklm source check-freshness --source-id <id> -f json` + - `node dist/main.js notebooklm source refresh --source-id <id> -f json` + - both currently blocked when NotebookLM browser binding drifts to `?addSource=true` page / wrong notebook + +## 2026-03-31 Source Runtime Stability + +- 本轮只做 source 管理命令运行态稳定性,不新增命令。 +- 代码改动: + - `src/clis/notebooklm/utils.ts` + - 新增 canonical notebook URL 回正逻辑 + - `ensureNotebooklmNotebookBinding(...)` 改为先读真实页面状态,再决定是否 rebinding + - bind 后补一次 canonicalize + - `src/browser/page.ts` + - `Page.evaluate(...)` 现在会对 `Detached while handling command.` 做一次最小重试 +- 测试改动: + - `src/clis/notebooklm/binding.test.ts` + - 覆盖 stale currentUrl + real notebook/addSource + - 覆盖 bind-current 后 canonicalize + - `src/browser/page.test.ts` + - 覆盖 detached retry + +### Verification + +- targeted tests: + - `npx vitest run src/clis/notebooklm/binding.test.ts --reporter=verbose` + - `npx vitest run src/browser/page.test.ts --reporter=verbose` +- aggregate: + - `npx vitest run src/clis/notebooklm/binding.test.ts src/clis/notebooklm/source-refresh.test.ts src/clis/notebooklm/source-check-freshness.test.ts src/browser/page.test.ts src/clis/notebooklm/utils.test.ts --reporter=verbose` + - result: `5 files, 59 tests passed` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live prep: + - `node dist/main.js notebooklm status -f json` + - canonical notebook after fix: + - `https://notebooklm.google.com/notebook/6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - `node dist/main.js notebooklm source-add-url "https://example.com/?opencli-source-stability=2026-03-31" -f json` + - created source id: `16e5137e-8108-4df2-a294-8511216697c5` +- repeated live validation: + - round 1: + - `node dist/main.js notebooklm source check-freshness --source-id 16e5137e-8108-4df2-a294-8511216697c5 -f json` + - `node dist/main.js notebooklm source refresh --source-id 16e5137e-8108-4df2-a294-8511216697c5 -f json` + - `node dist/main.js notebooklm status -f json` + - round 2: + - same 3 commands + - round 3: + - same 3 commands + - all 3 rounds stayed on: + - notebook id `6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - canonical URL without `?addSource=true` +- live cleanup: + - `node dist/main.js notebooklm source delete --source-id 16e5137e-8108-4df2-a294-8511216697c5 -f json` + +## 2026-03-31 Notebook Minimal Video Download + +- 这轮范围保持收敛,只做 `notebooklm download video`: + - 不碰 `artifact/*` + - 不扩 `generate/*` + - 不碰 notes/source/notebook 其他线 + - 不碰 command tree / framework +- 先做最小取证后确认: + - upstream `notebooklm-cdp-cli` 的 video helper 仍从 `gArtLc` raw artifact rows 取媒体 URL + - live raw probe 已确认 video artifact type = `3` + - completed video row 的媒体 metadata 位于 `row[8]` + - media variants 位于 `row[8][4]` + - 当前真实样本同时包含: + - direct `video/mp4` + - HLS + - DASH + - 备用 `video/mp4` (`-dv`) +- 当前最短下载路径: + - `gArtLc` raw list -> select completed `type=3` artifact -> extract `row[8][4]` + - 优先第一个 `video/mp4` + - 用现有 `httpDownload(...)` + browser cookie forwarding 落盘 +- 按 TDD 顺序落地: + - 先新增失败测试: + - `src/clis/notebooklm/download-video.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 初次失败点: + - 缺 `download-video.ts` + - 缺 `extractNotebooklmVideoDownloadVariant(...)` + - 再新增实现: + - `src/clis/notebooklm/download-video.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` +- 命令形态: + - canonical: `notebooklm download video <output_path>` + - alias: `notebooklm download-video <output_path>` + - optional: + - `--artifact-id <id>` + +### Verification + +- red -> green tests: + - `npx vitest run src\\clis\\notebooklm\\download-video.test.ts src\\clis\\notebooklm\\utils.test.ts` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live prep: + - navigated browser workspace `site:notebooklm` to `https://notebooklm.google.com/notebook/6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - `node dist/main.js notebooklm current -f json` +- live: + - `node dist/main.js notebooklm download video "E:\\web\\opencli\\tmp\\notebooklm-video-cli-smoke.mp4" --artifact-id 82115e07-8602-4047-8b17-a1773c4fdcde -f json` + - `Get-Item "E:\\web\\opencli\\tmp\\notebooklm-video-cli-smoke.mp4" | Select-Object FullName,Length,LastWriteTime` +- live 结果: + - 当前 browser workspace 已绑定 notebook `6fd8aeb5-ddd1-4114-bcda-c376389a8508` + - video artifact `82115e07-8602-4047-8b17-a1773c4fdcde` 已成功下载 + - 输出文件: + - `E:\\web\\opencli\\tmp\\notebooklm-video-cli-smoke.mp4` + - `1212240` bytes + +## 2026-03-31 Notebook Minimal Download Index + +- 这轮范围保持收敛,只做一个最小下载索引命令: + - 不新增新的具体下载类型 + - 不碰 `artifact/*` + - 不扩 `generate/*` + - 不碰 notes/source/notebook 其他线 + - 不碰 command tree / framework +- 命令名最终选择: + - canonical: `notebooklm download list` + - alias: `notebooklm download-list` +- 实现目标收口为: + - 基于现有 `gArtLc` raw rows + - 只列出现有 download 命令真正需要的索引信息 + - 不展开完整 artifact payload +- 按 TDD 顺序落地: + - 先新增失败测试: + - `src/clis/notebooklm/download-list.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 初次失败点: + - 缺 `download-list.ts` + - 缺 `parseNotebooklmDownloadListRows(...)` + - 再新增实现: + - `src/clis/notebooklm/download-list.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` +- 当前索引输出字段: + - `artifact_id` + - `artifact_type` + - `status` + - `title` + - `created_at` + - `download_variants` + - `source` +- 当前索引范围只覆盖已支持的 downloadable types: + - `report` + - `audio` + - `video` + - `slide_deck` + +### Verification + +- red -> green tests: + - `npx vitest run src\\clis\\notebooklm\\download-list.test.ts src\\clis\\notebooklm\\utils.test.ts` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm download list -f json` +- live 结果: + - 当前 browser workspace 已绑定 notebook `6fd8aeb5-ddd1-4114-bcda-c376389a8508` +- `download list` 返回: + - `video` artifact 1 条 + - `slide_deck` artifact 2 条 + - `report` artifact 1 条 +- 当前 slide-deck 样本里 `download_variants` 只显示了已稳定可见的 `pdf` + +## 2026-03-31 Notebook Minimal Generate + +- 本轮范围只做最小 `generate/*` 闭环: + - `generate/report` + - `generate/audio` + - `generate/slide-deck` + - 不碰 source ingest / notes / notebook CRUD / share / research / command tree / artifact/* +- 按顺序执行并保持 TDD: + 1. `generate/report` + - 先加失败测试: + - `src/clis/notebooklm/generate-report.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 再加实现: + - `src/clis/notebooklm/generate-report.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` + - report 相关测试转绿后才继续下一项 + 2. `generate/audio` + - 先加失败测试: + - `src/clis/notebooklm/generate-audio.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 再加实现: + - `src/clis/notebooklm/generate-audio.ts` + - `src/clis/notebooklm/utils.ts` + - audio 相关测试转绿后才继续下一项 + 3. `generate/slide-deck` + - 先加失败测试: + - `src/clis/notebooklm/generate-slide-deck.test.ts` + - `src/clis/notebooklm/utils.test.ts` + - 再加实现: + - `src/clis/notebooklm/generate-slide-deck.ts` + - `src/clis/notebooklm/utils.ts` +- 实现收口: + - 统一提交 RPC: + - `R7cb6c` + - 新增最小 payload builder: + - `buildNotebooklmGenerateReportParams(...)` + - `buildNotebooklmGenerateAudioParams(...)` + - `buildNotebooklmGenerateSlideDeckParams(...)` + - 新增最小 generation parser / wait helper: + - `parseNotebooklmGenerationResult(...)` + - `waitForNotebooklmGeneratedArtifactViaRpc(...)` + - 新增 generate helpers: + - `generateNotebooklmReportViaRpc(...)` + - `generateNotebooklmAudioViaRpc(...)` + - `generateNotebooklmSlideDeckViaRpc(...)` + - 新增最小返回行: + - `NotebooklmGenerateRow` + - 字段只含: + - `notebook_id` + - `artifact_id` + - `artifact_type` + - `status` + - `created_at` + - `source` +- 命令面保持最小: + - canonical: + - `notebooklm generate report` + - `notebooklm generate audio` + - `notebooklm generate slide-deck` + - aliases: + - `notebooklm generate-report` + - `notebooklm generate-audio` + - `notebooklm generate-slide-deck` + - 只暴露一个最小开关: + - `--wait` + +### Verification + +- related vitest: + - `npx vitest run src\\clis\\notebooklm\\generate-report.test.ts src\\clis\\notebooklm\\generate-audio.test.ts src\\clis\\notebooklm\\generate-slide-deck.test.ts src\\clis\\notebooklm\\utils.test.ts` + - 结果:`4 files, 61 tests passed` +- type/build: + - `npx tsc --noEmit` + - `npm run build` +- live notebook: + - `node dist/main.js notebooklm current -f json` + - notebook id: `6fd8aeb5-ddd1-4114-bcda-c376389a8508` +- live report: + - `node dist/main.js notebooklm generate report --wait -f json` + - returned artifact: `1d525e55-7334-4407-b435-e4fbdc3f6349` + - closure: + - `node dist/main.js notebooklm download report "E:\\web\\opencli\\tmp\\notebooklm-generate-report-smoke.md" --artifact-id 1d525e55-7334-4407-b435-e4fbdc3f6349 -f json` +- live audio: + - first wait attempt: + - `$env:OPENCLI_BROWSER_COMMAND_TIMEOUT='300'; node dist/main.js notebooklm generate audio --wait -f json` + - result: internal minimal wait timed out after `180` seconds + - minimal submit path: + - `node dist/main.js notebooklm generate audio -f json` + - returned artifact: `7603262d-d1d5-4a75-b266-61d275e293ad` + - `status: "in_progress"` + - follow-up visibility: + - `node dist/main.js notebooklm download list -f json` + - observed new audio artifact `2f81c937-52fa-4b7c-95a0-29884b78ba1a` with media variants +- live slide-deck: + - wait attempts: + - `$env:OPENCLI_BROWSER_COMMAND_TIMEOUT='300'; node dist/main.js notebooklm generate slide-deck --wait -f json` + - internal minimal wait timed out after `180` seconds + - follow-up visibility: + - `node dist/main.js notebooklm download list -f json` + - observed new slide-deck artifacts: + - `978ef654-a702-46b9-bdba-231253f1c3a6` + - `9e4b5582-1b63-482b-ba9f-0223241377c9` + - closure: + - `node dist/main.js notebooklm download slide-deck "E:\\web\\opencli\\tmp\\notebooklm-generate-slide-deck-smoke.pdf" --artifact-id 978ef654-a702-46b9-bdba-231253f1c3a6 -f json` + + +## 2026-03-31 Source Ingest Add-File And Wait + +- 范围控制: + - 只做 source ingest 相邻 3 个命令: + - `source/add-file` + - `source/wait-for-sources` + - `source/wait` + - 不碰 notes / notebook CRUD / share / language / download / artifact / generate / framework +- 按顺序推进并保持 TDD: + 1. 先补失败测试: + - `source-add-file.test.ts` + - `source-wait-for-sources.test.ts` + - `source-wait.test.ts` + - `utils.test.ts` + 2. 初次失败点: + - 缺命令文件 + - 缺 add-file params builder + - 缺带 status 的 source parser + - 缺 wait polling helper + 3. 再落实现: + - `src/clis/notebooklm/source-add-file.ts` + - `src/clis/notebooklm/source-wait-for-sources.ts` + - `src/clis/notebooklm/source-wait.ts` + - `src/clis/notebooklm/shared.ts` + - `src/clis/notebooklm/utils.ts` +- 实现收口: + - `add-file` 走: + - `o4cbdc` 注册 file source + - NotebookLM resumable upload start + - upload URL finalize + - `wait-for-sources` / `wait` 共用 `waitForNotebooklmSourcesReadyViaRpc(...)` + - 当前 source row 新增最小 status 字段: + - `status` + - `status_code` +- 中途只修了一个最小类型问题: + - `fetch` 上传流的 `duplex` 声明在当前 TS lib 下报错 + - 改成局部交叉类型声明,不改变实际上传行为 +- 验证: + - related vitest:`8 files, 58 tests passed` + - `npx tsc --noEmit`:通过 + - `npm run build`:通过 + - live: + - add-file 创建 source `6143e8b6-cb0d-4b18-9192-fbcd2abbebc1` + - wait / wait-for-sources 都等到 ready + +## 2026-04-01 Source Add-Drive + +- 范围控制: + - 只做 `source/add-drive` + - 不扩 `add-research` + - 不碰 notes / notebook CRUD / share / generate / download / artifact / framework +- 先取证,再实现: + - 读取了: + - `notebooklm-cdp-cli` 的 `cli.py` / `notebooklm_ops.py` + - `notebooklm-py` 的 `_sources.py` / `cli/source.py` / `rpc/types.py` + - 确认上游最小输入是: + - `file_id` + - `title` + - `mime_type` + - 确认链路是: + - `izAoDd` RPC + - 不需要 DOM 点击 + - 不需要 Drive URL 解析 +- TDD: + 1. 新增失败测试: + - `src/clis/notebooklm/source-add-drive.test.ts` + - `src/clis/notebooklm/utils.test.ts` 中 add-drive builder 用例 + 2. 初次失败点: + - 缺 `source-add-drive.ts` + - 缺 `buildNotebooklmAddDriveParams(...)` + 3. 最小实现: + - `src/clis/notebooklm/source-add-drive.ts` + - `src/clis/notebooklm/utils.ts` +- 当前命令边界: + - positional: + - `<file-id>` + - `<title>` + - optional: + - `--mime-type` + - 默认 MIME: + - `application/vnd.google-apps.document` +- 验证结果: + - targeted vitest: + - `source-add-drive.test.ts` 通过 + - add-drive builder 过滤测试通过 + - source ingest 相关回归测试 `4 files, 4 tests passed` + - `npx tsc --noEmit` / `npm run build`: + - 失败 + - 原因是 repo 里已存在的 infographic 相关缺件,不是 add-drive 本身 + - live: + - 尝试建立 smoke notebook 失败:NotebookLM home RPC 400 + - 当前 browser workspace 停在 home,且没有 visible notebook tab + - 因此本轮未能完成 add-drive live 闭环 + +## 2026-03-31 From-0 Integration Test Summary (9 Modules, Historical Only) + +### Test Environment +- Browser Bridge daemon: port 19825, extension v1.5.5, connected +- 手动 `curl .../navigate` 可将 browser bridge tab 导航到 notebook URL(opencli CLI 内无内置 navigate 命令) +- `bind-current` / `use` 在 browser 无 notebook tab 时失败,需要先手动 navigate +- **关键运行态问题**:每次 CLI 命令执行后,browser bridge tab 会偶发漂回 home 页(约 2-3 条命令后),严重影响 notebook-context 命令连续测试 + +### 测试模块 0:基础环境 — PASS +- `npx tsc --noEmit` → EXIT 0 +- `npm run build` → EXIT 0,475 entries +- `list -f json` → notebooklm 命令被发现 +- `notebooklm --help` → 正常展开 60+ 命令 +- `completion bash` → 正常输出 + +### 测试模块 1:绑定前置条件 — PASS(需手动 navigate) +- `bind-current` → FAIL(browser 无 notebook tab) +- 手动 `curl navigate` → PASS +- `status -f json` → `page: "notebook"`,`url: https://notebooklm.google.com/notebook/6fd8aeb5-...` +- 当前测试 notebook:`6fd8aeb5-ddd1-4114-bcda-c376389a8508`(Electron Debugging 2026) + +### 测试模块 2:Notebook 基础读写 — 大部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `status` | PASS | | +| `current` | PASS | `source: "current-page"` | +| `get` | PASS | `source: "rpc"`,含 source_count | +| `metadata` | PASS | alias of get | +| `describe` | PASS | `source: "summary-dom"`,summary 正文返回 | +| `list` | PASS | RPC | +| `create` | PASS | 创建 notebook | +| `rename` | PASS | 重命名成功 | +| `remove-from-recent` | PASS | | +| `delete` | PASS | | +| `summary` | FAIL | browser drift 导致,需要重新 navigate | +| `history` | FAIL | browser drift 导致,需要重新 navigate | + +### 测试模块 3:Source 读链路 — PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source list` | PASS | 7 sources,`type` 解析正确(pdf/web/audio/pasted-text/youtube),`source: "rpc"` | +| `source get` | PASS | 按 title "粘贴的文字" 匹配 | +| `source fulltext` | PASS | 返回 893 字符 markdown 内容 | +| `source guide` | PASS | 返回 summary + 5 keywords | +| `source-list`(flat)| FAIL | browser drift 导致 | + +### 测试模块 4:Source ingest 与管理 — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source-add-text` | PASS | | +| `source-add-url` | PASS | | +| `source-wait` | PASS | 等到 ready | +| `source-check-freshness` | PASS | `is_fresh: true` | +| `source-rename` | FAIL | browser drift | +| `source-refresh` | FAIL | browser drift | +| `source-delete` | FAIL | browser drift | + +### 测试模块 5:Notes — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `notes list` | PASS(不稳定)| browser drift 导致不稳定 | +| `notes create` | PASS | 创建 note | +| `notes rename` | PASS | | +| `notes delete` | FAIL | browser drift | +| `notes-save` | NOT TESTED | 需要 visible note editor | + +### 测试模块 6:Ask — PASS +`ask --prompt "用一句话总结"` → PASS,返回 answer 正文(中文) + +### 测试模块 7:Generate — PASS +`generate report` → PASS,返回 `artifact_id: b9934a76-53df-47ec-9f24-f990a8da8072` + +### 测试模块 8:Download — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `download list` | PASS | 显示 report/audio/slide_deck,`status` 字段正确 | +| `download report` | PASS | 成功写出 5563 字节 .md 文件,内容正确 | +| `download audio` | FAIL | "fetch failed" — artifact URL 过期(运行态,非代码缺陷) | +| `download slide-deck` | FAIL | "fetch failed" — artifact URL 过期(运行态)| + +### 测试模块 9:兼容层与命令树 — PASS +- `source --help`、`notes --help`、`download --help`、`language --help` → 全部正常 +- `language get` 和 `language-get` → 均 PASS(alias 正常) + +### 历史测试结论(不作为当前统一 PR 判断依据) + +**当时可直接确认的命令:** +1. `status` / `list` — 稳定,RPC +2. `create` / `rename` / `delete` / `remove-from-recent` — 稳定,RPC,闭环验证通过 +3. `current` / `get` / `metadata` / `describe` — 稳定 +4. `source list` / `source get` / `source fulltext` / `source guide` — 稳定,RPC +5. `source-add-text` / `source-add-url` / `source-wait` / `source-check-freshness` — 稳定 +6. `notes create` / `notes rename` — 稳定 +7. `ask` — 稳定,返回 answer +8. `generate report` — 稳定,返回 artifact_id +9. `download list` — 稳定,正确索引 artifacts +10. `download report` — 稳定,成功写出文件 +11. `language-list` / `language-get` / `language-set` — 稳定,RPC +12. 命令树三层结构和帮助文本 — 框架稳定 + +**当时受运行态影响、未纳入判断的命令:** +- `source-rename` / `source-refresh` / `source-delete` — browser drift 导致测试不稳定,需要稳定性修复后再验证 +- `notes delete` / `notes-save` — 同上 +- `download audio` / `download slide-deck` — artifact URL 过期是运行态问题,不是代码缺陷;但需要稳定可用的 artifact 样本才能验证 + +**设计边界确认(不是 bug):** +- `summary` / `history` 偶发 FAIL 是 browser drift,不是代码缺陷 +- `bind-current` 在无 notebook tab 时失败是设计预期,需要先 navigate +- `download audio/slide-deck` 的 "fetch failed" 是 URL 过期,不是实现问题 + +### 历史运行态问题:Browser Drift(仅用于排障记录) +- 每次 CLI 命令执行后,browser bridge CDP session 会偶发漂回 home 页 +- 在连续 2-3 条命令后必然发生 +- 影响所有 notebook-context 命令的连续测试 +- 根因:`navigate` 命令在 daemon 端执行后,下一次 CLI 调用时 CDP session 丢失了 notebook URL 上下文 +- 当前 workaround:每次 FAIL 后重新 `curl navigate`,然后继续测试 +- 建议:这是 daemon/CDP session 管理问题,不是 NotebookLM adapter 代码缺陷 + +## 2026-03-31 Artifact-ID Completion + +### Session Summary + +- Scoped this round strictly to `artifact-id` completion for existing NotebookLM download commands. +- Confirmed the original completion path was static-only and could not return flag-value candidates. +- Added a minimal async completion path: + - `src/completion.ts` + - `src/main.ts` + - `src/registry.ts` +- Added arg-level completion metadata without expanding into a generic id-completion subsystem. +- Reused the existing NotebookLM download index (`gArtLc` -> `listNotebooklmDownloadArtifactsViaRpc`) as the completion data source. +- Added NotebookLM completion helpers in `src/clis/notebooklm/utils.ts`: + - type-filtered candidate picker + - live artifact-id completion probe using `workspace: site:notebooklm` +- Wired `artifact-id` completion into these existing commands only: + - `download/report` + - `download/audio` + - `download/video` + - `download/slide-deck` +- Kept nested and flat alias forms both supported: + - `notebooklm download <type>` + - `notebooklm download-<type>` + +### Tests and Verification + +- Added failing-then-passing tests in: + - `src/completion.test.ts` + - `src/clis/notebooklm/utils.test.ts` +- Verified: + - `npx vitest run src/completion.test.ts src/clis/notebooklm/utils.test.ts` + - `npx tsc --noEmit` + - `npm run build` + - `node dist/main.js completion bash` +- Live completion probes: + - `node dist/main.js notebooklm download list -f json` + - current notebook: `a45591ed-37bd-4038-a131-141a295c024b` + - current downloadable artifact sample: slide deck `ef34f755-809b-4d86-8517-e65049dca2d2` + - `node dist/main.js --get-completions --cursor 6 notebooklm download slide-deck out.pdf --artifact-id` + - returned `ef34f755-809b-4d86-8517-e65049dca2d2` + - `node dist/main.js --get-completions --cursor 5 notebooklm download-slide-deck out.pdf --artifact-id` + - returned `ef34f755-809b-4d86-8517-e65049dca2d2` + - `node dist/main.js --get-completions --cursor 6 notebooklm download report out.md --artifact-id` + - returned empty, matching current notebook having no report artifact + - `node dist/main.js --get-completions --cursor 6 notebooklm download audio out.m4a --artifact-id` + - returned empty, matching current notebook having no audio artifact + - `node dist/main.js --get-completions --cursor 6 notebooklm download video out.mp4 --artifact-id` + - returned empty, matching current notebook having no video artifact + +## 2026-03-31 统一 PR 前验证(第 2 轮) + +> 以下结论是当前统一 PR 的唯一判断口径。上面的 From-0 结果只保留为历史排障记录,不再作为 PR judgment 依据。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(浏览器自动化工具全解析) +- `bind-current` → ✅ +- `status` → ✅ `page: "notebook"` + +### 模块 A:Source ingest 闭环 +| 命令 | 结果 | 备注 | +|------|------|------| +| `source-add-text` | ✅ | RPC,source id 返回 | +| `source wait` | ✅ | `status_code: 2, status: "ready"` | +| `source wait-for-sources` | ✅ | 逗号分隔 ids | +| `source-add-file` | ❌ | `fetch failed`,upload URL 过期(运行态)| + +### 模块 B:Notes 精确操作 +| 命令 | 结果 | 备注 | +|------|------|------| +| `notes list` | ✅ | 3 条,含两条同名"新建笔记" | +| `notes get --note-id` | ✅ | 绕过重复标题歧义 | +| `notes create` | ✅ | RPC,id 返回 | +| `notes delete --note-id` | ✅ | RPC | +| `notes rename --note-id` | ✅ | RPC | +| `notes-save` | ❌ | 无 visible note editor(设计边界)| + +### 模块 C:Generate 最小闭环 +| 命令 | 结果 | 备注 | +|------|------|------| +| `generate report --wait` | ✅ | 完整闭环,9.9K 写出 | +| `generate audio` | ❌ | `failed`,notebook 内容限制 | +| `generate slide-deck` | ❌ | `failed` | +| `generate audio/slide-deck --wait` | ❌ | 超时 | + +### 模块 D:Download 闭环 +| 命令 | 结果 | 备注 | +|------|------|------| +| `download list` | ✅ | 正确索引 | +| `download report` | ✅ | 新 artifact,9.9K 写出 | +| `download slide-deck` | ❌ | URL 过期(运行态)| + +### 模块 E:artifact-id 补全 +| probe | 结果 | +|--------|------| +| `download slide-deck --artifact-id` | ✅ | +| `download report --artifact-id` | ✅ | +| `download audio --artifact-id` | ✅ 空 | +| `download video --artifact-id` | ✅ 空 | +| 平面 alias | ✅ | + +### 统一 PR 建议(当前有效) +**可进入统一 PR:** +- source ingest:`source-add-text/wait/wait-for-sources/delete` +- notes:`list/get --note-id/create/delete --note-id/rename --note-id` +- generate:`report --wait` +- download:`list/report` +- completion:`--artifact-id` 补全 + +**运行态问题(不应阻塞 PR):** +- `source-add-file`:该能力此前已 live 验证通过;最新定向验证中的失败属于 daemon 侧网络/上传会话问题 +- `download slide-deck`:该能力此前已用 fresh artifact live 验证通过;最新定向验证未能建立新 artifact 前置条件 +- `notes-save`:设计边界,需 visible editor + +**仍待独立调查:** +- `generate audio` — 内容丰富 notebook 上仍 `status: failed`;report 成功说明非网络问题;**不排除代码实现问题** +- `generate slide-deck` — 与 audio 同模式 + +**设计边界:** +- signed URL TTL ~1 天 +- `notes-save` 要求 visible editor + +## 2026-03-31 剩余运行态定向验证(第 3 轮) + +> 本轮用于检查剩余边界,不覆盖此前已经完成的 live 成功样本;若与更早的成功验证冲突,以“能力已验证通过,但本轮样本/环境未满足前置条件”记载。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(10 sources,内容丰富) +- 该 notebook 已成功 generate report ✅ +- `bind-current` + `status` ✅ + +### 定向验证结果 + +| 模块 | 结果 | 分类 | +|------|------|------| +| `source-add-file` | ❌ HTTP fetch failed | 本轮受 daemon 网络/上传会话影响;该能力此前已 live 验证通过 | +| `notes-save` | ⚠️ 前置条件未满足 | 设计边界,需 visible editor | +| `generate audio` | ❌ `status: failed` | 内容丰富 notebook 上仍失败;report 成功≠网络问题;**不排除代码实现问题** | +| `generate slide-deck` | ❌ `status: failed` | 与 audio 同根因 | +| `download slide-deck` | ⚠️ 本轮前置条件未满足 | 本轮依赖模块 4;该能力此前已用 fresh artifact live 验证通过 | + +### 关键发现 +- `generate audio/slide-deck` 在该 notebook(10 sources)上失败,report 成功 → 网络正常,R7cb6c 对 report 有效 +- audio/slide-deck 值得独立调查 payload 类型差异或 server-side 限制,但这不推翻当前统一 PR 的 report/download 主路径 +- 建议后续单开 follow-up 调查 audio/slide-deck 的 R7cb6c payload 与 report 的差异 + +## 2026-03-31 Generate Audio / Slide-Deck专项调查 + +### Session Summary + +- Scoped this round strictly to investigation only. +- Re-read the current generate implementation in: + - `src/clis/notebooklm/generate-report.ts` + - `src/clis/notebooklm/generate-audio.ts` + - `src/clis/notebooklm/generate-slide-deck.ts` + - `src/clis/notebooklm/utils.ts` + - `src/clis/notebooklm/shared.ts` +- Compared opencli payload builders against upstream: + - `E:\\web\\notebooklm-cdp-cli\\src\\notebooklm_cdp_cli\\notebooklm_ops.py` + - `E:\\web\\tools\\notebooklm-py\\src\\notebooklm\\_artifacts.py` + - `E:\\web\\tools\\notebooklm-py\\src\\notebooklm\\rpc\\types.py` +- Confirmed statically that opencli is not missing the entire audio/slide-deck payload subtrees. +- Reproduced live on the current rich notebook: + - `node dist/main.js notebooklm generate report -f json` + - `node dist/main.js notebooklm generate audio -f json` + - `node dist/main.js notebooklm generate slide-deck -f json` +- Captured live `R7cb6c` request params and raw responses through a one-off read-only probe script that reused the existing browser workspace: + - report returns a normal artifact row + - audio returns `null + UserDisplayableError` + - slide-deck returns `null + UserDisplayableError` +- Ran a narrow parameter probe: + - audio with explicit `length=2` + - audio with explicit `format=1/2` + - slide-deck with explicit `format=1/2, length=1` + - all still failed with the same `UserDisplayableError` +- Inspected the live page snapshot and found the key UI evidence: + - Studio panel currently shows: + - `您已达到每日音频概览和幻灯片数量上限,改日再来吧。 或进行升级。` + +### Outcome + +- Current best explanation is now server-side quota / eligibility gating for audio overview and slide deck. +- Current evidence does not support changing: + - `buildNotebooklmGenerateAudioParams(...)` + - `buildNotebooklmGenerateSlideDeckParams(...)` +- If a follow-up fix is needed, the minimal target should be: + - `src/clis/notebooklm/rpc.ts` + - or `src/clis/notebooklm/utils.ts` + - specifically to classify and surface this quota-style `UserDisplayableError` clearly + +### Verification + +- No product code was changed in this round. +- Investigation commands run: + - `node dist/main.js notebooklm current -f json` + - `node dist/main.js notebooklm generate report -f json` + - `node dist/main.js notebooklm generate audio -f json` + - `node dist/main.js notebooklm generate slide-deck -f json` + - `node dist/main.js notebooklm download list -f json` +- Additional read-only live probes were run via inline Node scripts against the existing browser workspace to capture: + - raw `R7cb6c` params + - raw `R7cb6c` responses + - current Studio-panel snapshot text diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 72bcf87f..ada44261 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -326,7 +326,17 @@ class CDPPage implements IPage { } async getCurrentUrl(): Promise<string | null> { - return this._lastUrl; + if (this._lastUrl) return this._lastUrl; + try { + const current = await this.evaluate('window.location.href'); + if (typeof current === 'string' && current) { + this._lastUrl = current; + return current; + } + } catch { + // Best-effort: direct CDP sessions may not have a ready page yet. + } + return null; } async installInterceptor(pattern: string): Promise<void> { diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 4798cb7e..97e7b782 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -19,7 +19,7 @@ function generateId(): string { export interface DaemonCommand { id: string; - action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input'; + action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'bind-current'; tabId?: number; code?: string; workspace?: string; @@ -27,6 +27,8 @@ export interface DaemonCommand { op?: string; index?: number; domain?: string; + matchDomain?: string; + matchPathPrefix?: string; format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean; @@ -145,3 +147,7 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> { return Array.isArray(result) ? result : []; } +export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> { + return sendCommand('bind-current', { workspace, ...opts }); +} + diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts new file mode 100644 index 00000000..48ae95d7 --- /dev/null +++ b/src/browser/page.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sendCommandMock } = vi.hoisted(() => ({ + sendCommandMock: vi.fn(), +})); + +vi.mock('./daemon-client.js', () => ({ + sendCommand: sendCommandMock, +})); + +import { Page } from './page.js'; + +describe('Page.getCurrentUrl', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + }); + + it('reads the real browser URL when no local navigation cache exists', async () => { + sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live'); + + const page = new Page('site:notebooklm'); + const url = await page.getCurrentUrl(); + + expect(url).toBe('https://notebooklm.google.com/notebook/nb-live'); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ + workspace: 'site:notebooklm', + })); + }); + + it('caches the discovered browser URL for later reads', async () => { + sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live'); + + const page = new Page('site:notebooklm'); + expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live'); + expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live'); + + expect(sendCommandMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('Page.evaluate', () => { + beforeEach(() => { + sendCommandMock.mockReset(); + }); + + it('retries once when the inspected target navigated during exec', async () => { + sendCommandMock + .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}')) + .mockResolvedValueOnce(42); + + const page = new Page('site:notebooklm'); + const value = await page.evaluate('21 + 21'); + + expect(value).toBe(42); + expect(sendCommandMock).toHaveBeenCalledTimes(2); + }); + + it('retries once when the daemon reports a detached target during exec', async () => { + sendCommandMock + .mockRejectedValueOnce(new Error('Detached while handling command.')) + .mockResolvedValueOnce(42); + + const page = new Page('site:notebooklm'); + const value = await page.evaluate('21 + 21'); + + expect(value).toBe(42); + expect(sendCommandMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts index bfbd8aff..4bc84d94 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -33,6 +33,7 @@ import { export function isRetryableSettleError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return message.includes('Inspected target navigated or closed') + || message.includes('Detached while handling command') || (message.includes('-32000') && message.toLowerCase().includes('target')); } @@ -108,7 +109,17 @@ export class Page implements IPage { } async getCurrentUrl(): Promise<string | null> { - return this._lastUrl; + if (this._lastUrl) return this._lastUrl; + try { + const current = await this.evaluate('window.location.href'); + if (typeof current === 'string' && current) { + this._lastUrl = current; + return current; + } + } catch { + // Best-effort: some commands may run before a debuggable tab is ready. + } + return null; } /** Close the automation window in the extension */ @@ -122,7 +133,13 @@ export class Page implements IPage { async evaluate(js: string): Promise<unknown> { const code = wrapForEval(js); - return sendCommand('exec', { code, ...this._cmdOpts() }); + try { + return await sendCommand('exec', { code, ...this._cmdOpts() }); + } catch (err) { + if (!isRetryableSettleError(err)) throw err; + await new Promise((resolve) => setTimeout(resolve, 200)); + return sendCommand('exec', { code, ...this._cmdOpts() }); + } } async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> { diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts index 7f8a6cbe..694507a8 100644 --- a/src/build-manifest.test.ts +++ b/src/build-manifest.test.ts @@ -84,6 +84,7 @@ describe('manifest helper rules', () => { description: 'dynamic command', strategy: Strategy.PUBLIC, browser: false, + aliases: ['metadata'], args: [ { name: 'model', @@ -109,6 +110,7 @@ describe('manifest helper rules', () => { domain: 'localhost', strategy: 'public', browser: false, + aliases: ['metadata'], args: [ { name: 'model', @@ -195,4 +197,31 @@ describe('manifest helper rules', () => { getRegistry().delete(screenKey); getRegistry().delete(statusKey); }); + + it('preserves path-like command names in manifest entries', async () => { + const site = `manifest-nested-${Date.now()}`; + const key = `${site}/source/list`; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-')); + tempDirs.push(dir); + const file = path.join(dir, `${site}.ts`); + fs.writeFileSync(file, `export const nested = cli({ site: '${site}', name: 'source/list' });`); + + const entries = await loadTsManifestEntries(file, site, async () => ({ + nested: cli({ + site, + name: 'source/list', + description: 'nested command', + }), + })); + + expect(entries).toEqual([ + expect.objectContaining({ + site, + name: 'source/list', + modulePath: `${site}/${site}.js`, + }), + ]); + + getRegistry().delete(key); + }); }); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index fa1b2579..6579c324 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -23,6 +23,7 @@ const OUTPUT = path.resolve(__dirname, '..', 'dist', 'cli-manifest.json'); export interface ManifestEntry { site: string; name: string; + aliases?: string[]; description: string; domain?: string; strategy: string; @@ -84,6 +85,7 @@ function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry { return { site: cmd.site, name: cmd.name, + aliases: cmd.aliases, description: cmd.description ?? '', domain: cmd.domain, strategy: (cmd.strategy ?? 'public').toString().toLowerCase(), @@ -119,6 +121,9 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null { domain: cliDef.domain, strategy: strategy.toLowerCase(), browser, + aliases: isRecord(cliDef) && Array.isArray((cliDef as Record<string, unknown>).aliases) + ? ((cliDef as Record<string, unknown>).aliases as unknown[]).filter((value): value is string => typeof value === 'string') + : undefined, args, columns: cliDef.columns, pipeline: cliDef.pipeline, diff --git a/src/cli.ts b/src/cli.ts index fe358144..6e1e355a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; +import { type CliCommand, formatCommandInvocation, fullName, getRegistry, strategyLabel } from './registry.js'; import { serializeCommand, formatArgSummary } from './serialization.js'; import { render as renderOutput } from './output.js'; import { getBrowserFactory, browserSession } from './runtime.js'; @@ -36,7 +36,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { .option('--json', 'JSON output (deprecated)') .action((opts) => { const registry = getRegistry(); - const commands = [...registry.values()].sort((a, b) => fullName(a).localeCompare(fullName(b))); + const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b))); const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format; const isStructured = fmt === 'json' || fmt === 'yaml'; @@ -45,8 +45,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { ? commands.map(serializeCommand) : commands.map(c => ({ command: fullName(c), + invocation: formatCommandInvocation(c), site: c.site, name: c.name, + aliases: c.aliases?.join(', ') ?? '', description: c.description, strategy: strategyLabel(c), browser: !!c.browser, @@ -54,7 +56,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { })); renderOutput(rows, { fmt, - columns: ['command', 'site', 'name', 'description', 'strategy', 'browser', 'args', + columns: ['command', 'invocation', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args', ...(isStructured ? ['columns', 'domain'] : [])], title: 'opencli/list', source: 'opencli list', @@ -80,7 +82,8 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { const tag = label === 'public' ? chalk.green('[public]') : chalk.yellow(`[${label}]`); - console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`); + const aliases = cmd.aliases?.length ? chalk.dim(` (aliases: ${cmd.aliases.join(', ')})`) : ''; + console.log(` ${formatCommandInvocation(cmd).slice(cmd.site.length + 1)} ${tag}${aliases}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`); } console.log(); } diff --git a/src/clis/notebooklm/ask.test.ts b/src/clis/notebooklm/ask.test.ts new file mode 100644 index 00000000..5cbb1cc0 --- /dev/null +++ b/src/clis/notebooklm/ask.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAskNotebooklmQuestionViaQuery, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockAskNotebooklmQuestionViaQuery: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + askNotebooklmQuestionViaQuery: mockAskNotebooklmQuestionViaQuery, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './ask.js'; + +describe('notebooklm ask', () => { + const command = getRegistry().get('notebooklm/ask'); + + beforeEach(() => { + mockAskNotebooklmQuestionViaQuery.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('submits the prompt to the current notebook and returns the answer body', async () => { + mockAskNotebooklmQuestionViaQuery.mockResolvedValue({ + notebook_id: 'nb-demo', + prompt: '用一句话总结这个 notebook', + answer: '这是一个关于 Browser Automation 的 notebook。', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'query-endpoint', + }); + + const result = await command!.func!({} as any, { prompt: '用一句话总结这个 notebook' }); + + expect(mockAskNotebooklmQuestionViaQuery).toHaveBeenCalledWith( + expect.anything(), + '用一句话总结这个 notebook', + ); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + prompt: '用一句话总结这个 notebook', + answer: '这是一个关于 Browser Automation 的 notebook。', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'query-endpoint', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/ask.ts b/src/clis/notebooklm/ask.ts new file mode 100644 index 00000000..aeec062f --- /dev/null +++ b/src/clis/notebooklm/ask.ts @@ -0,0 +1,52 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + askNotebooklmQuestionViaQuery, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'ask', + description: 'Ask the current NotebookLM notebook and return the answer body', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'prompt', + required: true, + help: 'Prompt to ask the current notebook', + }, + ], + columns: ['answer', 'source', 'notebook_id', 'url'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm ask', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const prompt = typeof kwargs.prompt === 'string' ? kwargs.prompt.trim() : String(kwargs.prompt ?? '').trim(); + if (!prompt) { + throw new ArgumentError('The --prompt option cannot be empty.'); + } + + const answer = await askNotebooklmQuestionViaQuery(page, prompt); + if (answer) return [answer]; + + throw new EmptyResultError( + 'opencli notebooklm ask', + 'NotebookLM did not return an answer for the current prompt.', + ); + }, +}); diff --git a/src/clis/notebooklm/bind-current.test.ts b/src/clis/notebooklm/bind-current.test.ts new file mode 100644 index 00000000..a2d66ade --- /dev/null +++ b/src/clis/notebooklm/bind-current.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockBindCurrentTab } = vi.hoisted(() => ({ + mockBindCurrentTab: vi.fn(), +})); + +vi.mock('../../browser/daemon-client.js', () => ({ + bindCurrentTab: mockBindCurrentTab, +})); + +import { getRegistry } from '../../registry.js'; +import './bind-current.js'; + +describe('notebooklm bind-current', () => { + const command = getRegistry().get('notebooklm/bind-current'); + + beforeEach(() => { + mockBindCurrentTab.mockReset(); + }); + + it('binds the current notebook tab into site:notebooklm', async () => { + mockBindCurrentTab.mockResolvedValue({ + workspace: 'site:notebooklm', + tabId: 123, + title: 'Bound Notebook', + url: 'https://notebooklm.google.com/notebook/nb-live', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockBindCurrentTab).toHaveBeenCalledWith('site:notebooklm', { + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }); + expect(result).toEqual([{ + workspace: 'site:notebooklm', + tab_id: 123, + notebook_id: 'nb-live', + title: 'Bound Notebook', + url: 'https://notebooklm.google.com/notebook/nb-live', + }]); + }); +}); diff --git a/src/clis/notebooklm/bind-current.ts b/src/clis/notebooklm/bind-current.ts new file mode 100644 index 00000000..f56bc107 --- /dev/null +++ b/src/clis/notebooklm/bind-current.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '../../registry.js'; +import { bindCurrentTab } from '../../browser/daemon-client.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { parseNotebooklmIdFromUrl } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'bind-current', + aliases: ['use'], + description: 'Bind the current active NotebookLM notebook tab into the site:notebooklm workspace', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['workspace', 'tab_id', 'notebook_id', 'title', 'url'], + func: async () => { + const result = await bindCurrentTab(`site:${NOTEBOOKLM_SITE}`, { + matchDomain: NOTEBOOKLM_DOMAIN, + matchPathPrefix: '/notebook/', + }) as { + tabId?: number; + workspace?: string; + title?: string; + url?: string; + }; + + return [{ + workspace: result.workspace ?? `site:${NOTEBOOKLM_SITE}`, + tab_id: result.tabId ?? null, + notebook_id: result.url ? parseNotebooklmIdFromUrl(result.url) : '', + title: result.title ?? '', + url: result.url ?? '', + }]; + }, +}); diff --git a/src/clis/notebooklm/binding.test.ts b/src/clis/notebooklm/binding.test.ts new file mode 100644 index 00000000..52e05d51 --- /dev/null +++ b/src/clis/notebooklm/binding.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockBindCurrentTab } = vi.hoisted(() => ({ + mockBindCurrentTab: vi.fn(), +})); + +vi.mock('../../browser/daemon-client.js', () => ({ + bindCurrentTab: mockBindCurrentTab, +})); + +import { ensureNotebooklmNotebookBinding } from './utils.js'; + +describe('notebooklm automatic binding', () => { + const originalEndpoint = process.env.OPENCLI_CDP_ENDPOINT; + + beforeEach(() => { + mockBindCurrentTab.mockReset(); + if (originalEndpoint === undefined) delete process.env.OPENCLI_CDP_ENDPOINT; + else process.env.OPENCLI_CDP_ENDPOINT = originalEndpoint; + }); + + it('does nothing when the current page is already a notebook page', async () => { + const page = { + getCurrentUrl: async () => 'https://notebooklm.google.com/notebook/nb-demo', + }; + + await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(false); + expect(mockBindCurrentTab).not.toHaveBeenCalled(); + }); + + it('best-effort binds a notebook page through the browser bridge when currently on home', async () => { + const page = { + getCurrentUrl: async () => 'https://notebooklm.google.com/', + }; + + mockBindCurrentTab.mockResolvedValue({}); + await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(true); + expect(mockBindCurrentTab).toHaveBeenCalledWith('site:notebooklm', { + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }); + }); + + it('skips daemon binding in direct CDP mode', async () => { + process.env.OPENCLI_CDP_ENDPOINT = 'ws://127.0.0.1:9222/devtools/page/1'; + const page = { + getCurrentUrl: async () => 'https://notebooklm.google.com/', + }; + + await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(false); + expect(mockBindCurrentTab).not.toHaveBeenCalled(); + }); + + it('does not rebind to another notebook when the real page is already a notebook add-source url', async () => { + const page = { + getCurrentUrl: async () => 'https://notebooklm.google.com/', + evaluate: vi.fn(async () => ({ + url: 'https://notebooklm.google.com/notebook/nb-demo?addSource=true', + title: 'NotebookLM', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + path: '/notebook/nb-demo', + })), + goto: vi.fn(async () => undefined), + wait: vi.fn(async () => undefined), + }; + + await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(false); + expect(mockBindCurrentTab).not.toHaveBeenCalled(); + expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-demo'); + }); + + it('canonicalizes the bound notebook page after bind-current lands on add-source', async () => { + const page = { + getCurrentUrl: async () => 'https://notebooklm.google.com/', + evaluate: vi.fn() + .mockResolvedValueOnce({ + url: 'https://notebooklm.google.com/', + title: 'NotebookLM', + hostname: 'notebooklm.google.com', + kind: 'home', + notebookId: '', + loginRequired: false, + notebookCount: 1, + path: '/', + }) + .mockResolvedValueOnce({ + url: 'https://notebooklm.google.com/notebook/nb-live?addSource=true', + title: 'NotebookLM', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-live', + loginRequired: false, + notebookCount: 1, + path: '/notebook/nb-live', + }), + goto: vi.fn(async () => undefined), + wait: vi.fn(async () => undefined), + }; + + mockBindCurrentTab.mockResolvedValue({}); + await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(true); + expect(mockBindCurrentTab).toHaveBeenCalledWith('site:notebooklm', { + matchDomain: 'notebooklm.google.com', + matchPathPrefix: '/notebook/', + }); + expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-live'); + }); +}); diff --git a/src/clis/notebooklm/compat.test.ts b/src/clis/notebooklm/compat.test.ts new file mode 100644 index 00000000..68a151ec --- /dev/null +++ b/src/clis/notebooklm/compat.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '../../registry.js'; +import './bind-current.js'; +import './get.js'; +import './language-get.js'; +import './language-list.js'; +import './language-set.js'; +import './note-list.js'; +import './notes-get.js'; +import './source-fulltext.js'; +import './source-get.js'; +import './source-guide.js'; +import './source-list.js'; + +describe('notebooklm compatibility aliases', () => { + it('registers use as a compatibility alias for bind-current', () => { + expect(getRegistry().get('notebooklm/use')).toBe(getRegistry().get('notebooklm/bind-current')); + }); + + it('registers metadata as a compatibility alias for get', () => { + expect(getRegistry().get('notebooklm/metadata')).toBe(getRegistry().get('notebooklm/get')); + }); + + it('registers notes-list as a compatibility alias for note-list', () => { + expect(getRegistry().get('notebooklm/notes-list')).toBe(getRegistry().get('notebooklm/note-list')); + }); + + it('remounts source commands onto nested canonical paths while keeping flat aliases', () => { + expect(getRegistry().get('notebooklm/source/list')).toBe(getRegistry().get('notebooklm/source-list')); + expect(getRegistry().get('notebooklm/source/get')).toBe(getRegistry().get('notebooklm/source-get')); + expect(getRegistry().get('notebooklm/source/fulltext')).toBe(getRegistry().get('notebooklm/source-fulltext')); + expect(getRegistry().get('notebooklm/source/guide')).toBe(getRegistry().get('notebooklm/source-guide')); + }); + + it('remounts note and language commands onto nested canonical paths while keeping flat aliases', () => { + expect(getRegistry().get('notebooklm/notes/list')).toBe(getRegistry().get('notebooklm/note-list')); + expect(getRegistry().get('notebooklm/notes/list')).toBe(getRegistry().get('notebooklm/notes-list')); + expect(getRegistry().get('notebooklm/notes/get')).toBe(getRegistry().get('notebooklm/notes-get')); + expect(getRegistry().get('notebooklm/language/list')).toBe(getRegistry().get('notebooklm/language-list')); + expect(getRegistry().get('notebooklm/language/get')).toBe(getRegistry().get('notebooklm/language-get')); + expect(getRegistry().get('notebooklm/language/set')).toBe(getRegistry().get('notebooklm/language-set')); + }); +}); diff --git a/src/clis/notebooklm/create.test.ts b/src/clis/notebooklm/create.test.ts new file mode 100644 index 00000000..069785aa --- /dev/null +++ b/src/clis/notebooklm/create.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCreateNotebooklmNotebookViaRpc, + mockEnsureNotebooklmHome, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockCreateNotebooklmNotebookViaRpc: vi.fn(), + mockEnsureNotebooklmHome: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + createNotebooklmNotebookViaRpc: mockCreateNotebooklmNotebookViaRpc, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './create.js'; + +describe('notebooklm create', () => { + const command = getRegistry().get('notebooklm/create'); + + beforeEach(() => { + mockCreateNotebooklmNotebookViaRpc.mockReset(); + mockEnsureNotebooklmHome.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmHome.mockResolvedValue(undefined); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + }); + + it('creates a new notebook via rpc and returns the created notebook row', async () => { + mockCreateNotebooklmNotebookViaRpc.mockResolvedValue({ + id: 'nb-created', + title: '新建 Notebook', + url: 'https://notebooklm.google.com/notebook/nb-created', + source: 'rpc', + is_owner: true, + created_at: '2026-03-31T09:12:00.000Z', + updated_at: '2026-03-31T09:12:00.000Z', + emoji: null, + source_count: 0, + }); + + const result = await command!.func!({} as any, { title: '新建 Notebook' }); + + expect(mockCreateNotebooklmNotebookViaRpc).toHaveBeenCalledWith(expect.anything(), '新建 Notebook'); + expect(result).toEqual([ + expect.objectContaining({ + id: 'nb-created', + title: '新建 Notebook', + source: 'rpc', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/create.ts b/src/clis/notebooklm/create.ts new file mode 100644 index 00000000..feff89b6 --- /dev/null +++ b/src/clis/notebooklm/create.ts @@ -0,0 +1,45 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + createNotebooklmNotebookViaRpc, + ensureNotebooklmHome, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'create', + description: 'Create a new NotebookLM notebook with the given title', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'title', + positional: true, + required: true, + help: 'Title for the new notebook', + }, + ], + columns: ['id', 'title', 'created_at', 'updated_at', 'source_count', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await requireNotebooklmSession(page); + await ensureNotebooklmHome(page); + + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : String(kwargs.title ?? '').trim(); + if (!title) { + throw new ArgumentError('The notebook title cannot be empty.'); + } + + const notebook = await createNotebooklmNotebookViaRpc(page, title); + if (notebook) return [notebook]; + + throw new EmptyResultError( + 'opencli notebooklm create', + 'NotebookLM did not return the created notebook row.', + ); + }, +}); diff --git a/src/clis/notebooklm/current.ts b/src/clis/notebooklm/current.ts new file mode 100644 index 00000000..bd930636 --- /dev/null +++ b/src/clis/notebooklm/current.ts @@ -0,0 +1,38 @@ +import { cli, Strategy } from '../../registry.js'; +import { EmptyResultError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { ensureNotebooklmNotebookBinding, getNotebooklmPageState, readCurrentNotebooklm, requireNotebooklmSession } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'current', + description: 'Show metadata for the currently opened NotebookLM notebook tab', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['id', 'title', 'url', 'source'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm current', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const current = await readCurrentNotebooklm(page); + if (!current) { + throw new EmptyResultError( + 'opencli notebooklm current', + 'NotebookLM notebook metadata was not found on the current page.', + ); + } + + return [current]; + }, +}); diff --git a/src/clis/notebooklm/delete.test.ts b/src/clis/notebooklm/delete.test.ts new file mode 100644 index 00000000..fe9480f9 --- /dev/null +++ b/src/clis/notebooklm/delete.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDeleteNotebooklmNotebookViaRpc, + mockEnsureNotebooklmHome, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDeleteNotebooklmNotebookViaRpc: vi.fn(), + mockEnsureNotebooklmHome: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + deleteNotebooklmNotebookViaRpc: mockDeleteNotebooklmNotebookViaRpc, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './delete.js'; + +describe('notebooklm delete', () => { + const command = getRegistry().get('notebooklm/delete'); + + beforeEach(() => { + mockDeleteNotebooklmNotebookViaRpc.mockReset(); + mockEnsureNotebooklmHome.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockDeleteNotebooklmNotebookViaRpc.mockResolvedValue({ + notebook_id: 'nb-delete', + deleted: true, + source: 'rpc', + }); + mockEnsureNotebooklmHome.mockResolvedValue(undefined); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + }); + + it('deletes a notebook via rpc and returns the mutation result', async () => { + const result = await command!.func!({} as any, { notebook_id: 'nb-delete' }); + + expect(mockDeleteNotebooklmNotebookViaRpc).toHaveBeenCalledWith(expect.anything(), 'nb-delete'); + expect(result).toEqual([ + { + notebook_id: 'nb-delete', + deleted: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/delete.ts b/src/clis/notebooklm/delete.ts new file mode 100644 index 00000000..3e9f8454 --- /dev/null +++ b/src/clis/notebooklm/delete.ts @@ -0,0 +1,47 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + deleteNotebooklmNotebookViaRpc, + ensureNotebooklmHome, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'delete', + description: 'Delete a NotebookLM notebook by notebook id', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'notebook_id', + positional: true, + required: true, + help: 'Notebook id to delete', + }, + ], + columns: ['notebook_id', 'deleted', 'source'], + func: async (page: IPage, kwargs) => { + await requireNotebooklmSession(page); + await ensureNotebooklmHome(page); + + const notebookId = typeof kwargs.notebook_id === 'string' + ? kwargs.notebook_id.trim() + : String(kwargs.notebook_id ?? '').trim(); + if (!notebookId) { + throw new ArgumentError('The notebook id cannot be empty.'); + } + + const result = await deleteNotebooklmNotebookViaRpc(page, notebookId); + if (result) return [result]; + + throw new EmptyResultError( + 'opencli notebooklm delete', + 'NotebookLM did not acknowledge the notebook deletion request.', + ); + }, +}); diff --git a/src/clis/notebooklm/describe.test.ts b/src/clis/notebooklm/describe.test.ts new file mode 100644 index 00000000..54a68cef --- /dev/null +++ b/src/clis/notebooklm/describe.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDescribeNotebooklmNotebookViaRpc, + mockEnsureNotebooklmHome, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDescribeNotebooklmNotebookViaRpc: vi.fn(), + mockEnsureNotebooklmHome: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + describeNotebooklmNotebookViaRpc: mockDescribeNotebooklmNotebookViaRpc, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './describe.js'; + +describe('notebooklm describe', () => { + const command = getRegistry().get('notebooklm/describe'); + + beforeEach(() => { + mockDescribeNotebooklmNotebookViaRpc.mockReset(); + mockEnsureNotebooklmHome.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmHome.mockResolvedValue(undefined); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + }); + + it('returns the notebook description and suggested topics via rpc', async () => { + mockDescribeNotebooklmNotebookViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + summary: '这是 notebook 的摘要。', + suggested_topics: [ + { question: '问题一?', prompt: 'Prompt one' }, + ], + suggested_topic_count: 1, + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { notebook_id: 'nb-demo' }); + + expect(mockDescribeNotebooklmNotebookViaRpc).toHaveBeenCalledWith(expect.anything(), 'nb-demo'); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + summary: '这是 notebook 的摘要。', + suggested_topic_count: 1, + source: 'rpc', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/describe.ts b/src/clis/notebooklm/describe.ts new file mode 100644 index 00000000..bdbc61fd --- /dev/null +++ b/src/clis/notebooklm/describe.ts @@ -0,0 +1,47 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + describeNotebooklmNotebookViaRpc, + ensureNotebooklmHome, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'describe', + description: 'Get NotebookLM summary and suggested topics for a notebook id', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'notebook_id', + positional: true, + required: true, + help: 'Notebook id to describe', + }, + ], + columns: ['notebook_id', 'summary', 'suggested_topic_count', 'source', 'url'], + func: async (page: IPage, kwargs) => { + await requireNotebooklmSession(page); + await ensureNotebooklmHome(page); + + const notebookId = typeof kwargs.notebook_id === 'string' + ? kwargs.notebook_id.trim() + : String(kwargs.notebook_id ?? '').trim(); + if (!notebookId) { + throw new ArgumentError('The notebook id cannot be empty.'); + } + + const description = await describeNotebooklmNotebookViaRpc(page, notebookId); + if (description) return [description]; + + throw new EmptyResultError( + 'opencli notebooklm describe', + 'NotebookLM did not return a summary or suggested topics for this notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/download-audio.test.ts b/src/clis/notebooklm/download-audio.test.ts new file mode 100644 index 00000000..c8ef87a7 --- /dev/null +++ b/src/clis/notebooklm/download-audio.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDownloadNotebooklmAudioViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDownloadNotebooklmAudioViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + downloadNotebooklmAudioViaRpc: mockDownloadNotebooklmAudioViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-audio.js'; + +describe('notebooklm download-audio', () => { + const command = getRegistry().get('notebooklm/download/audio'); + + beforeEach(() => { + mockDownloadNotebooklmAudioViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('downloads the latest completed audio artifact when artifact id is omitted', async () => { + mockDownloadNotebooklmAudioViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'audio-2', + artifact_type: 'audio', + title: 'Browser Automation Audio', + output_path: 'E:\\tmp\\browser-automation.m4a', + created_at: '2026-03-31T12:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest-audio-dv', + mime_type: 'audio/mp4', + source: 'rpc+artifact-url', + }); + + const result = await command!.func!({} as any, { output_path: 'E:\\tmp\\browser-automation.m4a' }); + + expect(mockDownloadNotebooklmAudioViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.m4a', + undefined, + ); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'audio-2', + artifact_type: 'audio', + output_path: 'E:\\tmp\\browser-automation.m4a', + }), + ]); + }); + + it('passes --artifact-id through to the audio download helper', async () => { + mockDownloadNotebooklmAudioViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'audio-1', + artifact_type: 'audio', + title: 'Browser Automation Audio', + output_path: 'E:\\tmp\\browser-automation.m4a', + created_at: '2026-03-30T10:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest-audio', + mime_type: 'audio/mp4', + source: 'rpc+artifact-url', + }); + + await command!.func!({} as any, { + output_path: 'E:\\tmp\\browser-automation.m4a', + 'artifact-id': 'audio-1', + }); + + expect(mockDownloadNotebooklmAudioViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.m4a', + 'audio-1', + ); + }); +}); diff --git a/src/clis/notebooklm/download-audio.ts b/src/clis/notebooklm/download-audio.ts new file mode 100644 index 00000000..d87b4e6e --- /dev/null +++ b/src/clis/notebooklm/download-audio.ts @@ -0,0 +1,68 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + completeNotebooklmArtifactIds, + downloadNotebooklmAudioViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/audio', + aliases: ['download-audio'], + description: 'Download one completed NotebookLM audio artifact', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'output_path', + positional: true, + required: true, + help: 'Audio file path to write', + }, + { + name: 'artifact-id', + help: 'Specific completed audio artifact id', + completion: () => completeNotebooklmArtifactIds('audio'), + }, + ], + columns: ['artifact_id', 'artifact_type', 'mime_type', 'created_at', 'output_path', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download audio', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const outputPath = typeof kwargs.output_path === 'string' + ? kwargs.output_path.trim() + : String(kwargs.output_path ?? '').trim(); + if (!outputPath) { + throw new ArgumentError('The audio output path cannot be empty.'); + } + + const artifactId = typeof kwargs['artifact-id'] === 'string' + ? kwargs['artifact-id'].trim() + : ''; + const downloaded = await downloadNotebooklmAudioViaRpc(page, outputPath, artifactId || undefined); + if (downloaded) return [downloaded]; + + throw new EmptyResultError( + 'opencli notebooklm download audio', + artifactId + ? `Completed audio artifact "${artifactId}" was not found in the current notebook.` + : 'No completed audio artifacts were found in the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/download-infographic.test.ts b/src/clis/notebooklm/download-infographic.test.ts new file mode 100644 index 00000000..6104319b --- /dev/null +++ b/src/clis/notebooklm/download-infographic.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDownloadNotebooklmInfographicViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDownloadNotebooklmInfographicViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + downloadNotebooklmInfographicViaRpc: mockDownloadNotebooklmInfographicViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-infographic.js'; + +describe('notebooklm download-infographic', () => { + const command = getRegistry().get('notebooklm/download/infographic'); + + beforeEach(() => { + mockDownloadNotebooklmInfographicViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('downloads the latest completed infographic artifact when artifact id is omitted', async () => { + mockDownloadNotebooklmInfographicViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-2', + artifact_type: 'infographic', + title: 'Browser Automation Infographic', + output_path: 'E:\\tmp\\browser-automation.png', + created_at: '2026-03-31T12:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest.png', + source: 'rpc+artifact-url', + }); + + const result = await command!.func!({} as any, { output_path: 'E:\\tmp\\browser-automation.png' }); + + expect(mockDownloadNotebooklmInfographicViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.png', + undefined, + ); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'infographic-2', + artifact_type: 'infographic', + output_path: 'E:\\tmp\\browser-automation.png', + }), + ]); + }); + + it('passes --artifact-id through to the infographic download helper', async () => { + mockDownloadNotebooklmInfographicViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-1', + artifact_type: 'infographic', + title: 'Browser Automation Infographic', + output_path: 'E:\\tmp\\browser-automation.png', + created_at: '2026-03-30T10:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/specific.png', + source: 'rpc+artifact-url', + }); + + await command!.func!({} as any, { + output_path: 'E:\\tmp\\browser-automation.png', + 'artifact-id': 'infographic-1', + }); + + expect(mockDownloadNotebooklmInfographicViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.png', + 'infographic-1', + ); + }); +}); diff --git a/src/clis/notebooklm/download-infographic.ts b/src/clis/notebooklm/download-infographic.ts new file mode 100644 index 00000000..a132899a --- /dev/null +++ b/src/clis/notebooklm/download-infographic.ts @@ -0,0 +1,68 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + completeNotebooklmArtifactIds, + downloadNotebooklmInfographicViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/infographic', + aliases: ['download-infographic'], + description: 'Download one completed NotebookLM infographic artifact as png', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'output_path', + positional: true, + required: true, + help: 'Infographic file path to write', + }, + { + name: 'artifact-id', + help: 'Specific completed infographic artifact id', + completion: () => completeNotebooklmArtifactIds('infographic'), + }, + ], + columns: ['artifact_id', 'artifact_type', 'created_at', 'output_path', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download infographic', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const outputPath = typeof kwargs.output_path === 'string' + ? kwargs.output_path.trim() + : String(kwargs.output_path ?? '').trim(); + if (!outputPath) { + throw new ArgumentError('The infographic output path cannot be empty.'); + } + + const artifactId = typeof kwargs['artifact-id'] === 'string' + ? kwargs['artifact-id'].trim() + : ''; + const downloaded = await downloadNotebooklmInfographicViaRpc(page, outputPath, artifactId || undefined); + if (downloaded) return [downloaded]; + + throw new EmptyResultError( + 'opencli notebooklm download infographic', + artifactId + ? `Completed infographic artifact "${artifactId}" was not found in the current notebook.` + : 'No completed infographic artifacts were found in the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/download-list.test.ts b/src/clis/notebooklm/download-list.test.ts new file mode 100644 index 00000000..84d32c39 --- /dev/null +++ b/src/clis/notebooklm/download-list.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmDownloadArtifactsViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmDownloadArtifactsViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmDownloadArtifactsViaRpc: mockListNotebooklmDownloadArtifactsViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-list.js'; + +describe('notebooklm download-list', () => { + const command = getRegistry().get('notebooklm/download/list'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmDownloadArtifactsViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('lists downloadable artifacts for the current notebook', async () => { + mockListNotebooklmDownloadArtifactsViaRpc.mockResolvedValue([ + { + notebook_id: 'nb-demo', + artifact_id: 'slide-1', + artifact_type: 'slide_deck', + status: 'completed', + title: 'Browser Automation Deck', + created_at: '2026-03-31T12:00:00.000Z', + download_variants: ['pdf', 'pptx'], + source: 'rpc+artifact-list', + }, + ]); + + const result = await command!.func!({} as any, {}); + + expect(mockListNotebooklmDownloadArtifactsViaRpc).toHaveBeenCalledWith(expect.anything()); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'slide-1', + artifact_type: 'slide_deck', + download_variants: ['pdf', 'pptx'], + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/download-list.ts b/src/clis/notebooklm/download-list.ts new file mode 100644 index 00000000..4c2618a8 --- /dev/null +++ b/src/clis/notebooklm/download-list.ts @@ -0,0 +1,43 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmDownloadArtifactsViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/list', + aliases: ['download-list'], + description: 'List currently downloadable NotebookLM artifacts in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['artifact_type', 'status', 'title', 'download_variants', 'created_at', 'artifact_id', 'source'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download list', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rows = await listNotebooklmDownloadArtifactsViaRpc(page); + if (rows.length > 0) return rows; + + throw new EmptyResultError( + 'opencli notebooklm download list', + 'No supported downloadable NotebookLM artifacts were found in the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/download-report.test.ts b/src/clis/notebooklm/download-report.test.ts new file mode 100644 index 00000000..7fe1d29f --- /dev/null +++ b/src/clis/notebooklm/download-report.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDownloadNotebooklmReportViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDownloadNotebooklmReportViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + downloadNotebooklmReportViaRpc: mockDownloadNotebooklmReportViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-report.js'; + +describe('notebooklm download-report', () => { + const command = getRegistry().get('notebooklm/download/report'); + + beforeEach(() => { + mockDownloadNotebooklmReportViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('downloads the latest completed report when artifact id is omitted', async () => { + mockDownloadNotebooklmReportViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'report-2', + title: 'Study Guide: Browser Automation', + kind: 'report', + output_path: 'E:\\tmp\\browser-automation.md', + created_at: '2026-03-31T12:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { output_path: 'E:\\tmp\\browser-automation.md' }); + + expect(mockDownloadNotebooklmReportViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.md', + undefined, + ); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'report-2', + title: 'Study Guide: Browser Automation', + output_path: 'E:\\tmp\\browser-automation.md', + }), + ]); + }); + + it('passes --artifact-id through to the report download helper', async () => { + mockDownloadNotebooklmReportViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'report-1', + title: 'Briefing Doc: Browser Automation', + kind: 'report', + output_path: 'E:\\tmp\\briefing.md', + created_at: '2026-03-30T10:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + await command!.func!({} as any, { + output_path: 'E:\\tmp\\briefing.md', + 'artifact-id': 'report-1', + }); + + expect(mockDownloadNotebooklmReportViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\briefing.md', + 'report-1', + ); + }); +}); diff --git a/src/clis/notebooklm/download-report.ts b/src/clis/notebooklm/download-report.ts new file mode 100644 index 00000000..ac0a48d7 --- /dev/null +++ b/src/clis/notebooklm/download-report.ts @@ -0,0 +1,68 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + completeNotebooklmArtifactIds, + downloadNotebooklmReportViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/report', + aliases: ['download-report'], + description: 'Download one completed NotebookLM report artifact as markdown', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'output_path', + positional: true, + required: true, + help: 'Markdown file path to write', + }, + { + name: 'artifact-id', + help: 'Specific completed report artifact id', + completion: () => completeNotebooklmArtifactIds('report'), + }, + ], + columns: ['title', 'artifact_id', 'created_at', 'output_path', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download report', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const outputPath = typeof kwargs.output_path === 'string' + ? kwargs.output_path.trim() + : String(kwargs.output_path ?? '').trim(); + if (!outputPath) { + throw new ArgumentError('The report output path cannot be empty.'); + } + + const artifactId = typeof kwargs['artifact-id'] === 'string' + ? kwargs['artifact-id'].trim() + : ''; + const downloaded = await downloadNotebooklmReportViaRpc(page, outputPath, artifactId || undefined); + if (downloaded) return [downloaded]; + + throw new EmptyResultError( + 'opencli notebooklm download report', + artifactId + ? `Completed report artifact "${artifactId}" was not found in the current notebook.` + : 'No completed report artifacts were found in the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/download-slide-deck.test.ts b/src/clis/notebooklm/download-slide-deck.test.ts new file mode 100644 index 00000000..2095b56f --- /dev/null +++ b/src/clis/notebooklm/download-slide-deck.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDownloadNotebooklmSlideDeckViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDownloadNotebooklmSlideDeckViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + downloadNotebooklmSlideDeckViaRpc: mockDownloadNotebooklmSlideDeckViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-slide-deck.js'; + +describe('notebooklm download-slide-deck', () => { + const command = getRegistry().get('notebooklm/download/slide-deck'); + + beforeEach(() => { + mockDownloadNotebooklmSlideDeckViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('downloads the latest completed slide deck as pdf by default', async () => { + mockDownloadNotebooklmSlideDeckViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'slide-2', + artifact_type: 'slide_deck', + title: 'Browser Automation Deck', + output_path: 'E:\\tmp\\browser-automation.pdf', + created_at: '2026-03-31T12:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest.pdf', + download_format: 'pdf', + source: 'rpc+artifact-url', + }); + + const result = await command!.func!({} as any, { output_path: 'E:\\tmp\\browser-automation.pdf' }); + + expect(mockDownloadNotebooklmSlideDeckViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.pdf', + undefined, + 'pdf', + ); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'slide-2', + artifact_type: 'slide_deck', + output_path: 'E:\\tmp\\browser-automation.pdf', + download_format: 'pdf', + }), + ]); + }); + + it('passes --artifact-id and --output-format through to the slide-deck download helper', async () => { + mockDownloadNotebooklmSlideDeckViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'slide-1', + artifact_type: 'slide_deck', + title: 'Browser Automation Deck', + output_path: 'E:\\tmp\\browser-automation.pptx', + created_at: '2026-03-30T10:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest.pptx', + download_format: 'pptx', + source: 'rpc+artifact-url', + }); + + await command!.func!({} as any, { + output_path: 'E:\\tmp\\browser-automation.pptx', + 'artifact-id': 'slide-1', + 'output-format': 'pptx', + }); + + expect(mockDownloadNotebooklmSlideDeckViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.pptx', + 'slide-1', + 'pptx', + ); + }); +}); diff --git a/src/clis/notebooklm/download-slide-deck.ts b/src/clis/notebooklm/download-slide-deck.ts new file mode 100644 index 00000000..a1a135b8 --- /dev/null +++ b/src/clis/notebooklm/download-slide-deck.ts @@ -0,0 +1,84 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE, type NotebooklmSlideDeckDownloadFormat } from './shared.js'; +import { + completeNotebooklmArtifactIds, + downloadNotebooklmSlideDeckViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +function normalizeSlideDeckFormat(value: unknown): NotebooklmSlideDeckDownloadFormat { + return value === 'pptx' ? 'pptx' : 'pdf'; +} + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/slide-deck', + aliases: ['download-slide-deck'], + description: 'Download one completed NotebookLM slide deck artifact as pdf or pptx', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'output_path', + positional: true, + required: true, + help: 'Slide deck file path to write', + }, + { + name: 'artifact-id', + help: 'Specific completed slide deck artifact id', + completion: () => completeNotebooklmArtifactIds('slide_deck'), + }, + { + name: 'output-format', + default: 'pdf', + choices: ['pdf', 'pptx'], + help: 'Download format', + }, + ], + columns: ['artifact_id', 'artifact_type', 'download_format', 'created_at', 'output_path', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download slide-deck', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const outputPath = typeof kwargs.output_path === 'string' + ? kwargs.output_path.trim() + : String(kwargs.output_path ?? '').trim(); + if (!outputPath) { + throw new ArgumentError('The slide-deck output path cannot be empty.'); + } + + const artifactId = typeof kwargs['artifact-id'] === 'string' + ? kwargs['artifact-id'].trim() + : ''; + const outputFormat = normalizeSlideDeckFormat(kwargs['output-format']); + const downloaded = await downloadNotebooklmSlideDeckViaRpc( + page, + outputPath, + artifactId || undefined, + outputFormat, + ); + if (downloaded) return [downloaded]; + + throw new EmptyResultError( + 'opencli notebooklm download slide-deck', + artifactId + ? `Completed slide-deck artifact "${artifactId}" with format "${outputFormat}" was not found in the current notebook.` + : `No completed slide-deck artifacts with format "${outputFormat}" were found in the current notebook.`, + ); + }, +}); diff --git a/src/clis/notebooklm/download-video.test.ts b/src/clis/notebooklm/download-video.test.ts new file mode 100644 index 00000000..d2e23ea3 --- /dev/null +++ b/src/clis/notebooklm/download-video.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDownloadNotebooklmVideoViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDownloadNotebooklmVideoViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + downloadNotebooklmVideoViaRpc: mockDownloadNotebooklmVideoViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './download-video.js'; + +describe('notebooklm download-video', () => { + const command = getRegistry().get('notebooklm/download/video'); + + beforeEach(() => { + mockDownloadNotebooklmVideoViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('downloads the latest completed video artifact when artifact id is omitted', async () => { + mockDownloadNotebooklmVideoViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'video-2', + artifact_type: 'video', + title: 'Browser Automation Video', + output_path: 'E:\\tmp\\browser-automation.mp4', + created_at: '2026-03-31T12:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest-video', + mime_type: 'video/mp4', + source: 'rpc+artifact-url', + }); + + const result = await command!.func!({} as any, { output_path: 'E:\\tmp\\browser-automation.mp4' }); + + expect(mockDownloadNotebooklmVideoViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.mp4', + undefined, + ); + expect(result).toEqual([ + expect.objectContaining({ + artifact_id: 'video-2', + artifact_type: 'video', + output_path: 'E:\\tmp\\browser-automation.mp4', + }), + ]); + }); + + it('passes --artifact-id through to the video download helper', async () => { + mockDownloadNotebooklmVideoViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'video-1', + artifact_type: 'video', + title: 'Browser Automation Video', + output_path: 'E:\\tmp\\browser-automation.mp4', + created_at: '2026-03-30T10:00:00.000Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + download_url: 'https://example.com/latest-video-dv', + mime_type: 'video/mp4', + source: 'rpc+artifact-url', + }); + + await command!.func!({} as any, { + output_path: 'E:\\tmp\\browser-automation.mp4', + 'artifact-id': 'video-1', + }); + + expect(mockDownloadNotebooklmVideoViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'E:\\tmp\\browser-automation.mp4', + 'video-1', + ); + }); +}); diff --git a/src/clis/notebooklm/download-video.ts b/src/clis/notebooklm/download-video.ts new file mode 100644 index 00000000..6b4cc92f --- /dev/null +++ b/src/clis/notebooklm/download-video.ts @@ -0,0 +1,68 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + completeNotebooklmArtifactIds, + downloadNotebooklmVideoViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'download/video', + aliases: ['download-video'], + description: 'Download one completed NotebookLM video artifact', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'output_path', + positional: true, + required: true, + help: 'Video file path to write', + }, + { + name: 'artifact-id', + help: 'Specific completed video artifact id', + completion: () => completeNotebooklmArtifactIds('video'), + }, + ], + columns: ['artifact_id', 'artifact_type', 'mime_type', 'created_at', 'output_path', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm download video', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const outputPath = typeof kwargs.output_path === 'string' + ? kwargs.output_path.trim() + : String(kwargs.output_path ?? '').trim(); + if (!outputPath) { + throw new ArgumentError('The video output path cannot be empty.'); + } + + const artifactId = typeof kwargs['artifact-id'] === 'string' + ? kwargs['artifact-id'].trim() + : ''; + const downloaded = await downloadNotebooklmVideoViaRpc(page, outputPath, artifactId || undefined); + if (downloaded) return [downloaded]; + + throw new EmptyResultError( + 'opencli notebooklm download video', + artifactId + ? `Completed video artifact "${artifactId}" was not found in the current notebook.` + : 'No completed video artifacts were found in the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/generate-audio.test.ts b/src/clis/notebooklm/generate-audio.test.ts new file mode 100644 index 00000000..dec08df1 --- /dev/null +++ b/src/clis/notebooklm/generate-audio.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGenerateNotebooklmAudioViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGenerateNotebooklmAudioViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + generateNotebooklmAudioViaRpc: mockGenerateNotebooklmAudioViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './generate-audio.js'; + +describe('notebooklm generate-audio', () => { + const command = getRegistry().get('notebooklm/generate/audio'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGenerateNotebooklmAudioViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('submits an audio generation request for the current notebook', async () => { + mockGenerateNotebooklmAudioViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'audio-gen-1', + artifact_type: 'audio', + status: 'pending', + source: 'rpc+create-artifact', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockGenerateNotebooklmAudioViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: false }), + ); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + artifact_id: 'audio-gen-1', + artifact_type: 'audio', + status: 'pending', + }), + ]); + }); + + it('passes the wait flag through to the audio generate helper', async () => { + mockGenerateNotebooklmAudioViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'audio-gen-2', + artifact_type: 'audio', + status: 'completed', + created_at: '2026-03-31T04:00:00.000Z', + source: 'rpc+create-artifact+artifact-list', + }); + + await command!.func!({} as any, { wait: true }); + + expect(mockGenerateNotebooklmAudioViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: true }), + ); + }); +}); diff --git a/src/clis/notebooklm/generate-audio.ts b/src/clis/notebooklm/generate-audio.ts new file mode 100644 index 00000000..2f72d1b2 --- /dev/null +++ b/src/clis/notebooklm/generate-audio.ts @@ -0,0 +1,52 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + generateNotebooklmAudioViaRpc, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'generate/audio', + aliases: ['generate-audio'], + description: 'Generate one NotebookLM audio artifact in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'wait', + type: 'bool', + default: false, + help: 'Wait for the generated audio artifact to become visible and ready', + }, + ], + columns: ['artifact_type', 'status', 'artifact_id', 'created_at', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm generate audio', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const generated = await generateNotebooklmAudioViaRpc(page, { + wait: Boolean(kwargs.wait), + }); + if (generated) return [generated]; + + throw new EmptyResultError( + 'opencli notebooklm generate audio', + 'NotebookLM did not accept an audio generation request for the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/generate-infographic.test.ts b/src/clis/notebooklm/generate-infographic.test.ts new file mode 100644 index 00000000..269abe25 --- /dev/null +++ b/src/clis/notebooklm/generate-infographic.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGenerateNotebooklmInfographicViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGenerateNotebooklmInfographicViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + generateNotebooklmInfographicViaRpc: mockGenerateNotebooklmInfographicViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './generate-infographic.js'; + +describe('notebooklm generate-infographic', () => { + const command = getRegistry().get('notebooklm/generate/infographic'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGenerateNotebooklmInfographicViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('submits an infographic generation request for the current notebook', async () => { + mockGenerateNotebooklmInfographicViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-gen-1', + artifact_type: 'infographic', + status: 'pending', + source: 'rpc+create-artifact', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockGenerateNotebooklmInfographicViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: false }), + ); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-gen-1', + artifact_type: 'infographic', + status: 'pending', + }), + ]); + }); + + it('passes infographic options through to the generate helper', async () => { + mockGenerateNotebooklmInfographicViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-gen-2', + artifact_type: 'infographic', + status: 'completed', + source: 'rpc+create-artifact+artifact-list', + }); + + await command!.func!({} as any, { + wait: true, + instructions: 'Focus on performance bottlenecks', + orientation: 'portrait', + detail: 'detailed', + style: 'scientific', + }); + + expect(mockGenerateNotebooklmInfographicViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + wait: true, + instructions: 'Focus on performance bottlenecks', + orientation: 'portrait', + detail: 'detailed', + style: 'scientific', + }), + ); + }); +}); diff --git a/src/clis/notebooklm/generate-infographic.ts b/src/clis/notebooklm/generate-infographic.ts new file mode 100644 index 00000000..85b63e78 --- /dev/null +++ b/src/clis/notebooklm/generate-infographic.ts @@ -0,0 +1,124 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { + NOTEBOOKLM_DOMAIN, + NOTEBOOKLM_SITE, + type NotebooklmInfographicDetail, + type NotebooklmInfographicOrientation, + type NotebooklmInfographicStyle, +} from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + generateNotebooklmInfographicViaRpc, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +function normalizeOrientation(value: unknown): NotebooklmInfographicOrientation | null { + return value === 'portrait' || value === 'square' || value === 'landscape' + ? value + : null; +} + +function normalizeDetail(value: unknown): NotebooklmInfographicDetail | null { + return value === 'concise' || value === 'standard' || value === 'detailed' + ? value + : null; +} + +function normalizeStyle(value: unknown): NotebooklmInfographicStyle | null { + switch (value) { + case 'auto_select': + case 'sketch_note': + case 'professional': + case 'bento_grid': + case 'editorial': + case 'instructional': + case 'bricks': + case 'clay': + case 'anime': + case 'kawaii': + case 'scientific': + return value; + default: + return null; + } +} + +cli({ + site: NOTEBOOKLM_SITE, + name: 'generate/infographic', + aliases: ['generate-infographic'], + description: 'Generate one NotebookLM infographic artifact in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'instructions', + help: 'Optional custom infographic instructions', + }, + { + name: 'orientation', + choices: ['landscape', 'portrait', 'square'], + help: 'Infographic orientation', + }, + { + name: 'detail', + choices: ['concise', 'standard', 'detailed'], + help: 'Infographic detail level', + }, + { + name: 'style', + choices: [ + 'auto_select', + 'sketch_note', + 'professional', + 'bento_grid', + 'editorial', + 'instructional', + 'bricks', + 'clay', + 'anime', + 'kawaii', + 'scientific', + ], + help: 'Infographic visual style', + }, + { + name: 'wait', + type: 'bool', + default: false, + help: 'Wait for the generated infographic artifact to become visible and ready', + }, + ], + columns: ['artifact_type', 'status', 'artifact_id', 'created_at', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm generate infographic', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const generated = await generateNotebooklmInfographicViaRpc(page, { + instructions: typeof kwargs.instructions === 'string' ? kwargs.instructions : null, + orientation: normalizeOrientation(kwargs.orientation), + detail: normalizeDetail(kwargs.detail), + style: normalizeStyle(kwargs.style), + wait: Boolean(kwargs.wait), + }); + if (generated) return [generated]; + + throw new EmptyResultError( + 'opencli notebooklm generate infographic', + 'NotebookLM did not accept an infographic generation request for the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/generate-report.test.ts b/src/clis/notebooklm/generate-report.test.ts new file mode 100644 index 00000000..88466691 --- /dev/null +++ b/src/clis/notebooklm/generate-report.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGenerateNotebooklmReportViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGenerateNotebooklmReportViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + generateNotebooklmReportViaRpc: mockGenerateNotebooklmReportViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './generate-report.js'; + +describe('notebooklm generate-report', () => { + const command = getRegistry().get('notebooklm/generate/report'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGenerateNotebooklmReportViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('submits a report generation request for the current notebook', async () => { + mockGenerateNotebooklmReportViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'report-gen-1', + artifact_type: 'report', + status: 'pending', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc+create-artifact', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockGenerateNotebooklmReportViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: false }), + ); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + artifact_id: 'report-gen-1', + artifact_type: 'report', + status: 'pending', + }), + ]); + }); + + it('passes the wait flag through to the generate helper', async () => { + mockGenerateNotebooklmReportViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'report-gen-2', + artifact_type: 'report', + status: 'completed', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc+create-artifact+artifact-list', + }); + + await command!.func!({} as any, { wait: true }); + + expect(mockGenerateNotebooklmReportViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: true }), + ); + }); +}); diff --git a/src/clis/notebooklm/generate-report.ts b/src/clis/notebooklm/generate-report.ts new file mode 100644 index 00000000..339a352d --- /dev/null +++ b/src/clis/notebooklm/generate-report.ts @@ -0,0 +1,52 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + generateNotebooklmReportViaRpc, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'generate/report', + aliases: ['generate-report'], + description: 'Generate one NotebookLM report artifact in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'wait', + type: 'bool', + default: false, + help: 'Wait for the generated report artifact to become visible and ready', + }, + ], + columns: ['artifact_type', 'status', 'artifact_id', 'created_at', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm generate report', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const generated = await generateNotebooklmReportViaRpc(page, { + wait: Boolean(kwargs.wait), + }); + if (generated) return [generated]; + + throw new EmptyResultError( + 'opencli notebooklm generate report', + 'NotebookLM did not accept a report generation request for the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/generate-slide-deck.test.ts b/src/clis/notebooklm/generate-slide-deck.test.ts new file mode 100644 index 00000000..ba4dc0dd --- /dev/null +++ b/src/clis/notebooklm/generate-slide-deck.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGenerateNotebooklmSlideDeckViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGenerateNotebooklmSlideDeckViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + generateNotebooklmSlideDeckViaRpc: mockGenerateNotebooklmSlideDeckViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './generate-slide-deck.js'; + +describe('notebooklm generate-slide-deck', () => { + const command = getRegistry().get('notebooklm/generate/slide-deck'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGenerateNotebooklmSlideDeckViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('submits a slide-deck generation request for the current notebook', async () => { + mockGenerateNotebooklmSlideDeckViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'deck-gen-1', + artifact_type: 'slide_deck', + status: 'pending', + source: 'rpc+create-artifact', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockGenerateNotebooklmSlideDeckViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: false }), + ); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + artifact_id: 'deck-gen-1', + artifact_type: 'slide_deck', + status: 'pending', + }), + ]); + }); + + it('passes the wait flag through to the slide-deck generate helper', async () => { + mockGenerateNotebooklmSlideDeckViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + artifact_id: 'deck-gen-2', + artifact_type: 'slide_deck', + status: 'completed', + created_at: '2026-03-31T04:15:00.000Z', + source: 'rpc+create-artifact+artifact-list', + }); + + await command!.func!({} as any, { wait: true }); + + expect(mockGenerateNotebooklmSlideDeckViaRpc).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ wait: true }), + ); + }); +}); diff --git a/src/clis/notebooklm/generate-slide-deck.ts b/src/clis/notebooklm/generate-slide-deck.ts new file mode 100644 index 00000000..b3c0cbe7 --- /dev/null +++ b/src/clis/notebooklm/generate-slide-deck.ts @@ -0,0 +1,52 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + generateNotebooklmSlideDeckViaRpc, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'generate/slide-deck', + aliases: ['generate-slide-deck'], + description: 'Generate one NotebookLM slide deck artifact in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'wait', + type: 'bool', + default: false, + help: 'Wait for the generated slide deck artifact to become visible and ready', + }, + ], + columns: ['artifact_type', 'status', 'artifact_id', 'created_at', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm generate slide-deck', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const generated = await generateNotebooklmSlideDeckViaRpc(page, { + wait: Boolean(kwargs.wait), + }); + if (generated) return [generated]; + + throw new EmptyResultError( + 'opencli notebooklm generate slide-deck', + 'NotebookLM did not accept a slide-deck generation request for the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/get.ts b/src/clis/notebooklm/get.ts new file mode 100644 index 00000000..97f98120 --- /dev/null +++ b/src/clis/notebooklm/get.ts @@ -0,0 +1,53 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmDetailViaRpc, + getNotebooklmPageState, + readCurrentNotebooklm, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'get', + aliases: ['metadata'], + description: 'Get rich metadata for the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['id', 'title', 'emoji', 'source_count', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm get', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rpcRow = await getNotebooklmDetailViaRpc(page).catch(() => null); + if (rpcRow) return [rpcRow]; + + const current = await readCurrentNotebooklm(page); + if (!current) { + throw new EmptyResultError( + 'opencli notebooklm get', + 'NotebookLM notebook metadata was not found on the current page.', + ); + } + + return [{ + ...current, + emoji: null, + source_count: null, + updated_at: null, + }]; + }, +}); diff --git a/src/clis/notebooklm/history.test.ts b/src/clis/notebooklm/history.test.ts new file mode 100644 index 00000000..7678913e --- /dev/null +++ b/src/clis/notebooklm/history.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmHistoryViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmHistoryViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmHistoryViaRpc: mockListNotebooklmHistoryViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './history.js'; + +describe('notebooklm history', () => { + const history = getRegistry().get('notebooklm/history'); + + beforeEach(() => { + mockListNotebooklmHistoryViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('lists notebook history threads from the browser rpc', async () => { + mockListNotebooklmHistoryViaRpc.mockResolvedValue([ + { + notebook_id: 'nb-demo', + thread_id: '28e0f2cb-4591-45a3-a661-7653666f7c78', + item_count: 0, + preview: 'Summarize this notebook', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + + const result = await history!.func!({} as any, {}); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + thread_id: '28e0f2cb-4591-45a3-a661-7653666f7c78', + item_count: 0, + preview: 'Summarize this notebook', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/history.ts b/src/clis/notebooklm/history.ts new file mode 100644 index 00000000..4a549392 --- /dev/null +++ b/src/clis/notebooklm/history.ts @@ -0,0 +1,36 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmHistoryViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'history', + description: 'List NotebookLM conversation history threads in the current notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['thread_id', 'item_count', 'preview', 'source', 'notebook_id', 'url'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm history', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rows = await listNotebooklmHistoryViaRpc(page); + return rows; + }, +}); diff --git a/src/clis/notebooklm/language-get.ts b/src/clis/notebooklm/language-get.ts new file mode 100644 index 00000000..63e8a951 --- /dev/null +++ b/src/clis/notebooklm/language-get.ts @@ -0,0 +1,26 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { getNotebooklmOutputLanguageViaRpc } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'language/get', + aliases: ['language-get'], + description: 'Get the current global NotebookLM output language', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['language', 'name', 'source'], + func: async (page: IPage) => { + const row = await getNotebooklmOutputLanguageViaRpc(page); + if (row) return [row]; + throw new EmptyResultError( + 'opencli notebooklm language-get', + 'NotebookLM did not return the current output language.', + ); + }, +}); diff --git a/src/clis/notebooklm/language-list.ts b/src/clis/notebooklm/language-list.ts new file mode 100644 index 00000000..c39d327c --- /dev/null +++ b/src/clis/notebooklm/language-list.ts @@ -0,0 +1,13 @@ +import { cli } from '../../registry.js'; +import { NOTEBOOKLM_SITE } from './shared.js'; +import { listNotebooklmSupportedLanguages } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'language/list', + aliases: ['language-list'], + description: 'List supported NotebookLM output language codes', + args: [], + columns: ['code', 'name', 'source'], + func: async () => listNotebooklmSupportedLanguages(), +}); diff --git a/src/clis/notebooklm/language-set.ts b/src/clis/notebooklm/language-set.ts new file mode 100644 index 00000000..d7b855e7 --- /dev/null +++ b/src/clis/notebooklm/language-set.ts @@ -0,0 +1,42 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { NOTEBOOKLM_SUPPORTED_LANGUAGES } from './languages.js'; +import { setNotebooklmOutputLanguageViaRpc } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'language/set', + aliases: ['language-set'], + description: 'Set the global NotebookLM output language', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'code', + positional: true, + required: true, + help: 'Language code from notebooklm language-list', + }, + ], + columns: ['language', 'name', 'source'], + func: async (page: IPage, kwargs) => { + const code = typeof kwargs.code === 'string' ? kwargs.code.trim() : String(kwargs.code ?? '').trim(); + if (!code) { + throw new ArgumentError('The language code cannot be empty.'); + } + if (!(code in NOTEBOOKLM_SUPPORTED_LANGUAGES)) { + throw new ArgumentError(`Unknown language code: ${code}`); + } + + const row = await setNotebooklmOutputLanguageViaRpc(page, code); + if (row) return [row]; + throw new EmptyResultError( + 'opencli notebooklm language-set', + `NotebookLM did not confirm the language update for "${code}".`, + ); + }, +}); diff --git a/src/clis/notebooklm/language.test.ts b/src/clis/notebooklm/language.test.ts new file mode 100644 index 00000000..2c721a64 --- /dev/null +++ b/src/clis/notebooklm/language.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockGetNotebooklmOutputLanguageViaRpc, + mockSetNotebooklmOutputLanguageViaRpc, +} = vi.hoisted(() => ({ + mockGetNotebooklmOutputLanguageViaRpc: vi.fn(), + mockSetNotebooklmOutputLanguageViaRpc: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmOutputLanguageViaRpc: mockGetNotebooklmOutputLanguageViaRpc, + setNotebooklmOutputLanguageViaRpc: mockSetNotebooklmOutputLanguageViaRpc, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './language-get.js'; +import './language-list.js'; +import './language-set.js'; + +describe('notebooklm language commands', () => { + const listCommand = getRegistry().get('notebooklm/language-list'); + const getCommand = getRegistry().get('notebooklm/language-get'); + const setCommand = getRegistry().get('notebooklm/language-set'); + + beforeEach(() => { + mockGetNotebooklmOutputLanguageViaRpc.mockReset(); + mockSetNotebooklmOutputLanguageViaRpc.mockReset(); + }); + + it('lists supported language codes from the static upstream table', async () => { + const result = await listCommand!.func!({} as any, {}); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'en', name: 'English', source: 'static' }), + expect.objectContaining({ code: 'zh_Hans', name: '中文(简体)', source: 'static' }), + ]), + ); + }); + + it('gets the current output language via rpc', async () => { + mockGetNotebooklmOutputLanguageViaRpc.mockResolvedValue({ + language: 'ja', + name: '日本語', + source: 'rpc', + }); + + const result = await getCommand!.func!({} as any, {}); + + expect(mockGetNotebooklmOutputLanguageViaRpc).toHaveBeenCalledWith(expect.anything()); + expect(result).toEqual([ + { + language: 'ja', + name: '日本語', + source: 'rpc', + }, + ]); + }); + + it('sets the current output language via rpc', async () => { + mockSetNotebooklmOutputLanguageViaRpc.mockResolvedValue({ + language: 'zh_Hans', + name: '中文(简体)', + source: 'rpc', + }); + + const result = await setCommand!.func!({} as any, { code: 'zh_Hans' }); + + expect(mockSetNotebooklmOutputLanguageViaRpc).toHaveBeenCalledWith(expect.anything(), 'zh_Hans'); + expect(result).toEqual([ + { + language: 'zh_Hans', + name: '中文(简体)', + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/languages.ts b/src/clis/notebooklm/languages.ts new file mode 100644 index 00000000..10841397 --- /dev/null +++ b/src/clis/notebooklm/languages.ts @@ -0,0 +1,83 @@ +export const NOTEBOOKLM_SUPPORTED_LANGUAGES: Record<string, string> = { + en: 'English', + zh_Hans: '中文(简体)', + zh_Hant: '中文(繁體)', + es: 'Español', + es_419: 'Español (Latinoamérica)', + es_MX: 'Español (México)', + hi: 'हिन्दी', + ar_001: 'العربية', + ar_eg: 'العربية (مصر)', + pt_BR: 'Português (Brasil)', + pt_PT: 'Português (Portugal)', + bn: 'বাংলা', + ru: 'Русский', + ja: '日本語', + pa: 'ਪੰਜਾਬੀ', + de: 'Deutsch', + jv: 'Basa Jawa', + ko: '한국어', + fr: 'Français', + fr_CA: 'Français (Canada)', + te: 'తెలుగు', + vi: 'Tiếng Việt', + mr: 'मराठी', + ta: 'தமிழ்', + tr: 'Türkçe', + ur: 'اردو', + it: 'Italiano', + th: 'ไทย', + gu: 'ગુજરાતી', + fa: 'فارسی', + pl: 'Polski', + uk: 'Українська', + ml: 'മലയാളം', + kn: 'ಕನ್ನಡ', + or: 'ଓଡ଼ିଆ', + my: 'မြန်မာဘာသာ', + sw: 'Kiswahili', + nl_NL: 'Nederlands', + ro: 'Română', + hu: 'Magyar', + el: 'Ελληνικά', + cs: 'Čeština', + sv: 'Svenska', + be: 'Беларуская', + bg: 'Български', + hr: 'Hrvatski', + sk: 'Slovenčina', + da: 'Dansk', + fi: 'Suomi', + nb_NO: 'Norsk Bokmål', + nn_NO: 'Norsk Nynorsk', + he: 'עברית', + iw: 'עברית', + id: 'Bahasa Indonesia', + ms: 'Bahasa Melayu', + fil: 'Filipino', + ceb: 'Cebuano', + sr: 'Српски', + sl: 'Slovenščina', + sq: 'Shqip', + mk: 'Македонски', + lt: 'Lietuvių', + lv: 'Latviešu', + et: 'Eesti', + hy: 'Հայերեն', + ka: 'ქართული', + az: 'Azərbaycanca', + af: 'Afrikaans', + am: 'አማርኛ', + eu: 'Euskara', + ca: 'Català', + gl: 'Galego', + is: 'Íslenska', + la: 'Latina', + ne: 'नेपाली', + ps: 'پښتو', + sd: 'سنڌي', + si: 'සිංහල', + ht: 'Kreyòl Ayisyen', + kok: 'कोंकणी', + mai: 'मैथिली', +}; diff --git a/src/clis/notebooklm/list.ts b/src/clis/notebooklm/list.ts new file mode 100644 index 00000000..11797829 --- /dev/null +++ b/src/clis/notebooklm/list.ts @@ -0,0 +1,40 @@ +import { cli, Strategy } from '../../registry.js'; +import { AuthRequiredError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmHome, + listNotebooklmLinks, + listNotebooklmViaRpc, + readCurrentNotebooklm, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'list', + description: 'List NotebookLM notebooks via in-page batchexecute RPC in the current logged-in session', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['title', 'id', 'is_owner', 'created_at', 'source', 'url'], + func: async (page: IPage) => { + const currentFallback = await readCurrentNotebooklm(page).catch(() => null); + await ensureNotebooklmHome(page); + await requireNotebooklmSession(page); + + try { + const rpcRows = await listNotebooklmViaRpc(page); + if (rpcRows.length > 0) return rpcRows; + } catch (error) { + if (error instanceof AuthRequiredError) throw error; + } + + const domRows = await listNotebooklmLinks(page); + if (domRows.length > 0) return domRows; + if (currentFallback) return [currentFallback]; + return []; + }, +}); diff --git a/src/clis/notebooklm/note-list.test.ts b/src/clis/notebooklm/note-list.test.ts new file mode 100644 index 00000000..5da7b71a --- /dev/null +++ b/src/clis/notebooklm/note-list.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockListNotebooklmNotesFromPage, mockListNotebooklmNotesViaRpc, mockGetNotebooklmPageState, mockRequireNotebooklmSession } = vi.hoisted(() => ({ + mockListNotebooklmNotesFromPage: vi.fn(), + mockListNotebooklmNotesViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmNotesFromPage: mockListNotebooklmNotesFromPage, + listNotebooklmNotesViaRpc: mockListNotebooklmNotesViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './note-list.js'; + +describe('notebooklm note-list', () => { + const command = getRegistry().get('notebooklm/note-list'); + + beforeEach(() => { + mockListNotebooklmNotesFromPage.mockReset(); + mockListNotebooklmNotesViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockListNotebooklmNotesViaRpc.mockResolvedValue([]); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('lists notebook notes from the Studio panel', async () => { + mockListNotebooklmNotesFromPage.mockResolvedValue([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + created_at: '6 分钟前', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-list', + }, + ]); + + const result = await command!.func!({} as any, {}); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + created_at: '6 分钟前', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-list', + }, + ]); + }); + + it('falls back to rpc when the Studio panel dom is empty', async () => { + mockListNotebooklmNotesFromPage.mockResolvedValue([]); + mockListNotebooklmNotesViaRpc.mockResolvedValue([ + { + notebook_id: 'nb-demo', + id: 'note-rpc-1', + title: '新建笔记', + content: '', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + + const result = await command!.func!({} as any, {}); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-rpc-1', + title: '新建笔记', + content: '', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + expect(mockListNotebooklmNotesViaRpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/clis/notebooklm/note-list.ts b/src/clis/notebooklm/note-list.ts new file mode 100644 index 00000000..aea7a2c6 --- /dev/null +++ b/src/clis/notebooklm/note-list.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmNotesFromPage, + listNotebooklmNotesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes/list', + aliases: ['note-list', 'notes-list'], + description: 'List saved notes from the Studio panel of the current NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['title', 'id', 'created_at', 'source', 'url'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm note-list', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rows = await listNotebooklmNotesFromPage(page); + if (rows.length > 0) return rows; + + const rpcRows = await listNotebooklmNotesViaRpc(page); + if (rpcRows.length > 0) return rpcRows; + + throw new EmptyResultError( + 'opencli notebooklm note-list', + 'No NotebookLM notes were available from the Studio panel or the notebook notes RPC. Open a specific notebook tab and retry.', + ); + }, +}); diff --git a/src/clis/notebooklm/notes-create.test.ts b/src/clis/notebooklm/notes-create.test.ts new file mode 100644 index 00000000..d693293c --- /dev/null +++ b/src/clis/notebooklm/notes-create.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCreateNotebooklmNoteViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockCreateNotebooklmNoteViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + createNotebooklmNoteViaRpc: mockCreateNotebooklmNoteViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './notes-create.js'; + +describe('notebooklm notes create', () => { + const command = getRegistry().get('notebooklm/notes/create'); + + beforeEach(() => { + mockCreateNotebooklmNoteViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('creates a note via rpc and returns the created note row', async () => { + mockCreateNotebooklmNoteViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + id: 'note-created', + title: '新建笔记', + content: '这是正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + title: '新建笔记', + content: '这是正文', + }); + + expect(mockCreateNotebooklmNoteViaRpc).toHaveBeenCalledWith( + expect.anything(), + '新建笔记', + '这是正文', + ); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-created', + title: '新建笔记', + content: '这是正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/notes-create.ts b/src/clis/notebooklm/notes-create.ts new file mode 100644 index 00000000..1837c638 --- /dev/null +++ b/src/clis/notebooklm/notes-create.ts @@ -0,0 +1,61 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + createNotebooklmNoteViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes/create', + aliases: ['notes-create'], + description: 'Create a new note in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'title', + positional: true, + required: true, + help: 'Title for the new note', + }, + { + name: 'content', + positional: true, + required: false, + help: 'Initial content for the new note', + }, + ], + columns: ['title', 'id', 'content', 'source', 'url'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm notes-create', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : ''; + if (!title) throw new ArgumentError('Provide a note title.'); + + const content = typeof kwargs.content === 'string' ? kwargs.content : String(kwargs.content ?? ''); + const created = await createNotebooklmNoteViaRpc(page, title, content); + if (!created) { + throw new EmptyResultError( + 'opencli notebooklm notes-create', + 'NotebookLM did not return a created note id. Retry from the target notebook page.', + ); + } + + return [created]; + }, +}); diff --git a/src/clis/notebooklm/notes-delete.test.ts b/src/clis/notebooklm/notes-delete.test.ts new file mode 100644 index 00000000..a3bcdc7e --- /dev/null +++ b/src/clis/notebooklm/notes-delete.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDeleteNotebooklmNoteViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmNotesViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDeleteNotebooklmNoteViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmNotesViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + deleteNotebooklmNoteViaRpc: mockDeleteNotebooklmNoteViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmNotesViaRpc: mockListNotebooklmNotesViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './notes-delete.js'; + +describe('notebooklm notes delete', () => { + const command = getRegistry().get('notebooklm/notes/delete'); + + beforeEach(() => { + mockDeleteNotebooklmNoteViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmNotesViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockListNotebooklmNotesViaRpc.mockResolvedValue([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('deletes a note via rpc when --note-id is provided', async () => { + mockDeleteNotebooklmNoteViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + note_id: 'note-1', + deleted: true, + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + 'note-id': 'note-1', + }); + + expect(mockDeleteNotebooklmNoteViaRpc).toHaveBeenCalledWith(expect.anything(), 'note-1'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + note_id: 'note-1', + deleted: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/notes-delete.ts b/src/clis/notebooklm/notes-delete.ts new file mode 100644 index 00000000..835c6d96 --- /dev/null +++ b/src/clis/notebooklm/notes-delete.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + deleteNotebooklmNoteViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmNotesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes/delete', + aliases: ['notes-delete'], + description: 'Delete a note in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'note', + positional: true, + required: false, + help: 'Existing note title when not using --note-id', + }, + { + name: 'note-id', + help: 'Stable note id from notebooklm notes list', + }, + ], + columns: ['note_id', 'deleted', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm notes-delete', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const explicitId = typeof kwargs['note-id'] === 'string' ? kwargs['note-id'].trim() : ''; + let noteId = explicitId; + + const rows = await listNotebooklmNotesViaRpc(page); + if (noteId) { + const matched = rows.find((row) => row.id === noteId) ?? null; + if (!matched) { + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_NOT_FOUND', + `NotebookLM note id "${noteId}" was not found in the current notebook.`, + `No NotebookLM note with id "${noteId}" was found in the current notebook.`, + ); + } + } else { + const query = typeof kwargs.note === 'string' ? kwargs.note.trim() : ''; + if (!query) throw new ArgumentError('Provide either a note title or --note-id.'); + + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_NOTE_NOT_FOUND', + `NotebookLM note "${query}" was not found in the current notebook.`, + `No NotebookLM note titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_NOTE_AMBIGUOUS', + `NotebookLM found multiple notes titled "${query}"`, + 'Use --note-id with notebooklm notes delete when duplicate note titles exist.', + ); + } + noteId = matches[0]!.id ?? ''; + } + + const deleted = await deleteNotebooklmNoteViaRpc(page, noteId); + if (!deleted) { + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_NOT_FOUND', + `NotebookLM note id "${noteId}" was not found in the current notebook.`, + `No NotebookLM note with id "${noteId}" was found in the current notebook.`, + ); + } + + return [deleted]; + }, +}); diff --git a/src/clis/notebooklm/notes-get.test.ts b/src/clis/notebooklm/notes-get.test.ts new file mode 100644 index 00000000..64afc1e2 --- /dev/null +++ b/src/clis/notebooklm/notes-get.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmNotesFromPage, + mockListNotebooklmNotesViaRpc, + mockReadNotebooklmVisibleNoteFromPage, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmNotesFromPage: vi.fn(), + mockListNotebooklmNotesViaRpc: vi.fn(), + mockReadNotebooklmVisibleNoteFromPage: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + listNotebooklmNotesFromPage: mockListNotebooklmNotesFromPage, + listNotebooklmNotesViaRpc: mockListNotebooklmNotesViaRpc, + readNotebooklmVisibleNoteFromPage: mockReadNotebooklmVisibleNoteFromPage, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import { CliError } from '../../errors.js'; +import './notes-get.js'; + +describe('notebooklm notes-get', () => { + const command = getRegistry().get('notebooklm/notes-get'); + + beforeEach(() => { + mockListNotebooklmNotesFromPage.mockReset(); + mockListNotebooklmNotesViaRpc.mockReset(); + mockReadNotebooklmVisibleNoteFromPage.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns a note directly by rpc id when --note-id is provided', async () => { + mockReadNotebooklmVisibleNoteFromPage.mockResolvedValue(null); + mockListNotebooklmNotesViaRpc.mockResolvedValue([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '第一段\\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + + const result = await command!.func!({} as any, { 'note-id': 'note-1' }); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '第一段\\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('reports a missing note id explicitly', async () => { + mockReadNotebooklmVisibleNoteFromPage.mockResolvedValue(null); + mockListNotebooklmNotesViaRpc.mockResolvedValue([]); + + await expect(command!.func!({} as any, { 'note-id': 'missing-note' })).rejects.toMatchObject({ + message: expect.stringMatching(/missing-note/), + hint: expect.stringMatching(/No NotebookLM note with id/i), + } satisfies Partial<CliError>); + }); + + it('returns the currently visible note editor content when the title matches', async () => { + mockReadNotebooklmVisibleNoteFromPage.mockResolvedValue({ + notebook_id: 'nb-demo', + title: '新建笔记', + content: '第一段\\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-editor', + }); + + const result = await command!.func!({} as any, { note: '新建笔记' }); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + title: '新建笔记', + content: '第一段\\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-editor', + }, + ]); + }); + + it('explains the current visible-note limitation when the target note is listed but not open', async () => { + mockReadNotebooklmVisibleNoteFromPage.mockResolvedValue(null); + mockListNotebooklmNotesFromPage.mockResolvedValue([ + { + notebook_id: 'nb-demo', + title: '新建笔记', + created_at: '6 分钟前', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-list', + }, + ]); + + await expect(command!.func!({} as any, { note: '新建笔记' })).rejects.toMatchObject({ + hint: expect.stringMatching(/currently reads note content only from the visible note editor/i), + } satisfies Partial<CliError>); + }); +}); diff --git a/src/clis/notebooklm/notes-get.ts b/src/clis/notebooklm/notes-get.ts new file mode 100644 index 00000000..a6c4ad59 --- /dev/null +++ b/src/clis/notebooklm/notes-get.ts @@ -0,0 +1,89 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + findNotebooklmNoteRow, + getNotebooklmPageState, + listNotebooklmNotesFromPage, + listNotebooklmNotesViaRpc, + readNotebooklmVisibleNoteFromPage, + requireNotebooklmSession, +} from './utils.js'; + +function matchesNoteTitle(title: string, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return false; + const normalized = title.trim().toLowerCase(); + return normalized === needle || normalized.includes(needle); +} + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes/get', + aliases: ['notes-get'], + description: 'Get one note from the current NotebookLM notebook by title from the visible note editor', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'note', + positional: true, + required: false, + help: 'Note title or id from the current notebook', + }, + { + name: 'note-id', + help: 'Stable note id from notebooklm notes list', + }, + ], + columns: ['title', 'id', 'content', 'source', 'url'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm notes-get', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const noteId = typeof kwargs['note-id'] === 'string' ? kwargs['note-id'].trim() : ''; + if (noteId) { + const rows = await listNotebooklmNotesViaRpc(page); + const matched = rows.find((row) => row.id === noteId) ?? null; + if (matched) return [matched]; + + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_NOT_FOUND', + `NotebookLM note id "${noteId}" was not found in the current notebook.`, + `No NotebookLM note with id "${noteId}" was found in the current notebook.`, + ); + } + + const query = typeof kwargs.note === 'string' ? kwargs.note : String(kwargs.note ?? ''); + if (!query.trim()) { + throw new ArgumentError('Provide either a note title or --note-id.'); + } + const visible = await readNotebooklmVisibleNoteFromPage(page); + if (visible && matchesNoteTitle(visible.title, query)) return [visible]; + + const rows = await listNotebooklmNotesFromPage(page); + const listed = findNotebooklmNoteRow(rows, query); + if (listed) { + throw new EmptyResultError( + 'opencli notebooklm notes-get', + `Note "${query}" is listed in Studio, but opencli currently reads note content only from the visible note editor. Open that note in NotebookLM, then retry.`, + ); + } + + throw new EmptyResultError( + 'opencli notebooklm notes-get', + `Note "${query}" was not found in the current notebook.`, + ); + }, +}); diff --git a/src/clis/notebooklm/notes-rename.test.ts b/src/clis/notebooklm/notes-rename.test.ts new file mode 100644 index 00000000..df39752b --- /dev/null +++ b/src/clis/notebooklm/notes-rename.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRenameNotebooklmNoteViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRenameNotebooklmNoteViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + renameNotebooklmNoteViaRpc: mockRenameNotebooklmNoteViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './notes-rename.js'; + +describe('notebooklm notes rename', () => { + const command = getRegistry().get('notebooklm/notes/rename'); + + it('keeps the new title as the only positional argument so --note-id stays callable', () => { + expect(command?.args).toEqual([ + expect.objectContaining({ + name: 'title', + positional: true, + required: true, + }), + expect.objectContaining({ + name: 'note', + }), + expect.objectContaining({ + name: 'note-id', + }), + ]); + expect(command?.args?.[1]?.positional).not.toBe(true); + expect(command?.args?.[2]?.positional).not.toBe(true); + }); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRenameNotebooklmNoteViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('renames a note via rpc when --note-id is provided', async () => { + mockRenameNotebooklmNoteViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + id: 'note-1', + title: '重命名后的笔记', + content: '原正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + 'note-id': 'note-1', + title: '重命名后的笔记', + }); + + expect(mockRenameNotebooklmNoteViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'note-1', + '重命名后的笔记', + ); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '重命名后的笔记', + content: '原正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/notes-rename.ts b/src/clis/notebooklm/notes-rename.ts new file mode 100644 index 00000000..f652364d --- /dev/null +++ b/src/clis/notebooklm/notes-rename.ts @@ -0,0 +1,90 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmNotesViaRpc, + renameNotebooklmNoteViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes/rename', + aliases: ['notes-rename'], + description: 'Rename a note in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'title', + positional: true, + required: true, + help: 'New title for the note', + }, + { + name: 'note', + help: 'Existing note title when not using --note-id', + }, + { + name: 'note-id', + help: 'Stable note id from notebooklm notes list', + }, + ], + columns: ['title', 'id', 'content', 'source', 'url'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm notes-rename', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : ''; + if (!title) throw new ArgumentError('Provide the new note title.'); + + const explicitId = typeof kwargs['note-id'] === 'string' ? kwargs['note-id'].trim() : ''; + let noteId = explicitId; + + if (!noteId) { + const query = typeof kwargs.note === 'string' ? kwargs.note.trim() : ''; + if (!query) throw new ArgumentError('Provide either a note title or --note-id.'); + + const rows = await listNotebooklmNotesViaRpc(page); + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_NOTE_NOT_FOUND', + `NotebookLM note "${query}" was not found in the current notebook.`, + `No NotebookLM note titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_NOTE_AMBIGUOUS', + `NotebookLM found multiple notes titled "${query}"`, + 'Use --note-id with notebooklm notes rename when duplicate note titles exist.', + ); + } + noteId = matches[0]!.id ?? ''; + } + + const renamed = await renameNotebooklmNoteViaRpc(page, noteId, title); + if (!renamed) { + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_NOT_FOUND', + `NotebookLM note id "${noteId}" was not found in the current notebook.`, + `No NotebookLM note with id "${noteId}" was found in the current notebook.`, + ); + } + + return [renamed]; + }, +}); diff --git a/src/clis/notebooklm/notes-save.test.ts b/src/clis/notebooklm/notes-save.test.ts new file mode 100644 index 00000000..f1e9c4f1 --- /dev/null +++ b/src/clis/notebooklm/notes-save.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CliError } from '../../errors.js'; + +const { + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, + mockSaveNotebooklmVisibleNoteViaRpc, +} = vi.hoisted(() => ({ + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), + mockSaveNotebooklmVisibleNoteViaRpc: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + saveNotebooklmVisibleNoteViaRpc: mockSaveNotebooklmVisibleNoteViaRpc, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './notes-save.js'; + +describe('notebooklm notes-save', () => { + const command = getRegistry().get('notebooklm/notes-save'); + + beforeEach(() => { + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockSaveNotebooklmVisibleNoteViaRpc.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('saves the currently visible note editor content through rpc and returns the saved row', async () => { + mockSaveNotebooklmVisibleNoteViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '第一段\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockSaveNotebooklmVisibleNoteViaRpc).toHaveBeenCalledWith(expect.anything()); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '第一段\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('passes --note-id through to the rpc save helper', async () => { + mockSaveNotebooklmVisibleNoteViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + id: 'note-2', + title: '新建笔记', + content: '第二条正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { 'note-id': 'note-2' }); + + expect(mockSaveNotebooklmVisibleNoteViaRpc).toHaveBeenCalledWith(expect.anything(), 'note-2'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-2', + title: '新建笔记', + content: '第二条正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('reports a missing visible editor explicitly when --note-id is provided', async () => { + mockSaveNotebooklmVisibleNoteViaRpc.mockResolvedValue(null); + + await expect(command!.func!({} as any, { 'note-id': 'note-2' })).rejects.toMatchObject({ + message: expect.stringMatching(/note-2/), + hint: expect.stringMatching(/visible .*note editor/i), + } satisfies Partial<CliError>); + }); +}); diff --git a/src/clis/notebooklm/notes-save.ts b/src/clis/notebooklm/notes-save.ts new file mode 100644 index 00000000..9cbb5b93 --- /dev/null +++ b/src/clis/notebooklm/notes-save.ts @@ -0,0 +1,57 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, + saveNotebooklmVisibleNoteViaRpc, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'notes-save', + description: 'Save the currently visible NotebookLM note editor via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'note-id', + help: 'Stable note id from notebooklm notes list', + }, + ], + columns: ['title', 'id', 'content', 'source', 'url'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm notes-save', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const noteId = typeof kwargs['note-id'] === 'string' ? kwargs['note-id'].trim() : ''; + const saved = noteId + ? await saveNotebooklmVisibleNoteViaRpc(page, noteId) + : await saveNotebooklmVisibleNoteViaRpc(page); + if (saved) return [saved]; + + if (noteId) { + throw new CliError( + 'NOTEBOOKLM_NOTE_EDITOR_MISSING', + `NotebookLM could not save note "${noteId}" because the current page has no visible note editor.`, + 'Open the target note in a visible NotebookLM note editor, then retry notes-save with the same --note-id.', + ); + } + + throw new EmptyResultError( + 'opencli notebooklm notes-save', + 'Open a NotebookLM note editor first, then retry notes-save.', + ); + }, +}); diff --git a/src/clis/notebooklm/remove-from-recent.test.ts b/src/clis/notebooklm/remove-from-recent.test.ts new file mode 100644 index 00000000..a82942c0 --- /dev/null +++ b/src/clis/notebooklm/remove-from-recent.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmHome, + mockRemoveNotebooklmFromRecentViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmHome: vi.fn(), + mockRemoveNotebooklmFromRecentViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + removeNotebooklmFromRecentViaRpc: mockRemoveNotebooklmFromRecentViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './remove-from-recent.js'; + +describe('notebooklm remove-from-recent', () => { + const command = getRegistry().get('notebooklm/remove-from-recent'); + + beforeEach(() => { + mockEnsureNotebooklmHome.mockReset(); + mockRemoveNotebooklmFromRecentViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmHome.mockResolvedValue(undefined); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockRemoveNotebooklmFromRecentViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + removed_from_recent: true, + source: 'rpc', + }); + }); + + it('removes a notebook from the recent list via rpc', async () => { + const result = await command!.func!({} as any, { notebook_id: 'nb-demo' }); + + expect(mockRemoveNotebooklmFromRecentViaRpc).toHaveBeenCalledWith(expect.anything(), 'nb-demo'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + removed_from_recent: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/remove-from-recent.ts b/src/clis/notebooklm/remove-from-recent.ts new file mode 100644 index 00000000..cfa60d14 --- /dev/null +++ b/src/clis/notebooklm/remove-from-recent.ts @@ -0,0 +1,47 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmHome, + removeNotebooklmFromRecentViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'remove-from-recent', + description: 'Remove a NotebookLM notebook from the home recent list by notebook id', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'notebook_id', + positional: true, + required: true, + help: 'Notebook id to remove from recent', + }, + ], + columns: ['notebook_id', 'removed_from_recent', 'source'], + func: async (page: IPage, kwargs) => { + await requireNotebooklmSession(page); + await ensureNotebooklmHome(page); + + const notebookId = typeof kwargs.notebook_id === 'string' + ? kwargs.notebook_id.trim() + : String(kwargs.notebook_id ?? '').trim(); + if (!notebookId) { + throw new ArgumentError('The notebook id cannot be empty.'); + } + + const result = await removeNotebooklmFromRecentViaRpc(page, notebookId); + if (result) return [result]; + + throw new EmptyResultError( + 'opencli notebooklm remove-from-recent', + 'NotebookLM did not acknowledge the remove-from-recent request.', + ); + }, +}); diff --git a/src/clis/notebooklm/rename.test.ts b/src/clis/notebooklm/rename.test.ts new file mode 100644 index 00000000..6ee30459 --- /dev/null +++ b/src/clis/notebooklm/rename.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmHome, + mockRenameNotebooklmNotebookViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmHome: vi.fn(), + mockRenameNotebooklmNotebookViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + renameNotebooklmNotebookViaRpc: mockRenameNotebooklmNotebookViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './rename.js'; + +describe('notebooklm rename', () => { + const command = getRegistry().get('notebooklm/rename'); + + beforeEach(() => { + mockEnsureNotebooklmHome.mockReset(); + mockRenameNotebooklmNotebookViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockEnsureNotebooklmHome.mockResolvedValue(undefined); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + }); + + it('renames a notebook via rpc and returns the updated notebook row', async () => { + mockRenameNotebooklmNotebookViaRpc.mockResolvedValue({ + id: 'nb-demo', + title: '重命名后的 Notebook', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + is_owner: true, + created_at: null, + updated_at: '2026-03-31T09:30:00.000Z', + emoji: null, + source_count: 0, + }); + + const result = await command!.func!({} as any, { + notebook_id: 'nb-demo', + title: '重命名后的 Notebook', + }); + + expect(mockRenameNotebooklmNotebookViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'nb-demo', + '重命名后的 Notebook', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'nb-demo', + title: '重命名后的 Notebook', + source: 'rpc', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/rename.ts b/src/clis/notebooklm/rename.ts new file mode 100644 index 00000000..8ee0fee4 --- /dev/null +++ b/src/clis/notebooklm/rename.ts @@ -0,0 +1,57 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmHome, + renameNotebooklmNotebookViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'rename', + description: 'Rename a NotebookLM notebook by notebook id', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'notebook_id', + positional: true, + required: true, + help: 'Notebook id to rename', + }, + { + name: 'title', + positional: true, + required: true, + help: 'New title for the notebook', + }, + ], + columns: ['id', 'title', 'updated_at', 'source_count', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await requireNotebooklmSession(page); + await ensureNotebooklmHome(page); + + const notebookId = typeof kwargs.notebook_id === 'string' + ? kwargs.notebook_id.trim() + : String(kwargs.notebook_id ?? '').trim(); + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : String(kwargs.title ?? '').trim(); + if (!notebookId) { + throw new ArgumentError('The notebook id cannot be empty.'); + } + if (!title) { + throw new ArgumentError('The notebook title cannot be empty.'); + } + + const notebook = await renameNotebooklmNotebookViaRpc(page, notebookId, title); + if (notebook) return [notebook]; + + throw new EmptyResultError( + 'opencli notebooklm rename', + 'NotebookLM did not return the updated notebook row after rename.', + ); + }, +}); diff --git a/src/clis/notebooklm/rpc.test.ts b/src/clis/notebooklm/rpc.test.ts new file mode 100644 index 00000000..d7ff461e --- /dev/null +++ b/src/clis/notebooklm/rpc.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { + buildNotebooklmRpcBody, + extractNotebooklmRpcUserDisplayableError, + extractNotebooklmRpcResult, + getNotebooklmPageAuth, + parseNotebooklmChunkedResponse, +} from './rpc.js'; + +describe('notebooklm rpc transport', () => { + it('extracts auth tokens from the page html via page evaluation', async () => { + const page = { + evaluate: vi.fn(async (script: string) => { + expect(script).toContain('document.documentElement.innerHTML'); + return { + html: '<html>"SNlM0e":"csrf-123","FdrFJe":"sess-456"</html>', + sourcePath: '/', + }; + }), + } as unknown as IPage; + + await expect(getNotebooklmPageAuth(page)).resolves.toEqual({ + csrfToken: 'csrf-123', + sessionId: 'sess-456', + sourcePath: '/', + }); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('falls back to WIZ_global_data tokens when html regex data is missing', async () => { + const page = { + evaluate: vi.fn(async () => ({ + html: '<html><body>NotebookLM</body></html>', + sourcePath: '/notebook/nb-demo', + readyState: 'complete', + csrfToken: 'csrf-wiz', + sessionId: 'sess-wiz', + })), + } as unknown as IPage; + + await expect(getNotebooklmPageAuth(page)).resolves.toEqual({ + csrfToken: 'csrf-wiz', + sessionId: 'sess-wiz', + sourcePath: '/notebook/nb-demo', + }); + }); + + it('retries token extraction once when the first probe returns no tokens', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce({ + html: '<html><body>Loading…</body></html>', + sourcePath: '/notebook/nb-demo', + readyState: 'interactive', + csrfToken: '', + sessionId: '', + }) + .mockResolvedValueOnce({ + html: '<html>"SNlM0e":"csrf-123","FdrFJe":"sess-456"</html>', + sourcePath: '/notebook/nb-demo', + readyState: 'complete', + csrfToken: '', + sessionId: '', + }), + wait: vi.fn(async () => undefined), + } as unknown as IPage; + + await expect(getNotebooklmPageAuth(page)).resolves.toEqual({ + csrfToken: 'csrf-123', + sessionId: 'sess-456', + sourcePath: '/notebook/nb-demo', + }); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('builds the rpc body with the expected notebooklm payload shape', () => { + const body = buildNotebooklmRpcBody('wXbhsf', [null, 1, null, [2]], 'csrf-123'); + + expect(body).toContain('f.req='); + expect(body).toContain('at=csrf-123'); + expect(body.endsWith('&')).toBe(true); + expect(decodeURIComponent(body)).toContain('"[null,1,null,[2]]"'); + }); + + it('parses chunked batchexecute responses into json chunks', () => { + const raw = `)]}'\n107\n[["wrb.fr","wXbhsf","[[[\\\"Notebook One\\\",null,\\\"nb1\\\",null,null,[null,false,null,null,null,[1704067200]]]]]"]]`; + const chunks = parseNotebooklmChunkedResponse(raw); + + expect(chunks).toHaveLength(1); + expect(Array.isArray(chunks[0])).toBe(true); + expect(chunks[0]).toEqual([ + [ + 'wrb.fr', + 'wXbhsf', + '[[["Notebook One",null,"nb1",null,null,[null,false,null,null,null,[1704067200]]]]]', + ], + ]); + }); + + it('extracts the rpc payload from wrb.fr responses', () => { + const raw = `)]}'\n107\n[["wrb.fr","wXbhsf","[[[\\\"Notebook One\\\",null,\\\"nb1\\\",null,null,[null,false,null,null,null,[1704067200]]]]]"]]`; + + const result = extractNotebooklmRpcResult(raw, 'wXbhsf'); + + expect(result).toEqual([ + [ + ['Notebook One', null, 'nb1', null, null, [null, false, null, null, null, [1704067200]]], + ], + ]); + }); + + it('extracts a readable UserDisplayableError message from wrb.fr responses when present', () => { + const raw = `)]}'\n228\n[["wrb.fr","R7cb6c",null,null,"You reached your daily limit for audio overviews and slides today. Upgrade to continue.",[8,null,[["type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",[]]]],"generic"]]`; + + expect(extractNotebooklmRpcUserDisplayableError(raw, 'R7cb6c')).toEqual({ + kind: 'UserDisplayableError', + message: 'You reached your daily limit for audio overviews and slides today. Upgrade to continue.', + }); + }); + + it('still identifies UserDisplayableError envelopes when no readable message is present', () => { + const raw = `)]}'\n220\n[["wrb.fr","R7cb6c",null,null,null,[8,null,[["type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",[[null,[[1]]]]]]],"generic"]]`; + + expect(extractNotebooklmRpcUserDisplayableError(raw, 'R7cb6c')).toEqual({ + kind: 'UserDisplayableError', + message: null, + }); + }); + + it('classifies auth errors as AuthRequiredError', () => { + const raw = `)]}'\n25\n[["er",null,null,null,null,401,"generic"]]`; + + expect(() => extractNotebooklmRpcResult(raw, 'wXbhsf')).toThrow(AuthRequiredError); + + try { + extractNotebooklmRpcResult(raw, 'wXbhsf'); + } catch (error) { + expect(error).toBeInstanceOf(AuthRequiredError); + expect((error as AuthRequiredError).domain).toBe('notebooklm.google.com'); + expect((error as AuthRequiredError).code).toBe('AUTH_REQUIRED'); + } + }); +}); diff --git a/src/clis/notebooklm/rpc.ts b/src/clis/notebooklm/rpc.ts new file mode 100644 index 00000000..e3f81e91 --- /dev/null +++ b/src/clis/notebooklm/rpc.ts @@ -0,0 +1,345 @@ +import { AuthRequiredError, CliError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN } from './shared.js'; + +export type NotebooklmPageAuth = { + csrfToken: string; + sessionId: string; + sourcePath: string; +}; + +type NotebooklmAuthProbe = { + html: string; + sourcePath: string; + readyState: string; + csrfToken: string; + sessionId: string; +}; + +export type NotebooklmFetchResponse = { + ok: boolean; + status: number; + body: string; + finalUrl: string; +}; + +export type NotebooklmRpcCallResult = { + auth: NotebooklmPageAuth; + url: string; + requestBody: string; + response: NotebooklmFetchResponse; + result: unknown; +}; + +export type NotebooklmRpcUserDisplayableError = { + kind: 'UserDisplayableError'; + message: string | null; +}; + +function collectNotebooklmChunkStrings(value: unknown, strings: string[]): string[] { + if (typeof value === 'string') { + const normalized = value.trim(); + if (normalized) strings.push(normalized); + return strings; + } + + if (!Array.isArray(value)) return strings; + for (const item of value) collectNotebooklmChunkStrings(item, strings); + return strings; +} + +function pickNotebooklmReadableErrorMessage(strings: string[], rpcId: string): string | null { + const candidates = strings.filter((value) => + value !== 'wrb.fr' && + value !== rpcId && + value !== 'generic' && + !/UserDisplayableError/i.test(value) && + !value.startsWith('type.googleapis.com/') && + !/^[\d-]+$/.test(value), + ); + + const readable = candidates.find((value) => /[\u4e00-\u9fff]/.test(value) || /\s/.test(value) || value.length >= 16); + return readable ?? null; +} + +export function extractNotebooklmPageAuthFromHtml( + html: string, + sourcePath: string = '/', + preferredTokens?: { csrfToken?: string; sessionId?: string }, +): NotebooklmPageAuth { + const csrfMatch = html.match(/"SNlM0e":"([^"]+)"/); + const sessionMatch = html.match(/"FdrFJe":"([^"]+)"/); + const csrfToken = preferredTokens?.csrfToken?.trim() || (csrfMatch ? csrfMatch[1] : ''); + const sessionId = preferredTokens?.sessionId?.trim() || (sessionMatch ? sessionMatch[1] : ''); + + if (!csrfToken || !sessionId) { + throw new CliError( + 'NOTEBOOKLM_TOKENS', + 'NotebookLM page tokens were not found in the current page HTML', + 'Open the NotebookLM notebook page in Chrome, wait for it to finish loading, then retry with --verbose if it still fails.', + ); + } + + return { csrfToken, sessionId, sourcePath: sourcePath || '/' }; +} + +async function probeNotebooklmPageAuth(page: IPage): Promise<NotebooklmAuthProbe> { + const raw = await page.evaluate(`(() => { + const wiz = window.WIZ_global_data || {}; + const html = document.documentElement.innerHTML; + return { + html, + sourcePath: location.pathname || '/', + readyState: document.readyState || '', + csrfToken: typeof wiz.SNlM0e === 'string' ? wiz.SNlM0e : '', + sessionId: typeof wiz.FdrFJe === 'string' ? wiz.FdrFJe : '', + }; + })()`) as Partial<NotebooklmAuthProbe> | null; + + return { + html: String(raw?.html ?? ''), + sourcePath: String(raw?.sourcePath ?? '/'), + readyState: String(raw?.readyState ?? ''), + csrfToken: String(raw?.csrfToken ?? ''), + sessionId: String(raw?.sessionId ?? ''), + }; +} + +export async function getNotebooklmPageAuth(page: IPage): Promise<NotebooklmPageAuth> { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + const probe = await probeNotebooklmPageAuth(page); + try { + return extractNotebooklmPageAuthFromHtml( + probe.html, + probe.sourcePath, + { csrfToken: probe.csrfToken, sessionId: probe.sessionId }, + ); + } catch (error) { + lastError = error; + if (attempt === 0 && typeof page.wait === 'function') { + await page.wait(0.5).catch(() => undefined); + continue; + } + } + } + + throw lastError; +} + +export function buildNotebooklmRpcBody( + rpcId: string, + params: unknown[] | Record<string, unknown> | null, + csrfToken: string, +): string { + const rpcRequest = [[[rpcId, JSON.stringify(params), null, 'generic']]]; + return `f.req=${encodeURIComponent(JSON.stringify(rpcRequest))}&at=${encodeURIComponent(csrfToken)}&`; +} + +export function stripNotebooklmAntiXssi(rawBody: string): string { + if (!rawBody.startsWith(")]}'")) return rawBody; + return rawBody.replace(/^\)\]\}'\r?\n/, ''); +} + +export function parseNotebooklmChunkedResponse(rawBody: string): unknown[] { + const cleaned = stripNotebooklmAntiXssi(rawBody).trim(); + if (!cleaned) return []; + + const lines = cleaned.split('\n'); + const chunks: unknown[] = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line) continue; + + if (/^\d+$/.test(line)) { + const nextLine = lines[i + 1]; + if (!nextLine) continue; + try { + chunks.push(JSON.parse(nextLine)); + } catch { + // Ignore malformed chunks and keep scanning. + } + i += 1; + continue; + } + + if (line.startsWith('[')) { + try { + chunks.push(JSON.parse(line)); + } catch { + // Ignore malformed chunks and keep scanning. + } + } + } + + return chunks; +} + +export function extractNotebooklmRpcResult(rawBody: string, rpcId: string): unknown { + const chunks = parseNotebooklmChunkedResponse(rawBody); + + for (const chunk of chunks) { + if (!Array.isArray(chunk)) continue; + const items = Array.isArray(chunk[0]) ? chunk : [chunk]; + + for (const item of items) { + if (!Array.isArray(item) || item.length < 1) continue; + + if (item[0] === 'er') { + const errorCode = typeof item[2] === 'number' + ? item[2] + : typeof item[5] === 'number' + ? item[5] + : null; + + if (errorCode === 401 || errorCode === 403) { + throw new AuthRequiredError( + NOTEBOOKLM_DOMAIN, + `NotebookLM RPC returned auth error (${errorCode})`, + ); + } + + throw new CliError( + 'NOTEBOOKLM_RPC', + `NotebookLM RPC failed${errorCode ? ` (code=${errorCode})` : ''}`, + 'Retry from an already logged-in NotebookLM session, or inspect the raw response with debug logging.', + ); + } + + if (item[0] === 'wrb.fr' && item[1] === rpcId) { + const payload = item[2]; + if (typeof payload === 'string') { + try { + return JSON.parse(payload); + } catch { + return payload; + } + } + return payload; + } + } + } + + return null; +} + +export function extractNotebooklmRpcUserDisplayableError( + rawBody: string, + rpcId: string, +): NotebooklmRpcUserDisplayableError | null { + const chunks = parseNotebooklmChunkedResponse(rawBody); + + for (const chunk of chunks) { + if (!Array.isArray(chunk)) continue; + const items = Array.isArray(chunk[0]) ? chunk : [chunk]; + + for (const item of items) { + if (!Array.isArray(item) || item[0] !== 'wrb.fr' || item[1] !== rpcId) continue; + const strings = collectNotebooklmChunkStrings(item, []); + const hasUserDisplayableError = strings.some((value) => /UserDisplayableError/i.test(value)); + if (!hasUserDisplayableError) continue; + + return { + kind: 'UserDisplayableError', + message: pickNotebooklmReadableErrorMessage(strings, rpcId), + }; + } + } + + return null; +} + +export async function fetchNotebooklmInPage( + page: IPage, + url: string, + options: { + method?: 'GET' | 'POST'; + headers?: Record<string, string>; + body?: string; + } = {}, +): Promise<NotebooklmFetchResponse> { + const method = options.method ?? 'GET'; + const headers = options.headers ?? {}; + const body = options.body ?? ''; + + const raw = await page.evaluate(`(async () => { + const request = { + url: ${JSON.stringify(url)}, + method: ${JSON.stringify(method)}, + headers: ${JSON.stringify(headers)}, + body: ${JSON.stringify(body)}, + }; + + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.method === 'GET' ? undefined : request.body, + credentials: 'include', + }); + + return { + ok: response.ok, + status: response.status, + body: await response.text(), + finalUrl: response.url, + }; + })()`) as Partial<NotebooklmFetchResponse> | null; + + return { + ok: Boolean(raw?.ok), + status: Number(raw?.status ?? 0), + body: String(raw?.body ?? ''), + finalUrl: String(raw?.finalUrl ?? url), + }; +} + +export async function callNotebooklmRpc( + page: IPage, + rpcId: string, + params: unknown[] | Record<string, unknown> | null, + options: { + hl?: string; + sourcePath?: string; + } = {}, +): Promise<NotebooklmRpcCallResult> { + const auth = await getNotebooklmPageAuth(page); + const requestBody = buildNotebooklmRpcBody(rpcId, params, auth.csrfToken); + const sourcePath = options.sourcePath ?? auth.sourcePath; + const url = + `https://${NOTEBOOKLM_DOMAIN}/_/LabsTailwindUi/data/batchexecute` + + `?rpcids=${rpcId}&source-path=${encodeURIComponent(sourcePath)}` + + `&hl=${encodeURIComponent(options.hl ?? 'en')}` + + `&f.sid=${encodeURIComponent(auth.sessionId)}&rt=c`; + + const response = await fetchNotebooklmInPage(page, url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: requestBody, + }); + + if (response.status === 401 || response.status === 403) { + throw new AuthRequiredError( + NOTEBOOKLM_DOMAIN, + `NotebookLM RPC returned auth error (${response.status})`, + ); + } + + if (!response.ok) { + throw new CliError( + 'NOTEBOOKLM_RPC', + `NotebookLM RPC request failed with HTTP ${response.status}`, + 'Retry from the NotebookLM home page in an already logged-in Chrome session.', + ); + } + + return { + auth, + url, + requestBody, + response, + result: extractNotebooklmRpcResult(response.body, rpcId), + }; +} diff --git a/src/clis/notebooklm/share-status.test.ts b/src/clis/notebooklm/share-status.test.ts new file mode 100644 index 00000000..61704d58 --- /dev/null +++ b/src/clis/notebooklm/share-status.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockGetNotebooklmPageState, + mockGetNotebooklmShareStatusViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockGetNotebooklmPageState: vi.fn(), + mockGetNotebooklmShareStatusViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + getNotebooklmShareStatusViaRpc: mockGetNotebooklmShareStatusViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './share-status.js'; + +describe('notebooklm share-status', () => { + const command = getRegistry().get('notebooklm/share-status'); + + beforeEach(() => { + mockGetNotebooklmPageState.mockReset(); + mockGetNotebooklmShareStatusViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns the current notebook share status via rpc', async () => { + mockGetNotebooklmShareStatusViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + is_public: false, + access: 'restricted', + view_level: 'full', + share_url: null, + shared_user_count: 1, + shared_users: [ + { + email: 'user@example.com', + permission: 'viewer', + display_name: 'User Example', + avatar_url: null, + }, + ], + source: 'rpc', + }); + + const result = await command!.func!({} as any, {}); + + expect(mockGetNotebooklmShareStatusViaRpc).toHaveBeenCalledWith(expect.anything()); + expect(result).toEqual([ + expect.objectContaining({ + notebook_id: 'nb-demo', + access: 'restricted', + source: 'rpc', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/share-status.ts b/src/clis/notebooklm/share-status.ts new file mode 100644 index 00000000..fa2929ff --- /dev/null +++ b/src/clis/notebooklm/share-status.ts @@ -0,0 +1,41 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + getNotebooklmShareStatusViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'share-status', + description: 'Get sharing status for the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['access', 'is_public', 'shared_user_count', 'share_url', 'source'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm share-status', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const status = await getNotebooklmShareStatusViaRpc(page); + if (status) return [status]; + + throw new EmptyResultError( + 'opencli notebooklm share-status', + 'NotebookLM share status was not available for the current notebook.', + ); + }, +}); diff --git a/src/clis/notebooklm/shared.ts b/src/clis/notebooklm/shared.ts new file mode 100644 index 00000000..fe37ed9c --- /dev/null +++ b/src/clis/notebooklm/shared.ts @@ -0,0 +1,289 @@ +export const NOTEBOOKLM_SITE = 'notebooklm'; +export const NOTEBOOKLM_DOMAIN = 'notebooklm.google.com'; +export const NOTEBOOKLM_HOME_URL = 'https://notebooklm.google.com/'; + +export type NotebooklmPageKind = 'notebook' | 'home' | 'unknown'; + +export interface NotebooklmPageState { + url: string; + title: string; + hostname: string; + kind: NotebooklmPageKind; + notebookId: string; + loginRequired: boolean; + notebookCount: number; +} + +export interface NotebooklmRow { + id: string; + title: string; + url: string; + source: 'current-page' | 'home-links' | 'rpc'; + is_owner?: boolean; + created_at?: string | null; +} + +export interface NotebooklmSourceRow { + id: string; + notebook_id: string; + title: string; + url: string; + source: 'current-page' | 'rpc'; + type?: string | null; + type_code?: number | null; + size?: number | null; + created_at?: string | null; + updated_at?: string | null; + status?: 'processing' | 'ready' | 'error' | 'preparing' | 'unknown' | null; + status_code?: number | null; +} + +export interface NotebooklmSourceFulltextRow { + source_id: string; + notebook_id: string; + title: string; + kind?: string | null; + content: string; + char_count: number; + url?: string | null; + source: 'rpc'; +} + +export interface NotebooklmSourceGuideRow { + source_id: string; + notebook_id: string; + title: string; + type?: string | null; + summary: string; + keywords: string[]; + source: 'rpc'; +} + +export interface NotebooklmSourceDeleteRow { + notebook_id: string; + source_id: string; + deleted: boolean; + source: 'rpc'; +} + +export interface NotebooklmSourceFreshnessRow { + notebook_id: string; + source_id: string; + is_fresh: boolean; + is_stale: boolean; + source: 'rpc'; +} + +export interface NotebooklmSourceRefreshRow { + notebook_id: string; + source_id: string; + refreshed: boolean; + source: 'rpc'; +} + +export interface NotebooklmAskRow { + notebook_id: string; + prompt: string; + answer: string; + url: string; + source: 'query-endpoint'; +} + +export interface NotebooklmReportDownloadRow { + notebook_id: string; + artifact_id: string; + title: string; + kind: 'report'; + output_path: string; + created_at?: string | null; + url: string; + source: 'rpc'; +} + +export interface NotebooklmAudioDownloadRow { + notebook_id: string; + artifact_id: string; + artifact_type: 'audio'; + title: string; + output_path: string; + created_at?: string | null; + url: string; + download_url: string; + mime_type?: string | null; + source: 'rpc+artifact-url'; +} + +export interface NotebooklmInfographicDownloadRow { + notebook_id: string; + artifact_id: string; + artifact_type: 'infographic'; + title: string; + output_path: string; + created_at?: string | null; + url: string; + download_url: string; + source: 'rpc+artifact-url'; +} + +export interface NotebooklmVideoDownloadRow { + notebook_id: string; + artifact_id: string; + artifact_type: 'video'; + title: string; + output_path: string; + created_at?: string | null; + url: string; + download_url: string; + mime_type?: string | null; + source: 'rpc+artifact-url'; +} + +export interface NotebooklmDownloadListRow { + notebook_id: string; + artifact_id: string; + artifact_type: 'report' | 'audio' | 'video' | 'infographic' | 'slide_deck'; + status: string; + title: string; + created_at: string | null; + download_variants: string[]; + source: 'rpc+artifact-list'; +} + +export type NotebooklmInfographicOrientation = 'landscape' | 'portrait' | 'square'; +export type NotebooklmInfographicDetail = 'concise' | 'standard' | 'detailed'; +export type NotebooklmInfographicStyle = + | 'auto_select' + | 'sketch_note' + | 'professional' + | 'bento_grid' + | 'editorial' + | 'instructional' + | 'bricks' + | 'clay' + | 'anime' + | 'kawaii' + | 'scientific'; + +export type NotebooklmGenerationErrorType = + | 'daily_limit_reached' + | 'feature_not_eligible' + | 'content_insufficient' + | 'generation_failed_unknown'; + +export interface NotebooklmGenerateRow { + notebook_id: string; + artifact_id: string | null; + artifact_type: 'report' | 'audio' | 'infographic' | 'slide_deck'; + status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'unknown'; + created_at?: string | null; + error_type?: NotebooklmGenerationErrorType | null; + message?: string | null; + source: 'rpc+create-artifact' | 'rpc+create-artifact+artifact-list'; +} + +export type NotebooklmSlideDeckDownloadFormat = 'pdf' | 'pptx'; + +export interface NotebooklmSlideDeckDownloadRow { + notebook_id: string; + artifact_id: string; + artifact_type: 'slide_deck'; + title: string; + output_path: string; + created_at?: string | null; + url: string; + download_url: string; + download_format: NotebooklmSlideDeckDownloadFormat; + source: 'rpc+artifact-url'; +} + +export interface NotebooklmNotebookDetailRow extends NotebooklmRow { + emoji?: string | null; + source_count?: number | null; + updated_at?: string | null; +} + +export interface NotebooklmHistoryRow { + thread_id: string; + notebook_id: string; + item_count: number; + preview?: string | null; + url: string; + source: 'rpc'; +} + +export interface NotebooklmNoteRow { + notebook_id: string; + id?: string | null; + title: string; + created_at?: string | null; + url: string; + source: 'studio-list'; +} + +export interface NotebooklmSummaryRow { + notebook_id: string; + title: string; + summary: string; + url: string; + source: 'summary-dom' | 'rpc'; +} + +export interface NotebooklmSuggestedTopicRow { + question: string; + prompt: string; +} + +export interface NotebooklmNotebookDescriptionRow { + notebook_id: string; + summary: string; + suggested_topics: NotebooklmSuggestedTopicRow[]; + suggested_topic_count: number; + url: string; + source: 'rpc' | 'summary-dom'; +} + +export interface NotebooklmNoteDetailRow { + notebook_id: string; + id?: string | null; + title: string; + content: string; + url: string; + source: 'studio-editor' | 'rpc'; +} + +export interface NotebooklmNoteDeleteRow { + notebook_id: string; + note_id: string; + deleted: boolean; + source: 'rpc'; +} + +export interface NotebooklmShareUserRow { + email: string; + permission: 'owner' | 'editor' | 'viewer' | 'unknown'; + display_name?: string | null; + avatar_url?: string | null; +} + +export interface NotebooklmShareStatusRow { + notebook_id: string; + is_public: boolean; + access: 'restricted' | 'anyone_with_link'; + view_level: 'full' | 'chat_only'; + share_url?: string | null; + shared_user_count: number; + shared_users: NotebooklmShareUserRow[]; + source: 'rpc'; +} + +export interface NotebooklmLanguageRow { + code: string; + name: string; + source: 'static'; +} + +export interface NotebooklmLanguageStatusRow { + language: string; + name?: string | null; + source: 'rpc'; +} diff --git a/src/clis/notebooklm/source-add-drive.test.ts b/src/clis/notebooklm/source-add-drive.test.ts new file mode 100644 index 00000000..b108c6b7 --- /dev/null +++ b/src/clis/notebooklm/source-add-drive.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddNotebooklmDriveSourceViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockAddNotebooklmDriveSourceViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + addNotebooklmDriveSourceViaRpc: mockAddNotebooklmDriveSourceViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-add-drive.js'; + +describe('notebooklm source add-drive', () => { + const command = getRegistry().get('notebooklm/source/add-drive'); + + beforeEach(() => { + mockAddNotebooklmDriveSourceViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('adds a Google Drive source using file id, title, and optional mime type', async () => { + mockAddNotebooklmDriveSourceViaRpc.mockResolvedValue({ + id: 'src-drive', + notebook_id: 'nb-demo', + title: 'Shared Spec', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'type-1', + type_code: 1, + size: null, + created_at: '2026-04-01T02:30:00.000Z', + updated_at: null, + }); + + const result = await command!.func!({} as any, { + 'file-id': '1abcDriveFileIdXYZ', + title: 'Shared Spec', + 'mime-type': 'application/vnd.google-apps.document', + }); + + expect(mockAddNotebooklmDriveSourceViaRpc).toHaveBeenCalledWith( + expect.anything(), + '1abcDriveFileIdXYZ', + 'Shared Spec', + 'application/vnd.google-apps.document', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-drive', + title: 'Shared Spec', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-add-drive.ts b/src/clis/notebooklm/source-add-drive.ts new file mode 100644 index 00000000..f1550cb1 --- /dev/null +++ b/src/clis/notebooklm/source-add-drive.ts @@ -0,0 +1,80 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + addNotebooklmDriveSourceViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/add-drive', + aliases: ['source-add-drive'], + description: 'Add a Google Drive source to the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'file-id', + positional: true, + required: true, + help: 'Google Drive file id to add to the current notebook', + }, + { + name: 'title', + positional: true, + required: true, + help: 'Display title for the Drive source', + }, + { + name: 'mime-type', + help: 'Drive MIME type, for example application/vnd.google-apps.document', + default: 'application/vnd.google-apps.document', + }, + ], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source add-drive', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const fileId = typeof kwargs['file-id'] === 'string' + ? kwargs['file-id'].trim() + : String(kwargs['file-id'] ?? '').trim(); + const title = typeof kwargs.title === 'string' + ? kwargs.title.trim() + : String(kwargs.title ?? '').trim(); + const mimeType = typeof kwargs['mime-type'] === 'string' + ? kwargs['mime-type'].trim() + : String(kwargs['mime-type'] ?? '').trim(); + + if (!fileId) { + throw new ArgumentError('The Google Drive file id cannot be empty.'); + } + if (!title) { + throw new ArgumentError('The Drive source title cannot be empty.'); + } + if (!mimeType) { + throw new ArgumentError('The Drive MIME type cannot be empty.'); + } + + const source = await addNotebooklmDriveSourceViaRpc(page, fileId, title, mimeType); + if (source) return [source]; + + throw new EmptyResultError( + 'opencli notebooklm source add-drive', + 'NotebookLM did not return the created source for this Drive file.', + ); + }, +}); diff --git a/src/clis/notebooklm/source-add-file.test.ts b/src/clis/notebooklm/source-add-file.test.ts new file mode 100644 index 00000000..4c7d909b --- /dev/null +++ b/src/clis/notebooklm/source-add-file.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddNotebooklmFileSourceViaUpload, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockAddNotebooklmFileSourceViaUpload: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + addNotebooklmFileSourceViaUpload: mockAddNotebooklmFileSourceViaUpload, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-add-file.js'; + +describe('notebooklm source add-file', () => { + const command = getRegistry().get('notebooklm/source/add-file'); + + beforeEach(() => { + mockAddNotebooklmFileSourceViaUpload.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('uploads a local file through the notebooklm ingest path and returns the created source row', async () => { + mockAddNotebooklmFileSourceViaUpload.mockResolvedValue({ + id: 'src-file', + notebook_id: 'nb-demo', + title: 'demo.txt', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: null, + type_code: null, + size: 18, + created_at: null, + updated_at: null, + status: 'preparing', + status_code: 5, + }); + + const result = await command!.func!({} as any, { + 'file-path': 'C:\\temp\\demo.txt', + }); + + expect(mockAddNotebooklmFileSourceViaUpload).toHaveBeenCalledWith( + expect.anything(), + 'C:\\temp\\demo.txt', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-file', + title: 'demo.txt', + status: 'preparing', + status_code: 5, + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-add-file.ts b/src/clis/notebooklm/source-add-file.ts new file mode 100644 index 00000000..a2d3cad1 --- /dev/null +++ b/src/clis/notebooklm/source-add-file.ts @@ -0,0 +1,52 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + addNotebooklmFileSourceViaUpload, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/add-file', + aliases: ['source-add-file'], + description: 'Add a local file source to the current NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'file-path', + positional: true, + required: true, + help: 'Local file path to upload into the current notebook', + }, + ], + columns: ['title', 'id', 'type', 'size', 'status', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source add-file', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const filePath = typeof kwargs['file-path'] === 'string' + ? kwargs['file-path'].trim() + : String(kwargs['file-path'] ?? '').trim(); + const source = await addNotebooklmFileSourceViaUpload(page, filePath); + if (source) return [source]; + + throw new EmptyResultError( + 'opencli notebooklm source add-file', + 'NotebookLM did not return a created source for this file upload.', + ); + }, +}); diff --git a/src/clis/notebooklm/source-add-text.test.ts b/src/clis/notebooklm/source-add-text.test.ts new file mode 100644 index 00000000..ec813e0e --- /dev/null +++ b/src/clis/notebooklm/source-add-text.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddNotebooklmTextSourceViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockAddNotebooklmTextSourceViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + addNotebooklmTextSourceViaRpc: mockAddNotebooklmTextSourceViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-add-text.js'; + +describe('notebooklm source-add-text', () => { + const command = getRegistry().get('notebooklm/source-add-text'); + + beforeEach(() => { + mockAddNotebooklmTextSourceViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('adds pasted text to the current notebook and returns the created source row', async () => { + mockAddNotebooklmTextSourceViaRpc.mockResolvedValue({ + id: 'src-created', + notebook_id: 'nb-demo', + title: '贴入内容', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'pasted-text', + type_code: 8, + size: 12, + created_at: '2026-03-30T12:03:03.855Z', + updated_at: null, + }); + + const result = await command!.func!({} as any, { + title: '贴入内容', + content: '第一段\n第二段', + }); + + expect(mockAddNotebooklmTextSourceViaRpc).toHaveBeenCalledWith( + expect.anything(), + '贴入内容', + '第一段\n第二段', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-created', + title: '贴入内容', + type: 'pasted-text', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-add-text.ts b/src/clis/notebooklm/source-add-text.ts new file mode 100644 index 00000000..159b3d0b --- /dev/null +++ b/src/clis/notebooklm/source-add-text.ts @@ -0,0 +1,63 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + addNotebooklmTextSourceViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source-add-text', + description: 'Add a pasted-text source to the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'title', + positional: true, + required: true, + help: 'Title for the pasted-text source', + }, + { + name: 'content', + positional: true, + required: true, + help: 'Text content to add to the current notebook', + }, + ], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-add-text', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : String(kwargs.title ?? '').trim(); + const content = typeof kwargs.content === 'string' ? kwargs.content.trim() : String(kwargs.content ?? '').trim(); + if (!title) { + throw new ArgumentError('The source title cannot be empty.'); + } + if (!content) { + throw new ArgumentError('The source content cannot be empty.'); + } + + const source = await addNotebooklmTextSourceViaRpc(page, title, content); + if (source) return [source]; + + throw new EmptyResultError( + 'opencli notebooklm source-add-text', + 'NotebookLM did not return the created source for this text payload.', + ); + }, +}); diff --git a/src/clis/notebooklm/source-add-url.test.ts b/src/clis/notebooklm/source-add-url.test.ts new file mode 100644 index 00000000..fff36e63 --- /dev/null +++ b/src/clis/notebooklm/source-add-url.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddNotebooklmUrlSourceViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockAddNotebooklmUrlSourceViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + addNotebooklmUrlSourceViaRpc: mockAddNotebooklmUrlSourceViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-add-url.js'; + +describe('notebooklm source-add-url', () => { + const command = getRegistry().get('notebooklm/source-add-url'); + + beforeEach(() => { + mockAddNotebooklmUrlSourceViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('adds a url source to the current notebook and returns the created source row', async () => { + mockAddNotebooklmUrlSourceViaRpc.mockResolvedValue({ + id: 'src-url', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: null, + }); + + const result = await command!.func!({} as any, { + url: 'https://example.com/article', + }); + + expect(mockAddNotebooklmUrlSourceViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'https://example.com/article', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-url', + title: 'Example Domain', + type: 'web', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-add-url.ts b/src/clis/notebooklm/source-add-url.ts new file mode 100644 index 00000000..6b2aa9a8 --- /dev/null +++ b/src/clis/notebooklm/source-add-url.ts @@ -0,0 +1,53 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + addNotebooklmUrlSourceViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source-add-url', + description: 'Add a URL source to the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'url', + positional: true, + required: true, + help: 'URL to add to the current notebook', + }, + ], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-add-url', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const url = typeof kwargs.url === 'string' ? kwargs.url.trim() : String(kwargs.url ?? '').trim(); + if (!url) { + throw new ArgumentError('The source URL cannot be empty.'); + } + + const source = await addNotebooklmUrlSourceViaRpc(page, url); + if (source) return [source]; + + throw new EmptyResultError( + 'opencli notebooklm source-add-url', + 'NotebookLM did not return the created source for this URL.', + ); + }, +}); diff --git a/src/clis/notebooklm/source-check-freshness.test.ts b/src/clis/notebooklm/source-check-freshness.test.ts new file mode 100644 index 00000000..a000050e --- /dev/null +++ b/src/clis/notebooklm/source-check-freshness.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCheckNotebooklmSourceFreshnessViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmSourcesViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockCheckNotebooklmSourceFreshnessViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + checkNotebooklmSourceFreshnessViaRpc: mockCheckNotebooklmSourceFreshnessViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-check-freshness.js'; + +describe('notebooklm source check-freshness', () => { + const command = getRegistry().get('notebooklm/source/check-freshness'); + + beforeEach(() => { + mockCheckNotebooklmSourceFreshnessViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmSourcesViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('checks source freshness via rpc when --source-id is provided', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }, + ]); + mockCheckNotebooklmSourceFreshnessViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + source_id: 'src-1', + is_fresh: false, + is_stale: true, + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + 'source-id': 'src-1', + }); + + expect(mockCheckNotebooklmSourceFreshnessViaRpc).toHaveBeenCalledWith(expect.anything(), 'src-1'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + source_id: 'src-1', + is_fresh: false, + is_stale: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/source-check-freshness.ts b/src/clis/notebooklm/source-check-freshness.ts new file mode 100644 index 00000000..4eb15571 --- /dev/null +++ b/src/clis/notebooklm/source-check-freshness.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + checkNotebooklmSourceFreshnessViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/check-freshness', + aliases: ['source-check-freshness'], + description: 'Check whether a source is fresh in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: false, + help: 'Existing source title when not using --source-id', + }, + { + name: 'source-id', + help: 'Stable source id from notebooklm source list', + }, + ], + columns: ['source_id', 'is_fresh', 'is_stale', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-check-freshness', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const explicitId = typeof kwargs['source-id'] === 'string' ? kwargs['source-id'].trim() : ''; + let sourceId = explicitId; + const rows = await listNotebooklmSourcesViaRpc(page); + + if (sourceId) { + const matched = rows.find((row) => row.id === sourceId) ?? null; + if (!matched) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + } else { + const query = typeof kwargs.source === 'string' ? kwargs.source.trim() : ''; + if (!query) throw new ArgumentError('Provide either a source title or --source-id.'); + + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_NOT_FOUND', + `NotebookLM source "${query}" was not found in the current notebook.`, + `No NotebookLM source titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_AMBIGUOUS', + `NotebookLM found multiple sources titled "${query}"`, + 'Use --source-id with notebooklm source check-freshness when duplicate source titles exist.', + ); + } + sourceId = matches[0]!.id; + } + + const freshness = await checkNotebooklmSourceFreshnessViaRpc(page, sourceId); + if (!freshness) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + + return [freshness]; + }, +}); diff --git a/src/clis/notebooklm/source-delete.test.ts b/src/clis/notebooklm/source-delete.test.ts new file mode 100644 index 00000000..77333d99 --- /dev/null +++ b/src/clis/notebooklm/source-delete.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDeleteNotebooklmSourceViaRpc, + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmSourcesViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockDeleteNotebooklmSourceViaRpc: vi.fn(), + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + deleteNotebooklmSourceViaRpc: mockDeleteNotebooklmSourceViaRpc, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-delete.js'; + +describe('notebooklm source delete', () => { + const command = getRegistry().get('notebooklm/source/delete'); + + beforeEach(() => { + mockDeleteNotebooklmSourceViaRpc.mockReset(); + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmSourcesViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('deletes a source via rpc when --source-id is provided', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }, + ]); + mockDeleteNotebooklmSourceViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + source_id: 'src-1', + deleted: true, + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + 'source-id': 'src-1', + }); + + expect(mockDeleteNotebooklmSourceViaRpc).toHaveBeenCalledWith(expect.anything(), 'src-1'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + source_id: 'src-1', + deleted: true, + source: 'rpc', + }, + ]); + }); + + it('falls back to a unique exact source title when --source-id is omitted', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }, + ]); + mockDeleteNotebooklmSourceViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + source_id: 'src-1', + deleted: true, + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + source: 'Example Domain', + }); + + expect(mockDeleteNotebooklmSourceViaRpc).toHaveBeenCalledWith(expect.anything(), 'src-1'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + source_id: 'src-1', + deleted: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/source-delete.ts b/src/clis/notebooklm/source-delete.ts new file mode 100644 index 00000000..8ad69865 --- /dev/null +++ b/src/clis/notebooklm/source-delete.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + deleteNotebooklmSourceViaRpc, + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/delete', + aliases: ['source-delete'], + description: 'Delete a source in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: false, + help: 'Existing source title when not using --source-id', + }, + { + name: 'source-id', + help: 'Stable source id from notebooklm source list', + }, + ], + columns: ['source_id', 'deleted', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-delete', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const explicitId = typeof kwargs['source-id'] === 'string' ? kwargs['source-id'].trim() : ''; + let sourceId = explicitId; + const rows = await listNotebooklmSourcesViaRpc(page); + + if (sourceId) { + const matched = rows.find((row) => row.id === sourceId) ?? null; + if (!matched) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + } else { + const query = typeof kwargs.source === 'string' ? kwargs.source.trim() : ''; + if (!query) throw new ArgumentError('Provide either a source title or --source-id.'); + + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_NOT_FOUND', + `NotebookLM source "${query}" was not found in the current notebook.`, + `No NotebookLM source titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_AMBIGUOUS', + `NotebookLM found multiple sources titled "${query}"`, + 'Use --source-id with notebooklm source delete when duplicate source titles exist.', + ); + } + sourceId = matches[0]!.id; + } + + const deleted = await deleteNotebooklmSourceViaRpc(page, sourceId); + if (!deleted) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + + return [deleted]; + }, +}); diff --git a/src/clis/notebooklm/source-fulltext.test.ts b/src/clis/notebooklm/source-fulltext.test.ts new file mode 100644 index 00000000..7e3ad4aa --- /dev/null +++ b/src/clis/notebooklm/source-fulltext.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmSourcesViaRpc, + mockListNotebooklmSourcesFromPage, + mockGetNotebooklmSourceFulltextViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockListNotebooklmSourcesFromPage: vi.fn(), + mockGetNotebooklmSourceFulltextViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + listNotebooklmSourcesFromPage: mockListNotebooklmSourcesFromPage, + getNotebooklmSourceFulltextViaRpc: mockGetNotebooklmSourceFulltextViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-fulltext.js'; + +describe('notebooklm source-fulltext', () => { + const command = getRegistry().get('notebooklm/source-fulltext'); + + beforeEach(() => { + mockListNotebooklmSourcesViaRpc.mockReset(); + mockListNotebooklmSourcesFromPage.mockReset(); + mockGetNotebooklmSourceFulltextViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns fulltext for a source matched from rpc source rows', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'pasted-text', + }, + ]); + mockGetNotebooklmSourceFulltextViaRpc.mockResolvedValue({ + source_id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + kind: 'generated-text', + content: '第一段\n第二段', + char_count: 7, + url: 'https://example.com/source', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { source: 'src-1' }); + + expect(result).toEqual([ + { + source_id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + kind: 'generated-text', + content: '第一段\n第二段', + char_count: 7, + url: 'https://example.com/source', + source: 'rpc', + }, + ]); + }); + + it('matches by title from dom rows when rpc source list is unavailable', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([]); + mockListNotebooklmSourcesFromPage.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'current-page', + }, + ]); + mockGetNotebooklmSourceFulltextViaRpc.mockResolvedValue({ + source_id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + kind: 'generated-text', + content: '第一段\n第二段', + char_count: 7, + url: 'https://example.com/source', + source: 'rpc', + }); + + const result = await command!.func!({} as any, { source: '粘贴的文字' }); + + expect(result).toEqual([ + expect.objectContaining({ + source_id: 'src-1', + title: '粘贴的文字', + content: '第一段\n第二段', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-fulltext.ts b/src/clis/notebooklm/source-fulltext.ts new file mode 100644 index 00000000..5ba5d522 --- /dev/null +++ b/src/clis/notebooklm/source-fulltext.ts @@ -0,0 +1,70 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + findNotebooklmSourceRow, + getNotebooklmPageState, + getNotebooklmSourceFulltextViaRpc, + listNotebooklmSourcesFromPage, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/fulltext', + aliases: ['source-fulltext'], + description: 'Get the extracted fulltext for one source in the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: true, + help: 'Source id or title from the current notebook', + }, + ], + columns: ['title', 'kind', 'char_count', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-fulltext', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); + const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); + if (rows.length === 0) { + throw new EmptyResultError( + 'opencli notebooklm source-fulltext', + 'No NotebookLM sources were found on the current page.', + ); + } + + const query = typeof kwargs.source === 'string' ? kwargs.source : String(kwargs.source ?? ''); + const matched = findNotebooklmSourceRow(rows, query); + if (!matched) { + throw new EmptyResultError( + 'opencli notebooklm source-fulltext', + `Source "${query}" was not found in the current notebook.`, + ); + } + + const fulltext = await getNotebooklmSourceFulltextViaRpc(page, matched.id).catch(() => null); + if (fulltext) return [fulltext]; + + throw new EmptyResultError( + 'opencli notebooklm source-fulltext', + `NotebookLM fulltext was not available for source "${matched.title}".`, + ); + }, +}); diff --git a/src/clis/notebooklm/source-get.test.ts b/src/clis/notebooklm/source-get.test.ts new file mode 100644 index 00000000..d9af61e6 --- /dev/null +++ b/src/clis/notebooklm/source-get.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmSourcesViaRpc, + mockListNotebooklmSourcesFromPage, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockListNotebooklmSourcesFromPage: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + listNotebooklmSourcesFromPage: mockListNotebooklmSourcesFromPage, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-get.js'; + +describe('notebooklm source-get', () => { + const command = getRegistry().get('notebooklm/source-get'); + + beforeEach(() => { + mockListNotebooklmSourcesViaRpc.mockReset(); + mockListNotebooklmSourcesFromPage.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns a source by exact id from rpc results', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Release Notes', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + }, + ]); + + const result = await command!.func!({} as any, { source: 'src-1' }); + + expect(result).toEqual([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Release Notes', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + }, + ]); + expect(mockListNotebooklmSourcesFromPage).not.toHaveBeenCalled(); + }); + + it('falls back to page results and matches by title when rpc is empty', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([]); + mockListNotebooklmSourcesFromPage.mockResolvedValue([ + { + id: 'Meeting Notes', + notebook_id: 'nb-demo', + title: 'Meeting Notes', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'current-page', + }, + ]); + + const result = await command!.func!({} as any, { source: 'meeting notes' }); + + expect(result).toEqual([ + { + id: 'Meeting Notes', + notebook_id: 'nb-demo', + title: 'Meeting Notes', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'current-page', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/source-get.ts b/src/clis/notebooklm/source-get.ts new file mode 100644 index 00000000..467d3402 --- /dev/null +++ b/src/clis/notebooklm/source-get.ts @@ -0,0 +1,61 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + findNotebooklmSourceRow, + getNotebooklmPageState, + listNotebooklmSourcesFromPage, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/get', + aliases: ['source-get'], + description: 'Get one source from the currently opened NotebookLM notebook by id or title', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: true, + help: 'Source id or title from the current notebook', + }, + ], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-get', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); + const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); + if (rows.length === 0) { + throw new EmptyResultError( + 'opencli notebooklm source-get', + 'No NotebookLM sources were found on the current page.', + ); + } + + const query = typeof kwargs.source === 'string' ? kwargs.source : String(kwargs.source ?? ''); + const matched = findNotebooklmSourceRow(rows, query); + if (matched) return [matched]; + + throw new EmptyResultError( + 'opencli notebooklm source-get', + `Source "${query}" was not found in the current notebook.`, + ); + }, +}); diff --git a/src/clis/notebooklm/source-guide.test.ts b/src/clis/notebooklm/source-guide.test.ts new file mode 100644 index 00000000..2b0e9083 --- /dev/null +++ b/src/clis/notebooklm/source-guide.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmSourcesViaRpc, + mockListNotebooklmSourcesFromPage, + mockGetNotebooklmSourceGuideViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockListNotebooklmSourcesFromPage: vi.fn(), + mockGetNotebooklmSourceGuideViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + listNotebooklmSourcesFromPage: mockListNotebooklmSourcesFromPage, + getNotebooklmSourceGuideViaRpc: mockGetNotebooklmSourceGuideViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-guide.js'; + +describe('notebooklm source-guide', () => { + const command = getRegistry().get('notebooklm/source-guide'); + + beforeEach(() => { + mockListNotebooklmSourcesViaRpc.mockReset(); + mockListNotebooklmSourcesFromPage.mockReset(); + mockGetNotebooklmSourceGuideViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns source guide for a source matched from rpc source rows', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'youtube', + type_code: 9, + }, + ]); + mockGetNotebooklmSourceGuideViaRpc.mockResolvedValue({ + source_id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + type: 'youtube', + summary: 'Guide summary.', + keywords: ['AI', 'agents'], + source: 'rpc', + }); + + const result = await command!.func!({} as any, { source: 'src-yt' }); + + expect(result).toEqual([ + { + source_id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + type: 'youtube', + summary: 'Guide summary.', + keywords: ['AI', 'agents'], + source: 'rpc', + }, + ]); + }); + + it('matches by title from dom rows when rpc source list is unavailable', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([]); + mockListNotebooklmSourcesFromPage.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Source', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'current-page', + }, + ]); + mockGetNotebooklmSourceGuideViaRpc.mockResolvedValue({ + source_id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Source', + type: null, + summary: 'Guide summary.', + keywords: ['topic'], + source: 'rpc', + }); + + const result = await command!.func!({} as any, { source: 'example source' }); + + expect(result).toEqual([ + expect.objectContaining({ + source_id: 'src-1', + title: 'Example Source', + summary: 'Guide summary.', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-guide.ts b/src/clis/notebooklm/source-guide.ts new file mode 100644 index 00000000..be398333 --- /dev/null +++ b/src/clis/notebooklm/source-guide.ts @@ -0,0 +1,70 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + findNotebooklmSourceRow, + getNotebooklmPageState, + getNotebooklmSourceGuideViaRpc, + listNotebooklmSourcesFromPage, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/guide', + aliases: ['source-guide'], + description: 'Get the guide summary and keywords for one source in the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: true, + help: 'Source id or title from the current notebook', + }, + ], + columns: ['source_id', 'notebook_id', 'title', 'type', 'summary', 'keywords', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-guide', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); + const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page); + if (rows.length === 0) { + throw new EmptyResultError( + 'opencli notebooklm source-guide', + 'No NotebookLM sources were found on the current page.', + ); + } + + const query = typeof kwargs.source === 'string' ? kwargs.source : String(kwargs.source ?? ''); + const matched = findNotebooklmSourceRow(rows, query); + if (!matched) { + throw new EmptyResultError( + 'opencli notebooklm source-guide', + `Source "${query}" was not found in the current notebook.`, + ); + } + + const guide = await getNotebooklmSourceGuideViaRpc(page, matched).catch(() => null); + if (guide) return [guide]; + + throw new EmptyResultError( + 'opencli notebooklm source-guide', + `NotebookLM guide was not available for source "${matched.title}".`, + ); + }, +}); diff --git a/src/clis/notebooklm/source-list.ts b/src/clis/notebooklm/source-list.ts new file mode 100644 index 00000000..5e5a028d --- /dev/null +++ b/src/clis/notebooklm/source-list.ts @@ -0,0 +1,46 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmSourcesFromPage, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/list', + aliases: ['source-list'], + description: 'List sources for the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-list', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []); + if (rpcRows.length > 0) return rpcRows; + + const domRows = await listNotebooklmSourcesFromPage(page); + if (domRows.length > 0) return domRows; + + throw new EmptyResultError( + 'opencli notebooklm source-list', + 'No NotebookLM sources were found on the current page.', + ); + }, +}); diff --git a/src/clis/notebooklm/source-refresh.test.ts b/src/clis/notebooklm/source-refresh.test.ts new file mode 100644 index 00000000..179c7d1d --- /dev/null +++ b/src/clis/notebooklm/source-refresh.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmSourcesViaRpc, + mockRefreshNotebooklmSourceViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockRefreshNotebooklmSourceViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + refreshNotebooklmSourceViaRpc: mockRefreshNotebooklmSourceViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-refresh.js'; + +describe('notebooklm source refresh', () => { + const command = getRegistry().get('notebooklm/source/refresh'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmSourcesViaRpc.mockReset(); + mockRefreshNotebooklmSourceViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('refreshes a source via rpc when --source-id is provided', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }, + ]); + mockRefreshNotebooklmSourceViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + source_id: 'src-1', + refreshed: true, + source: 'rpc', + }); + + const result = await command!.func!({} as any, { + 'source-id': 'src-1', + }); + + expect(mockRefreshNotebooklmSourceViaRpc).toHaveBeenCalledWith(expect.anything(), 'src-1'); + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + source_id: 'src-1', + refreshed: true, + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/source-refresh.ts b/src/clis/notebooklm/source-refresh.ts new file mode 100644 index 00000000..924676fd --- /dev/null +++ b/src/clis/notebooklm/source-refresh.ts @@ -0,0 +1,92 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmSourcesViaRpc, + refreshNotebooklmSourceViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/refresh', + aliases: ['source-refresh'], + description: 'Refresh a source in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source', + positional: true, + required: false, + help: 'Existing source title when not using --source-id', + }, + { + name: 'source-id', + help: 'Stable source id from notebooklm source list', + }, + ], + columns: ['source_id', 'refreshed', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-refresh', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const explicitId = typeof kwargs['source-id'] === 'string' ? kwargs['source-id'].trim() : ''; + let sourceId = explicitId; + const rows = await listNotebooklmSourcesViaRpc(page); + + if (sourceId) { + const matched = rows.find((row) => row.id === sourceId) ?? null; + if (!matched) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + } else { + const query = typeof kwargs.source === 'string' ? kwargs.source.trim() : ''; + if (!query) throw new ArgumentError('Provide either a source title or --source-id.'); + + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_NOT_FOUND', + `NotebookLM source "${query}" was not found in the current notebook.`, + `No NotebookLM source titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_AMBIGUOUS', + `NotebookLM found multiple sources titled "${query}"`, + 'Use --source-id with notebooklm source refresh when duplicate source titles exist.', + ); + } + sourceId = matches[0]!.id; + } + + const refreshed = await refreshNotebooklmSourceViaRpc(page, sourceId); + if (!refreshed) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + + return [refreshed]; + }, +}); diff --git a/src/clis/notebooklm/source-rename.test.ts b/src/clis/notebooklm/source-rename.test.ts new file mode 100644 index 00000000..9c9824db --- /dev/null +++ b/src/clis/notebooklm/source-rename.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockListNotebooklmSourcesViaRpc, + mockRenameNotebooklmSourceViaRpc, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockListNotebooklmSourcesViaRpc: vi.fn(), + mockRenameNotebooklmSourceViaRpc: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmSourcesViaRpc: mockListNotebooklmSourcesViaRpc, + renameNotebooklmSourceViaRpc: mockRenameNotebooklmSourceViaRpc, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-rename.js'; + +describe('notebooklm source rename', () => { + const command = getRegistry().get('notebooklm/source/rename'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockListNotebooklmSourcesViaRpc.mockReset(); + mockRenameNotebooklmSourceViaRpc.mockReset(); + mockRequireNotebooklmSession.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('renames a source via rpc when --source-id is provided', async () => { + mockRenameNotebooklmSourceViaRpc.mockResolvedValue({ + id: 'src-1', + notebook_id: 'nb-demo', + title: '重命名后的来源', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }); + + const result = await command!.func!({} as any, { + 'source-id': 'src-1', + title: '重命名后的来源', + }); + + expect(mockRenameNotebooklmSourceViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'src-1', + '重命名后的来源', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-1', + title: '重命名后的来源', + type: 'web', + }), + ]); + }); + + it('falls back to a unique exact source title when --source-id is omitted', async () => { + mockListNotebooklmSourcesViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:40:00.000Z', + }, + ]); + mockRenameNotebooklmSourceViaRpc.mockResolvedValue({ + id: 'src-1', + notebook_id: 'nb-demo', + title: 'Example Domain Updated', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'web', + type_code: 5, + size: 42, + created_at: '2026-03-31T01:30:00.000Z', + updated_at: '2026-03-31T01:45:00.000Z', + }); + + const result = await command!.func!({} as any, { + source: 'Example Domain', + title: 'Example Domain Updated', + }); + + expect(mockRenameNotebooklmSourceViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'src-1', + 'Example Domain Updated', + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-1', + title: 'Example Domain Updated', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-rename.ts b/src/clis/notebooklm/source-rename.ts new file mode 100644 index 00000000..9754c4da --- /dev/null +++ b/src/clis/notebooklm/source-rename.ts @@ -0,0 +1,90 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { ArgumentError, CliError, EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + listNotebooklmSourcesViaRpc, + renameNotebooklmSourceViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/rename', + aliases: ['source-rename'], + description: 'Rename a source in the current NotebookLM notebook via RPC', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'title', + positional: true, + required: true, + help: 'New title for the source', + }, + { + name: 'source', + help: 'Existing source title when not using --source-id', + }, + { + name: 'source-id', + help: 'Stable source id from notebooklm source list', + }, + ], + columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source-rename', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : ''; + if (!title) throw new ArgumentError('Provide the new source title.'); + + const explicitId = typeof kwargs['source-id'] === 'string' ? kwargs['source-id'].trim() : ''; + let sourceId = explicitId; + + if (!sourceId) { + const query = typeof kwargs.source === 'string' ? kwargs.source.trim() : ''; + if (!query) throw new ArgumentError('Provide either a source title or --source-id.'); + + const rows = await listNotebooklmSourcesViaRpc(page); + const matches = rows.filter((row) => row.title.trim().toLowerCase() === query.toLowerCase()); + if (matches.length === 0) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_NOT_FOUND', + `NotebookLM source "${query}" was not found in the current notebook.`, + `No NotebookLM source titled "${query}" was found in the current notebook.`, + ); + } + if (matches.length > 1) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_AMBIGUOUS', + `NotebookLM found multiple sources titled "${query}"`, + 'Use --source-id with notebooklm source rename when duplicate source titles exist.', + ); + } + sourceId = matches[0]!.id; + } + + const renamed = await renameNotebooklmSourceViaRpc(page, sourceId, title); + if (!renamed) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ID_NOT_FOUND', + `NotebookLM source id "${sourceId}" was not found in the current notebook.`, + `No NotebookLM source with id "${sourceId}" was found in the current notebook.`, + ); + } + + return [renamed]; + }, +}); diff --git a/src/clis/notebooklm/source-wait-for-sources.test.ts b/src/clis/notebooklm/source-wait-for-sources.test.ts new file mode 100644 index 00000000..10332eff --- /dev/null +++ b/src/clis/notebooklm/source-wait-for-sources.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, + mockWaitForNotebooklmSourcesReadyViaRpc, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), + mockWaitForNotebooklmSourcesReadyViaRpc: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + waitForNotebooklmSourcesReadyViaRpc: mockWaitForNotebooklmSourcesReadyViaRpc, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-wait-for-sources.js'; + +describe('notebooklm source wait-for-sources', () => { + const command = getRegistry().get('notebooklm/source/wait-for-sources'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockWaitForNotebooklmSourcesReadyViaRpc.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('waits for multiple comma-separated source ids and returns ready rows in the same order', async () => { + mockWaitForNotebooklmSourcesReadyViaRpc.mockResolvedValue([ + { + id: 'src-1', + notebook_id: 'nb-demo', + title: 'doc-1.txt', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'markdown', + type_code: 8, + size: 12, + created_at: null, + updated_at: null, + status: 'ready', + status_code: 2, + }, + { + id: 'src-2', + notebook_id: 'nb-demo', + title: 'doc-2.txt', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'markdown', + type_code: 8, + size: 13, + created_at: null, + updated_at: null, + status: 'ready', + status_code: 2, + }, + ]); + + const result = await command!.func!({} as any, { + 'source-ids': 'src-1, src-2', + timeout: 45, + 'initial-interval': 0.5, + 'max-interval': 5, + }); + + expect(mockWaitForNotebooklmSourcesReadyViaRpc).toHaveBeenCalledWith( + expect.anything(), + ['src-1', 'src-2'], + expect.objectContaining({ + timeout: 45, + initialInterval: 0.5, + maxInterval: 5, + }), + ); + expect(result).toEqual([ + expect.objectContaining({ id: 'src-1', status: 'ready' }), + expect.objectContaining({ id: 'src-2', status: 'ready' }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-wait-for-sources.ts b/src/clis/notebooklm/source-wait-for-sources.ts new file mode 100644 index 00000000..75e93252 --- /dev/null +++ b/src/clis/notebooklm/source-wait-for-sources.ts @@ -0,0 +1,73 @@ +import { ArgumentError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, + waitForNotebooklmSourcesReadyViaRpc, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/wait-for-sources', + aliases: ['source-wait-for-sources'], + description: 'Wait until one or more NotebookLM sources are fully processed', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source-ids', + positional: true, + required: true, + help: 'Comma-separated source ids to wait for', + }, + { + name: 'initial-interval', + help: 'Initial polling interval in seconds', + default: 1, + }, + { + name: 'max-interval', + help: 'Maximum polling interval in seconds', + default: 10, + }, + { + name: 'timeout', + help: 'Overall wait timeout in seconds', + default: 120, + }, + ], + columns: ['title', 'id', 'type', 'size', 'status', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source wait-for-sources', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const rawIds = typeof kwargs['source-ids'] === 'string' + ? kwargs['source-ids'] + : String(kwargs['source-ids'] ?? ''); + const sourceIds = rawIds + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (sourceIds.length === 0) { + throw new ArgumentError('Provide at least one source id.'); + } + + return waitForNotebooklmSourcesReadyViaRpc(page, sourceIds, { + timeout: Number(kwargs.timeout ?? 120), + initialInterval: Number(kwargs['initial-interval'] ?? 1), + maxInterval: Number(kwargs['max-interval'] ?? 10), + }); + }, +}); diff --git a/src/clis/notebooklm/source-wait.test.ts b/src/clis/notebooklm/source-wait.test.ts new file mode 100644 index 00000000..585c68e7 --- /dev/null +++ b/src/clis/notebooklm/source-wait.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockEnsureNotebooklmNotebookBinding, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, + mockWaitForNotebooklmSourceReadyViaRpc, +} = vi.hoisted(() => ({ + mockEnsureNotebooklmNotebookBinding: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), + mockWaitForNotebooklmSourceReadyViaRpc: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + ensureNotebooklmNotebookBinding: mockEnsureNotebooklmNotebookBinding, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + waitForNotebooklmSourceReadyViaRpc: mockWaitForNotebooklmSourceReadyViaRpc, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './source-wait.js'; + +describe('notebooklm source wait', () => { + const command = getRegistry().get('notebooklm/source/wait'); + + beforeEach(() => { + mockEnsureNotebooklmNotebookBinding.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockWaitForNotebooklmSourceReadyViaRpc.mockReset(); + + mockEnsureNotebooklmNotebookBinding.mockResolvedValue(false); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('waits for a single source id by delegating to the shared wait core', async () => { + mockWaitForNotebooklmSourceReadyViaRpc.mockResolvedValue({ + id: 'src-1', + notebook_id: 'nb-demo', + title: 'doc-1.txt', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'markdown', + type_code: 8, + size: 12, + created_at: null, + updated_at: null, + status: 'ready', + status_code: 2, + }); + + const result = await command!.func!({} as any, { + 'source-id': 'src-1', + timeout: 30, + 'initial-interval': 0.25, + 'max-interval': 3, + }); + + expect(mockWaitForNotebooklmSourceReadyViaRpc).toHaveBeenCalledWith( + expect.anything(), + 'src-1', + expect.objectContaining({ + timeout: 30, + initialInterval: 0.25, + maxInterval: 3, + }), + ); + expect(result).toEqual([ + expect.objectContaining({ + id: 'src-1', + status: 'ready', + }), + ]); + }); +}); diff --git a/src/clis/notebooklm/source-wait.ts b/src/clis/notebooklm/source-wait.ts new file mode 100644 index 00000000..88363cfb --- /dev/null +++ b/src/clis/notebooklm/source-wait.ts @@ -0,0 +1,71 @@ +import { EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + requireNotebooklmSession, + waitForNotebooklmSourceReadyViaRpc, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'source/wait', + aliases: ['source-wait'], + description: 'Wait until a NotebookLM source is fully processed', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [ + { + name: 'source-id', + positional: true, + required: true, + help: 'Source id to wait for', + }, + { + name: 'initial-interval', + help: 'Initial polling interval in seconds', + default: 1, + }, + { + name: 'max-interval', + help: 'Maximum polling interval in seconds', + default: 10, + }, + { + name: 'timeout', + help: 'Overall wait timeout in seconds', + default: 120, + }, + ], + columns: ['title', 'id', 'type', 'size', 'status', 'created_at', 'updated_at', 'url', 'source'], + func: async (page: IPage, kwargs) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm source wait', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const sourceId = typeof kwargs['source-id'] === 'string' + ? kwargs['source-id'].trim() + : String(kwargs['source-id'] ?? '').trim(); + const source = await waitForNotebooklmSourceReadyViaRpc(page, sourceId, { + timeout: Number(kwargs.timeout ?? 120), + initialInterval: Number(kwargs['initial-interval'] ?? 1), + maxInterval: Number(kwargs['max-interval'] ?? 10), + }); + if (source) return [source]; + + throw new EmptyResultError( + 'opencli notebooklm source wait', + `NotebookLM did not return source "${sourceId}" after waiting.`, + ); + }, +}); diff --git a/src/clis/notebooklm/status.ts b/src/clis/notebooklm/status.ts new file mode 100644 index 00000000..fbf461dc --- /dev/null +++ b/src/clis/notebooklm/status.ts @@ -0,0 +1,34 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_SITE } from './shared.js'; +import { ensureNotebooklmNotebookBinding, getNotebooklmPageState } from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'status', + description: 'Check NotebookLM page availability and login state in the current Chrome session', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['status', 'login', 'page', 'url', 'title', 'notebooks'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + const currentUrl = await page.getCurrentUrl?.().catch(() => null); + if (!currentUrl || !currentUrl.includes(NOTEBOOKLM_DOMAIN)) { + await page.goto(NOTEBOOKLM_HOME_URL); + await page.wait(2); + } + + const state = await getNotebooklmPageState(page); + return [{ + status: state.hostname === NOTEBOOKLM_DOMAIN ? 'Connected' : 'Unavailable', + login: state.loginRequired ? 'Required' : 'OK', + page: state.kind, + url: state.url, + title: state.title, + notebooks: state.notebookCount, + }]; + }, +}); diff --git a/src/clis/notebooklm/summary.test.ts b/src/clis/notebooklm/summary.test.ts new file mode 100644 index 00000000..3d857529 --- /dev/null +++ b/src/clis/notebooklm/summary.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockReadNotebooklmSummaryFromPage, + mockGetNotebooklmSummaryViaRpc, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockReadNotebooklmSummaryFromPage: vi.fn(), + mockGetNotebooklmSummaryViaRpc: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js'); + return { + ...actual, + readNotebooklmSummaryFromPage: mockReadNotebooklmSummaryFromPage, + getNotebooklmSummaryViaRpc: mockGetNotebooklmSummaryViaRpc, + getNotebooklmPageState: mockGetNotebooklmPageState, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); + +import { getRegistry } from '../../registry.js'; +import './summary.js'; + +describe('notebooklm summary', () => { + const command = getRegistry().get('notebooklm/summary'); + + beforeEach(() => { + mockReadNotebooklmSummaryFromPage.mockReset(); + mockGetNotebooklmSummaryViaRpc.mockReset(); + mockGetNotebooklmPageState.mockReset(); + mockRequireNotebooklmSession.mockReset(); + mockRequireNotebooklmSession.mockResolvedValue(undefined); + mockGetNotebooklmPageState.mockResolvedValue({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Browser Automation', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 1, + }); + }); + + it('returns the current notebook summary from the visible page first', async () => { + mockReadNotebooklmSummaryFromPage.mockResolvedValue({ + notebook_id: 'nb-demo', + title: 'Browser Automation', + summary: 'A concise notebook summary.', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'summary-dom', + }); + + const result = await command!.func!({} as any, {}); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + title: 'Browser Automation', + summary: 'A concise notebook summary.', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'summary-dom', + }, + ]); + expect(mockGetNotebooklmSummaryViaRpc).not.toHaveBeenCalled(); + }); + + it('falls back to rpc summary extraction when no visible summary block is found', async () => { + mockReadNotebooklmSummaryFromPage.mockResolvedValue(null); + mockGetNotebooklmSummaryViaRpc.mockResolvedValue({ + notebook_id: 'nb-demo', + title: 'Browser Automation', + summary: 'Summary recovered from rpc.', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + + const result = await command!.func!({} as any, {}); + + expect(result).toEqual([ + { + notebook_id: 'nb-demo', + title: 'Browser Automation', + summary: 'Summary recovered from rpc.', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/summary.ts b/src/clis/notebooklm/summary.ts new file mode 100644 index 00000000..afc42650 --- /dev/null +++ b/src/clis/notebooklm/summary.ts @@ -0,0 +1,45 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { EmptyResultError } from '../../errors.js'; +import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js'; +import { + ensureNotebooklmNotebookBinding, + getNotebooklmPageState, + getNotebooklmSummaryViaRpc, + readNotebooklmSummaryFromPage, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'summary', + description: 'Get the summary block from the currently opened NotebookLM notebook', + domain: NOTEBOOKLM_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: ['title', 'summary', 'source', 'url'], + func: async (page: IPage) => { + await ensureNotebooklmNotebookBinding(page); + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') { + throw new EmptyResultError( + 'opencli notebooklm summary', + 'Open a specific NotebookLM notebook tab first, then retry.', + ); + } + + const domSummary = await readNotebooklmSummaryFromPage(page); + if (domSummary) return [domSummary]; + + const rpcSummary = await getNotebooklmSummaryViaRpc(page).catch(() => null); + if (rpcSummary) return [rpcSummary]; + + throw new EmptyResultError( + 'opencli notebooklm summary', + 'NotebookLM summary was not found on the current page.', + ); + }, +}); diff --git a/src/clis/notebooklm/utils.test.ts b/src/clis/notebooklm/utils.test.ts new file mode 100644 index 00000000..c833872d --- /dev/null +++ b/src/clis/notebooklm/utils.test.ts @@ -0,0 +1,1306 @@ +import { describe, expect, it } from 'vitest'; +import type { NotebooklmDownloadListRow } from './shared.js'; +import { + buildNotebooklmCreateNotebookParams, + buildNotebooklmDeleteNotebookParams, + buildNotebooklmGenerateAudioParams, + buildNotebooklmGenerateInfographicParams, + buildNotebooklmGenerateReportParams, + buildNotebooklmGenerateSlideDeckParams, + buildNotebooklmRemoveFromRecentParams, + buildNotebooklmRenameNotebookParams, + buildNotebooklmAddTextParams, + buildNotebooklmAddDriveParams, + buildNotebooklmAddFileParams, + buildNotebooklmAddUrlParams, + buildNotebooklmAddYoutubeParams, + buildNotebooklmGetLanguageParams, + buildNotebooklmRenameSourceParams, + buildNotebooklmSetLanguageParams, + buildNotebooklmUpdateNoteParams, + buildNotebooklmAskBody, + buildNotebooklmRpcBody, + classifyNotebooklmPage, + extractNotebooklmStableIdFromHints, + extractNotebooklmHistoryPreview, + extractNotebooklmRpcResult, + getNotebooklmPageState, + normalizeNotebooklmTitle, + parseNotebooklmAskResponse, + parseNotebooklmArtifactListResult, + parseNotebooklmCreatedSourceResult, + parseNotebooklmHistoryThreadIdsResult, + parseNotebooklmIdFromUrl, + parseNotebooklmLanguageGetResult, + parseNotebooklmLanguageSetResult, + parseNotebooklmNotebookDescriptionResult, + parseNotebooklmListResult, + parseNotebooklmNotesRpcResult, + parseNotebooklmNoteListRawRows, + parseNotebooklmNotebookDetailResult, + parseNotebooklmShareStatusResult, + parseNotebooklmSourceFulltextResult, + parseNotebooklmSourceGuideResult, + parseNotebooklmSourceFreshnessResult, + parseNotebooklmSourceListResult, + parseNotebooklmSourceListResultWithStatus, + parseNotebooklmDownloadListRows, + parseNotebooklmGenerationFailureFromRpcBody, + classifyNotebooklmGenerationFailureMessage, + pickNotebooklmArtifactIdCompletionCandidates, + parseNotebooklmGenerationResult, + selectNotebooklmCompletedArtifact, + extractNotebooklmAudioDownloadVariant, + extractNotebooklmInfographicDownloadUrl, + extractNotebooklmVideoDownloadVariant, + extractNotebooklmSlideDeckDownloadUrl, + extractNotebooklmReportMarkdown, + resolveNotebooklmVisibleNoteId, +} from './utils.js'; + +describe('notebooklm utils', () => { + it('parses notebook id from a notebook url', () => { + expect(parseNotebooklmIdFromUrl('https://notebooklm.google.com/notebook/abc-123')).toBe('abc-123'); + }); + + it('returns empty string when notebook id is absent', () => { + expect(parseNotebooklmIdFromUrl('https://notebooklm.google.com/')).toBe(''); + }); + + it('classifies notebook pages correctly', () => { + expect(classifyNotebooklmPage('https://notebooklm.google.com/notebook/demo-id')).toBe('notebook'); + expect(classifyNotebooklmPage('https://notebooklm.google.com/')).toBe('home'); + expect(classifyNotebooklmPage('https://example.com/notebook/demo-id')).toBe('unknown'); + }); + + it('normalizes notebook titles', () => { + expect(normalizeNotebooklmTitle(' Demo Notebook ')).toBe('Demo Notebook'); + expect(normalizeNotebooklmTitle('', 'Untitled')).toBe('Untitled'); + }); + + it('builds the notebooklm rpc request body with csrf token', () => { + const body = buildNotebooklmRpcBody('wXbhsf', [null, 1, null, [2]], 'csrf123'); + expect(body).toContain('f.req='); + expect(body).toContain('at=csrf123'); + expect(body.endsWith('&')).toBe(true); + expect(decodeURIComponent(body)).toContain('"[null,1,null,[2]]"'); + }); + + it('builds the notebooklm ask body with source ids, prompt, and csrf token', () => { + const body = buildNotebooklmAskBody( + ['src-1', 'src-2'], + '用一句话总结这个 notebook', + 'csrf123', + 'conv-123', + ); + const params = new URLSearchParams(body.slice(0, -1)); + const encodedRequest = params.get('f.req'); + const [, encodedPayload] = JSON.parse(encodedRequest ?? '[]') as [null, string]; + const [sourceIds, prompt, conversationHistory, chatOptions, conversationId] = JSON.parse(encodedPayload); + + expect(body).toContain('f.req='); + expect(body).toContain('at=csrf123'); + expect(body.endsWith('&')).toBe(true); + expect(sourceIds).toEqual([[[ 'src-1' ]], [[ 'src-2' ]]]); + expect(prompt).toBe('用一句话总结这个 notebook'); + expect(conversationHistory).toBeNull(); + expect(chatOptions).toEqual([2, null, [1]]); + expect(conversationId).toBe('conv-123'); + }); + + it('builds add-text rpc params with title and content in the pasted-text slot', () => { + expect(buildNotebooklmAddTextParams('贴入内容', '第一段\n第二段', 'nb-demo')).toEqual([ + [[null, ['贴入内容', '第一段\n第二段'], null, null, null, null, null, null]], + 'nb-demo', + [2], + null, + null, + ]); + }); + + it('builds add-file rpc params with the filename nested in the upload registration slot', () => { + expect(buildNotebooklmAddFileParams('demo.txt', 'nb-demo')).toEqual([ + [['demo.txt']], + 'nb-demo', + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]); + }); + + it('builds add-drive rpc params with file id, mime type, and display title in the drive slot', () => { + expect(buildNotebooklmAddDriveParams( + '1abcDriveFileIdXYZ', + 'Shared Spec', + 'nb-demo', + 'application/vnd.google-apps.document', + )).toEqual([ + [[ + ['1abcDriveFileIdXYZ', 'application/vnd.google-apps.document', 1, 'Shared Spec'], + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + ]], + 'nb-demo', + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]); + }); + + it('builds create-notebook rpc params for the home-page create flow', () => { + expect(buildNotebooklmCreateNotebookParams('新建 Notebook')).toEqual([ + '新建 Notebook', + null, + null, + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]); + }); + + it('builds rename-notebook rpc params for the home-scope notebook settings endpoint', () => { + expect(buildNotebooklmRenameNotebookParams('nb-demo', '重命名后的 Notebook')).toEqual([ + 'nb-demo', + [[null, null, null, [null, '重命名后的 Notebook']]], + ]); + }); + + it('builds delete-notebook rpc params for the home-scope delete endpoint', () => { + expect(buildNotebooklmDeleteNotebookParams('nb-demo')).toEqual([ + ['nb-demo'], + [2], + ]); + }); + + it('builds remove-from-recent rpc params for the home-scope recent list endpoint', () => { + expect(buildNotebooklmRemoveFromRecentParams('nb-demo')).toEqual(['nb-demo']); + }); + + it('builds add-url rpc params with the target url in the web slot', () => { + expect(buildNotebooklmAddUrlParams('https://example.com/article', 'nb-demo')).toEqual([ + [[null, null, ['https://example.com/article'], null, null, null, null, null]], + 'nb-demo', + [2], + null, + null, + ]); + }); + + it('builds youtube add-source params when the url is a youtube video', () => { + expect(buildNotebooklmAddYoutubeParams('https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'nb-demo')).toEqual([ + [[null, null, null, null, null, null, null, ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'], null, null, 1]], + 'nb-demo', + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]); + }); + + it('builds update-note rpc params with notebook id, note id, title, and content', () => { + expect(buildNotebooklmUpdateNoteParams('nb-demo', 'note-1', '重命名后的标题', '更新后的内容')).toEqual([ + 'nb-demo', + 'note-1', + [[['更新后的内容', '重命名后的标题', [], 0]]], + ]); + }); + + it('builds rename-source rpc params with source id and new title', () => { + expect(buildNotebooklmRenameSourceParams('src-1', '重命名后的来源')).toEqual([ + null, + ['src-1'], + [[['重命名后的来源']]], + ]); + }); + + it('builds get-language rpc params for the global user settings endpoint', () => { + expect(buildNotebooklmGetLanguageParams()).toEqual([ + null, + [1, null, null, null, null, null, null, null, null, null, [1]], + ]); + }); + + it('extracts a stable note id from note-label and artifact-label dom hints', () => { + expect(extractNotebooklmStableIdFromHints([ + 'ql-editor', + 'note-labels-ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + 'artifact-labels-ignored', + ])).toBe('ca68cf01-4c3d-47e5-88b6-6e5f259b7313'); + + expect(extractNotebooklmStableIdFromHints([ + 'artifact-labels-6a31b7d3-7b9c-402d-a4dc-fcc396430de4', + ])).toBe('6a31b7d3-7b9c-402d-a4dc-fcc396430de4'); + }); + + it('prefers the visible editor id when duplicate titles exist in rpc notes', () => { + expect(resolveNotebooklmVisibleNoteId( + { + id: 'ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + title: '新建笔记', + content: '', + }, + [ + { + notebook_id: 'nb-demo', + id: '28bad145-f62f-4a62-ad3d-61a2327d3c6f', + title: '新建笔记', + content: '旧内容', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + { + notebook_id: 'nb-demo', + id: 'ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + title: '新建笔记', + content: '', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ], + )).toEqual({ + id: 'ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + reason: 'visible-id', + }); + }); + + it('falls back to an exact title-and-content match when the visible editor has no id', () => { + expect(resolveNotebooklmVisibleNoteId( + { + id: null, + title: '新建笔记', + content: '这是当前打开的正文', + }, + [ + { + notebook_id: 'nb-demo', + id: 'note-older', + title: '新建笔记', + content: '另一条内容', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + { + notebook_id: 'nb-demo', + id: 'note-current', + title: '新建笔记', + content: '这是当前打开的正文', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ], + )).toEqual({ + id: 'note-current', + reason: 'title-content', + }); + }); + + it('builds set-language rpc params for the global user settings endpoint', () => { + expect(buildNotebooklmSetLanguageParams('zh_Hans')).toEqual([ + [[null, [[null, null, null, null, ['zh_Hans']]]]], + ]); + }); + + it('extracts notebooklm rpc payload from chunked batchexecute response', () => { + const raw = ')]}\'\n107\n[["wrb.fr","wXbhsf","[[[\\"Notebook One\\",null,\\"nb1\\",null,null,[null,false,null,null,null,[1704067200]]]]]"]]'; + const result = extractNotebooklmRpcResult(raw, 'wXbhsf'); + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[])[0]).toBeDefined(); + }); + + it('parses notebook rows from notebooklm rpc payload', () => { + const rows = parseNotebooklmListResult([ + [ + ['Notebook One', null, 'nb1', null, null, [null, false, null, null, null, [1704067200]]], + ], + ]); + + expect(rows).toEqual([ + { + id: 'nb1', + title: 'Notebook One', + url: 'https://notebooklm.google.com/notebook/nb1', + source: 'rpc', + is_owner: true, + created_at: '2024-01-01T00:00:00.000Z', + }, + ]); + }); + + it('parses notebook metadata from notebook detail rpc payload', () => { + const notebook = parseNotebooklmNotebookDetailResult([ + 'Browser Automation', + [ + [ + [['src1']], + 'Pasted text', + [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ]); + + expect(notebook).toEqual({ + id: 'nb-demo', + title: 'Browser Automation', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + emoji: '🕸️', + source_count: 1, + is_owner: true, + created_at: '2026-03-30T12:02:41.361Z', + updated_at: '2026-03-30T16:52:38.348Z', + }); + }); + + it('parses notebook metadata when detail rpc wraps the payload in a singleton envelope', () => { + const notebook = parseNotebooklmNotebookDetailResult([ + [ + 'Browser Automation', + [ + [ + [['src1']], + 'Pasted text', + [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ], + ]); + + expect(notebook).toEqual({ + id: 'nb-demo', + title: 'Browser Automation', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + emoji: '🕸️', + source_count: 1, + is_owner: true, + created_at: '2026-03-30T12:02:41.361Z', + updated_at: '2026-03-30T16:52:38.348Z', + }); + }); + + it('parses sources from notebook detail rpc payload', () => { + const rows = parseNotebooklmSourceListResult([ + 'Browser Automation', + [ + [ + [['src1']], + 'Pasted text', + [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ]); + + expect(rows).toEqual([ + { + id: 'src1', + notebook_id: 'nb-demo', + title: 'Pasted text', + type: 'pasted-text', + type_code: 8, + size: 359, + created_at: '2026-03-30T12:03:03.855Z', + updated_at: '2026-03-30T12:03:05.395Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('parses sources when detail rpc wraps the payload in a singleton envelope', () => { + const rows = parseNotebooklmSourceListResult([ + [ + 'Browser Automation', + [ + [ + [['src1']], + 'Pasted text', + [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ], + ]); + + expect(rows).toEqual([ + { + id: 'src1', + notebook_id: 'nb-demo', + title: 'Pasted text', + type: 'pasted-text', + type_code: 8, + size: 359, + created_at: '2026-03-30T12:03:03.855Z', + updated_at: '2026-03-30T12:03:05.395Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('parses sources when the source id container is only wrapped once', () => { + const rows = parseNotebooklmSourceListResult([ + [ + 'Browser Automation', + [ + [ + ['src-live'], + 'Pasted text', + [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ], + ]); + + expect(rows).toEqual([ + { + id: 'src-live', + notebook_id: 'nb-demo', + title: 'Pasted text', + type: 'pasted-text', + type_code: 8, + size: 359, + created_at: '2026-03-30T12:03:03.855Z', + updated_at: '2026-03-30T12:03:05.395Z', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('parses source type from metadata slot instead of the stale entry[3] envelope', () => { + const rows = parseNotebooklmSourceListResult([ + [ + 'Browser Automation', + [ + [ + ['src-pdf'], + 'Manual.pdf', + [null, 18940, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 3, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + [ + ['src-web'], + 'Example Site', + [null, 131, [1774872183, 855096000], ['doc2', [1774872183, 356519000]], 5, ['https://example.com'], 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + [ + ['src-yt'], + 'Video Source', + [null, 11958, [1774872183, 855096000], ['doc3', [1774872183, 356519000]], 9, ['https://youtu.be/demo', 'demo', 'Uploader'], 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ], + ]); + + expect(rows).toEqual([ + expect.objectContaining({ + id: 'src-pdf', + type: 'pdf', + type_code: 3, + }), + expect.objectContaining({ + id: 'src-web', + type: 'web', + type_code: 5, + }), + expect.objectContaining({ + id: 'src-yt', + type: 'youtube', + type_code: 9, + }), + ]); + }); + + it('parses notebook history thread ids from hPTbtc payload', () => { + const threadIds = parseNotebooklmHistoryThreadIdsResult([ + [[['28e0f2cb-4591-45a3-a661-7653666f7c78']]], + ]); + + expect(threadIds).toEqual(['28e0f2cb-4591-45a3-a661-7653666f7c78']); + }); + + it('extracts a notebook history preview from khqZz payload', () => { + const preview = extractNotebooklmHistoryPreview([ + [ + ['28e0f2cb-4591-45a3-a661-7653666f7c78'], + [null, 'Summarize this notebook'], + ], + ]); + + expect(preview).toBe('Summarize this notebook'); + }); + + it('parses notebook notes from studio note rows', () => { + const rows = parseNotebooklmNoteListRawRows( + [ + { + id: 'note-labels-ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + title: '新建笔记', + text: 'sticky_note_2 新建笔记 6 分钟前 more_vert', + }, + ], + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + ); + + expect(rows).toEqual([ + { + notebook_id: 'nb-demo', + id: 'ca68cf01-4c3d-47e5-88b6-6e5f259b7313', + title: '新建笔记', + created_at: '6 分钟前', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-list', + }, + ]); + }); + + it('parses source fulltext from hizoJc payload', () => { + const row = parseNotebooklmSourceFulltextResult( + [ + [ + [['src-1']], + '粘贴的文字', + [null, 359, [1774872183, 855096000], null, 8, null, 1, ['https://example.com/source']], + [null, 2], + ], + null, + null, + [ + [ + [ + [0, 5, [[[0, 5, ['第一段']]]]], + [5, 10, [[[5, 10, ['第二段']]]]], + ], + ], + ], + ], + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + ); + + expect(row).toEqual({ + source_id: 'src-1', + notebook_id: 'nb-demo', + title: '粘贴的文字', + kind: 'pasted-text', + content: '第一段\n第二段', + char_count: 7, + url: 'https://example.com/source', + source: 'rpc', + }); + }); + + it('parses source guide from tr032e payloads with either null or source-id envelope in slot 0', () => { + const source = { + id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + type: 'youtube', + }; + + expect(parseNotebooklmSourceGuideResult([ + [ + [ + null, + ['Guide summary'], + [['AI', 'agents']], + [], + ], + ], + ], source)).toEqual({ + source_id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + type: 'youtube', + summary: 'Guide summary', + keywords: ['AI', 'agents'], + source: 'rpc', + }); + + expect(parseNotebooklmSourceGuideResult([ + [ + [ + [['src-yt']], + ['Guide summary'], + [['AI', 'agents']], + [], + ], + ], + ], source)).toEqual({ + source_id: 'src-yt', + notebook_id: 'nb-demo', + title: 'Video Source', + type: 'youtube', + summary: 'Guide summary', + keywords: ['AI', 'agents'], + source: 'rpc', + }); + }); + + it('parses notebook description rows from summarize rpc payload', () => { + expect(parseNotebooklmNotebookDescriptionResult( + [ + ['这是 notebook 的摘要。'], + [[ + ['问题一?', 'Prompt one'], + ['问题二?', 'Prompt two'], + ]], + ], + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + )).toEqual({ + notebook_id: 'nb-demo', + summary: '这是 notebook 的摘要。', + suggested_topics: [ + { question: '问题一?', prompt: 'Prompt one' }, + { question: '问题二?', prompt: 'Prompt two' }, + ], + suggested_topic_count: 2, + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }); + }); + + it('parses note rows from get-notes rpc payload', () => { + expect(parseNotebooklmNotesRpcResult([ + [ + ['note-1', ['note-1', '第一段\n第二段', null, null, '新建笔记']], + ['note-2', null, 2], + ], + ], 'nb-demo', 'https://notebooklm.google.com/notebook/nb-demo')).toEqual([ + { + notebook_id: 'nb-demo', + id: 'note-1', + title: '新建笔记', + content: '第一段\n第二段', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + }, + ]); + }); + + it('parses source processing status from source[3][1] when requested by wait helpers', () => { + const rows = parseNotebooklmSourceListResultWithStatus([ + [ + 'Browser Automation', + [ + [ + ['src-processing'], + 'Uploading.pdf', + [null, 18940, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 3, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 5], + ], + [ + ['src-ready'], + 'Ready.md', + [null, 131, [1774872183, 855096000], ['doc2', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]], + [null, 2], + ], + ], + 'nb-demo', + '🕸️', + null, + [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1], + ], + ]); + + expect(rows).toEqual([ + expect.objectContaining({ + id: 'src-processing', + status: 'preparing', + status_code: 5, + }), + expect.objectContaining({ + id: 'src-ready', + status: 'ready', + status_code: 2, + }), + ]); + }); + + it('parses share status rows from get-share-status rpc payload', () => { + expect(parseNotebooklmShareStatusResult( + [ + [ + ['user@example.com', 3, [], ['User Example', 'https://avatar.test/user.png']], + ], + [1], + 1000, + ], + 'nb-demo', + )).toEqual({ + notebook_id: 'nb-demo', + is_public: true, + access: 'anyone_with_link', + view_level: 'full', + share_url: 'https://notebooklm.google.com/notebook/nb-demo', + shared_user_count: 1, + shared_users: [ + { + email: 'user@example.com', + permission: 'viewer', + display_name: 'User Example', + avatar_url: 'https://avatar.test/user.png', + }, + ], + source: 'rpc', + }); + }); + + it('parses the current output language from get-user-settings payload', () => { + expect(parseNotebooklmLanguageGetResult([ + [null, null, [null, null, null, null, ['ja']]], + ])).toBe('ja'); + }); + + it('parses the updated output language from set-user-settings payload', () => { + expect(parseNotebooklmLanguageSetResult([null, null, [null, null, null, null, ['zh_Hans']]])).toBe('zh_Hans'); + }); + + it('parses source freshness results across url, drive, and boolean payload shapes', () => { + expect(parseNotebooklmSourceFreshnessResult([])).toBe(true); + expect(parseNotebooklmSourceFreshnessResult([[null, true, ['src-1']]])).toBe(true); + expect(parseNotebooklmSourceFreshnessResult(true)).toBe(true); + expect(parseNotebooklmSourceFreshnessResult(false)).toBe(false); + expect(parseNotebooklmSourceFreshnessResult([[null, false, ['src-1']]])).toBe(false); + }); + + it('parses a created source from add-source rpc payload', () => { + const row = parseNotebooklmCreatedSourceResult( + [ + [ + [ + ['src-created'], + '贴入内容', + [null, 12, [1774872183, 855096000], null, 8, null, 1, null], + ], + ], + ], + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + ); + + expect(row).toEqual({ + id: 'src-created', + notebook_id: 'nb-demo', + title: '贴入内容', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'rpc', + type: 'pasted-text', + type_code: 8, + size: 12, + created_at: '2026-03-30T12:03:03.855Z', + updated_at: null, + }); + }); + + it('extracts the longest marked answer from the notebooklm ask response', () => { + const partialChunk = JSON.stringify([ + [ + 'wrb.fr', + null, + JSON.stringify([ + [ + '较短的中间输出', + null, + [], + null, + [[], null, null, 0], + ], + ]), + ], + ]); + + const finalChunk = JSON.stringify([ + [ + 'wrb.fr', + null, + JSON.stringify([ + [ + '最终回答正文', + null, + [], + null, + [[], null, null, 1], + ], + ]), + ], + ]); + + const raw = `)]}'\n128\n${partialChunk}\n256\n${finalChunk}`; + expect(parseNotebooklmAskResponse(raw)).toBe('最终回答正文'); + }); + + it('parses artifact rows, selects the latest completed report, and extracts markdown content', () => { + const result = [ + [ + ['report-1', 'Briefing Doc: Browser Automation', 2, null, 3, null, null, ['# older'], null, null, null, null, null, null, null, [100]], + ['report-2', 'Study Guide: Browser Automation', 2, null, 3, null, null, ['# latest'], null, null, null, null, null, null, null, [200]], + ['slide-1', 'Browser Automation Deck', 8, null, 3, null, null, null, null, null, null, null, null, null, null, [300]], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const report = selectNotebooklmCompletedArtifact(rows, 2); + + expect(rows).toHaveLength(3); + expect(report?.[0]).toBe('report-2'); + expect(extractNotebooklmReportMarkdown(report ?? null)).toBe('# latest'); + }); + + it('builds the NotebookLM report generation payload for create-artifact RPC', () => { + expect(buildNotebooklmGenerateReportParams('nb-demo', ['src-1', 'src-2'])).toEqual([ + [2], + 'nb-demo', + [ + null, + null, + 2, + [[['src-1']], [['src-2']]], + null, + null, + null, + [ + null, + [ + 'Briefing Doc', + 'Key insights and important quotes', + null, + [['src-1'], ['src-2']], + 'en', + 'Create a comprehensive briefing document that includes an Executive Summary, detailed analysis of key themes, important quotes with context, and actionable insights.', + null, + true, + ], + ], + ], + ]); + }); + + it('builds the NotebookLM audio generation payload for create-artifact RPC', () => { + expect(buildNotebooklmGenerateAudioParams('nb-demo', ['src-1', 'src-2'])).toEqual([ + [2], + 'nb-demo', + [ + null, + null, + 1, + [[['src-1']], [['src-2']]], + null, + null, + [ + null, + [ + null, + null, + null, + [['src-1'], ['src-2']], + 'en', + null, + null, + ], + ], + ], + ]); + }); + + it('builds the NotebookLM slide-deck generation payload for create-artifact RPC', () => { + expect(buildNotebooklmGenerateSlideDeckParams('nb-demo', ['src-1', 'src-2'])).toEqual([ + [2], + 'nb-demo', + [ + null, + null, + 8, + [[['src-1']], [['src-2']]], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [[null, 'en', null, null]], + ], + ]); + }); + + it('builds the NotebookLM infographic generation payload for create-artifact RPC', () => { + expect(buildNotebooklmGenerateInfographicParams('nb-demo', ['src-1', 'src-2'], { + instructions: 'Focus on the architecture trade-offs', + orientation: 'portrait', + detail: 'detailed', + style: 'scientific', + })).toEqual([ + [2], + 'nb-demo', + [ + null, + null, + 7, + [[['src-1']], [['src-2']]], + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [['Focus on the architecture trade-offs', 'en', null, 2, 3, 11]], + ], + ]); + }); + + it('parses NotebookLM generation RPC results into artifact id and normalized status', () => { + expect(parseNotebooklmGenerationResult([ + ['artifact-pending', 'Briefing Doc', 2, null, 2], + ])).toEqual({ + artifact_id: 'artifact-pending', + status: 'pending', + }); + + expect(parseNotebooklmGenerationResult([ + ['artifact-completed', 'Briefing Doc', 2, null, 3], + ])).toEqual({ + artifact_id: 'artifact-completed', + status: 'completed', + }); + + expect(parseNotebooklmGenerationResult(null)).toEqual({ + artifact_id: null, + status: 'failed', + }); + }); + + it('extracts slide-deck download urls from the completed artifact payload', () => { + const result = [ + [ + ['slide-1', 'Older deck', 8, null, 3, null, null, null, null, null, null, null, null, null, null, [100], [null, null, null, 'https://example.com/older.pdf', 'https://example.com/older.pptx']], + ['slide-2', 'Latest deck', 8, null, 3, null, null, null, null, null, null, null, null, null, null, [200], [null, null, null, 'https://example.com/latest.pdf', 'https://example.com/latest.pptx']], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const slideDeck = selectNotebooklmCompletedArtifact(rows, 8); + + expect(slideDeck?.[0]).toBe('slide-2'); + expect(extractNotebooklmSlideDeckDownloadUrl(slideDeck ?? null, 'pdf')).toBe('https://example.com/latest.pdf'); + expect(extractNotebooklmSlideDeckDownloadUrl(slideDeck ?? null, 'pptx')).toBe('https://example.com/latest.pptx'); + }); + + it('extracts the infographic png download url from the completed artifact payload', () => { + const result = [ + [ + ['infographic-1', 'Older infographic', 7, null, 3, null, null, null, null, null, null, null, null, null, null, [100], [null, null, [[null, ['https://example.com/older.png']]]]], + ['infographic-2', 'Latest infographic', 7, null, 3, null, null, null, null, null, null, null, null, null, null, [200], [null, null, [[null, ['https://example.com/latest.png']]]]], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const infographic = selectNotebooklmCompletedArtifact(rows, 7); + + expect(infographic?.[0]).toBe('infographic-2'); + expect(extractNotebooklmInfographicDownloadUrl(infographic ?? null)).toBe('https://example.com/latest.png'); + }); + + it('extracts the preferred audio/mp4 download variant from the completed artifact payload', () => { + const result = [ + [ + ['audio-1', 'Older audio', 1, null, 3, null, [null, null, null, null, null, [['https://example.com/older-hls', 2], ['https://example.com/older-audio', 1, 'audio/mp4']]], null, null, null, null, null, null, null, null, [100]], + ['audio-2', 'Latest audio', 1, null, 3, null, [null, null, null, null, null, [['https://example.com/latest-audio-dv', 4, 'audio/mp4'], ['https://example.com/latest-audio', 1, 'audio/mp4'], ['https://example.com/latest-hls', 2], ['https://example.com/latest-dash', 3]]], null, null, null, null, null, null, null, null, [200]], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const audio = selectNotebooklmCompletedArtifact(rows, 1); + + expect(audio?.[0]).toBe('audio-2'); + expect(extractNotebooklmAudioDownloadVariant(audio ?? null)).toEqual({ + url: 'https://example.com/latest-audio-dv', + mime_type: 'audio/mp4', + }); + }); + + it('falls back to the first audio variant when no mime-tagged audio/mp4 variant exists', () => { + const row = [ + 'audio-3', 'Fallback audio', 1, null, 3, null, + [null, null, null, null, null, [['https://example.com/fallback-hls', 2], ['https://example.com/fallback-dash', 3]]], + ]; + + expect(extractNotebooklmAudioDownloadVariant(row)).toEqual({ + url: 'https://example.com/fallback-hls', + mime_type: null, + }); + }); + + it('extracts the preferred video/mp4 download variant from the completed artifact payload', () => { + const result = [ + [ + ['video-1', 'Older video', 3, null, 3, null, null, null, [null, null, null, 'https://example.com/older-video-dv', [['https://example.com/older-video', 1, 'video/mp4'], ['https://example.com/older-video-dv', 4, 'video/mp4'], ['https://example.com/older-video-hls', 2], ['https://example.com/older-video-dash', 3]]], null, null, null, null, null, null, [100]], + ['video-2', 'Latest video', 3, null, 3, null, null, null, [null, null, null, 'https://example.com/latest-video-dv', [['https://example.com/latest-video', 1, 'video/mp4'], ['https://example.com/latest-video-dv', 4, 'video/mp4'], ['https://example.com/latest-video-hls', 2], ['https://example.com/latest-video-dash', 3]], [314, 999999000]], null, null, null, null, null, null, [200]], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const video = selectNotebooklmCompletedArtifact(rows, 3); + + expect(video?.[0]).toBe('video-2'); + expect(extractNotebooklmVideoDownloadVariant(video ?? null)).toEqual({ + url: 'https://example.com/latest-video', + mime_type: 'video/mp4', + }); + }); + + it('falls back to the first video variant when no mime-tagged video/mp4 variant exists', () => { + const row = [ + 'video-3', 'Fallback video', 3, null, 3, null, null, null, + [null, null, null, null, [['https://example.com/fallback-hls', 2], ['https://example.com/fallback-dash', 3]]], + ]; + + expect(extractNotebooklmVideoDownloadVariant(row)).toEqual({ + url: 'https://example.com/fallback-hls', + mime_type: null, + }); + }); + + it('parses supported downloadable artifact rows into a minimal download index', () => { + const result = [ + [ + ['report-1', 'Browser Automation Report', 2, null, 3, null, null, ['# report body'], null, null, null, null, null, null, null, [100]], + ['audio-1', 'Browser Automation Audio', 1, null, 3, null, [null, null, null, null, null, [['https://example.com/audio.mp4', 1, 'audio/mp4'], ['https://example.com/audio.m3u8', 2], ['https://example.com/audio.mpd', 3]]], null, null, null, null, null, null, null, null, [200]], + ['video-1', 'Browser Automation Video', 3, null, 2, null, null, null, [null, null, null, null, [['https://example.com/video.m3u8', 2], ['https://example.com/video.mp4', 1, 'video/mp4']]], null, null, null, null, null, null, [300]], + ['slide-1', 'Browser Automation Deck', 8, null, 3, null, null, null, null, null, null, null, null, null, null, [400], [null, null, null, 'https://example.com/deck.pdf', 'https://example.com/deck.pptx']], + ['infographic-1', 'Browser Automation Infographic', 7, null, 3, null, null, null, null, null, null, null, null, null, null, [450], [null, null, [[null, ['https://example.com/infographic.png']]]]], + ['other-1', 'Unsupported Artifact', 4, null, 3, null, null, null, null, null, null, null, null, null, null, [500]], + ], + ]; + + const rows = parseNotebooklmArtifactListResult(result); + const downloadRows = parseNotebooklmDownloadListRows( + rows, + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + ); + + expect(downloadRows).toHaveLength(5); + expect(downloadRows.map((row) => row.artifact_id)).toEqual(['infographic-1', 'slide-1', 'video-1', 'audio-1', 'report-1']); + expect(downloadRows.find((row) => row.artifact_id === 'report-1')).toEqual({ + notebook_id: 'nb-demo', + artifact_id: 'report-1', + artifact_type: 'report', + status: 'completed', + title: 'Browser Automation Report', + created_at: '1970-01-01T00:01:40.000Z', + download_variants: ['markdown'], + source: 'rpc+artifact-list', + }); + expect(downloadRows.find((row) => row.artifact_id === 'audio-1')).toEqual({ + notebook_id: 'nb-demo', + artifact_id: 'audio-1', + artifact_type: 'audio', + status: 'completed', + title: 'Browser Automation Audio', + created_at: '1970-01-01T00:03:20.000Z', + download_variants: ['audio/mp4', 'hls', 'dash'], + source: 'rpc+artifact-list', + }); + expect(downloadRows.find((row) => row.artifact_id === 'video-1')).toEqual({ + notebook_id: 'nb-demo', + artifact_id: 'video-1', + artifact_type: 'video', + status: 'status_2', + title: 'Browser Automation Video', + created_at: '1970-01-01T00:05:00.000Z', + download_variants: ['hls', 'video/mp4'], + source: 'rpc+artifact-list', + }); + expect(downloadRows.find((row) => row.artifact_id === 'slide-1')).toEqual({ + notebook_id: 'nb-demo', + artifact_id: 'slide-1', + artifact_type: 'slide_deck', + status: 'completed', + title: 'Browser Automation Deck', + created_at: '1970-01-01T00:06:40.000Z', + download_variants: ['pdf', 'pptx'], + source: 'rpc+artifact-list', + }); + expect(downloadRows.find((row) => row.artifact_id === 'infographic-1')).toEqual({ + notebook_id: 'nb-demo', + artifact_id: 'infographic-1', + artifact_type: 'infographic', + status: 'completed', + title: 'Browser Automation Infographic', + created_at: '1970-01-01T00:07:30.000Z', + download_variants: ['png'], + source: 'rpc+artifact-list', + }); + }); + + it('filters artifact-id completion candidates by supported NotebookLM download type', () => { + const rows: NotebooklmDownloadListRow[] = [ + { + notebook_id: 'nb-demo', + artifact_id: 'report-1', + artifact_type: 'report', + status: 'completed', + title: 'Report', + created_at: '2026-03-31T00:00:00.000Z', + download_variants: ['markdown'], + source: 'rpc+artifact-list', + }, + { + notebook_id: 'nb-demo', + artifact_id: 'audio-1', + artifact_type: 'audio', + status: 'completed', + title: 'Audio', + created_at: '2026-03-31T00:01:00.000Z', + download_variants: ['audio/mp4'], + source: 'rpc+artifact-list', + }, + { + notebook_id: 'nb-demo', + artifact_id: 'video-1', + artifact_type: 'video', + status: 'completed', + title: 'Video', + created_at: '2026-03-31T00:02:00.000Z', + download_variants: ['video/mp4'], + source: 'rpc+artifact-list', + }, + { + notebook_id: 'nb-demo', + artifact_id: 'slide-1', + artifact_type: 'slide_deck', + status: 'completed', + title: 'Deck', + created_at: '2026-03-31T00:03:00.000Z', + download_variants: ['pdf', 'pptx'], + source: 'rpc+artifact-list', + }, + { + notebook_id: 'nb-demo', + artifact_id: 'infographic-1', + artifact_type: 'infographic', + status: 'completed', + title: 'Infographic', + created_at: '2026-03-31T00:04:00.000Z', + download_variants: ['png'], + source: 'rpc+artifact-list', + }, + ]; + + expect(pickNotebooklmArtifactIdCompletionCandidates(rows, 'report')).toEqual(['report-1']); + expect(pickNotebooklmArtifactIdCompletionCandidates(rows, 'audio')).toEqual(['audio-1']); + expect(pickNotebooklmArtifactIdCompletionCandidates(rows, 'video')).toEqual(['video-1']); + expect(pickNotebooklmArtifactIdCompletionCandidates(rows, 'slide_deck')).toEqual(['slide-1']); + expect(pickNotebooklmArtifactIdCompletionCandidates(rows, 'infographic')).toEqual(['infographic-1']); + }); + + it('classifies generation failure messages into explicit diagnostic buckets', () => { + expect(classifyNotebooklmGenerationFailureMessage( + 'You reached your daily limit for audio overviews and slides today. Upgrade to continue.', + )).toBe('daily_limit_reached'); + expect(classifyNotebooklmGenerationFailureMessage( + 'This feature is not eligible on your current plan. Upgrade to continue.', + )).toBe('feature_not_eligible'); + expect(classifyNotebooklmGenerationFailureMessage( + 'There is not enough content in this notebook to generate an audio overview yet.', + )).toBe('content_insufficient'); + expect(classifyNotebooklmGenerationFailureMessage(null)).toBe('generation_failed_unknown'); + }); + + it('parses UserDisplayableError generation failures into diagnostic metadata', () => { + const raw = `)]}'\n228\n[["wrb.fr","R7cb6c",null,null,"You reached your daily limit for audio overviews and slides today. Upgrade to continue.",[8,null,[["type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",[]]]],"generic"]]`; + + expect(parseNotebooklmGenerationFailureFromRpcBody(raw)).toEqual({ + error_type: 'daily_limit_reached', + message: 'You reached your daily limit for audio overviews and slides today. Upgrade to continue.', + }); + }); + + it('falls back to unknown generation failures when UserDisplayableError has no readable message', () => { + const raw = `)]}'\n220\n[["wrb.fr","R7cb6c",null,null,null,[8,null,[["type.googleapis.com/google.internal.labs.tailwind.orchestration.v1.UserDisplayableError",[[null,[[1]]]]]]],"generic"]]`; + + expect(parseNotebooklmGenerationFailureFromRpcBody(raw)).toEqual({ + error_type: 'generation_failed_unknown', + message: null, + }); + }); + + it('prefers real NotebookLM page tokens over login text heuristics', async () => { + let call = 0; + const page = { + evaluate: async () => { + call += 1; + if (call === 1) { + return { + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Demo Notebook - NotebookLM', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: true, + notebookCount: 0, + }; + } + return { + html: '<html>"SNlM0e":"csrf-123","FdrFJe":"sess-456"</html>', + sourcePath: '/notebook/nb-demo', + }; + }, + }; + + await expect(getNotebooklmPageState(page as any)).resolves.toEqual({ + url: 'https://notebooklm.google.com/notebook/nb-demo', + title: 'Demo Notebook - NotebookLM', + hostname: 'notebooklm.google.com', + kind: 'notebook', + notebookId: 'nb-demo', + loginRequired: false, + notebookCount: 0, + }); + }); +}); diff --git a/src/clis/notebooklm/utils.ts b/src/clis/notebooklm/utils.ts new file mode 100644 index 00000000..8a6925e1 --- /dev/null +++ b/src/clis/notebooklm/utils.ts @@ -0,0 +1,3477 @@ +import { randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, resolve as resolvePath } from 'node:path'; +import { AuthRequiredError, CliError } from '../../errors.js'; +import { formatCookieHeader, httpDownload } from '../../download/index.js'; +import { fetchWithNodeNetwork } from '../../node-network.js'; +import { browserSession, getBrowserFactory } from '../../runtime.js'; +import type { IPage } from '../../types.js'; +import { bindCurrentTab } from '../../browser/daemon-client.js'; +import { + type NotebooklmAskRow, + type NotebooklmAudioDownloadRow, + type NotebooklmDownloadListRow, + type NotebooklmGenerateRow, + type NotebooklmGenerationErrorType, + type NotebooklmInfographicDetail, + type NotebooklmInfographicDownloadRow, + type NotebooklmInfographicOrientation, + type NotebooklmInfographicStyle, + type NotebooklmVideoDownloadRow, + NOTEBOOKLM_DOMAIN, + NOTEBOOKLM_HOME_URL, + NOTEBOOKLM_SITE, + type NotebooklmHistoryRow, + type NotebooklmLanguageRow, + type NotebooklmLanguageStatusRow, + type NotebooklmNotebookDescriptionRow, + type NotebooklmNotebookDetailRow, + type NotebooklmNoteDeleteRow, + type NotebooklmNoteDetailRow, + type NotebooklmNoteRow, + type NotebooklmPageKind, + type NotebooklmPageState, + type NotebooklmReportDownloadRow, + type NotebooklmRow, + type NotebooklmSlideDeckDownloadFormat, + type NotebooklmSlideDeckDownloadRow, + type NotebooklmShareStatusRow, + type NotebooklmShareUserRow, + type NotebooklmSourceFulltextRow, + type NotebooklmSourceDeleteRow, + type NotebooklmSourceFreshnessRow, + type NotebooklmSourceGuideRow, + type NotebooklmSourceRefreshRow, + type NotebooklmSourceRow, + type NotebooklmSummaryRow, +} from './shared.js'; +import { NOTEBOOKLM_SUPPORTED_LANGUAGES } from './languages.js'; +import { + callNotebooklmRpc, + buildNotebooklmRpcBody, + extractNotebooklmRpcUserDisplayableError, + extractNotebooklmRpcResult, + fetchNotebooklmInPage, + getNotebooklmPageAuth, + parseNotebooklmChunkedResponse, + stripNotebooklmAntiXssi, +} from './rpc.js'; + +export { + buildNotebooklmRpcBody, + extractNotebooklmRpcResult, + fetchNotebooklmInPage, + getNotebooklmPageAuth, + parseNotebooklmChunkedResponse, + stripNotebooklmAntiXssi, +} from './rpc.js'; + +const NOTEBOOKLM_LIST_RPC_ID = 'wXbhsf'; +const NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID = 'rLM1Ne'; +const NOTEBOOKLM_CREATE_NOTEBOOK_RPC_ID = 'CCqFvf'; +const NOTEBOOKLM_HISTORY_THREADS_RPC_ID = 'hPTbtc'; +const NOTEBOOKLM_HISTORY_DETAIL_RPC_ID = 'khqZz'; +const NOTEBOOKLM_LIST_ARTIFACTS_RPC_ID = 'gArtLc'; +const NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID = 'R7cb6c'; +const NOTEBOOKLM_ADD_FILE_RPC_ID = 'o4cbdc'; +const NOTEBOOKLM_ASK_QUERY_URL = + `https://${NOTEBOOKLM_DOMAIN}` + + '/_/LabsTailwindUi/data/google.internal.labs.tailwind.orchestration.v1.LabsTailwindOrchestrationService/GenerateFreeFormStreamed'; +const NOTEBOOKLM_UPLOAD_URL = `https://${NOTEBOOKLM_DOMAIN}/upload/_/`; +const NOTEBOOKLM_ASK_BL = + process.env.NOTEBOOKLM_BL ?? 'boq_labs-tailwind-frontend_20251221.14_p0'; +const NOTEBOOKLM_ARTIFACT_STATUS_COMPLETED = 3; +const NOTEBOOKLM_ARTIFACT_TYPE_AUDIO = 1; +const NOTEBOOKLM_ARTIFACT_TYPE_REPORT = 2; +const NOTEBOOKLM_ARTIFACT_TYPE_VIDEO = 3; +const NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC = 7; +const NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK = 8; +const NOTEBOOKLM_SOURCE_STATUS_PROCESSING = 1; +const NOTEBOOKLM_SOURCE_STATUS_READY = 2; +const NOTEBOOKLM_SOURCE_STATUS_ERROR = 3; +const NOTEBOOKLM_SOURCE_STATUS_PREPARING = 5; +const NOTEBOOKLM_DOWNLOADABLE_ARTIFACT_TYPES = new Map<number, NotebooklmDownloadListRow['artifact_type']>([ + [NOTEBOOKLM_ARTIFACT_TYPE_REPORT, 'report'], + [NOTEBOOKLM_ARTIFACT_TYPE_AUDIO, 'audio'], + [NOTEBOOKLM_ARTIFACT_TYPE_VIDEO, 'video'], + [NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC, 'infographic'], + [NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK, 'slide_deck'], +]); + +const NOTEBOOKLM_INFOGRAPHIC_ORIENTATION_CODES: Record<NotebooklmInfographicOrientation, number> = { + landscape: 1, + portrait: 2, + square: 3, +}; + +const NOTEBOOKLM_INFOGRAPHIC_DETAIL_CODES: Record<NotebooklmInfographicDetail, number> = { + concise: 1, + standard: 2, + detailed: 3, +}; + +const NOTEBOOKLM_INFOGRAPHIC_STYLE_CODES: Record<NotebooklmInfographicStyle, number> = { + auto_select: 1, + sketch_note: 2, + professional: 3, + bento_grid: 4, + editorial: 5, + instructional: 6, + bricks: 7, + clay: 8, + anime: 9, + kawaii: 10, + scientific: 11, +}; + +function unwrapNotebooklmSingletonResult(result: unknown): unknown { + let current = result; + while (Array.isArray(current) && current.length === 1 && Array.isArray(current[0])) { + current = current[0]; + } + return current; +} + +export function parseNotebooklmIdFromUrl(url: string): string { + const match = url.match(/\/notebook\/([^/?#]+)/); + return match?.[1] ?? ''; +} + +export function classifyNotebooklmPage(url: string): NotebooklmPageKind { + try { + const parsed = new URL(url); + if (parsed.hostname !== NOTEBOOKLM_DOMAIN) return 'unknown'; + if (/\/notebook\/[^/?#]+/.test(parsed.pathname)) return 'notebook'; + return 'home'; + } catch { + return 'unknown'; + } +} + +export function normalizeNotebooklmTitle(value: unknown, fallback: string = ''): string { + if (typeof value !== 'string') return fallback; + let normalized = value.replace(/\s+/g, ' ').trim(); + if (/^Untitled\b/i.test(normalized) && /otebook$/i.test(normalized) && normalized !== 'Untitled notebook') { + normalized = 'Untitled notebook'; + } + return normalized || fallback; +} + +function normalizeNotebooklmCreatedAt(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) return trimmed; + return new Date(parsed).toISOString(); +} + +function toNotebooklmIsoTimestamp(epochSeconds: unknown): string | null { + if (typeof epochSeconds === 'number' && Number.isFinite(epochSeconds)) { + try { + return new Date(epochSeconds * 1000).toISOString(); + } catch { + return null; + } + } + + if (Array.isArray(epochSeconds) && typeof epochSeconds[0] === 'number' && Number.isFinite(epochSeconds[0])) { + const seconds = epochSeconds[0]; + const nanos = typeof epochSeconds[1] === 'number' && Number.isFinite(epochSeconds[1]) ? epochSeconds[1] : 0; + try { + return new Date(seconds * 1000 + Math.floor(nanos / 1_000_000)).toISOString(); + } catch { + return null; + } + } + + return null; +} + +function parseNotebooklmSourceTypeCode(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (!Array.isArray(value) || typeof value[1] !== 'number' || !Number.isFinite(value[1])) return null; + return value[1]; +} + +function parseNotebooklmSourceType(value: unknown): string | null { + const code = parseNotebooklmSourceTypeCode(value); + if (code === 8) return 'pasted-text'; + if (code === 9) return 'youtube'; + if (code === 2) return 'generated-text'; + if (code === 3) return 'pdf'; + if (code === 4) return 'audio'; + if (code === 5) return 'web'; + if (code === 6) return 'video'; + return code == null ? null : `type-${code}`; +} + +function parseNotebooklmSharePermission(value: unknown): NotebooklmShareUserRow['permission'] { + if (value === 1) return 'owner'; + if (value === 2) return 'editor'; + if (value === 3) return 'viewer'; + return 'unknown'; +} + +function findFirstNotebooklmString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (!Array.isArray(value)) return null; + for (const item of value) { + const found = findFirstNotebooklmString(item); + if (found) return found; + } + return null; +} + +function isNotebooklmUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +function collectNotebooklmStrings(value: unknown, results: string[]): string[] { + if (typeof value === 'string') { + const normalized = normalizeNotebooklmTitle(value); + if (!normalized) return results; + if (isNotebooklmUuid(normalized)) return results; + if (/^[\d\s]+$/.test(normalized)) return results; + if (/^(null|undefined)$/i.test(normalized)) return results; + results.push(normalized); + return results; + } + + if (!Array.isArray(value)) return results; + for (const item of value) collectNotebooklmStrings(item, results); + return results; +} + +function collectNotebooklmLeafStrings(value: unknown, results: string[]): string[] { + if (typeof value === 'string') { + const normalized = value.trim(); + if (normalized) results.push(normalized); + return results; + } + if (!Array.isArray(value)) return results; + for (const item of value) collectNotebooklmLeafStrings(item, results); + return results; +} + +export function extractNotebooklmStableIdFromHints(hints: unknown[]): string | null { + for (const hint of hints) { + if (typeof hint !== 'string') continue; + const trimmed = hint.trim(); + if (!trimmed) continue; + + const labelledIdMatch = trimmed.match(/(?:note|artifact)-labels-([A-Za-z0-9_-]{6,})/i); + if (labelledIdMatch?.[1]) return labelledIdMatch[1]; + + const uuidMatch = trimmed.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); + if (uuidMatch?.[0]) return uuidMatch[0]; + } + + return null; +} + +export function resolveNotebooklmVisibleNoteId( + visible: Pick<NotebooklmNoteDetailRow, 'id' | 'title' | 'content'>, + rows: NotebooklmNoteDetailRow[], +): NotebooklmVisibleNoteIdResolution { + const visibleId = typeof visible.id === 'string' ? visible.id.trim() : ''; + if (visibleId) { + return { id: visibleId, reason: 'visible-id' }; + } + + const normalizedTitle = visible.title.trim().toLowerCase(); + const normalizedContent = visible.content.replace(/\r\n/g, '\n').trim(); + const titleMatches = rows.filter((row) => row.title.trim().toLowerCase() === normalizedTitle); + if (titleMatches.length === 0) return { id: null, reason: 'missing' }; + + const contentMatches = titleMatches.filter( + (row) => row.content.replace(/\r\n/g, '\n').trim() === normalizedContent, + ); + if (contentMatches.length === 1) { + return { id: contentMatches[0]?.id ?? null, reason: 'title-content' }; + } + if (contentMatches.length > 1) { + return { id: null, reason: 'ambiguous' }; + } + + if (titleMatches.length === 1) { + return { id: titleMatches[0]?.id ?? null, reason: 'title' }; + } + + return { id: null, reason: 'ambiguous' }; +} + +type NotebooklmRawNoteRow = { + id?: string | null; + title?: string | null; + text?: string | null; +}; + +type NotebooklmRawSummaryRow = { + title?: string | null; + summary?: string | null; +}; + +type NotebooklmRawVisibleNoteRow = { + id?: string | null; + title?: string | null; + content?: string | null; +}; + +type NotebooklmVisibleNoteIdResolution = { + id: string | null; + reason: 'visible-id' | 'title-content' | 'title' | 'missing' | 'ambiguous'; +}; + +function collectNotebooklmThreadIds(value: unknown, results: string[], seen: Set<string>): string[] { + if (typeof value === 'string') { + const normalized = value.trim(); + if (isNotebooklmUuid(normalized) && !seen.has(normalized)) { + seen.add(normalized); + results.push(normalized); + } + return results; + } + + if (!Array.isArray(value)) return results; + for (const item of value) collectNotebooklmThreadIds(item, results, seen); + return results; +} + +export function parseNotebooklmHistoryThreadIdsResult(result: unknown): string[] { + return collectNotebooklmThreadIds(result, [], new Set<string>()); +} + +export function extractNotebooklmHistoryPreview(result: unknown): string | null { + const strings = collectNotebooklmStrings(result, []); + return strings.length > 0 ? strings[0] : null; +} + +export function buildNotebooklmAskBody( + sourceIds: string[], + prompt: string, + csrfToken: string, + conversationId: string, +): string { + const params = [ + sourceIds.map((sourceId) => [[sourceId]]), + prompt, + null, + [2, null, [1]], + conversationId, + ]; + const body = JSON.stringify([null, JSON.stringify(params)]); + return `f.req=${encodeURIComponent(body)}&at=${encodeURIComponent(csrfToken)}&`; +} + +export function buildNotebooklmAddTextParams( + title: string, + content: string, + notebookId: string, +): unknown[] { + return [ + [[null, [title, content], null, null, null, null, null, null]], + notebookId, + [2], + null, + null, + ]; +} + +export function buildNotebooklmAddFileParams( + filename: string, + notebookId: string, +): unknown[] { + return [ + [[filename]], + notebookId, + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]; +} + +export function buildNotebooklmAddDriveParams( + fileId: string, + title: string, + notebookId: string, + mimeType: string, +): unknown[] { + return [ + [[ + [fileId, mimeType, 1, title], + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + ]], + notebookId, + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]; +} + +export function buildNotebooklmCreateNotebookParams(title: string): unknown[] { + return [ + title, + null, + null, + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]; +} + +export function buildNotebooklmRenameNotebookParams( + notebookId: string, + title: string, +): unknown[] { + return [ + notebookId, + [[null, null, null, [null, title]]], + ]; +} + +export function buildNotebooklmDeleteNotebookParams(notebookId: string): unknown[] { + return [ + [notebookId], + [2], + ]; +} + +export function buildNotebooklmRemoveFromRecentParams(notebookId: string): unknown[] { + return [notebookId]; +} + +export function buildNotebooklmAddUrlParams( + url: string, + notebookId: string, +): unknown[] { + return [ + [[null, null, [url], null, null, null, null, null]], + notebookId, + [2], + null, + null, + ]; +} + +export function buildNotebooklmAddYoutubeParams( + url: string, + notebookId: string, +): unknown[] { + return [ + [[null, null, null, null, null, null, null, [url], null, null, 1]], + notebookId, + [2], + [1, null, null, null, null, null, null, null, null, null, [1]], + ]; +} + +export function buildNotebooklmRenameSourceParams( + sourceId: string, + title: string, +): unknown[] { + return [null, [sourceId], [[[title]]]]; +} + +export function buildNotebooklmUpdateNoteParams( + notebookId: string, + noteId: string, + title: string, + content: string, +): unknown[] { + return [ + notebookId, + noteId, + [[[content, title, [], 0]]], + ]; +} + +export function buildNotebooklmCreateNoteParams(notebookId: string): unknown[] { + return [notebookId, '', [1], null, 'New Note']; +} + +export function buildNotebooklmDeleteNoteParams(notebookId: string, noteId: string): unknown[] { + return [notebookId, null, [noteId]]; +} + +export function buildNotebooklmGetLanguageParams(): unknown[] { + return [null, [1, null, null, null, null, null, null, null, null, null, [1]]]; +} + +export function buildNotebooklmSetLanguageParams(language: string): unknown[] { + return [[[null, [[null, null, null, null, [language]]]]]]; +} + +function extractNotebooklmAskChunk(chunk: string): { text: string | null; isAnswer: boolean } { + try { + const parsed = JSON.parse(chunk); + if (!Array.isArray(parsed)) return { text: null, isAnswer: false }; + + for (const item of parsed) { + if (!Array.isArray(item) || item[0] !== 'wrb.fr' || typeof item[2] !== 'string') continue; + + const inner = JSON.parse(item[2]); + if (!Array.isArray(inner) || !Array.isArray(inner[0])) continue; + + const first = inner[0]; + const text = typeof first[0] === 'string' ? first[0].trim() : ''; + const isAnswer = Array.isArray(first[4]) && first[4].length > 0 && first[4][first[4].length - 1] === 1; + if (text) return { text, isAnswer }; + } + } catch { + // Ignore malformed chunks and keep scanning. + } + + return { text: null, isAnswer: false }; +} + +function isNotebooklmYoutubeUrl(url: string): boolean { + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + if (host === 'youtu.be') return parsed.pathname.length > 1; + if (!host.endsWith('youtube.com')) return false; + if (parsed.pathname === '/watch') return parsed.searchParams.has('v'); + return /^\/(shorts|embed|live)\//.test(parsed.pathname); + } catch { + return false; + } +} + +function extractNotebooklmNestedString(value: unknown, path: number[]): string | null { + let current = value; + for (const index of path) { + if (!Array.isArray(current) || current.length <= index) return null; + current = current[index]; + } + return typeof current === 'string' && current.trim() ? current : null; +} + +export function parseNotebooklmAskResponse(rawBody: string): string { + const cleaned = stripNotebooklmAntiXssi(rawBody).trim(); + if (!cleaned) return ''; + + const lines = cleaned.split('\n'); + let bestMarked = ''; + let bestUnmarked = ''; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]?.trim(); + if (!line) continue; + + const chunk = /^\d+$/.test(line) ? lines[i + 1]?.trim() ?? '' : line; + if (/^\d+$/.test(line)) i += 1; + if (!chunk) continue; + + const { text, isAnswer } = extractNotebooklmAskChunk(chunk); + if (!text) continue; + + if (isAnswer) { + if (text.length > bestMarked.length) bestMarked = text; + continue; + } + + if (text.length > bestUnmarked.length) bestUnmarked = text; + } + + return bestMarked || bestUnmarked; +} + +export function parseNotebooklmArtifactListResult(result: unknown): unknown[][] { + const rows = Array.isArray(result) && Array.isArray(result[0]) + ? result[0] + : Array.isArray(result) + ? result + : []; + + return rows.filter((row): row is unknown[] => Array.isArray(row)); +} + +function getNotebooklmArtifactCreatedAt(row: unknown[]): number { + const createdAt = row[15]; + if (Array.isArray(createdAt) && typeof createdAt[0] === 'number' && Number.isFinite(createdAt[0])) { + return createdAt[0]; + } + return 0; +} + +function parseNotebooklmArtifactStatus(statusCode: unknown): string { + const value = Number(statusCode ?? NaN); + if (!Number.isFinite(value)) return 'unknown'; + return value === NOTEBOOKLM_ARTIFACT_STATUS_COMPLETED ? 'completed' : `status_${value}`; +} + +function parseNotebooklmGenerationStatus(statusCode: unknown): NotebooklmGenerateRow['status'] { + const value = Number(statusCode ?? NaN); + if (!Number.isFinite(value)) return 'failed'; + if (value === 1) return 'in_progress'; + if (value === 2) return 'pending'; + if (value === 3) return 'completed'; + if (value === 4) return 'failed'; + return 'unknown'; +} + +function buildNotebooklmGenerateSourceTriples(sourceIds: string[]): string[][][] { + return sourceIds.map((sourceId) => [[sourceId]]); +} + +function buildNotebooklmGenerateSourceDoubles(sourceIds: string[]): string[][] { + return sourceIds.map((sourceId) => [sourceId]); +} + +export function buildNotebooklmGenerateReportParams( + notebookId: string, + sourceIds: string[], +): unknown[] { + const sourceTriples = buildNotebooklmGenerateSourceTriples(sourceIds); + const sourceDoubles = buildNotebooklmGenerateSourceDoubles(sourceIds); + + return [ + [2], + notebookId, + [ + null, + null, + NOTEBOOKLM_ARTIFACT_TYPE_REPORT, + sourceTriples, + null, + null, + null, + [ + null, + [ + 'Briefing Doc', + 'Key insights and important quotes', + null, + sourceDoubles, + 'en', + 'Create a comprehensive briefing document that includes an Executive Summary, detailed analysis of key themes, important quotes with context, and actionable insights.', + null, + true, + ], + ], + ], + ]; +} + +export function buildNotebooklmGenerateAudioParams( + notebookId: string, + sourceIds: string[], +): unknown[] { + const sourceTriples = buildNotebooklmGenerateSourceTriples(sourceIds); + const sourceDoubles = buildNotebooklmGenerateSourceDoubles(sourceIds); + + return [ + [2], + notebookId, + [ + null, + null, + NOTEBOOKLM_ARTIFACT_TYPE_AUDIO, + sourceTriples, + null, + null, + [ + null, + [ + null, + null, + null, + sourceDoubles, + 'en', + null, + null, + ], + ], + ], + ]; +} + +export function buildNotebooklmGenerateSlideDeckParams( + notebookId: string, + sourceIds: string[], +): unknown[] { + const sourceTriples = buildNotebooklmGenerateSourceTriples(sourceIds); + + return [ + [2], + notebookId, + [ + null, + null, + NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK, + sourceTriples, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [[null, 'en', null, null]], + ], + ]; +} + +export function buildNotebooklmGenerateInfographicParams( + notebookId: string, + sourceIds: string[], + options: { + instructions?: string | null; + language?: string | null; + orientation?: NotebooklmInfographicOrientation | null; + detail?: NotebooklmInfographicDetail | null; + style?: NotebooklmInfographicStyle | null; + } = {}, +): unknown[] { + const sourceTriples = buildNotebooklmGenerateSourceTriples(sourceIds); + const language = typeof options.language === 'string' && options.language.trim() + ? options.language.trim() + : 'en'; + const instructions = typeof options.instructions === 'string' && options.instructions.trim() + ? options.instructions.trim() + : null; + const orientationCode = options.orientation ? NOTEBOOKLM_INFOGRAPHIC_ORIENTATION_CODES[options.orientation] : null; + const detailCode = options.detail ? NOTEBOOKLM_INFOGRAPHIC_DETAIL_CODES[options.detail] : null; + const styleCode = options.style ? NOTEBOOKLM_INFOGRAPHIC_STYLE_CODES[options.style] : null; + + return [ + [2], + notebookId, + [ + null, + null, + NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC, + sourceTriples, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + [[instructions, language, null, orientationCode, detailCode, styleCode]], + ], + ]; +} + +export function parseNotebooklmGenerationResult( + result: unknown, +): Pick<NotebooklmGenerateRow, 'artifact_id' | 'status'> { + const firstRow = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : null; + const artifactId = typeof firstRow?.[0] === 'string' && firstRow[0].trim() + ? firstRow[0].trim() + : null; + const statusCode = firstRow?.[4]; + + return { + artifact_id: artifactId, + status: artifactId ? parseNotebooklmGenerationStatus(statusCode) : 'failed', + }; +} + +export function classifyNotebooklmGenerationFailureMessage( + message: string | null | undefined, +): NotebooklmGenerationErrorType { + const normalized = typeof message === 'string' ? message.trim().toLowerCase() : ''; + if (!normalized) return 'generation_failed_unknown'; + + if ( + /daily .*limit|limit reached|quota|try again tomorrow|每日|上限|额度|改日再来|数量上限/.test(normalized) + ) { + return 'daily_limit_reached'; + } + + if ( + /not enough content|insufficient content|insufficient information|need more content|need more sources|more material|内容不足|材料不足|信息不足|更多来源/.test(normalized) + ) { + return 'content_insufficient'; + } + + if ( + /not eligible|eligible|upgrade|premium|plus|pro plan|进行升级|升级|资格/.test(normalized) + ) { + return 'feature_not_eligible'; + } + + return 'generation_failed_unknown'; +} + +export function parseNotebooklmGenerationFailureFromRpcBody( + rawBody: string, +): Pick<NotebooklmGenerateRow, 'error_type' | 'message'> | null { + const displayable = extractNotebooklmRpcUserDisplayableError(rawBody, NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID); + if (!displayable) return null; + + const message = typeof displayable.message === 'string' && displayable.message.trim() + ? displayable.message.trim() + : null; + + return { + error_type: classifyNotebooklmGenerationFailureMessage(message), + message, + }; +} + +function mapNotebooklmStreamingVariantCodeToLabel(code: unknown): string | null { + const value = Number(code ?? NaN); + if (!Number.isFinite(value)) return null; + if (value === 2) return 'hls'; + if (value === 3) return 'dash'; + return null; +} + +function extractNotebooklmVariantLabels(mediaList: unknown): string[] { + if (!Array.isArray(mediaList)) return []; + + const labels: string[] = []; + for (const item of mediaList) { + if (!Array.isArray(item) || typeof item[0] !== 'string' || !item[0].trim()) continue; + + const mimeType = typeof item[2] === 'string' && item[2].trim() ? item[2].trim() : null; + const label = mimeType ?? mapNotebooklmStreamingVariantCodeToLabel(item[1]); + if (label && !labels.includes(label)) labels.push(label); + } + + return labels; +} + +export function selectNotebooklmCompletedArtifact( + rows: unknown[][], + typeCode: number, + artifactId?: string | null, +): unknown[] | null { + const candidates = rows.filter((row) => + Number(row[2] ?? 0) === typeCode && + Number(row[4] ?? 0) === NOTEBOOKLM_ARTIFACT_STATUS_COMPLETED); + + if (artifactId) { + return candidates.find((row) => String(row[0] ?? '') === artifactId) ?? null; + } + + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => getNotebooklmArtifactCreatedAt(b) - getNotebooklmArtifactCreatedAt(a))[0]; +} + +export function extractNotebooklmReportMarkdown(row: unknown[] | null): string | null { + if (!Array.isArray(row) || row.length <= 7) return null; + const content = row[7]; + if (typeof content === 'string') return content; + if (Array.isArray(content) && typeof content[0] === 'string') return content[0]; + return null; +} + +export function extractNotebooklmAudioDownloadVariant( + row: unknown[] | null, +): { url: string; mime_type: string | null } | null { + if (!Array.isArray(row) || row.length <= 6) return null; + const metadata = row[6]; + if (!Array.isArray(metadata) || metadata.length <= 5 || !Array.isArray(metadata[5])) return null; + + const mediaList = metadata[5]; + for (const item of mediaList) { + if (!Array.isArray(item) || typeof item[0] !== 'string' || !item[0].trim()) continue; + if (item.length > 2 && item[2] === 'audio/mp4') { + return { + url: item[0].trim(), + mime_type: 'audio/mp4', + }; + } + } + + const fallback = mediaList[0]; + if (Array.isArray(fallback) && typeof fallback[0] === 'string' && fallback[0].trim()) { + return { + url: fallback[0].trim(), + mime_type: typeof fallback[2] === 'string' ? fallback[2] : null, + }; + } + + return null; +} + +export function extractNotebooklmVideoDownloadVariant( + row: unknown[] | null, +): { url: string; mime_type: string | null } | null { + if (!Array.isArray(row) || row.length <= 8) return null; + const metadata = row[8]; + if (!Array.isArray(metadata) || metadata.length <= 4 || !Array.isArray(metadata[4])) return null; + + const mediaList = metadata[4]; + for (const item of mediaList) { + if (!Array.isArray(item) || typeof item[0] !== 'string' || !item[0].trim()) continue; + if (item.length > 2 && item[2] === 'video/mp4') { + return { + url: item[0].trim(), + mime_type: 'video/mp4', + }; + } + } + + const fallback = mediaList[0]; + if (Array.isArray(fallback) && typeof fallback[0] === 'string' && fallback[0].trim()) { + return { + url: fallback[0].trim(), + mime_type: typeof fallback[2] === 'string' ? fallback[2] : null, + }; + } + + return null; +} + +export function extractNotebooklmInfographicDownloadUrl( + row: unknown[] | null, +): string | null { + if (!Array.isArray(row)) return null; + + for (const item of [...row].reverse()) { + if (!Array.isArray(item) || item.length <= 2) continue; + const content = item[2]; + if (!Array.isArray(content) || content.length === 0) continue; + const firstContent = content[0]; + if (!Array.isArray(firstContent) || firstContent.length <= 1) continue; + const imageData = firstContent[1]; + if (!Array.isArray(imageData) || typeof imageData[0] !== 'string' || !imageData[0].trim()) continue; + return imageData[0].trim(); + } + + return null; +} + +export function extractNotebooklmSlideDeckDownloadUrl( + row: unknown[] | null, + outputFormat: NotebooklmSlideDeckDownloadFormat = 'pdf', +): string | null { + if (!Array.isArray(row) || row.length <= 16) return null; + const payload = row[16]; + if (!Array.isArray(payload)) return null; + + const slotIndex = outputFormat === 'pptx' ? 4 : 3; + const candidate = payload[slotIndex]; + return typeof candidate === 'string' && candidate.trim() + ? candidate.trim() + : null; +} + +export function parseNotebooklmDownloadListRows( + rows: unknown[][], + notebookId: string, + url: string, +): NotebooklmDownloadListRow[] { + const parsed: Array<NotebooklmDownloadListRow | null> = rows + .filter((row) => NOTEBOOKLM_DOWNLOADABLE_ARTIFACT_TYPES.has(Number(row[2] ?? 0))) + .map((row) => { + const typeCode = Number(row[2] ?? 0); + const artifactType = NOTEBOOKLM_DOWNLOADABLE_ARTIFACT_TYPES.get(typeCode); + if (!artifactType) return null; + + let downloadVariants: string[] = []; + if (artifactType === 'report') { + downloadVariants = extractNotebooklmReportMarkdown(row) ? ['markdown'] : []; + } else if (artifactType === 'audio') { + downloadVariants = extractNotebooklmVariantLabels(Array.isArray(row[6]) ? row[6][5] : null); + } else if (artifactType === 'video') { + downloadVariants = extractNotebooklmVariantLabels(Array.isArray(row[8]) ? row[8][4] : null); + } else if (artifactType === 'infographic') { + downloadVariants = extractNotebooklmInfographicDownloadUrl(row) ? ['png'] : []; + } else if (artifactType === 'slide_deck') { + const variants: string[] = []; + if (extractNotebooklmSlideDeckDownloadUrl(row, 'pdf')) variants.push('pdf'); + if (extractNotebooklmSlideDeckDownloadUrl(row, 'pptx')) variants.push('pptx'); + downloadVariants = variants; + } + + return { + notebook_id: notebookId, + artifact_id: String(row[0] ?? ''), + artifact_type: artifactType, + status: parseNotebooklmArtifactStatus(row[4]), + title: normalizeNotebooklmTitle(row[1], `Untitled ${artifactType}`), + created_at: toNotebooklmIsoTimestamp(row[15]), + download_variants: downloadVariants, + source: 'rpc+artifact-list' as const, + }; + }) + .filter((row): row is NotebooklmDownloadListRow => row !== null && Boolean(row.artifact_id)); + + const filtered = parsed.filter((row) => row !== null && Boolean(row.artifact_id)) as NotebooklmDownloadListRow[]; + + return filtered.sort((a, b) => { + const left = Date.parse(b.created_at ?? '') || 0; + const right = Date.parse(a.created_at ?? '') || 0; + return left - right; + }); +} + +export function parseNotebooklmNoteListRawRows( + rows: NotebooklmRawNoteRow[], + notebookId: string, + url: string, +): NotebooklmNoteRow[] { + const parsed: Array<NotebooklmNoteRow | null> = rows.map((row) => { + const title = normalizeNotebooklmTitle(row.title, ''); + const text = String(row.text ?? '') + .replace(/\bsticky_note_2\b/g, ' ') + .replace(/\bmore_vert\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!title) return null; + const suffix = text.startsWith(title) + ? text.slice(title.length).trim() + : text.replace(title, '').trim(); + + return { + notebook_id: notebookId, + id: extractNotebooklmStableIdFromHints([row.id]), + title, + created_at: suffix || null, + url, + source: 'studio-list' as const, + }; + }); + + return parsed.filter((row): row is NotebooklmNoteRow => row !== null); +} + +export function parseNotebooklmNotesRpcResult( + result: unknown, + notebookId: string, + url: string, +): NotebooklmNoteDetailRow[] { + if (!Array.isArray(result) || !Array.isArray(result[0])) return []; + + return result[0] + .filter((item): item is unknown[] => Array.isArray(item) && typeof item[0] === 'string') + .filter((item) => !(item[1] == null && item[2] === 2)) + .map((item) => { + let title = ''; + let content = ''; + + if (typeof item[1] === 'string') { + content = item[1]; + } else if (Array.isArray(item[1])) { + const inner = item[1]; + content = typeof inner[1] === 'string' ? inner[1] : ''; + title = typeof inner[4] === 'string' ? inner[4] : ''; + } + + return { + notebook_id: notebookId, + id: String(item[0]), + title: normalizeNotebooklmTitle(title, ''), + content: String(content), + url, + source: 'rpc' as const, + }; + }) + .filter((row) => row.id && row.title); +} + +export function parseNotebooklmShareStatusResult( + result: unknown, + notebookId: string, +): NotebooklmShareStatusRow | null { + if (!Array.isArray(result)) return null; + + const sharedUsers = Array.isArray(result[0]) + ? result[0] + .filter((item): item is unknown[] => Array.isArray(item) && typeof item[0] === 'string') + .map((item) => ({ + email: String(item[0]), + permission: parseNotebooklmSharePermission(item[1]), + display_name: Array.isArray(item[3]) && typeof item[3][0] === 'string' ? item[3][0] : null, + avatar_url: Array.isArray(item[3]) && typeof item[3][1] === 'string' ? item[3][1] : null, + })) + : []; + + const isPublic = Array.isArray(result[1]) && result[1][0] === 1; + + return { + notebook_id: notebookId, + is_public: isPublic, + access: isPublic ? 'anyone_with_link' : 'restricted', + view_level: 'full', + share_url: isPublic ? `https://${NOTEBOOKLM_DOMAIN}/notebook/${notebookId}` : null, + shared_user_count: sharedUsers.length, + shared_users: sharedUsers, + source: 'rpc', + }; +} + +export function parseNotebooklmLanguageGetResult(result: unknown): string | null { + return extractNotebooklmNestedString(result, [0, 2, 4, 0]); +} + +export function parseNotebooklmLanguageSetResult(result: unknown): string | null { + return extractNotebooklmNestedString(result, [2, 4, 0]); +} + +function parseNotebooklmSummaryRawRow( + row: NotebooklmRawSummaryRow | null | undefined, + notebookId: string, + url: string, +): NotebooklmSummaryRow | null { + const title = normalizeNotebooklmTitle(row?.title, 'Untitled Notebook'); + const summary = String(row?.summary ?? '').trim(); + if (!summary) return null; + + return { + notebook_id: notebookId, + title, + summary, + url, + source: 'summary-dom', + }; +} + +function parseNotebooklmVisibleNoteRawRow( + row: NotebooklmRawVisibleNoteRow | null | undefined, + notebookId: string, + url: string, +): NotebooklmNoteDetailRow | null { + const title = normalizeNotebooklmTitle(row?.title, ''); + const content = String(row?.content ?? '').replace(/\r\n/g, '\n').trim(); + if (!title) return null; + + return { + notebook_id: notebookId, + id: extractNotebooklmStableIdFromHints([row?.id]), + title, + content, + url, + source: 'studio-editor', + }; +} + +export function parseNotebooklmListResult(result: unknown): NotebooklmRow[] { + if (!Array.isArray(result) || result.length === 0) return []; + const rawNotebooks = Array.isArray(result[0]) ? result[0] : result; + if (!Array.isArray(rawNotebooks)) return []; + + return rawNotebooks + .filter((item): item is unknown[] => Array.isArray(item)) + .map((item) => { + const meta = Array.isArray(item[5]) ? item[5] : []; + const timestamps = Array.isArray(meta[5]) ? meta[5] : []; + const id = typeof item[2] === 'string' ? item[2] : ''; + const title = typeof item[0] === 'string' + ? item[0].replace(/^thought\s*\n/, '') + : ''; + + return { + id, + title: normalizeNotebooklmTitle(title, 'Untitled Notebook'), + url: `https://${NOTEBOOKLM_DOMAIN}/notebook/${id}`, + source: 'rpc' as const, + is_owner: meta.length > 1 ? meta[1] === false : true, + created_at: timestamps.length > 0 ? toNotebooklmIsoTimestamp(timestamps[0]) : null, + }; + }) + .filter((row) => row.id); +} + +export function parseNotebooklmNotebookDetailResult(result: unknown): NotebooklmNotebookDetailRow | null { + const detail = unwrapNotebooklmSingletonResult(result); + if (!Array.isArray(detail) || detail.length < 3) return null; + + const id = typeof detail[2] === 'string' ? detail[2] : ''; + if (!id) return null; + + const title = normalizeNotebooklmTitle(detail[0], 'Untitled Notebook'); + const emoji = typeof detail[3] === 'string' ? detail[3] : null; + const meta = Array.isArray(detail[5]) ? detail[5] : []; + const sources = Array.isArray(detail[1]) ? detail[1] : []; + + return { + id, + title, + url: `https://${NOTEBOOKLM_DOMAIN}/notebook/${id}`, + source: 'rpc', + is_owner: meta.length > 1 ? meta[1] === false : true, + created_at: toNotebooklmIsoTimestamp(meta[8]), + updated_at: toNotebooklmIsoTimestamp(meta[5]), + emoji, + source_count: sources.length, + }; +} + +export function parseNotebooklmSourceListResult(result: unknown): NotebooklmSourceRow[] { + return parseNotebooklmSourceListRows(result, false); +} + +export function parseNotebooklmSourceListResultWithStatus(result: unknown): NotebooklmSourceRow[] { + return parseNotebooklmSourceListRows(result, true); +} + +function parseNotebooklmSourceListRows( + result: unknown, + withStatus: boolean, +): NotebooklmSourceRow[] { + const detail = unwrapNotebooklmSingletonResult(result); + const notebook = parseNotebooklmNotebookDetailResult(detail); + if (!notebook || !Array.isArray(detail)) return []; + + const rawSources = Array.isArray(detail[1]) ? detail[1] : []; + return rawSources + .filter((entry): entry is unknown[] => Array.isArray(entry)) + .map((entry) => { + const id = findFirstNotebooklmString(entry[0]) ?? ''; + const title = normalizeNotebooklmTitle(entry[1], 'Untitled source'); + const meta = Array.isArray(entry[2]) ? entry[2] : []; + const typeInfo = typeof meta[4] === 'number' ? meta[4] : entry[3]; + const statusCode = Array.isArray(entry[3]) && typeof entry[3][1] === 'number' + ? entry[3][1] + : null; + const row: NotebooklmSourceRow = { + id, + notebook_id: notebook.id, + title, + url: notebook.url, + source: 'rpc' as const, + type: parseNotebooklmSourceType(typeInfo), + type_code: parseNotebooklmSourceTypeCode(typeInfo), + size: typeof meta[1] === 'number' && Number.isFinite(meta[1]) ? meta[1] : null, + created_at: toNotebooklmIsoTimestamp(meta[2]), + updated_at: toNotebooklmIsoTimestamp(meta[14]), + }; + + if (withStatus) { + row.status_code = statusCode; + row.status = parseNotebooklmSourceStatus(statusCode); + } + + return row; + }) + .filter((row) => row.id); +} + +export function parseNotebooklmCreatedSourceResult( + result: unknown, + notebookId: string, + fallbackUrl: string, +): NotebooklmSourceRow | null { + const raw = unwrapNotebooklmSingletonResult(result); + if (!Array.isArray(raw)) return null; + + const entry = ( + raw.length >= 2 && + (typeof raw[1] === 'string' || Array.isArray(raw[2])) + ) + ? raw + : Array.isArray(raw[0]) + ? raw[0] + : raw; + if (!Array.isArray(entry)) return null; + + const id = findFirstNotebooklmString(entry[0]) ?? ''; + if (!id) return null; + + const title = normalizeNotebooklmTitle(entry[1], 'Untitled source'); + const meta = Array.isArray(entry[2]) ? entry[2] : []; + const typeInfo = typeof meta[4] === 'number' ? meta[4] : entry[3]; + + return { + id, + notebook_id: notebookId, + title, + url: fallbackUrl, + source: 'rpc', + type: parseNotebooklmSourceType(typeInfo), + type_code: parseNotebooklmSourceTypeCode(typeInfo), + size: typeof meta[1] === 'number' && Number.isFinite(meta[1]) ? meta[1] : null, + created_at: toNotebooklmIsoTimestamp(meta[2]), + updated_at: toNotebooklmIsoTimestamp(meta[14]), + }; +} + +export function parseNotebooklmSourceFreshnessResult(result: unknown): boolean { + if (result === true) return true; + if (result === false) return false; + if (!Array.isArray(result)) return false; + if (result.length === 0) return true; + + const first = result[0]; + if (Array.isArray(first) && first.length > 1 && first[1] === true) { + return true; + } + + return false; +} + +export function parseNotebooklmNotebookDescriptionResult( + result: unknown, + notebookId: string, + url: string, +): NotebooklmNotebookDescriptionRow | null { + if (!Array.isArray(result)) return null; + + const summary = Array.isArray(result[0]) && typeof result[0][0] === 'string' + ? result[0][0].trim() + : ''; + const suggestedTopics = Array.isArray(result[1]) && Array.isArray(result[1][0]) + ? result[1][0] + .filter((topic): topic is unknown[] => Array.isArray(topic)) + .map((topic) => ({ + question: typeof topic[0] === 'string' ? topic[0].trim() : '', + prompt: typeof topic[1] === 'string' ? topic[1].trim() : '', + })) + .filter((topic) => topic.question || topic.prompt) + : []; + + if (!summary && suggestedTopics.length === 0) return null; + + return { + notebook_id: notebookId, + summary, + suggested_topics: suggestedTopics, + suggested_topic_count: suggestedTopics.length, + url, + source: 'rpc', + }; +} + +export function parseNotebooklmSourceGuideResult( + result: unknown, + source: Pick<NotebooklmSourceRow, 'id' | 'notebook_id' | 'title' | 'type'>, +): NotebooklmSourceGuideRow | null { + if (!Array.isArray(result) || result.length === 0 || !Array.isArray(result[0])) return null; + + const outer = result[0]; + const guide = Array.isArray(outer) && outer.length > 0 && Array.isArray(outer[0]) + ? outer[0] + : outer; + if (!Array.isArray(guide)) return null; + + const summary = Array.isArray(guide[1]) && typeof guide[1][0] === 'string' + ? guide[1][0].trim() + : ''; + const keywords = Array.isArray(guide[2]) && Array.isArray(guide[2][0]) + ? guide[2][0].filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + : []; + + if (!summary) return null; + + return { + source_id: source.id, + notebook_id: source.notebook_id, + title: source.title, + type: source.type ?? null, + summary, + keywords, + source: 'rpc', + }; +} + +export function parseNotebooklmSourceFulltextResult( + result: unknown, + notebookId: string, + fallbackUrl: string, +): NotebooklmSourceFulltextRow | null { + if (!Array.isArray(result) || result.length === 0 || !Array.isArray(result[0])) return null; + + const source = result[0]; + const sourceId = findFirstNotebooklmString(source[0]) ?? ''; + const title = normalizeNotebooklmTitle(source[1], 'Untitled source'); + const meta = Array.isArray(source[2]) ? source[2] : []; + const url = Array.isArray(meta[7]) && typeof meta[7][0] === 'string' ? meta[7][0] : fallbackUrl; + const kind = parseNotebooklmSourceType([null, meta[4]]); + + const contentRoot = Array.isArray(result[3]) && result[3].length > 0 ? result[3][0] : []; + const content = collectNotebooklmLeafStrings(contentRoot, []).join('\n').trim(); + + if (!sourceId || !content) return null; + + return { + source_id: sourceId, + notebook_id: notebookId, + title, + kind, + content, + char_count: content.length, + url, + source: 'rpc', + }; +} + +export function findNotebooklmSourceRow( + rows: NotebooklmSourceRow[], + query: string, +): NotebooklmSourceRow | null { + const needle = query.trim().toLowerCase(); + if (!needle) return null; + + const exactId = rows.find((row) => row.id.trim().toLowerCase() === needle); + if (exactId) return exactId; + + const exactTitle = rows.find((row) => row.title.trim().toLowerCase() === needle); + if (exactTitle) return exactTitle; + + const partialMatches = rows.filter((row) => row.title.trim().toLowerCase().includes(needle)); + if (partialMatches.length === 1) return partialMatches[0]; + + return null; +} + +export function findNotebooklmNoteRow( + rows: NotebooklmNoteRow[], + query: string, +): NotebooklmNoteRow | null { + const needle = query.trim().toLowerCase(); + if (!needle) return null; + + const exactTitle = rows.find((row) => row.title.trim().toLowerCase() === needle); + if (exactTitle) return exactTitle; + + const partialMatches = rows.filter((row) => row.title.trim().toLowerCase().includes(needle)); + if (partialMatches.length === 1) return partialMatches[0]; + + return null; +} + +export async function listNotebooklmViaRpc(page: IPage): Promise<NotebooklmRow[]> { + const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_LIST_RPC_ID, [null, 1, null, [2]]); + return parseNotebooklmListResult(rpc.result); +} + +export async function createNotebooklmNotebookViaRpc( + page: IPage, + title: string, +): Promise<NotebooklmNotebookDetailRow | null> { + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_CREATE_NOTEBOOK_RPC_ID, + buildNotebooklmCreateNotebookParams(title), + { sourcePath: '/' }, + ); + return parseNotebooklmNotebookDetailResult(rpc.result); +} + +export async function getNotebooklmDetailViaRpc(page: IPage): Promise<NotebooklmNotebookDetailRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + return getNotebooklmDetailByIdViaRpc(page, state.notebookId); +} + +export async function getNotebooklmDetailByIdViaRpc( + page: IPage, + notebookId: string, +): Promise<NotebooklmNotebookDetailRow | null> { + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, + [notebookId, null, [2], null, 0], + { sourcePath: `/notebook/${notebookId}` }, + ); + return parseNotebooklmNotebookDetailResult(rpc.result); +} + +export async function renameNotebooklmNotebookViaRpc( + page: IPage, + notebookId: string, + title: string, +): Promise<NotebooklmNotebookDetailRow | null> { + await callNotebooklmRpc( + page, + 's0tc2d', + buildNotebooklmRenameNotebookParams(notebookId, title), + { sourcePath: '/' }, + ); + + return getNotebooklmDetailByIdViaRpc(page, notebookId); +} + +export async function deleteNotebooklmNotebookViaRpc( + page: IPage, + notebookId: string, +): Promise<{ notebook_id: string; deleted: true; source: 'rpc' }> { + await callNotebooklmRpc( + page, + 'WWINqb', + buildNotebooklmDeleteNotebookParams(notebookId), + { sourcePath: '/' }, + ); + + return { + notebook_id: notebookId, + deleted: true, + source: 'rpc', + }; +} + +export async function removeNotebooklmFromRecentViaRpc( + page: IPage, + notebookId: string, +): Promise<{ notebook_id: string; removed_from_recent: true; source: 'rpc' }> { + await callNotebooklmRpc( + page, + 'fejl7e', + buildNotebooklmRemoveFromRecentParams(notebookId), + { sourcePath: '/' }, + ); + + return { + notebook_id: notebookId, + removed_from_recent: true, + source: 'rpc', + }; +} + +export async function listNotebooklmSourcesViaRpc(page: IPage): Promise<NotebooklmSourceRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, + [state.notebookId, null, [2], null, 0], + ); + return parseNotebooklmSourceListResult(rpc.result); +} + +export async function listNotebooklmSourcesViaRpcWithStatus(page: IPage): Promise<NotebooklmSourceRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, + [state.notebookId, null, [2], null, 0], + ); + return parseNotebooklmSourceListResultWithStatus(rpc.result); +} + +export async function listNotebooklmHistoryViaRpc(page: IPage): Promise<NotebooklmHistoryRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const threadsRpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_HISTORY_THREADS_RPC_ID, + [[], null, state.notebookId, 20], + ); + const threadIds = parseNotebooklmHistoryThreadIdsResult(threadsRpc.result); + if (threadIds.length === 0) return []; + + const rows: NotebooklmHistoryRow[] = []; + for (const threadId of threadIds) { + const detailRpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_HISTORY_DETAIL_RPC_ID, + [[], null, null, threadId, 20], + ); + + rows.push({ + notebook_id: state.notebookId, + thread_id: threadId, + item_count: Array.isArray(detailRpc.result) ? detailRpc.result.length : 0, + preview: extractNotebooklmHistoryPreview(detailRpc.result), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }); + } + + return rows; +} + +export async function listNotebooklmNotesFromPage(page: IPage): Promise<NotebooklmNoteRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const raw = await page.evaluate(`(() => { + return Array.from(document.querySelectorAll('artifact-library-note')).map((node) => { + const titleNode = node.querySelector('.artifact-title'); + const labelledNode = node.querySelector('[aria-labelledby^="note-labels-"], [aria-labelledby^="artifact-labels-"], [id^="note-labels-"], [id^="artifact-labels-"]'); + return { + id: node.getAttribute('aria-labelledby') || labelledNode?.getAttribute?.('aria-labelledby') || labelledNode?.id || '', + title: (titleNode?.textContent || '').trim(), + text: (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim(), + }; + }); + })()`) as NotebooklmRawNoteRow[] | null; + + if (!Array.isArray(raw) || raw.length === 0) return []; + return parseNotebooklmNoteListRawRows( + raw, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function readNotebooklmSummaryFromPage(page: IPage): Promise<NotebooklmSummaryRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const raw = await page.evaluate(`(() => { + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const title = normalize(document.querySelector('.notebook-title, h1, [data-testid="notebook-title"]')?.textContent || document.title || ''); + const summaryNode = document.querySelector('.notebook-summary, .summary-content, [class*="summary"]'); + const summary = normalize(summaryNode?.textContent || ''); + return { title, summary }; + })()`) as NotebooklmRawSummaryRow | null; + + return parseNotebooklmSummaryRawRow( + raw, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function getNotebooklmSummaryViaRpc(page: IPage): Promise<NotebooklmSummaryRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, + [state.notebookId, null, [2], null, 0], + ); + const detail = unwrapNotebooklmSingletonResult(rpc.result); + if (!Array.isArray(detail)) return null; + + const title = normalizeNotebooklmTitle(detail[0], 'Untitled Notebook'); + const summary = detail + .filter((value, index) => index !== 0 && index !== 2 && index !== 3) + .find((value) => typeof value === 'string' && value.trim().length >= 80); + + if (typeof summary !== 'string') return null; + + return { + notebook_id: state.notebookId, + title, + summary: summary.trim(), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; +} + +export async function describeNotebooklmNotebookViaRpc( + page: IPage, + notebookId: string, +): Promise<NotebooklmNotebookDescriptionRow | null> { + const url = `https://${NOTEBOOKLM_DOMAIN}/notebook/${notebookId}`; + if (typeof page.goto === 'function') { + await page.goto(url); + if (typeof page.wait === 'function') { + await page.wait(2); + } + } + + const rpc = await callNotebooklmRpc( + page, + 'VfAZjd', + [notebookId, [2]], + { sourcePath: `/notebook/${notebookId}` }, + ); + const parsed = parseNotebooklmNotebookDescriptionResult( + rpc.result, + notebookId, + url, + ); + if (parsed) return parsed; + + const domSummary = await readNotebooklmSummaryFromPage(page); + if (domSummary) { + return { + notebook_id: notebookId, + summary: domSummary.summary, + suggested_topics: [], + suggested_topic_count: 0, + url, + source: domSummary.source, + }; + } + + const rpcSummary = await getNotebooklmSummaryViaRpc(page).catch(() => null); + if (!rpcSummary) return null; + + return { + notebook_id: notebookId, + summary: rpcSummary.summary, + suggested_topics: [], + suggested_topic_count: 0, + url, + source: rpcSummary.source, + }; +} + +export async function getNotebooklmSourceFulltextViaRpc( + page: IPage, + sourceId: string, +): Promise<NotebooklmSourceFulltextRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId || !sourceId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'hizoJc', + [[sourceId], [2], [2]], + ); + return parseNotebooklmSourceFulltextResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function getNotebooklmSourceGuideViaRpc( + page: IPage, + source: Pick<NotebooklmSourceRow, 'id' | 'notebook_id' | 'title' | 'type'>, +): Promise<NotebooklmSourceGuideRow | null> { + if (!source.id) return null; + + const rpc = await callNotebooklmRpc( + page, + 'tr032e', + [[[[source.id]]]], + ); + + return parseNotebooklmSourceGuideResult(rpc.result, source); +} + +export async function addNotebooklmTextSourceViaRpc( + page: IPage, + title: string, + content: string, +): Promise<NotebooklmSourceRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'izAoDd', + buildNotebooklmAddTextParams(title, content, state.notebookId), + ); + + return parseNotebooklmCreatedSourceResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function addNotebooklmUrlSourceViaRpc( + page: IPage, + url: string, +): Promise<NotebooklmSourceRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'izAoDd', + isNotebooklmYoutubeUrl(url) + ? buildNotebooklmAddYoutubeParams(url, state.notebookId) + : buildNotebooklmAddUrlParams(url, state.notebookId), + ); + + return parseNotebooklmCreatedSourceResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function addNotebooklmDriveSourceViaRpc( + page: IPage, + fileId: string, + title: string, + mimeType: string, +): Promise<NotebooklmSourceRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'izAoDd', + buildNotebooklmAddDriveParams(fileId, title, state.notebookId, mimeType), + ); + + return parseNotebooklmCreatedSourceResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +function parseNotebooklmSourceStatus(statusCode: unknown): NotebooklmSourceRow['status'] { + const value = Number(statusCode ?? NaN); + if (!Number.isFinite(value)) return 'unknown'; + + switch (value) { + case NOTEBOOKLM_SOURCE_STATUS_PROCESSING: + return 'processing'; + case NOTEBOOKLM_SOURCE_STATUS_READY: + return 'ready'; + case NOTEBOOKLM_SOURCE_STATUS_ERROR: + return 'error'; + case NOTEBOOKLM_SOURCE_STATUS_PREPARING: + return 'preparing'; + default: + return 'unknown'; + } +} + +function getNotebooklmAuthuser(): string { + const raw = String(process.env.NOTEBOOKLM_AUTHUSER ?? '0').trim(); + return /^\d+$/.test(raw) ? raw : '0'; +} + +function extractFirstNotebooklmNestedString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) return value; + if (!Array.isArray(value)) return null; + + for (const item of value) { + const nested = extractFirstNotebooklmNestedString(item); + if (nested) return nested; + } + + return null; +} + +export async function addNotebooklmFileSourceViaUpload( + page: IPage, + filePath: string, +): Promise<NotebooklmSourceRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const resolvedPath = resolvePath(filePath); + const fileInfo = await stat(resolvedPath).catch(() => null); + if (!fileInfo) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_FILE_NOT_FOUND', + `NotebookLM source file was not found: ${resolvedPath}`, + 'Provide a readable local file path and retry.', + ); + } + if (!fileInfo.isFile()) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_FILE_INVALID', + `NotebookLM source path is not a regular file: ${resolvedPath}`, + 'Provide a regular local file path and retry.', + ); + } + + const filename = basename(resolvedPath); + const registerRpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_ADD_FILE_RPC_ID, + buildNotebooklmAddFileParams(filename, state.notebookId), + { sourcePath: `/notebook/${state.notebookId}` }, + ); + const sourceId = extractFirstNotebooklmNestedString(registerRpc.result); + if (!sourceId) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ADD_FILE_REGISTER', + `NotebookLM did not return a source id for file "${filename}"`, + 'Retry from the target notebook page. If it persists, the NotebookLM file-upload RPC may have changed.', + ); + } + + const cookieHeader = formatCookieHeader(await page.getCookies({ url: NOTEBOOKLM_HOME_URL })); + const authuser = getNotebooklmAuthuser(); + const startUploadResponse = await fetchWithNodeNetwork( + `${NOTEBOOKLM_UPLOAD_URL}?authuser=${encodeURIComponent(authuser)}`, + { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + Origin: `https://${NOTEBOOKLM_DOMAIN}`, + Referer: NOTEBOOKLM_HOME_URL, + 'x-goog-authuser': authuser, + 'x-goog-upload-command': 'start', + 'x-goog-upload-header-content-length': String(fileInfo.size), + 'x-goog-upload-protocol': 'resumable', + }, + body: JSON.stringify({ + PROJECT_ID: state.notebookId, + SOURCE_NAME: filename, + SOURCE_ID: sourceId, + }), + }, + ); + if (!startUploadResponse.ok) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ADD_FILE_UPLOAD_START', + `NotebookLM upload session start failed with HTTP ${startUploadResponse.status}`, + 'Refresh the NotebookLM notebook page and retry the file upload.', + ); + } + + const uploadUrl = startUploadResponse.headers.get('x-goog-upload-url'); + if (!uploadUrl) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ADD_FILE_UPLOAD_URL', + `NotebookLM did not return an upload URL for file "${filename}"`, + 'Retry the file upload from the target notebook page.', + ); + } + + const uploadInit = { + method: 'POST', + headers: { + Accept: '*/*', + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + Origin: `https://${NOTEBOOKLM_DOMAIN}`, + Referer: NOTEBOOKLM_HOME_URL, + 'x-goog-authuser': authuser, + 'x-goog-upload-command': 'upload, finalize', + 'x-goog-upload-offset': '0', + }, + body: createReadStream(resolvedPath) as unknown as BodyInit, + duplex: 'half' as const, + } as RequestInit & { duplex: 'half' }; + + const uploadResponse = await fetchWithNodeNetwork(uploadUrl, uploadInit); + if (!uploadResponse.ok) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_ADD_FILE_UPLOAD', + `NotebookLM file upload failed with HTTP ${uploadResponse.status}`, + 'Retry the file upload. If it persists, the NotebookLM resumable-upload flow may have changed.', + ); + } + + return { + id: sourceId, + notebook_id: state.notebookId, + title: filename, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + type: null, + type_code: null, + size: fileInfo.size, + created_at: null, + updated_at: null, + status: 'preparing', + status_code: NOTEBOOKLM_SOURCE_STATUS_PREPARING, + }; +} + +export async function renameNotebooklmSourceViaRpc( + page: IPage, + sourceId: string, + title: string, +): Promise<NotebooklmSourceRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId || !sourceId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'b7Wfje', + buildNotebooklmRenameSourceParams(sourceId, title), + { sourcePath: `/notebook/${state.notebookId}` }, + ); + + return parseNotebooklmCreatedSourceResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ) ?? { + id: sourceId, + notebook_id: state.notebookId, + title, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + type: null, + type_code: null, + size: null, + created_at: null, + updated_at: null, + }; +} + +export async function deleteNotebooklmSourceViaRpc( + page: IPage, + sourceId: string, +): Promise<NotebooklmSourceDeleteRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId || !sourceId) return null; + + await callNotebooklmRpc( + page, + 'tGMBJ', + [[[sourceId]]], + { sourcePath: `/notebook/${state.notebookId}` }, + ); + + return { + notebook_id: state.notebookId, + source_id: sourceId, + deleted: true, + source: 'rpc', + }; +} + +export async function refreshNotebooklmSourceViaRpc( + page: IPage, + sourceId: string, +): Promise<NotebooklmSourceRefreshRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId || !sourceId) return null; + + await callNotebooklmRpc( + page, + 'FLmJqe', + [null, [sourceId], [2]], + { sourcePath: `/notebook/${state.notebookId}` }, + ); + + return { + notebook_id: state.notebookId, + source_id: sourceId, + refreshed: true, + source: 'rpc', + }; +} + +export async function checkNotebooklmSourceFreshnessViaRpc( + page: IPage, + sourceId: string, +): Promise<NotebooklmSourceFreshnessRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId || !sourceId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'yR9Yof', + [null, [sourceId], [2]], + { sourcePath: `/notebook/${state.notebookId}` }, + ); + + const isFresh = parseNotebooklmSourceFreshnessResult(rpc.result); + return { + notebook_id: state.notebookId, + source_id: sourceId, + is_fresh: isFresh, + is_stale: !isFresh, + source: 'rpc', + }; +} + +export async function waitForNotebooklmSourcesReadyViaRpc( + page: IPage, + sourceIds: string[], + options: { + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmSourceRow[]> { + const ids = sourceIds.map((value) => value.trim()).filter(Boolean); + if (ids.length === 0) return []; + + const timeout = Number.isFinite(options.timeout) ? Number(options.timeout) : 120; + const initialInterval = Number.isFinite(options.initialInterval) ? Number(options.initialInterval) : 1; + const maxInterval = Number.isFinite(options.maxInterval) ? Number(options.maxInterval) : 10; + const backoffFactor = Number.isFinite(options.backoffFactor) ? Number(options.backoffFactor) : 1.5; + + const startedAt = Date.now(); + let intervalSeconds = initialInterval; + + while (true) { + const rows = await listNotebooklmSourcesViaRpcWithStatus(page); + const byId = new Map(rows.map((row) => [row.id, row])); + const matched = ids.map((id) => byId.get(id) ?? null); + const failed = matched.find((row) => row?.status_code === NOTEBOOKLM_SOURCE_STATUS_ERROR) ?? null; + if (failed) { + throw new CliError( + 'NOTEBOOKLM_SOURCE_PROCESSING_FAILED', + `NotebookLM source "${failed.id}" failed while processing`, + 'Open the notebook in Chrome and inspect the source error state, then retry the ingest if needed.', + ); + } + + if (matched.every((row) => row?.status_code === NOTEBOOKLM_SOURCE_STATUS_READY)) { + return matched.filter((row): row is NotebooklmSourceRow => Boolean(row)); + } + + const elapsedSeconds = (Date.now() - startedAt) / 1000; + if (elapsedSeconds >= timeout) { + const pendingIds = matched + .map((row, index) => (row?.status_code === NOTEBOOKLM_SOURCE_STATUS_READY ? null : ids[index])) + .filter((value): value is string => Boolean(value)); + throw new CliError( + 'NOTEBOOKLM_SOURCE_WAIT_TIMEOUT', + `NotebookLM source wait timed out after ${timeout} seconds for: ${pendingIds.join(', ')}`, + 'Retry notebooklm source wait after the notebook finishes processing its sources.', + ); + } + + const remainingSeconds = Math.max(0, timeout - elapsedSeconds); + const sleepSeconds = Math.min(intervalSeconds, remainingSeconds); + if (sleepSeconds > 0) { + await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1000)); + } + intervalSeconds = Math.min(intervalSeconds * backoffFactor, maxInterval); + } +} + +export async function waitForNotebooklmSourceReadyViaRpc( + page: IPage, + sourceId: string, + options: { + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmSourceRow | null> { + const rows = await waitForNotebooklmSourcesReadyViaRpc(page, [sourceId], options); + return rows[0] ?? null; +} + +export async function listNotebooklmArtifactsViaRpc(page: IPage): Promise<unknown[][]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_LIST_ARTIFACTS_RPC_ID, + [[2], state.notebookId, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"'], + ); + + return parseNotebooklmArtifactListResult(rpc.result); +} + +function selectNotebooklmGeneratedArtifact( + rows: unknown[][], + typeCode: number, + baselineIds: Set<string>, + artifactId?: string | null, +): unknown[] | null { + const candidates = rows.filter((row) => Number(row[2] ?? 0) === typeCode); + if (artifactId) { + return candidates.find((row) => String(row[0] ?? '') === artifactId) ?? null; + } + + const newCandidates = candidates.filter((row) => !baselineIds.has(String(row[0] ?? ''))); + if (newCandidates.length === 0) return null; + return [...newCandidates].sort((a, b) => getNotebooklmArtifactCreatedAt(b) - getNotebooklmArtifactCreatedAt(a))[0]; +} + +async function waitForNotebooklmGeneratedArtifactViaRpc( + page: IPage, + options: { + artifactType: NotebooklmGenerateRow['artifact_type']; + typeCode: number; + baselineIds: Set<string>; + artifactId?: string | null; + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + isReady?: (row: unknown[]) => boolean; + }, +): Promise<unknown[] | null> { + const timeout = Number.isFinite(options.timeout) ? Number(options.timeout) : 180; + const initialInterval = Number.isFinite(options.initialInterval) ? Number(options.initialInterval) : 2; + const maxInterval = Number.isFinite(options.maxInterval) ? Number(options.maxInterval) : 10; + const backoffFactor = Number.isFinite(options.backoffFactor) ? Number(options.backoffFactor) : 1.5; + + const startedAt = Date.now(); + let intervalSeconds = initialInterval; + + while (true) { + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmGeneratedArtifact( + rows, + options.typeCode, + options.baselineIds, + options.artifactId, + ); + + if (artifact) { + const status = parseNotebooklmGenerationStatus(artifact[4]); + const ready = typeof options.isReady === 'function' ? options.isReady(artifact) : true; + if (status === 'failed' || (status === 'completed' && ready)) { + return artifact; + } + } + + const elapsedSeconds = (Date.now() - startedAt) / 1000; + if (elapsedSeconds >= timeout) { + throw new CliError( + 'NOTEBOOKLM_GENERATION_WAIT_TIMEOUT', + `NotebookLM ${options.artifactType} generation wait timed out after ${timeout} seconds`, + 'Retry without --wait to capture the submission handle immediately, or re-run download/list after NotebookLM finishes generating the artifact.', + ); + } + + const remainingSeconds = Math.max(0, timeout - elapsedSeconds); + const sleepSeconds = Math.min(intervalSeconds, remainingSeconds); + if (sleepSeconds > 0) { + await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1000)); + } + intervalSeconds = Math.min(intervalSeconds * backoffFactor, maxInterval); + } +} + +export async function generateNotebooklmReportViaRpc( + page: IPage, + options: { + wait?: boolean; + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmGenerateRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const sources = await listNotebooklmSourcesViaRpc(page); + const sourceIds = sources + .map((row) => (typeof row.id === 'string' ? row.id.trim() : '')) + .filter(Boolean); + if (sourceIds.length === 0) return null; + + const baselineRows = await listNotebooklmArtifactsViaRpc(page); + const baselineIds = new Set( + baselineRows + .filter((row) => Number(row[2] ?? 0) === NOTEBOOKLM_ARTIFACT_TYPE_REPORT) + .map((row) => String(row[0] ?? '')) + .filter(Boolean), + ); + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID, + buildNotebooklmGenerateReportParams(state.notebookId, sourceIds), + ); + const parsed = parseNotebooklmGenerationResult(rpc.result); + const initialFailure = !parsed.artifact_id + ? parseNotebooklmGenerationFailureFromRpcBody(rpc.response.body) + : null; + + let createdAt: string | null | undefined; + let artifactId = parsed.artifact_id; + let status = parsed.status; + let errorType = initialFailure?.error_type ?? (status === 'failed' ? 'generation_failed_unknown' : null); + let message = initialFailure?.message ?? null; + let source: NotebooklmGenerateRow['source'] = 'rpc+create-artifact'; + + if (options.wait) { + const artifact = await waitForNotebooklmGeneratedArtifactViaRpc(page, { + artifactType: 'report', + typeCode: NOTEBOOKLM_ARTIFACT_TYPE_REPORT, + artifactId, + baselineIds, + timeout: options.timeout, + initialInterval: options.initialInterval, + maxInterval: options.maxInterval, + backoffFactor: options.backoffFactor, + isReady: (row) => typeof extractNotebooklmReportMarkdown(row) === 'string', + }); + + if (artifact) { + artifactId = String(artifact[0] ?? '') || artifactId; + status = parseNotebooklmGenerationStatus(artifact[4]); + createdAt = toNotebooklmIsoTimestamp(artifact[15]); + errorType = status === 'failed' ? errorType ?? 'generation_failed_unknown' : null; + if (status !== 'failed') message = null; + source = 'rpc+create-artifact+artifact-list'; + } + } + + return { + notebook_id: state.notebookId, + artifact_id: artifactId, + artifact_type: 'report', + status, + created_at: createdAt, + error_type: errorType, + message, + source, + }; +} + +export async function generateNotebooklmAudioViaRpc( + page: IPage, + options: { + wait?: boolean; + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmGenerateRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const sources = await listNotebooklmSourcesViaRpc(page); + const sourceIds = sources + .map((row) => (typeof row.id === 'string' ? row.id.trim() : '')) + .filter(Boolean); + if (sourceIds.length === 0) return null; + + const baselineRows = await listNotebooklmArtifactsViaRpc(page); + const baselineIds = new Set( + baselineRows + .filter((row) => Number(row[2] ?? 0) === NOTEBOOKLM_ARTIFACT_TYPE_AUDIO) + .map((row) => String(row[0] ?? '')) + .filter(Boolean), + ); + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID, + buildNotebooklmGenerateAudioParams(state.notebookId, sourceIds), + ); + const parsed = parseNotebooklmGenerationResult(rpc.result); + const initialFailure = !parsed.artifact_id + ? parseNotebooklmGenerationFailureFromRpcBody(rpc.response.body) + : null; + + let createdAt: string | null | undefined; + let artifactId = parsed.artifact_id; + let status = parsed.status; + let errorType = initialFailure?.error_type ?? (status === 'failed' ? 'generation_failed_unknown' : null); + let message = initialFailure?.message ?? null; + let source: NotebooklmGenerateRow['source'] = 'rpc+create-artifact'; + + if (options.wait) { + const artifact = await waitForNotebooklmGeneratedArtifactViaRpc(page, { + artifactType: 'audio', + typeCode: NOTEBOOKLM_ARTIFACT_TYPE_AUDIO, + artifactId, + baselineIds, + timeout: options.timeout, + initialInterval: options.initialInterval, + maxInterval: options.maxInterval, + backoffFactor: options.backoffFactor, + isReady: (row) => Boolean(extractNotebooklmAudioDownloadVariant(row)), + }); + + if (artifact) { + artifactId = String(artifact[0] ?? '') || artifactId; + status = parseNotebooklmGenerationStatus(artifact[4]); + createdAt = toNotebooklmIsoTimestamp(artifact[15]); + errorType = status === 'failed' ? errorType ?? 'generation_failed_unknown' : null; + if (status !== 'failed') message = null; + source = 'rpc+create-artifact+artifact-list'; + } + } + + return { + notebook_id: state.notebookId, + artifact_id: artifactId, + artifact_type: 'audio', + status, + created_at: createdAt, + error_type: errorType, + message, + source, + }; +} + +export async function generateNotebooklmInfographicViaRpc( + page: IPage, + options: { + instructions?: string | null; + orientation?: NotebooklmInfographicOrientation | null; + detail?: NotebooklmInfographicDetail | null; + style?: NotebooklmInfographicStyle | null; + wait?: boolean; + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmGenerateRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const sources = await listNotebooklmSourcesViaRpc(page); + const sourceIds = sources + .map((row) => (typeof row.id === 'string' ? row.id.trim() : '')) + .filter(Boolean); + if (sourceIds.length === 0) return null; + + const baselineRows = await listNotebooklmArtifactsViaRpc(page); + const baselineIds = new Set( + baselineRows + .filter((row) => Number(row[2] ?? 0) === NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC) + .map((row) => String(row[0] ?? '')) + .filter(Boolean), + ); + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID, + buildNotebooklmGenerateInfographicParams(state.notebookId, sourceIds, { + instructions: options.instructions, + orientation: options.orientation, + detail: options.detail, + style: options.style, + }), + ); + const parsed = parseNotebooklmGenerationResult(rpc.result); + const initialFailure = !parsed.artifact_id + ? parseNotebooklmGenerationFailureFromRpcBody(rpc.response.body) + : null; + + let createdAt: string | null | undefined; + let artifactId = parsed.artifact_id; + let status = parsed.status; + let errorType = initialFailure?.error_type ?? (status === 'failed' ? 'generation_failed_unknown' : null); + let message = initialFailure?.message ?? null; + let source: NotebooklmGenerateRow['source'] = 'rpc+create-artifact'; + + if (options.wait) { + const artifact = await waitForNotebooklmGeneratedArtifactViaRpc(page, { + artifactType: 'infographic', + typeCode: NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC, + artifactId, + baselineIds, + timeout: options.timeout, + initialInterval: options.initialInterval, + maxInterval: options.maxInterval, + backoffFactor: options.backoffFactor, + isReady: (row) => Boolean(extractNotebooklmInfographicDownloadUrl(row)), + }); + + if (artifact) { + artifactId = String(artifact[0] ?? '') || artifactId; + status = parseNotebooklmGenerationStatus(artifact[4]); + createdAt = toNotebooklmIsoTimestamp(artifact[15]); + errorType = status === 'failed' ? errorType ?? 'generation_failed_unknown' : null; + if (status !== 'failed') message = null; + source = 'rpc+create-artifact+artifact-list'; + } + } + + return { + notebook_id: state.notebookId, + artifact_id: artifactId, + artifact_type: 'infographic', + status, + created_at: createdAt, + error_type: errorType, + message, + source, + }; +} + +export async function generateNotebooklmSlideDeckViaRpc( + page: IPage, + options: { + wait?: boolean; + timeout?: number; + initialInterval?: number; + maxInterval?: number; + backoffFactor?: number; + } = {}, +): Promise<NotebooklmGenerateRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const sources = await listNotebooklmSourcesViaRpc(page); + const sourceIds = sources + .map((row) => (typeof row.id === 'string' ? row.id.trim() : '')) + .filter(Boolean); + if (sourceIds.length === 0) return null; + + const baselineRows = await listNotebooklmArtifactsViaRpc(page); + const baselineIds = new Set( + baselineRows + .filter((row) => Number(row[2] ?? 0) === NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK) + .map((row) => String(row[0] ?? '')) + .filter(Boolean), + ); + + const rpc = await callNotebooklmRpc( + page, + NOTEBOOKLM_CREATE_ARTIFACT_RPC_ID, + buildNotebooklmGenerateSlideDeckParams(state.notebookId, sourceIds), + ); + const parsed = parseNotebooklmGenerationResult(rpc.result); + const initialFailure = !parsed.artifact_id + ? parseNotebooklmGenerationFailureFromRpcBody(rpc.response.body) + : null; + + let createdAt: string | null | undefined; + let artifactId = parsed.artifact_id; + let status = parsed.status; + let errorType = initialFailure?.error_type ?? (status === 'failed' ? 'generation_failed_unknown' : null); + let message = initialFailure?.message ?? null; + let source: NotebooklmGenerateRow['source'] = 'rpc+create-artifact'; + + if (options.wait) { + const artifact = await waitForNotebooklmGeneratedArtifactViaRpc(page, { + artifactType: 'slide_deck', + typeCode: NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK, + artifactId, + baselineIds, + timeout: options.timeout, + initialInterval: options.initialInterval, + maxInterval: options.maxInterval, + backoffFactor: options.backoffFactor, + isReady: (row) => Boolean( + extractNotebooklmSlideDeckDownloadUrl(row, 'pdf') || + extractNotebooklmSlideDeckDownloadUrl(row, 'pptx'), + ), + }); + + if (artifact) { + artifactId = String(artifact[0] ?? '') || artifactId; + status = parseNotebooklmGenerationStatus(artifact[4]); + createdAt = toNotebooklmIsoTimestamp(artifact[15]); + errorType = status === 'failed' ? errorType ?? 'generation_failed_unknown' : null; + if (status !== 'failed') message = null; + source = 'rpc+create-artifact+artifact-list'; + } + } + + return { + notebook_id: state.notebookId, + artifact_id: artifactId, + artifact_type: 'slide_deck', + status, + created_at: createdAt, + error_type: errorType, + message, + source, + }; +} + +export async function listNotebooklmDownloadArtifactsViaRpc(page: IPage): Promise<NotebooklmDownloadListRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const rows = await listNotebooklmArtifactsViaRpc(page); + return parseNotebooklmDownloadListRows( + rows, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export function pickNotebooklmArtifactIdCompletionCandidates( + rows: NotebooklmDownloadListRow[], + artifactType: NotebooklmDownloadListRow['artifact_type'], +): string[] { + return rows + .filter((row) => row.artifact_type === artifactType) + .map((row) => row.artifact_id) + .filter((artifactId) => typeof artifactId === 'string' && artifactId.trim().length > 0); +} + +async function listNotebooklmDownloadArtifactsForCompletionProbe(): Promise<NotebooklmDownloadListRow[] | null> { + return browserSession(getBrowserFactory(), async (page) => { + const rebound = await ensureNotebooklmNotebookBinding(page); + if (rebound) return null; + + await requireNotebooklmSession(page); + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook') return []; + + return listNotebooklmDownloadArtifactsViaRpc(page); + }, { workspace: `site:${NOTEBOOKLM_SITE}` }); +} + +export async function completeNotebooklmArtifactIds( + artifactType: NotebooklmDownloadListRow['artifact_type'], +): Promise<string[]> { + try { + const firstProbe = await listNotebooklmDownloadArtifactsForCompletionProbe(); + const rows = firstProbe ?? await listNotebooklmDownloadArtifactsForCompletionProbe(); + if (!Array.isArray(rows) || rows.length === 0) return []; + return pickNotebooklmArtifactIdCompletionCandidates(rows, artifactType); + } catch { + return []; + } +} + +export async function downloadNotebooklmReportViaRpc( + page: IPage, + outputPath: string, + artifactId?: string | null, +): Promise<NotebooklmReportDownloadRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmCompletedArtifact(rows, NOTEBOOKLM_ARTIFACT_TYPE_REPORT, artifactId); + if (!artifact) return null; + + const markdown = extractNotebooklmReportMarkdown(artifact); + if (typeof markdown !== 'string') return null; + + const resolvedOutputPath = resolvePath(outputPath); + await mkdir(dirname(resolvedOutputPath), { recursive: true }); + await writeFile(resolvedOutputPath, markdown, 'utf8'); + + return { + notebook_id: state.notebookId, + artifact_id: String(artifact[0] ?? ''), + title: normalizeNotebooklmTitle(artifact[1], 'Untitled Report'), + kind: 'report', + output_path: resolvedOutputPath, + created_at: toNotebooklmIsoTimestamp(artifact[15]), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; +} + +export async function downloadNotebooklmAudioViaRpc( + page: IPage, + outputPath: string, + artifactId?: string | null, +): Promise<NotebooklmAudioDownloadRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmCompletedArtifact(rows, NOTEBOOKLM_ARTIFACT_TYPE_AUDIO, artifactId); + if (!artifact) return null; + + const variant = extractNotebooklmAudioDownloadVariant(artifact); + if (!variant) return null; + + const resolvedOutputPath = resolvePath(outputPath); + const cookieHeader = formatCookieHeader(await page.getCookies({ url: variant.url })); + const result = await httpDownload(variant.url, resolvedOutputPath, { + cookies: cookieHeader || undefined, + headers: { + Referer: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + }, + timeout: 120000, + }); + + if (!result.success) { + throw new CliError( + 'DOWNLOAD_ERROR', + `Failed to download audio artifact "${String(artifact[0] ?? '')}": ${result.error || 'unknown error'}`, + 'The audio URL may have expired. Refresh the NotebookLM notebook tab and retry.', + ); + } + + return { + notebook_id: state.notebookId, + artifact_id: String(artifact[0] ?? ''), + artifact_type: 'audio', + title: normalizeNotebooklmTitle(artifact[1], 'Untitled Audio'), + output_path: resolvedOutputPath, + created_at: toNotebooklmIsoTimestamp(artifact[15]), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + download_url: variant.url, + mime_type: variant.mime_type, + source: 'rpc+artifact-url', + }; +} + +export async function downloadNotebooklmVideoViaRpc( + page: IPage, + outputPath: string, + artifactId?: string | null, +): Promise<NotebooklmVideoDownloadRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmCompletedArtifact(rows, NOTEBOOKLM_ARTIFACT_TYPE_VIDEO, artifactId); + if (!artifact) return null; + + const variant = extractNotebooklmVideoDownloadVariant(artifact); + if (!variant) return null; + + const resolvedOutputPath = resolvePath(outputPath); + const cookieHeader = formatCookieHeader(await page.getCookies({ url: variant.url })); + const result = await httpDownload(variant.url, resolvedOutputPath, { + cookies: cookieHeader || undefined, + headers: { + Referer: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + }, + timeout: 120000, + }); + + if (!result.success) { + throw new CliError( + 'DOWNLOAD_ERROR', + `Failed to download video artifact "${String(artifact[0] ?? '')}": ${result.error || 'unknown error'}`, + 'The video URL may have expired. Refresh the NotebookLM notebook tab and retry.', + ); + } + + return { + notebook_id: state.notebookId, + artifact_id: String(artifact[0] ?? ''), + artifact_type: 'video', + title: normalizeNotebooklmTitle(artifact[1], 'Untitled Video'), + output_path: resolvedOutputPath, + created_at: toNotebooklmIsoTimestamp(artifact[15]), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + download_url: variant.url, + mime_type: variant.mime_type, + source: 'rpc+artifact-url', + }; +} + +export async function downloadNotebooklmInfographicViaRpc( + page: IPage, + outputPath: string, + artifactId?: string | null, +): Promise<NotebooklmInfographicDownloadRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmCompletedArtifact(rows, NOTEBOOKLM_ARTIFACT_TYPE_INFOGRAPHIC, artifactId); + if (!artifact) return null; + + const downloadUrl = extractNotebooklmInfographicDownloadUrl(artifact); + if (!downloadUrl) return null; + + const resolvedOutputPath = resolvePath(outputPath); + const cookieHeader = formatCookieHeader(await page.getCookies({ url: downloadUrl })); + const result = await httpDownload(downloadUrl, resolvedOutputPath, { + cookies: cookieHeader || undefined, + headers: { + Referer: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + }, + timeout: 120000, + }); + + if (!result.success) { + throw new CliError( + 'DOWNLOAD_ERROR', + `Failed to download infographic artifact "${String(artifact[0] ?? '')}": ${result.error || 'unknown error'}`, + 'The artifact URL may have expired. Refresh the NotebookLM notebook tab and retry.', + ); + } + + return { + notebook_id: state.notebookId, + artifact_id: String(artifact[0] ?? ''), + artifact_type: 'infographic', + title: normalizeNotebooklmTitle(artifact[1], 'Untitled Infographic'), + output_path: resolvedOutputPath, + created_at: toNotebooklmIsoTimestamp(artifact[15]), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + download_url: downloadUrl, + source: 'rpc+artifact-url', + }; +} + +export async function downloadNotebooklmSlideDeckViaRpc( + page: IPage, + outputPath: string, + artifactId?: string | null, + outputFormat: NotebooklmSlideDeckDownloadFormat = 'pdf', +): Promise<NotebooklmSlideDeckDownloadRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmArtifactsViaRpc(page); + const artifact = selectNotebooklmCompletedArtifact(rows, NOTEBOOKLM_ARTIFACT_TYPE_SLIDE_DECK, artifactId); + if (!artifact) return null; + + const downloadUrl = extractNotebooklmSlideDeckDownloadUrl(artifact, outputFormat); + if (!downloadUrl) return null; + + const resolvedOutputPath = resolvePath(outputPath); + const cookieHeader = formatCookieHeader(await page.getCookies({ url: downloadUrl })); + const result = await httpDownload(downloadUrl, resolvedOutputPath, { + cookies: cookieHeader || undefined, + headers: { + Referer: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + }, + timeout: 120000, + }); + + if (!result.success) { + throw new CliError( + 'DOWNLOAD_ERROR', + `Failed to download slide deck artifact "${String(artifact[0] ?? '')}": ${result.error || 'unknown error'}`, + 'The artifact URL may have expired. Refresh the NotebookLM notebook tab and retry.', + ); + } + + return { + notebook_id: state.notebookId, + artifact_id: String(artifact[0] ?? ''), + artifact_type: 'slide_deck', + title: normalizeNotebooklmTitle(artifact[1], 'Untitled Slide Deck'), + output_path: resolvedOutputPath, + created_at: toNotebooklmIsoTimestamp(artifact[15]), + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + download_url: downloadUrl, + download_format: outputFormat, + source: 'rpc+artifact-url', + }; +} + +export async function listNotebooklmNotesViaRpc(page: IPage): Promise<NotebooklmNoteDetailRow[]> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return []; + + const rpc = await callNotebooklmRpc( + page, + 'cFji9', + [state.notebookId], + ); + + return parseNotebooklmNotesRpcResult( + rpc.result, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function createNotebooklmNoteViaRpc( + page: IPage, + title: string, + content: string, +): Promise<NotebooklmNoteDetailRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'CYK0Xb', + buildNotebooklmCreateNoteParams(state.notebookId), + ); + + const raw = unwrapNotebooklmSingletonResult(rpc.result); + const noteId = Array.isArray(raw) + ? typeof raw[0] === 'string' + ? raw[0] + : Array.isArray(raw[0]) && typeof raw[0][0] === 'string' + ? raw[0][0] + : '' + : ''; + if (!noteId) return null; + + await callNotebooklmRpc( + page, + 'cYAfTb', + buildNotebooklmUpdateNoteParams(state.notebookId, noteId, title, content), + ); + + return { + notebook_id: state.notebookId, + id: noteId, + title, + content, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; +} + +export async function renameNotebooklmNoteViaRpc( + page: IPage, + noteId: string, + title: string, +): Promise<NotebooklmNoteDetailRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rows = await listNotebooklmNotesViaRpc(page); + const matched = rows.find((row) => row.id === noteId) ?? null; + if (!matched) return null; + + await callNotebooklmRpc( + page, + 'cYAfTb', + buildNotebooklmUpdateNoteParams(state.notebookId, noteId, title, matched.content), + ); + + return { + notebook_id: state.notebookId, + id: noteId, + title, + content: matched.content, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; +} + +export async function deleteNotebooklmNoteViaRpc( + page: IPage, + noteId: string, +): Promise<NotebooklmNoteDeleteRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + await callNotebooklmRpc( + page, + 'AH0mwd', + buildNotebooklmDeleteNoteParams(state.notebookId, noteId), + ); + + return { + notebook_id: state.notebookId, + note_id: noteId, + deleted: true, + source: 'rpc', + }; +} + +export async function saveNotebooklmVisibleNoteViaRpc( + page: IPage, + noteId?: string, +): Promise<NotebooklmNoteDetailRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const visible = await readNotebooklmVisibleNoteFromPage(page); + if (!visible) return null; + + const rows = await listNotebooklmNotesViaRpc(page); + const explicitId = typeof noteId === 'string' ? noteId.trim() : ''; + if (explicitId) { + const matched = rows.find((row) => row.id === explicitId) ?? null; + if (!matched) { + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_NOT_FOUND', + `NotebookLM note id "${explicitId}" was not found`, + `No NotebookLM note with id "${explicitId}" was found in the current notebook.`, + ); + } + if (visible.id && visible.id !== explicitId) { + throw new CliError( + 'NOTEBOOKLM_NOTE_ID_MISMATCH', + `Requested note id "${explicitId}" does not match the currently visible note editor`, + `The visible note editor is currently bound to "${visible.id}". Open note "${explicitId}" first, or omit --note-id.`, + ); + } + + await callNotebooklmRpc( + page, + 'cYAfTb', + buildNotebooklmUpdateNoteParams(state.notebookId, explicitId, visible.title, visible.content), + ); + + return { + notebook_id: state.notebookId, + id: explicitId, + title: visible.title, + content: visible.content, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; + } + + const resolved = resolveNotebooklmVisibleNoteId(visible, rows); + + if (resolved.reason === 'ambiguous') { + throw new CliError( + 'NOTEBOOKLM_NOTE_AMBIGUOUS', + `NotebookLM found multiple notes titled "${visible.title}"`, + 'Open the current note editor so a stable note id is visible, or make the current note content/title unique before retrying notes-save.', + ); + } + + if (!resolved.id) { + throw new CliError( + 'NOTEBOOKLM_NOTE_UNRESOLVED', + `NotebookLM could not resolve the currently visible note "${visible.title}" to a stable note id`, + 'For now, notes-save requires either a stable editor note id or a unique title/content match in the notebook note list.', + ); + } + + await callNotebooklmRpc( + page, + 'cYAfTb', + buildNotebooklmUpdateNoteParams(state.notebookId, resolved.id, visible.title, visible.content), + ); + + return { + notebook_id: state.notebookId, + id: resolved.id, + title: visible.title, + content: visible.content, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'rpc', + }; +} + +export async function getNotebooklmShareStatusViaRpc(page: IPage): Promise<NotebooklmShareStatusRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const rpc = await callNotebooklmRpc( + page, + 'JFMDGd', + [state.notebookId, [2]], + ); + + return parseNotebooklmShareStatusResult(rpc.result, state.notebookId); +} + +export function listNotebooklmSupportedLanguages(): NotebooklmLanguageRow[] { + return Object.entries(NOTEBOOKLM_SUPPORTED_LANGUAGES).map(([code, name]) => ({ + code, + name, + source: 'static', + })); +} + +export async function getNotebooklmOutputLanguageViaRpc(page: IPage): Promise<NotebooklmLanguageStatusRow | null> { + const rpc = await callNotebooklmRpc( + page, + 'ZwVcOc', + buildNotebooklmGetLanguageParams(), + { sourcePath: '/' }, + ); + + const language = parseNotebooklmLanguageGetResult(rpc.result); + if (!language) return null; + + return { + language, + name: NOTEBOOKLM_SUPPORTED_LANGUAGES[language] ?? null, + source: 'rpc', + }; +} + +export async function setNotebooklmOutputLanguageViaRpc( + page: IPage, + language: string, +): Promise<NotebooklmLanguageStatusRow | null> { + const rpc = await callNotebooklmRpc( + page, + 'hT54vc', + buildNotebooklmSetLanguageParams(language), + { sourcePath: '/' }, + ); + + const current = parseNotebooklmLanguageSetResult(rpc.result) ?? language; + return { + language: current, + name: NOTEBOOKLM_SUPPORTED_LANGUAGES[current] ?? null, + source: 'rpc', + }; +} + +export async function askNotebooklmQuestionViaQuery( + page: IPage, + prompt: string, +): Promise<NotebooklmAskRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const sources = await listNotebooklmSourcesViaRpc(page); + const sourceIds = sources + .map((row) => row.id) + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + if (sourceIds.length === 0) { + throw new CliError( + 'NOTEBOOKLM_QUERY', + 'NotebookLM ask could not resolve source ids for the current notebook', + 'Retry after the notebook sources finish loading, then rerun the ask command.', + ); + } + + const auth = await getNotebooklmPageAuth(page); + const body = buildNotebooklmAskBody(sourceIds, prompt, auth.csrfToken, randomUUID()); + const urlParams = new URLSearchParams({ + bl: NOTEBOOKLM_ASK_BL, + hl: 'en', + _reqid: String(Date.now()), + rt: 'c', + }); + if (auth.sessionId) urlParams.set('f.sid', auth.sessionId); + + const response = await fetchNotebooklmInPage(page, `${NOTEBOOKLM_ASK_QUERY_URL}?${urlParams.toString()}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body, + }); + + if (response.status === 401 || response.status === 403) { + throw new AuthRequiredError( + NOTEBOOKLM_DOMAIN, + `NotebookLM ask returned auth error (${response.status})`, + ); + } + + if (!response.ok) { + throw new CliError( + 'NOTEBOOKLM_QUERY', + `NotebookLM ask request failed with HTTP ${response.status}`, + 'Retry from an already logged-in NotebookLM notebook tab.', + ); + } + + const answer = parseNotebooklmAskResponse(response.body).trim(); + if (!answer) return null; + + return { + notebook_id: state.notebookId, + prompt, + answer, + url: state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + source: 'query-endpoint', + }; +} + +export async function readNotebooklmVisibleNoteFromPage(page: IPage): Promise<NotebooklmNoteDetailRow | null> { + const state = await getNotebooklmPageState(page); + if (state.kind !== 'notebook' || !state.notebookId) return null; + + const raw = await page.evaluate(`(() => { + const normalizeText = (value) => (value || '').replace(/\\u00a0/g, ' ').replace(/\\r\\n/g, '\\n').trim(); + const collectAttributeHints = (node, hints, maxDepth = 8) => { + let current = node; + let depth = 0; + while (current && depth < maxDepth) { + for (const attr of Array.from(current.attributes || [])) { + if (/^(id|for|aria-labelledby|aria-controls|aria-describedby|data-)/.test(attr.name)) { + hints.push(attr.value || ''); + } + } + current = current.parentElement; + depth += 1; + } + }; + const collectSelectedNoteHints = (hints) => { + const selectors = [ + 'button[aria-labelledby^="note-labels-"][aria-selected="true"]', + 'button[aria-labelledby^="note-labels-"][aria-current="true"]', + 'button[aria-labelledby^="note-labels-"][aria-pressed="true"]', + '.selected button[aria-labelledby^="note-labels-"]', + '.active button[aria-labelledby^="note-labels-"]', + 'button[aria-labelledby^="artifact-labels-"][aria-selected="true"]', + 'button[aria-labelledby^="artifact-labels-"][aria-current="true"]', + 'button[aria-labelledby^="artifact-labels-"][aria-pressed="true"]', + '.selected button[aria-labelledby^="artifact-labels-"]', + '.active button[aria-labelledby^="artifact-labels-"]', + ]; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (!(node instanceof HTMLElement)) continue; + collectAttributeHints(node, hints, 2); + } + }; + const titleNode = document.querySelector('.note-header__editable-title'); + const title = titleNode instanceof HTMLInputElement || titleNode instanceof HTMLTextAreaElement + ? titleNode.value + : (titleNode?.textContent || ''); + const editor = document.querySelector('.note-editor .ql-editor, .note-editor [contenteditable="true"], .note-editor textarea'); + let content = ''; + if (editor instanceof HTMLTextAreaElement || editor instanceof HTMLInputElement) { + content = editor.value || ''; + } else if (editor) { + content = editor.innerText || editor.textContent || ''; + } + const idHints = []; + collectAttributeHints(titleNode, idHints); + collectAttributeHints(editor, idHints); + collectSelectedNoteHints(idHints); + return { + id: idHints.find((value) => /(?:note|artifact)-labels-[A-Za-z0-9_-]{6,}/i.test(value)) || '', + title: normalizeText(title), + content: normalizeText(content), + }; + })()`) as NotebooklmRawVisibleNoteRow | null; + + return parseNotebooklmVisibleNoteRawRow( + raw, + state.notebookId, + state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`, + ); +} + +export async function ensureNotebooklmHome(page: IPage): Promise<void> { + const currentUrl = page.getCurrentUrl + ? await page.getCurrentUrl().catch(() => null) + : null; + const currentKind = currentUrl ? classifyNotebooklmPage(currentUrl) : 'unknown'; + if (currentKind === 'home') return; + await page.goto(NOTEBOOKLM_HOME_URL); + await page.wait(2); +} + +function buildNotebooklmNotebookUrl(notebookId: string): string { + return `https://${NOTEBOOKLM_DOMAIN}/notebook/${notebookId}`; +} + +async function maybeCanonicalizeNotebooklmNotebookPage( + page: IPage, + state: Pick<NotebooklmPageState, 'kind' | 'notebookId' | 'url'>, +): Promise<void> { + if (state.kind !== 'notebook' || !state.notebookId) return; + const canonicalUrl = buildNotebooklmNotebookUrl(state.notebookId); + if (state.url === canonicalUrl) return; + await page.goto(canonicalUrl); + if (typeof page.wait === 'function') { + await page.wait(1); + } +} + +export async function ensureNotebooklmNotebookBinding(page: IPage): Promise<boolean> { + if (!page.getCurrentUrl) return false; + if (process.env.OPENCLI_CDP_ENDPOINT) return false; + + try { + const actualState = await getNotebooklmPageState(page); + if (actualState.kind === 'notebook') { + await maybeCanonicalizeNotebooklmNotebookPage(page, actualState); + return false; + } + } catch { + // Fall back to the lighter current-url heuristic if page evaluation is unavailable. + } + + const currentUrl = await page.getCurrentUrl().catch(() => null); + if (currentUrl && classifyNotebooklmPage(currentUrl) === 'notebook') return false; + + try { + await bindCurrentTab(`site:${NOTEBOOKLM_SITE}`, { + matchDomain: NOTEBOOKLM_DOMAIN, + matchPathPrefix: '/notebook/', + }); + try { + const reboundState = await getNotebooklmPageState(page); + await maybeCanonicalizeNotebooklmNotebookPage(page, reboundState); + } catch { + // Binding itself is still useful even when the immediate state probe fails. + } + return true; + } catch { + return false; + } +} + +export async function getNotebooklmPageState(page: IPage): Promise<NotebooklmPageState> { + const raw = await page.evaluate(`(() => { + const url = window.location.href; + const title = document.title || ''; + const hostname = window.location.hostname || ''; + const notebookMatch = url.match(/\\/notebook\\/([^/?#]+)/); + const notebookId = notebookMatch ? notebookMatch[1] : ''; + const path = window.location.pathname || '/'; + const kind = notebookId + ? 'notebook' + : (hostname === 'notebooklm.google.com' ? 'home' : 'unknown'); + + const textNodes = Array.from(document.querySelectorAll('a, button, [role="button"], h1, h2')) + .map(node => (node.textContent || '').trim().toLowerCase()) + .filter(Boolean); + const loginRequired = textNodes.some(text => + text.includes('sign in') || + text.includes('log in') || + text.includes('登录') || + text.includes('登入') + ); + + const notebookCount = Array.from(document.querySelectorAll('a[href*="/notebook/"]')) + .map(node => node instanceof HTMLAnchorElement ? node.href : '') + .filter(Boolean) + .reduce((count, href, index, list) => list.indexOf(href) === index ? count + 1 : count, 0); + + return { url, title, hostname, kind, notebookId, loginRequired, notebookCount, path }; + })()`) as Partial<NotebooklmPageState> | null; + + const state: NotebooklmPageState = { + url: String(raw?.url ?? ''), + title: normalizeNotebooklmTitle(raw?.title, 'NotebookLM'), + hostname: String(raw?.hostname ?? ''), + kind: raw?.kind === 'notebook' || raw?.kind === 'home' ? raw.kind : 'unknown', + notebookId: String(raw?.notebookId ?? ''), + loginRequired: Boolean(raw?.loginRequired), + notebookCount: Number(raw?.notebookCount ?? 0), + }; + + // Notebook pages can still contain "sign in" or login-related text fragments + // even when the active Google session is valid. Prefer the real page tokens + // as the stronger auth signal before declaring the session unauthenticated. + if (state.hostname === NOTEBOOKLM_DOMAIN && state.loginRequired) { + try { + await getNotebooklmPageAuth(page); + state.loginRequired = false; + } catch { + // Keep the heuristic result when page auth tokens are genuinely unavailable. + } + } + + return state; +} + +export async function readCurrentNotebooklm(page: IPage): Promise<NotebooklmRow | null> { + const raw = await page.evaluate(`(() => { + const url = window.location.href; + const match = url.match(/\\/notebook\\/([^/?#]+)/); + if (!match) return null; + + const titleNode = document.querySelector('h1, [data-testid="notebook-title"], [role="heading"]'); + const title = (titleNode?.textContent || document.title || '').trim(); + return { + id: match[1], + title, + url, + source: 'current-page', + }; + })()`) as NotebooklmRow | null; + + if (!raw) return null; + return { + id: String(raw.id ?? ''), + title: normalizeNotebooklmTitle(raw.title, 'Untitled Notebook'), + url: String(raw.url ?? ''), + source: 'current-page', + is_owner: true, + created_at: null, + }; +} + +export async function listNotebooklmLinks(page: IPage): Promise<NotebooklmRow[]> { + const raw = await page.evaluate(`(() => { + const rows = []; + const seen = new Set(); + + for (const node of Array.from(document.querySelectorAll('a[href*="/notebook/"]'))) { + if (!(node instanceof HTMLAnchorElement)) continue; + const href = node.href || ''; + const match = href.match(/\\/notebook\\/([^/?#]+)/); + if (!match) continue; + const id = match[1]; + if (seen.has(id)) continue; + seen.add(id); + + const parentCard = node.closest('mat-card, [role="listitem"], article, div'); + const titleNode = parentCard?.querySelector('.project-button-title, [id$="-title"]'); + const subtitleTitleNode = parentCard?.querySelector('.project-button-subtitle-part[title]'); + const subtitleTextNode = parentCard?.querySelector('.project-button-subtitle-part, .project-button-subtitle'); + const parentText = (parentCard?.textContent || '').trim(); + const parentLines = parentText + .split(/\\n+/) + .map((value) => value.trim()) + .filter(Boolean); + + const title = ( + titleNode?.textContent || + node.getAttribute('aria-label') || + node.getAttribute('title') || + parentLines.find((line) => !line.includes('个来源') && !line.includes('sources') && !line.includes('more_vert')) || + node.textContent || + '' + ).trim(); + const createdAtHint = ( + subtitleTitleNode?.getAttribute?.('title') || + subtitleTextNode?.textContent || + '' + ).trim(); + + rows.push({ + id, + title, + url: href, + source: 'home-links', + is_owner: true, + created_at: createdAtHint || null, + }); + } + + return rows; + })()`) as NotebooklmRow[] | null; + + if (!Array.isArray(raw)) return []; + return raw + .map((row) => ({ + id: String(row.id ?? ''), + title: normalizeNotebooklmTitle(row.title, 'Untitled Notebook'), + url: String(row.url ?? ''), + source: 'home-links' as const, + is_owner: row.is_owner === false ? false : true, + created_at: normalizeNotebooklmCreatedAt(row.created_at), + })) + .filter((row) => row.id && row.url); +} + +export async function listNotebooklmSourcesFromPage(page: IPage): Promise<NotebooklmSourceRow[]> { + const raw = await page.evaluate(`(() => { + const notebookMatch = window.location.href.match(/\\/notebook\\/([^/?#]+)/); + const notebookId = notebookMatch ? notebookMatch[1] : ''; + if (!notebookId) return []; + + const skip = new Set([ + '选择所有来源', + '添加来源', + '收起来源面板', + '更多', + 'Web', + 'Fast Research', + '提交', + '创建笔记本', + '分享笔记本', + '设置', + '对话选项', + '配置笔记本', + '音频概览', + '演示文稿', + '视频概览', + '思维导图', + '报告', + '闪卡', + '测验', + '信息图', + '数据表格', + '添加笔记', + '保存到笔记', + '复制摘要', + '摘要很棒', + '摘要欠佳', + ]); + + const rows = []; + const seen = new Set(); + for (const node of Array.from(document.querySelectorAll('button, [role="button"], input[type="checkbox"]'))) { + const text = (node.getAttribute?.('aria-label') || node.textContent || '').trim(); + if (!text || skip.has(text) || seen.has(text)) continue; + if (text.includes('个来源') || text.includes('来源') && text.length < 5) continue; + seen.add(text); + rows.push({ + id: text, + notebook_id: notebookId, + title: text, + url: window.location.href, + source: 'current-page', + }); + } + return rows; + })()`) as NotebooklmSourceRow[] | null; + + if (!Array.isArray(raw)) return []; + return raw.filter((row) => row.id && row.title); +} + +export async function requireNotebooklmSession(page: IPage): Promise<NotebooklmPageState> { + const state = await getNotebooklmPageState(page); + if (state.hostname !== NOTEBOOKLM_DOMAIN) { + throw new CliError( + 'NOTEBOOKLM_UNAVAILABLE', + 'NotebookLM page is not available in the current browser session', + `Open Chrome and navigate to ${NOTEBOOKLM_HOME_URL}`, + ); + } + if (state.loginRequired) { + throw new AuthRequiredError(NOTEBOOKLM_DOMAIN, 'NotebookLM requires a logged-in Google session'); + } + return state; +} diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 6d79fce5..a4bfa571 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -123,3 +123,73 @@ describe('commanderAdapter boolean alias support', () => { expect(kwargs.undo).toBe(false); }); }); + +describe('commanderAdapter command aliases', () => { + const cmd: CliCommand = { + site: 'notebooklm', + name: 'get', + aliases: ['metadata'], + description: 'Get notebook metadata', + browser: false, + args: [], + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('registers aliases with Commander so compatibility names execute the same command', async () => { + const program = new Command(); + const siteCmd = program.command('notebooklm'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'notebooklm', 'metadata']); + + expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false); + }); +}); + +describe('commanderAdapter nested command paths', () => { + const cmd: CliCommand = { + site: 'notebooklm', + name: 'source/list', + aliases: ['source-list'], + description: 'List notebook sources', + browser: false, + args: [], + func: vi.fn(), + }; + + beforeEach(() => { + mockExecuteCommand.mockReset(); + mockExecuteCommand.mockResolvedValue([]); + mockRenderOutput.mockReset(); + delete process.env.OPENCLI_VERBOSE; + process.exitCode = undefined; + }); + + it('registers path-like names as nested Commander subcommands', async () => { + const program = new Command(); + const siteCmd = program.command('notebooklm'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'notebooklm', 'source', 'list']); + + expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false); + }); + + it('keeps flat aliases working for nested canonical commands', async () => { + const program = new Command(); + const siteCmd = program.command('notebooklm'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'opencli', 'notebooklm', 'source-list']); + + expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 66204848..dee34f9e 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -12,7 +12,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import { type CliCommand, fullName, getRegistry } from './registry.js'; +import { type CliCommand, fullName, getRegistry, splitCommandPath } from './registry.js'; import { formatRegistryHelpText } from './serialization.js'; import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; @@ -44,14 +44,29 @@ export function normalizeArgValue(argType: string | undefined, value: unknown, n throw new ArgumentError(`"${name}" must be either "true" or "false".`); } -/** - * Register a single CliCommand as a Commander subcommand. - */ -export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): void { - if (siteCmd.commands.some((c: Command) => c.name() === cmd.name)) return; +function ensureCommandPath(root: Command, segments: string[]): Command { + if (segments.length === 0) return root; + + let parentCmd = root; + for (const segment of segments.slice(0, -1)) { + const existing = parentCmd.commands.find((child: Command) => child.name() === segment); + if (existing) { + parentCmd = existing; + continue; + } + parentCmd = parentCmd.command(segment).description(`${segment} commands`); + } + + const leafName = segments[segments.length - 1]; + const existingLeaf = parentCmd.commands.find((child: Command) => child.name() === leafName); + if (existingLeaf) return existingLeaf; + + return parentCmd.command(leafName); +} +function attachCommandDefinition(subCmd: Command, cmd: CliCommand): void { const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : ''; - const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`); + subCmd.description(`${cmd.description}${deprecatedSuffix}`); // Register positional args first, then named options const positionalArgs: typeof cmd.args = []; @@ -123,6 +138,28 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi }); } +/** + * Register a single CliCommand as a Commander subcommand. + */ +export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): void { + const segments = splitCommandPath(cmd.name); + const subCmd = ensureCommandPath(siteCmd, segments); + + if (segments.length === 1 && cmd.aliases?.length) { + subCmd.aliases(cmd.aliases.filter((alias) => splitCommandPath(alias).length === 1)); + } + attachCommandDefinition(subCmd, cmd); + + if (segments.length > 1 && cmd.aliases?.length) { + for (const alias of cmd.aliases) { + const aliasSegments = splitCommandPath(alias); + if (aliasSegments.length === 0) continue; + const aliasCmd = ensureCommandPath(siteCmd, aliasSegments); + attachCommandDefinition(aliasCmd, cmd); + } + } +} + // ── Exit code resolution ───────────────────────────────────────────────────── /** @@ -293,7 +330,10 @@ export function registerAllCommands( program: Command, siteGroups: Map<string, Command>, ): void { + const seen = new Set<CliCommand>(); for (const [, cmd] of getRegistry()) { + if (seen.has(cmd)) continue; + seen.add(cmd); let siteCmd = siteGroups.get(cmd.site); if (!siteCmd) { siteCmd = program.command(cmd.site).description(`${cmd.site} commands`); diff --git a/src/completion.test.ts b/src/completion.test.ts new file mode 100644 index 00000000..42f5effe --- /dev/null +++ b/src/completion.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cli, getRegistry } from './registry.js'; +import { getCompletions } from './completion.js'; + +describe('nested completion paths', () => { + const keys: string[] = []; + + afterEach(() => { + for (const key of keys.splice(0)) getRegistry().delete(key); + }); + + it('completes nested command groups and keeps flat commands available', async () => { + cli({ + site: 'notebooklm-tree', + name: 'source/list', + aliases: ['source-list'], + description: 'List sources', + }); + cli({ + site: 'notebooklm-tree', + name: 'status', + description: 'Status', + }); + + keys.push('notebooklm-tree/source/list', 'notebooklm-tree/status'); + + await expect(getCompletions(['notebooklm-tree'], 2)).resolves.toEqual(['source', 'source-list', 'status']); + await expect(getCompletions(['notebooklm-tree', 'source'], 3)).resolves.toEqual(['list']); + }); + + it('completes dynamic artifact-id values for nested and flat notebooklm download commands', async () => { + cli({ + site: 'notebooklm-tree', + name: 'download/report', + aliases: ['download-report'], + description: 'Download report', + args: [ + { name: 'output_path', positional: true, required: true }, + { + name: 'artifact-id', + completion: async () => ['report-1', 'report-2'], + }, + ], + }); + cli({ + site: 'notebooklm-tree', + name: 'download/audio', + aliases: ['download-audio'], + description: 'Download audio', + args: [ + { name: 'output_path', positional: true, required: true }, + { + name: 'artifact-id', + completion: async () => ['audio-1'], + }, + ], + }); + + keys.push( + 'notebooklm-tree/download/report', + 'notebooklm-tree/download/audio', + ); + + await expect( + getCompletions(['notebooklm-tree', 'download', 'report', 'out.md', '--artifact-id'], 6), + ).resolves.toEqual(['report-1', 'report-2']); + + await expect( + getCompletions(['notebooklm-tree', 'download-report', 'out.md', '--artifact-id'], 5), + ).resolves.toEqual(['report-1', 'report-2']); + + await expect( + getCompletions(['notebooklm-tree', 'download', 'audio', 'out.m4a', '--artifact-id'], 6), + ).resolves.toEqual(['audio-1']); + + await expect( + getCompletions(['notebooklm-tree', 'download', 'report', 'out.md'], 5), + ).resolves.toEqual([]); + }); +}); diff --git a/src/completion.ts b/src/completion.ts index 00c031ae..02342cb8 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -6,7 +6,8 @@ * - Dynamic completion logic that returns candidates for the current cursor position */ -import { getRegistry } from './registry.js'; +import { pathToFileURL } from 'node:url'; +import { getRegistry, splitCommandPath, type CliCommand, type InternalCliCommand } from './registry.js'; import { CliError } from './errors.js'; // ── Dynamic completion logic ─────────────────────────────────────────────── @@ -34,7 +35,7 @@ const BUILTIN_COMMANDS = [ * @param words - The argv after 'opencli' (words[0] is the first arg, e.g. site name) * @param cursor - 1-based position of the word being completed (1 = first arg) */ -export function getCompletions(words: string[], cursor: number): string[] { +export async function getCompletions(words: string[], cursor: number): Promise<string[]> { // cursor === 1 → completing the first argument (site name or built-in command) if (cursor <= 1) { const sites = new Set<string>(); @@ -51,19 +52,95 @@ export function getCompletions(words: string[], cursor: number): string[] { return []; } - // cursor === 2 → completing the sub-command name under a site - if (cursor === 2) { - const subcommands: string[] = []; - for (const [, cmd] of getRegistry()) { - if (cmd.site === site) { - subcommands.push(cmd.name); + const commandTokens = words.slice(1, Math.max(1, cursor - 1)); + const resolved = resolveCommandForArgs(site, commandTokens); + if (resolved) { + const loadedCmd = await ensureCompletionCommandLoaded(resolved.cmd); + const optionArg = resolveActiveOptionArg(loadedCmd, commandTokens.slice(resolved.path.length)); + const currentToken = words[cursor - 1] ?? ''; + if (!optionArg) return []; + + try { + const rawCandidates = optionArg.completion + ? await optionArg.completion({ + words, + cursor, + site, + command: loadedCmd.name, + currentToken, + }) + : optionArg.choices; + if (!Array.isArray(rawCandidates) || rawCandidates.length === 0) return []; + return [...new Set(rawCandidates.filter((value): value is string => typeof value === 'string' && value.trim().length > 0))].sort(); + } catch { + return []; + } + } + + const prefix = words.slice(1, Math.max(1, cursor - 1)); + const candidates = new Set<string>(); + + for (const [, cmd] of getRegistry()) { + if (cmd.site !== site) continue; + const paths = [cmd.name, ...(cmd.aliases ?? [])].map(splitCommandPath).filter(path => path.length > 0); + for (const path of paths) { + if (path.length < prefix.length + 1) continue; + const matches = prefix.every((segment, index) => path[index] === segment); + if (!matches) continue; + candidates.add(path[prefix.length]); + } + } + + return [...candidates].sort(); +} + +function resolveCommandForArgs(site: string, tokensBeforeCurrent: string[]) { + let best: { cmd: CliCommand; path: string[] } | null = null; + const seen = new Set<string>(); + + for (const [, cmd] of getRegistry()) { + if (cmd.site !== site) continue; + + const canonicalKey = `${cmd.site}/${cmd.name}`; + if (seen.has(canonicalKey)) continue; + seen.add(canonicalKey); + + const paths = [cmd.name, ...(cmd.aliases ?? [])].map(splitCommandPath).filter(path => path.length > 0); + for (const path of paths) { + if (tokensBeforeCurrent.length < path.length) continue; + const matches = path.every((segment, index) => tokensBeforeCurrent[index] === segment); + if (!matches) continue; + if (!best || path.length > best.path.length) { + best = { cmd, path }; } } - return subcommands.sort(); } - // cursor >= 3 → no further completion - return []; + return best; +} + +function resolveActiveOptionArg( + cmd: CliCommand, + tokensAfterCommand: string[], +) { + const previousToken = tokensAfterCommand[tokensAfterCommand.length - 1]; + if (!previousToken?.startsWith('--')) return null; + + const optionName = previousToken.slice(2); + return cmd.args.find((arg) => !arg.positional && arg.name === optionName) ?? null; +} + +async function ensureCompletionCommandLoaded(cmd: CliCommand): Promise<CliCommand> { + const internal = cmd as InternalCliCommand; + if (!internal._lazy || !internal._modulePath) return cmd; + + try { + await import(pathToFileURL(internal._modulePath).href); + } catch { + return cmd; + } + + return getRegistry().get(`${cmd.site}/${cmd.name}`) ?? cmd; } // ── Shell script generators ──────────────────────────────────────────────── diff --git a/src/discovery.ts b/src/discovery.ts index 1759229f..0483ccf2 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -113,6 +113,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< const cmd: CliCommand = { site: entry.site, name: entry.name, + aliases: entry.aliases, description: entry.description ?? '', domain: entry.domain, strategy, @@ -135,6 +136,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< const cmd: InternalCliCommand = { site: entry.site, name: entry.name, + aliases: entry.aliases, description: entry.description ?? '', domain: entry.domain, strategy, @@ -208,6 +210,9 @@ async function registerYamlCli(filePath: string, defaultSite: string): Promise<v const cmd: CliCommand = { site, name, + aliases: isRecord(cliDef) && Array.isArray((cliDef as Record<string, unknown>).aliases) + ? ((cliDef as Record<string, unknown>).aliases as unknown[]).filter((value): value is string => typeof value === 'string') + : undefined, description: cliDef.description ?? '', domain: cliDef.domain, strategy, diff --git a/src/main.ts b/src/main.ts index 4fdb3e9b..76db6e12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -57,7 +57,7 @@ if (getCompIdx !== -1) { } } if (cursor === undefined) cursor = words.length; - const candidates = getCompletions(words, cursor); + const candidates = await getCompletions(words, cursor); process.stdout.write(candidates.join('\n') + '\n'); process.exit(EXIT_CODES.SUCCESS); } diff --git a/src/registry.test.ts b/src/registry.test.ts index dfe5d2e7..32e270e5 100644 --- a/src/registry.test.ts +++ b/src/registry.test.ts @@ -60,6 +60,21 @@ describe('cli() registration', () => { const reg = getRegistry(); expect(reg.get('test-registry/overwrite')?.description).toBe('v2'); }); + + it('registers aliases as alternate registry keys for the same command', () => { + const cmd = cli({ + site: 'test-registry', + name: 'canonical', + description: 'test aliases', + aliases: ['compat', 'legacy-name'], + }); + + const registry = getRegistry(); + expect(cmd.aliases).toEqual(['compat', 'legacy-name']); + expect(registry.get('test-registry/canonical')).toBe(cmd); + expect(registry.get('test-registry/compat')).toBe(cmd); + expect(registry.get('test-registry/legacy-name')).toBe(cmd); + }); }); describe('fullName', () => { diff --git a/src/registry.ts b/src/registry.ts index c7363c52..bada9cdb 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -12,6 +12,16 @@ export enum Strategy { UI = 'ui', } +export interface ArgCompletionContext { + words: string[]; + cursor: number; + site: string; + command: string; + currentToken: string; +} + +export type ArgCompletionHandler = (ctx: ArgCompletionContext) => Promise<string[]> | string[]; + export interface Arg { name: string; type?: string; @@ -20,6 +30,7 @@ export interface Arg { positional?: boolean; help?: string; choices?: string[]; + completion?: ArgCompletionHandler; } export interface RequiredEnv { @@ -33,6 +44,7 @@ export type CommandArgs = Record<string, any>; export interface CliCommand { site: string; name: string; + aliases?: string[]; description: string; domain?: string; strategy?: Strategy; @@ -85,9 +97,11 @@ const _registry: Map<string, CliCommand> = export function cli(opts: CliOptions): CliCommand { const strategy = opts.strategy ?? (opts.browser === false ? Strategy.PUBLIC : Strategy.COOKIE); const browser = opts.browser ?? (strategy !== Strategy.PUBLIC); + const aliases = normalizeAliases(opts.aliases, opts.name); const cmd: CliCommand = { site: opts.site, name: opts.name, + aliases, description: opts.description ?? '', domain: opts.domain, strategy, @@ -104,8 +118,7 @@ export function cli(opts: CliOptions): CliCommand { navigateBefore: opts.navigateBefore, }; - const key = fullName(cmd); - _registry.set(key, cmd); + registerCommand(cmd); return cmd; } @@ -117,10 +130,48 @@ export function fullName(cmd: CliCommand): string { return `${cmd.site}/${cmd.name}`; } +export function splitCommandPath(name: string): string[] { + return name + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +export function formatCommandInvocation(cmd: CliCommand): string { + return [cmd.site, ...splitCommandPath(cmd.name)].join(' '); +} + export function strategyLabel(cmd: CliCommand): string { return cmd.strategy ?? Strategy.PUBLIC; } export function registerCommand(cmd: CliCommand): void { - _registry.set(fullName(cmd), cmd); + const canonicalKey = fullName(cmd); + const existing = _registry.get(canonicalKey); + if (existing) { + for (const [key, value] of _registry.entries()) { + if (value === existing && key !== canonicalKey) _registry.delete(key); + } + } + + const aliases = normalizeAliases(cmd.aliases, cmd.name); + cmd.aliases = aliases.length > 0 ? aliases : undefined; + _registry.set(canonicalKey, cmd); + for (const alias of aliases) { + _registry.set(`${cmd.site}/${alias}`, cmd); + } +} + +function normalizeAliases(aliases: string[] | undefined, commandName: string): string[] { + if (!Array.isArray(aliases) || aliases.length === 0) return []; + + const seen = new Set<string>(); + const normalized: string[] = []; + for (const alias of aliases) { + const value = typeof alias === 'string' ? alias.trim() : ''; + if (!value || value === commandName || seen.has(value)) continue; + seen.add(value); + normalized.push(value); + } + return normalized; } diff --git a/src/serialization.test.ts b/src/serialization.test.ts index 2bcdfd3c..7d1139a2 100644 --- a/src/serialization.test.ts +++ b/src/serialization.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { CliCommand } from './registry.js'; import { Strategy } from './registry.js'; -import { formatRegistryHelpText } from './serialization.js'; +import { formatRegistryHelpText, serializeCommand } from './serialization.js'; describe('formatRegistryHelpText', () => { it('summarizes long choices lists so help text stays readable', () => { @@ -23,4 +23,22 @@ describe('formatRegistryHelpText', () => { expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)'); }); + + it('includes aliases in structured serialization and help text', () => { + const cmd: CliCommand = { + site: 'demo', + name: 'get', + aliases: ['metadata'], + description: 'Demo command', + strategy: Strategy.COOKIE, + browser: true, + args: [], + }; + + expect(serializeCommand(cmd)).toMatchObject({ + command: 'demo/get', + aliases: ['metadata'], + }); + expect(formatRegistryHelpText(cmd)).toContain('Aliases: metadata'); + }); }); diff --git a/src/serialization.ts b/src/serialization.ts index c28ab671..b363eb62 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -6,7 +6,7 @@ */ import type { Arg, CliCommand } from './registry.js'; -import { fullName, strategyLabel } from './registry.js'; +import { formatCommandInvocation, fullName, strategyLabel } from './registry.js'; // ── Serialization ─────────────────────────────────────────────────────────── @@ -37,8 +37,10 @@ export function serializeArg(a: Arg): SerializedArg { export function serializeCommand(cmd: CliCommand) { return { command: fullName(cmd), + invocation: formatCommandInvocation(cmd), site: cmd.site, name: cmd.name, + aliases: cmd.aliases ?? [], description: cmd.description, strategy: strategyLabel(cmd), browser: !!cmd.browser, @@ -82,6 +84,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string { if (cmd.domain) meta.push(`Domain: ${cmd.domain}`); if (cmd.deprecated) meta.push(`Deprecated: ${typeof cmd.deprecated === 'string' ? cmd.deprecated : 'yes'}`); if (cmd.replacedBy) meta.push(`Use instead: ${cmd.replacedBy}`); + if (cmd.aliases?.length) meta.push(`Aliases: ${cmd.aliases.join(', ')}`); lines.push(meta.join(' | ')); if (cmd.columns?.length) lines.push(`Output columns: ${cmd.columns.join(', ')}`); return '\n' + lines.join('\n') + '\n'; diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 00000000..29625812 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,500 @@ +# NotebookLM OpenCLI Task Plan + +## Goal + +把 NotebookLM 逐步并入 `opencli`,以 `opencli` 现有 Browser Bridge / CDP 运行时为底座,先做稳定的 transport 层,再按能力波次扩展命令面,最终覆盖原 `notebooklm-cdp-cli` 的主要功能。 + +## Current Status + +- Phase 0 已完成:`status` / `list` / `current` 骨架已接入 `opencli` +- `list` 已验证走真实首页 RPC `wXbhsf`,不是 DOM fallback +- Linux 产品线继续留在 `notebooklm-cdp-cli` +- `opencli` 侧当前目标是 Windows / Browser Bridge 优先 + +## Phases + +| Phase | Status | Outcome | +|-------|--------|---------| +| 0. Baseline validation | complete | `status` / `list` / `current` 可运行,`list` 走真实 RPC | +| 1. Transport consolidation | in_progress | 已抽出 `rpc.ts` 和独立 transport 测试,并补了 auth / parser / page-eval 稳定性收口;待继续提升 RPC 命中率与诊断信息 | +| 2. Read-surface expansion | in_progress | 已补 `get` / `source-list` / `history` / `note-list`,并开始做与原 CLI 的兼容命名层;下一步继续做高价值读命令 | +| 3. Light write operations | in_progress | `ask`、`source-add-text`、`source-add-url`、`notes-save` 已落地;仍需后续决定是否继续补 share 写操作或更多 note/source 写命令 | +| 4. Long-running jobs | in_progress | 已补最小 `generate/report`、`generate/audio`、`generate/slide-deck` 提交链路与命令内最小等待;完整 artifact/poll 子系统仍未展开 | +| 5. Download and export | in_progress | 已完成 `download/report`、`download/slide-deck`、`download/audio`、`download/video`、`download/infographic` 和最小下载索引 `download/list`;更完整的 artifact/export 面仍待后续评估 | +| 6. Docs / release / PR | pending | 文档、测试矩阵、面向维护者的 PR 收口 | + +## Decisions + +- 不按“命令名逐个平移”推进,按 transport 能力层推进。 +- `opencli` 维持 `site + 单层 command` 结构,不把 `notebook source list` 这类三层命令硬搬进来。 +- 与原 `notebooklm-cdp-cli` 的命令习惯对齐,优先通过 alias / wrapper 做低成本兼容层。 +- 三层命令树重构单独按框架线程推进,当前现实方案是: + - 允许命令定义使用 path-like name,如 `source/list` + - 在 commander 层把它映射成真实嵌套子命令 `source list` + - 不要求这一轮同时迁移已有 NotebookLM 业务命令 +- `wXbhsf` 是当前首页 notebook list 的真实 RPC,后续新命令优先从 live network 反推。 +- 浏览器内执行为主,不引入 cookies replay / `storage_state.json` 主认证模型。 +- `opencli` 只承接 browser-bridge 路线;Linux direct CDP 继续留在原仓库。 +- Download 方向本轮优先级已明确: + - 先落 `download/report` + - 第二优先级是 `download/slide-deck` + - 第三优先级原先是 `download/audio` + - 暂不补 `artifact list/get/export` 公开命令面 +- 选择 `download/report` 的原因: + - `gArtLc` raw artifact list 已足够定位 report artifact id + - report 正文可直接从 artifact payload slot `7` 提取 markdown + - 不依赖 export RPC,也不依赖额外 signed download URL / cookie stream 下载 +- `download/audio` / `download/slide-deck` 的现实前置不是 generate,而是: + - 先复用内部 artifact raw list helper + - 再接现有 opencli download/cookie forwarding 基础设施 + - 但这一层复杂度明显高于 report 文本落盘,因此延后 +- `download/slide-deck` 已按这个判断落地: + - 仍然只复用内部 `gArtLc` raw artifact helper + - 直接消费 slot `16` 里的 PDF/PPTX URL + - 用现有 opencli `httpDownload` + browser cookie forwarding 落盘 + - 没有先实现 `artifact/list|get|export` +- `download/audio` 已按同一原则落地: + - 仍然只复用内部 `gArtLc` raw artifact helper + - 直接消费 slot `6[5]` 里的 media variants + - 选择规则与上游一致:优先首个 `audio/mp4`,否则回退第一个 variant URL + - 用现有 opencli `httpDownload` + browser cookie forwarding 落盘 + - 仍然没有先实现 `artifact/list|get|export` +- `download/video` 已按同一原则落地: + - 仍然只复用内部 `gArtLc` raw artifact helper + - video artifact type 已确认是 `type=3` + - 直接消费 slot `8[4]` 里的 media variants + - 当前 live raw row 同时给出 direct `video/mp4`、HLS、DASH 和一个备用 `video/mp4` + - 选择规则保持最小且稳定:优先首个 `video/mp4`,否则回退第一个 variant URL + - 用现有 opencli `httpDownload` + browser cookie forwarding 落盘 + - 仍然没有先实现 `artifact/list|get|export` +- `download/infographic` 已按同一原则落地: + - 仍然只复用内部 `gArtLc` raw artifact helper + - infographic artifact type 已确认是 `type=7` + - 下载 URL 不走 export,而是从 raw artifact 深层 metadata 里提取 PNG URL + - 用现有 opencli `httpDownload` + browser cookie forwarding 落盘 + - 仍然没有先实现 `artifact/list|get|export` +- 当前最小下载索引也已落地: + - 命令名选择 `download/list` + - 仍然只复用内部 `gArtLc` raw artifact helper + - 只输出当前 download 命令真正需要的字段: + - `artifact_id` + - `artifact_type` + - `status` + - `title` + - `download_variants` + - `source` + - 不暴露完整 artifact payload,也不扩成 `artifact/*` +- `artifact-id` 补全本轮也维持最小范围: + - 只覆盖现有 4 个 NotebookLM 下载命令 + - 不扩成通用 `source-id` / `note-id` / `notebook-id` 补全系统 + - 数据源直接复用当前 notebook 的 `download/list` / `gArtLc` 索引 + - 需要的框架改动只限 completion 入口异步化和按命令 lazy-load TS adapter metadata +- 当前最小 generate 方向也已落地: + - `generate/report` + - `generate/audio` + - `generate/slide-deck` + - `generate/infographic` + - 四者都直接复用 `R7cb6c` create-artifact RPC + - 提交前只取当前 notebook source ids 与同类型 artifact baseline + - `--wait` 只做命令内最小轮询,仍然不公开 `artifact wait/poll/list/get/export` + - `report` 等到 markdown 可见即可 + - `audio` 等到 media variant 可见即可判定 ready + - `infographic` 等到 PNG URL 可见即可判定 ready + - `slide-deck` 等到 PDF/PPTX 任一下载 URL 可见即可判定 ready + - 当前 live 说明: + - `report` 在最小 wait 窗口内可稳定闭环 + - `audio` / `slide-deck` 的真实生成时长可能超过最小 wait 窗口,因此提交路径与后续 artifact 可见性已验证,但不应把这版误写成完整长任务恢复体系 +- 对 `generate/audio` / `generate/slide-deck` 的专项调查已补充一个更强结论: + - 在当前 rich notebook 上,`R7cb6c` 对 report 会直接返回 artifact row + - 但对 audio / slide-deck 会稳定返回 `wrb.fr(..., null, ..., UserDisplayableError, ...)` + - 页面 Studio 面板同时出现明确 upsell/quota 文案: + - `您已达到每日音频概览和幻灯片数量上限,改日再来吧。 或进行升级。` + - 因此当前最可能根因是 server-side quota / eligibility gate,而不是 opencli 少传了某个最小 payload 槽位 + - 继续修复时应优先处理“把这类返回识别成 quota/eligibility failure 并给出明确诊断”,而不是盲目扩 payload +- 已完成:generate 错误分类最小增强 + - `UserDisplayableError` 现在会被显式识别 + - generate row 新增: + - `error_type` + - `message` + - 当前支持的分类: + - `daily_limit_reached` + - `feature_not_eligible` + - `content_insufficient` + - `generation_failed_unknown` + - 若服务端没有返回可读 message,则保守落到 `generation_failed_unknown` + +## Risks + +- NotebookLM RPC ID 和参数形状可能按功能分散且存在前端版本漂移。 +- 同一 workspace 下连续执行命令时,页面切换或 bridge 瞬态抖动会放大 auth token 获取和 page-eval 的偶发失败。 +- 长任务类命令需要轮询、状态恢复、下载流处理,复杂度明显高于 read path。 +- `opencli` 当前 doctor / bridge 状态展示与 live 执行路径仍可能存在观测不一致。 + +## Near-Term Next Step + +先继续收口 Phase 1/2 交界处的“稳底座 + 厚读命令”: + +- 已完成:框架级 `aliases` 支持,`use` / `metadata` / `notes-list` 兼容命名,以及 `source-get` wrapper +- 已完成:`history` token 获取和 `source-list` RPC 解析的稳定性修复,`dist` 下已验证 `source-list` 5/5 RPC 命中、`history` 8/8 返回 `thread_id` +- 已完成:`summary` 和 `notes-get` 两个高价值读命令 +- 已完成:`source-fulltext`,优先走独立 source RPC,不依赖当前 source 详情 DOM +- 已完成:`source-guide`,复用 source lookup 并调用 `tr032e` +- 已完成:`source-list` 的 source type/type_code 解析修正,当前 live notebook 已能区分 `pdf` / `web` / `pasted-text` / `youtube` +- 已完成:框架层最小三层命令树能力,支持把 `source/list` 这类 path-like 命令名映射成真实的 `source list` 子命令,并保持平面命令向后兼容 +- 已完成:NotebookLM 第一批业务命令 remount 到嵌套路径,同时保留旧平面命令兼容: + - `source/list` + - `source/get` + - `source/fulltext` + - `source/guide` + - `notes/list` + - `notes/get` + - `language/list` + - `language/get` + - `language/set` +- 本轮已完成的轻写 / 全局能力: + - `source-add-text` + - `source-add-url` + - `notes-save` + - `share-status` + - `language-list` / `language-get` / `language-set` +- 已完成:`notes-save` 从“标题唯一”提升到更稳的最小可用版: + - 优先尝试当前 visible note editor 周围的 DOM hint stable id + - 若 editor 周围没有稳定 id,则回退到 `title + content` 精确匹配 RPC note list +- 已完成:`notes-get` / `notes-save` 增加显式 `--note-id` + - `--note-id` 优先于标题和默认选择逻辑 + - 用于把重复标题、重复空正文的 note 消歧显式交给用户 +- 当前下一步: + - 本轮已完成 download 方向侦察,并在链路足够清晰后只落了一个最小命令: + - `download/report` + - `download/slide-deck` + - `download/audio` + - `download/video` + - `download/infographic` + - `download/list` + - 当前不继续扩 `artifact/*` + - 当前也不继续扩 `generate/*` 到: + - `video` + - `quiz` + - `flashcards` + - 若下一轮继续 download: + - 才评估是否要补最小 `artifact/list` + - 仍不必先做完整 `artifact list/get/export` 命令树 + - 若继续推进 generate / artifact: + - 优先评估是否真的需要公开最小 `artifact/list` + - 不应先铺完整 `artifact wait/poll/export` 面 + - 若继续推进 `generate/audio` / `generate/slide-deck`: + - 当前已完成 server-side `UserDisplayableError` 分类兜底 + - 若还要继续收窄原因,再决定是否需要额外 live UI request 对比或内部 envelope code 对照 + - 当前不建议先改 `R7cb6c` payload builder + - 若继续推进 `generate/infographic`: + - 当前 `type=7`、payload 槽位和 PNG artifact URL 形态都已确认 + - 当前 live 账号/样本上仍是提交即失败,且没有可读 message + - 后续若要继续收窄原因,应复用现有 `UserDisplayableError` 分类路径,而不是先扩 `artifact/*` + - `notes-save` 已不再只依赖标题唯一;当前剩余 live 阻塞变成“当前 visible editor 没有稳定 id,且 notebook 内存在 title 和 content 都完全相同的重复 note” + - 这类歧义现在可以通过显式 `--note-id` 解决,但前提仍然是当前页已经打开目标 note editor + - 若继续推进三层命令树,优先 remount 仍保留平面形态的 notebook/share 命令,而不是扩大业务范围 + - A 模块 notebook 轻写 CRUD 这一轮已完成: + - `create` + - `rename` + - `delete` + - `describe` + - `remove-from-recent` + - 其中 `describe` 的现实收口是: + - 先尝试真实 `VfAZjd` summary/topics RPC + - live 拿不到结构化 topics 时,回退到稳定的 summary wrapper + - 因而当前 `describe` 是“真实 RPC 优先 + summary wrapper fallback”,不是纯结构化 topics 命令 + - `notes/list` 这一轮已单独收口: + - 先确认 live `no data` 的一次复现来自浏览器里没有可绑定的 `/notebook/...` tab,不是已证实的 DOM selector 漂移 + - 当前真实 notebook 页上,旧 DOM selector `artifact-library-note` 仍然存在且可解析 note id/title + - 为降低 Studio 面板折叠或局部渲染缺失带来的脆弱性,`notes/list` 现在在 DOM 为空时回退到现有 `cFji9` RPC + - A 模块 notes 轻写 CRUD 这一轮已补完: + - `notes/create` + - `notes/rename` + - `notes/delete` + - 这轮 notes 写命令的现实收口是: + - `create` 直接走 `CYK0Xb`,再立刻用 `cYAfTb` 写入 title/content + - `rename` / `delete` 优先支持 `--note-id` + - 标题兼容只保留“唯一精确标题命中”,重复标题时明确要求 `--note-id` + - A 模块 source 中等复杂度管理命令这一轮已完成: + - `source/rename` + - `source/delete` + - `source/refresh` + - `source/check-freshness` + - 这轮 source 写/状态命令的现实收口是: + - `rename` / `delete` / `refresh` / `check-freshness` 都只针对“当前 notebook” + - 优先 `--source-id` + - 不带 `--source-id` 时,只接受“唯一精确标题命中” + - 不继续发明 partial title 或别的 source 选择 heuristics + - 当前 live 还存在一个运行态边界: + - NotebookLM 浏览器标签会偶发漂到 `?addSource=true` 的 add-source 页面 + - 一旦当前绑定 notebook 漂移,`refresh` / `check-freshness` 这类命令的前置 source 校验就会落到错误 notebook 上 + - 这轮先不扩“强制切回目标 notebook”的框架能力,只把该现象记录为运行态阻塞 + - A 模块 source 管理稳定性这一轮已完成最小修复: + - `ensureNotebooklmNotebookBinding(...)` 不再只信 `page.getCurrentUrl()`,而是优先看真实页面状态 + - 若实际已在 notebook 页但 URL 是 `?addSource=true` 等非 canonical 形态,会先回到 canonical notebook URL + - `Page.evaluate(...)` 现在把 `Detached while handling command.` 当成一次可重试瞬态 + - 当前结论: + - `source/refresh` + - `source/check-freshness` + 已能在同一 notebook 上连续多次 live 跑通 + - A 模块 source ingest 这一轮已完成: + - `source/add-file` + - `source/wait-for-sources` + - `source/wait` + - 这轮 source ingest 的现实收口是: + - `add-file` 不走脆弱 DOM 点击,而是走“`o4cbdc` 注册 source + NotebookLM resumable upload”链路 + - `wait-for-sources` / `wait` 共用同一个 RPC polling 核心,只在命令层分别收单个和逗号分隔的多个 source id + - 当前仍不扩 `source add-drive` / `source add-research` + - A 模块 source ingest follow-up 这一轮只做: + - `source/add-drive` + - 当前收口边界: + - 只支持 raw Google Drive `file_id` + - 需要显式 `title` + - `--mime-type` 默认为 `application/vnd.google-apps.document` + - 不假装支持整条 Drive URL,也不扩到 `add-research` + +## 2026-03-31 From-0 Integration Test Summary (9 Modules, Historical Only) + +### Test Environment +- Browser Bridge daemon: port 19825, extension v1.5.5, connected +- 手动 `curl .../navigate` 可将 browser bridge tab 导航到 notebook URL(opencli CLI 内无内置 navigate 命令) +- `bind-current` / `use` 在 browser 无 notebook tab 时失败,需要先手动 navigate +- **关键运行态问题**:每次 CLI 命令执行后,browser bridge tab 会偶发漂回 home 页(约 2-3 条命令后),严重影响 notebook-context 命令连续测试 + +### 测试模块 0:基础环境 — PASS +- `npx tsc --noEmit` → EXIT 0 +- `npm run build` → EXIT 0,475 entries +- `list -f json` → notebooklm 命令被发现 +- `notebooklm --help` → 正常展开 60+ 命令 +- `completion bash` → 正常输出 + +### 测试模块 1:绑定前置条件 — PASS(需手动 navigate) +- `bind-current` → FAIL(browser 无 notebook tab) +- 手动 `curl navigate` → PASS +- `status -f json` → `page: "notebook"`,`url: https://notebooklm.google.com/notebook/6fd8aeb5-...` +- 当前测试 notebook:`6fd8aeb5-ddd1-4114-bcda-c376389a8508`(Electron Debugging 2026) + +### 测试模块 2:Notebook 基础读写 — 大部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `status` | PASS | | +| `current` | PASS | `source: "current-page"` | +| `get` | PASS | `source: "rpc"`,含 source_count | +| `metadata` | PASS | alias of get | +| `describe` | PASS | `source: "summary-dom"`,summary 正文返回 | +| `list` | PASS | RPC | +| `create` | PASS | 创建 notebook | +| `rename` | PASS | 重命名成功 | +| `remove-from-recent` | PASS | | +| `delete` | PASS | | +| `summary` | FAIL | browser drift 导致,需要重新 navigate | +| `history` | FAIL | browser drift 导致,需要重新 navigate | + +### 测试模块 3:Source 读链路 — PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source list` | PASS | 7 sources,`type` 解析正确(pdf/web/audio/pasted-text/youtube),`source: "rpc"` | +| `source get` | PASS | 按 title "粘贴的文字" 匹配 | +| `source fulltext` | PASS | 返回 893 字符 markdown 内容 | +| `source guide` | PASS | 返回 summary + 5 keywords | +| `source-list`(flat)| FAIL | browser drift 导致 | + +### 测试模块 4:Source ingest 与管理 — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `source-add-text` | PASS | | +| `source-add-url` | PASS | | +| `source-wait` | PASS | 等到 ready | +| `source-check-freshness` | PASS | `is_fresh: true` | +| `source-rename` | FAIL | browser drift | +| `source-refresh` | FAIL | browser drift | +| `source-delete` | FAIL | browser drift | + +### 测试模块 5:Notes — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `notes list` | PASS(不稳定)| browser drift 导致不稳定 | +| `notes create` | PASS | 创建 note | +| `notes rename` | PASS | | +| `notes delete` | FAIL | browser drift | +| `notes-save` | NOT TESTED | 需要 visible note editor | + +### 测试模块 6:Ask — PASS +`ask --prompt "用一句话总结"` → PASS,返回 answer 正文(中文) + +### 测试模块 7:Generate — PASS +`generate report` → PASS,返回 `artifact_id: b9934a76-53df-47ec-9f24-f990a8da8072` + +### 测试模块 8:Download — 部分 PASS +| 命令 | 结果 | 备注 | +|------|------|------| +| `download list` | PASS | 显示 report/audio/slide_deck,`status` 字段正确 | +| `download report` | PASS | 成功写出 5563 字节 .md 文件,内容正确 | +| `download audio` | FAIL | "fetch failed" — artifact URL 过期(运行态,非代码缺陷) | +| `download slide-deck` | FAIL | "fetch failed" — artifact URL 过期(运行态)| + +### 测试模块 9:兼容层与命令树 — PASS +- `source --help`、`notes --help`、`download --help`、`language --help` → 全部正常 +- `language get` 和 `language-get` → 均 PASS(alias 正常) + +### 历史测试结论(不作为当前统一 PR 判断依据) + +**当时可直接确认的命令:** +1. `status` / `list` — 稳定,RPC +2. `create` / `rename` / `delete` / `remove-from-recent` — 稳定,RPC,闭环验证通过 +3. `current` / `get` / `metadata` / `describe` — 稳定 +4. `source list` / `source get` / `source fulltext` / `source guide` — 稳定,RPC +5. `source-add-text` / `source-add-url` / `source-wait` / `source-check-freshness` — 稳定 +6. `notes create` / `notes rename` — 稳定 +7. `ask` — 稳定,返回 answer +8. `generate report` — 稳定,返回 artifact_id +9. `download list` — 稳定,正确索引 artifacts +10. `download report` — 稳定,成功写出文件 +11. `language-list` / `language-get` / `language-set` — 稳定,RPC +12. 命令树三层结构和帮助文本 — 框架稳定 + +**当时受运行态影响、未纳入判断的命令:** +- `source-rename` / `source-refresh` / `source-delete` — browser drift 导致测试不稳定,需要稳定性修复后再验证 +- `notes delete` / `notes-save` — 同上 +- `download audio` / `download slide-deck` — artifact URL 过期是运行态问题,不是代码缺陷;但需要稳定可用的 artifact 样本才能验证 + +**设计边界确认(不是 bug):** +- `summary` / `history` 偶发 FAIL 是 browser drift,不是代码缺陷 +- `bind-current` 在无 notebook tab 时失败是设计预期,需要先 navigate +- `download audio/slide-deck` 的 "fetch failed" 是 URL 过期,不是实现问题 + +### 历史运行态问题:Browser Drift(仅用于排障记录) +- 每次 CLI 命令执行后,browser bridge CDP session 会偶发漂回 home 页 +- 在连续 2-3 条命令后必然发生 +- 影响所有 notebook-context 命令的连续测试 +- 根因:`navigate` 命令在 daemon 端执行后,下一次 CLI 调用时 CDP session 丢失了 notebook URL 上下文 +- 当前 workaround:每次 FAIL 后重新 `curl navigate`,然后继续测试 +- 建议:这是 daemon/CDP session 管理问题,不是 NotebookLM adapter 代码缺陷 + +## 2026-03-31 统一 PR 前验证(第 2 轮) + +> 以下结论是当前统一 PR 的唯一判断口径。上面的 From-0 结果只保留为历史排障记录,不再作为 PR judgment 依据。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(浏览器自动化工具全解析) +- `bind-current` → ✅ +- `status` → ✅ `page: "notebook"` + +### 模块 A:Source ingest 闭环 + +| 命令 | 结果 | 备注 | +|------|------|------| +| `source-add-text` | ✅ | source id 返回,RPC | +| `source wait` | ✅ | `status_code: 2, status: "ready"` | +| `source wait-for-sources` | ✅ | 单 id 逗号形式可用 | +| `source-add-file` | ❌ | `fetch failed` — resumable upload URL 过期(运行态)| + +- `source delete` ✅(清理测试 source) + +### 模块 B:Notes 精确操作 + +| 命令 | 结果 | 备注 | +|------|------|------| +| `notes list` | ✅ | 3 条,含两条同名"新建笔记" | +| `notes get --note-id` | ✅ | 绕过重复标题歧义,精确命中 | +| `notes create` | ✅ | RPC,id 返回 | +| `notes delete --note-id` | ✅ | RPC | +| `notes rename --note-id` | ✅ | RPC | +| `notes-save` | ❌ | 无 visible editor(设计边界,预期)| + +### 模块 C:Generate 最小闭环 + +| 命令 | 结果 | 备注 | +|------|------|------| +| `generate report --wait` | ✅ | artifact id `c0674240-...`,完整闭环 | +| `generate audio` | ❌ | `status: failed`,artifact_id=null(非超时,是生成失败)| +| `generate audio --wait` | ❌ | 超时,这个 notebook 内容可能不足以生成 audio | +| `generate slide-deck` | ❌ | `status: failed`,同上 | +| `generate slide-deck --wait` | ❌ | 超时 | + +- `generate audio/slide-deck` 失败是这个 notebook 的内容限制,不是代码缺陷 +- 在有足够源材料的 notebook(如 Electron Debugging 2026)上 audio/slide-deck 提交链路已验证 + +### 模块 D:Download 闭环 + +| 命令 | 结果 | 备注 | +|------|------|------| +| `download list` | ✅ | 正确索引 report + slide_deck | +| `download report` | ✅ | 新 artifact 9.9K 写出 | +| `download slide-deck` | ❌ | `fetch failed` — URL 过期(运行态)| + +### 模块 E:artifact-id 补全 + +| probe | 结果 | 备注 | +|------|------|------| +| `download slide-deck ... --artifact-id` | ✅ | 补全 slide-deck id | +| `download report ... --artifact-id` | ✅ | 补全 report id | +| `download audio ... --artifact-id` | ✅ | 空(无 audio artifact)| +| `download video ... --artifact-id` | ✅ | 空(无 video artifact)| +| `download-slide-deck`(flat)| ✅ | 同 slide-deck | +| `download-report`(flat)| ✅ | 同 report | + +### 统一 PR 准入复核(当前有效) + +**可进入统一 PR 的能力:** +1. `source-add-text` / `source wait` / `source wait-for-sources` / `source delete` — 稳定 +2. `notes list` / `notes get --note-id` / `notes create` / `notes delete --note-id` / `notes rename --note-id` — 稳定 +3. `generate report --wait` — 完整闭环,downloadable +4. `download list` / `download report` — 稳定 +5. `completion --artifact-id` 补全 — 正确,空时返回空 + +**不该因运行态问题阻塞 PR 的命令:** +- `source-add-file` — 该能力此前已 live 验证通过;最新定向验证中的失败属于 daemon 侧网络/上传会话运行态问题,不是 adapter 代码问题 +- `download slide-deck` — 该能力此前已用 fresh artifact live 验证通过;最新定向验证未能建立新 artifact 前置条件,不应反向否定下载实现 + +**仍建议拆 follow-up 的部分:** +- `generate audio` / `generate slide-deck` — 在该 notebook 上 `status: failed`;content 调查后仍失败,疑似 server-side payload 限制,不排除代码问题,需独立调查 +- `notes-save` 的 visible editor 要求 → 建议文档化而非改设计 + +**已知设计边界:** +- signed download URL 有 TTL(~1 天) +- resumable upload URL 有 TTL +- audio/slide-deck 生成依赖 notebook 内容丰富程度 +- `notes-save` 要求当前页有 visible note editor + +## 2026-03-31 剩余运行态定向验证(第 3 轮) + +> 本轮用于检查剩余边界,不覆盖此前已经完成的 live 成功样本;若与更早的成功验证冲突,以“能力已验证通过,但本轮样本/环境未满足前置条件”记载。 + +### 测试前提 +- notebook: `a45591ed-37bd-4038-a131-141a295c024b`(浏览器自动化工具全解析) +- 10 条 sources(pdf/web/youtube/pasted-text/audio),内容丰富 ✅ +- 该 notebook 之前已成功 generate report ✅ +- `bind-current` ✅,`status` ✅ `page: "notebook"` + +### 定向验证结果 + +**模块 1:source-add-file** +- 结果:❌ 运行态失败(HTTP fetch failed) +- 分类:**本轮受 daemon 侧网络/上传会话问题影响**;该能力此前已 live 验证通过 +- 清理了 3 条 stale `type-0` 测试 source 残留 + +**模块 2:notes-save** +- 结果:⚠️ 前置条件未满足 +- 分类:**设计边界**(需 visible note editor,CDP 自动化无法人工打开 editor) +- notes list 有 3 条 note,但无 visible editor + +**模块 3:generate audio** +- 结果:❌ `status: failed`(`artifact_id: null`) +- 分类:**运行态失败,在内容丰富的 notebook 上仍复现**;report 成功(说明非网络问题);不排除代码实现问题(payload 格式差异或 server-side 限制) +- 该 notebook 有 7 条正常 sources,内容充分 + +**模块 4:generate slide-deck** +- 结果:❌ `status: failed`(与 audio 同模式) +- 分类:同模块 3;疑似 audio/slide-deck 共用同一失败根因 + +**模块 5:download slide-deck** +- 结果:⚠️ 前置条件未满足(generate slide-deck 失败,无法获得新 artifact id) +- 分类:本轮依赖模块 4,闭环无法建立;该能力此前已用 fresh artifact live 验证通过 + +### 关键发现 +- `generate audio` / `generate slide-deck` 的 `status: failed` 在内容丰富的 notebook 上仍复现 +- `report` 成功说明核心 RPC 路径正确,R7cb6c 对 report 类型有效 +- audio/slide-deck 疑似 payload 类型或格式与 report 不同,或该 notebook 有 server-side 限制 +- 这值得独立调查,但不推翻当前统一 PR 的 report/download 主路径 +- `source-add-file` 本轮失败是 daemon 侧网络/上传会话问题,不是 adapter 代码问题;该能力此前已 live 验证通过