From 119116f2468b8dea4ef324c8b5f1bd348d82ac6a Mon Sep 17 00:00:00 2001 From: qiaoqiao147 Date: Tue, 31 Mar 2026 04:07:29 +0800 Subject: [PATCH 1/2] feat(notebooklm): add read commands and compatibility layer --- README.md | 1 + README.zh-CN.md | 1 + docs/.vitepress/config.mts | 1 + docs/adapters/browser/notebooklm.md | 41 + docs/adapters/index.md | 1 + extension/dist/background.js | 1307 +++++++++++-------- extension/src/background.test.ts | 204 ++- extension/src/background.ts | 184 ++- extension/src/protocol.ts | 6 +- findings.md | 309 +++++ progress.md | 128 ++ src/browser/cdp.ts | 12 +- src/browser/daemon-client.ts | 8 +- src/browser/page.test.ts | 58 + src/browser/page.ts | 20 +- src/build-manifest.test.ts | 2 + src/build-manifest.ts | 5 + src/cli.ts | 8 +- src/clis/notebooklm/bind-current.test.ts | 43 + src/clis/notebooklm/bind-current.ts | 36 + src/clis/notebooklm/binding.test.ts | 53 + src/clis/notebooklm/compat.test.ts | 19 + src/clis/notebooklm/current.ts | 38 + src/clis/notebooklm/get.ts | 53 + src/clis/notebooklm/history.test.ts | 70 + src/clis/notebooklm/history.ts | 36 + src/clis/notebooklm/list.ts | 40 + src/clis/notebooklm/note-list.test.ts | 64 + src/clis/notebooklm/note-list.ts | 42 + src/clis/notebooklm/notes-get.test.ts | 88 ++ src/clis/notebooklm/notes-get.ts | 67 + src/clis/notebooklm/rpc.test.ts | 126 ++ src/clis/notebooklm/rpc.ts | 286 ++++ src/clis/notebooklm/shared.ts | 98 ++ src/clis/notebooklm/source-fulltext.test.ts | 123 ++ src/clis/notebooklm/source-fulltext.ts | 69 + src/clis/notebooklm/source-get.test.ts | 100 ++ src/clis/notebooklm/source-get.ts | 60 + src/clis/notebooklm/source-guide.test.ts | 121 ++ src/clis/notebooklm/source-guide.ts | 69 + src/clis/notebooklm/source-list.ts | 45 + src/clis/notebooklm/status.ts | 34 + src/clis/notebooklm/summary.test.ts | 94 ++ src/clis/notebooklm/summary.ts | 45 + src/clis/notebooklm/utils.test.ts | 446 +++++++ src/clis/notebooklm/utils.ts | 893 +++++++++++++ src/commanderAdapter.test.ts | 30 + src/commanderAdapter.ts | 4 + src/completion.ts | 3 +- src/discovery.ts | 5 + src/registry.test.ts | 15 + src/registry.ts | 35 +- src/serialization.test.ts | 20 +- src/serialization.ts | 2 + task_plan.md | 55 + 55 files changed, 5185 insertions(+), 538 deletions(-) create mode 100644 docs/adapters/browser/notebooklm.md create mode 100644 findings.md create mode 100644 progress.md create mode 100644 src/browser/page.test.ts create mode 100644 src/clis/notebooklm/bind-current.test.ts create mode 100644 src/clis/notebooklm/bind-current.ts create mode 100644 src/clis/notebooklm/binding.test.ts create mode 100644 src/clis/notebooklm/compat.test.ts create mode 100644 src/clis/notebooklm/current.ts create mode 100644 src/clis/notebooklm/get.ts create mode 100644 src/clis/notebooklm/history.test.ts create mode 100644 src/clis/notebooklm/history.ts create mode 100644 src/clis/notebooklm/list.ts create mode 100644 src/clis/notebooklm/note-list.test.ts create mode 100644 src/clis/notebooklm/note-list.ts create mode 100644 src/clis/notebooklm/notes-get.test.ts create mode 100644 src/clis/notebooklm/notes-get.ts create mode 100644 src/clis/notebooklm/rpc.test.ts create mode 100644 src/clis/notebooklm/rpc.ts create mode 100644 src/clis/notebooklm/shared.ts create mode 100644 src/clis/notebooklm/source-fulltext.test.ts create mode 100644 src/clis/notebooklm/source-fulltext.ts create mode 100644 src/clis/notebooklm/source-get.test.ts create mode 100644 src/clis/notebooklm/source-get.ts create mode 100644 src/clis/notebooklm/source-guide.test.ts create mode 100644 src/clis/notebooklm/source-guide.ts create mode 100644 src/clis/notebooklm/source-list.ts create mode 100644 src/clis/notebooklm/status.ts create mode 100644 src/clis/notebooklm/summary.test.ts create mode 100644 src/clis/notebooklm/summary.ts create mode 100644 src/clis/notebooklm/utils.test.ts create mode 100644 src/clis/notebooklm/utils.ts create mode 100644 task_plan.md 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..4e1ba472 --- /dev/null +++ b/findings.md @@ -0,0 +1,309 @@ +# 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 后能力回退。 + +## 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 稳定性收口。 + +## 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. diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..45f06548 --- /dev/null +++ b/progress.md @@ -0,0 +1,128 @@ +# NotebookLM OpenCLI Progress + +## 2026-03-31 + +### 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 ` -> 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 ` 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 ` 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 ` 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"`. + +### 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` + +### 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. 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 { - 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 { 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 { return Array.isArray(result) ? result : []; } +export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise { + 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..61b76adc --- /dev/null +++ b/src/browser/page.test.ts @@ -0,0 +1,58 @@ +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); + }); +}); diff --git a/src/browser/page.ts b/src/browser/page.ts index bfbd8aff..4b10c58b 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -108,7 +108,17 @@ export class Page implements IPage { } async getCurrentUrl(): Promise { - 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 +132,13 @@ export class Page implements IPage { async evaluate(js: string): Promise { 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 { diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts index 7f8a6cbe..85c39976 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', 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).aliases) + ? ((cliDef as Record).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..4610c520 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -47,6 +47,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { command: fullName(c), site: c.site, name: c.name, + aliases: c.aliases?.join(', ') ?? '', description: c.description, strategy: strategyLabel(c), browser: !!c.browser, @@ -54,7 +55,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { })); renderOutput(rows, { fmt, - columns: ['command', 'site', 'name', 'description', 'strategy', 'browser', 'args', + columns: ['command', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args', ...(isStructured ? ['columns', 'domain'] : [])], title: 'opencli/list', source: 'opencli list', @@ -80,7 +81,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(` ${cmd.name} ${tag}${aliases}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`); } console.log(); } 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..da5ef6fb --- /dev/null +++ b/src/clis/notebooklm/binding.test.ts @@ -0,0 +1,53 @@ +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(); + }); +}); diff --git a/src/clis/notebooklm/compat.test.ts b/src/clis/notebooklm/compat.test.ts new file mode 100644 index 00000000..ab35c2fc --- /dev/null +++ b/src/clis/notebooklm/compat.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '../../registry.js'; +import './bind-current.js'; +import './get.js'; +import './note-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')); + }); +}); 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/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('./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/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..142779be --- /dev/null +++ b/src/clis/notebooklm/note-list.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockListNotebooklmNotesFromPage, mockGetNotebooklmPageState, mockRequireNotebooklmSession } = vi.hoisted(() => ({ + mockListNotebooklmNotesFromPage: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + listNotebooklmNotesFromPage: mockListNotebooklmNotesFromPage, + 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(); + 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 notes from the Studio panel', async () => { + mockListNotebooklmNotesFromPage.mockResolvedValue([ + { + notebook_id: 'nb-demo', + 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', + title: '新建笔记', + created_at: '6 分钟前', + url: 'https://notebooklm.google.com/notebook/nb-demo', + source: 'studio-list', + }, + ]); + }); +}); diff --git a/src/clis/notebooklm/note-list.ts b/src/clis/notebooklm/note-list.ts new file mode 100644 index 00000000..5f99ccd5 --- /dev/null +++ b/src/clis/notebooklm/note-list.ts @@ -0,0 +1,42 @@ +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, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: 'note-list', + aliases: ['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', '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; + + throw new EmptyResultError( + 'opencli notebooklm note-list', + 'No NotebookLM notes are visible in the Studio panel. Reload the notebook page or close the note editor and retry.', + ); + }, +}); diff --git a/src/clis/notebooklm/notes-get.test.ts b/src/clis/notebooklm/notes-get.test.ts new file mode 100644 index 00000000..1f74a3b2 --- /dev/null +++ b/src/clis/notebooklm/notes-get.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockListNotebooklmNotesFromPage, + mockReadNotebooklmVisibleNoteFromPage, + mockGetNotebooklmPageState, + mockRequireNotebooklmSession, +} = vi.hoisted(() => ({ + mockListNotebooklmNotesFromPage: vi.fn(), + mockReadNotebooklmVisibleNoteFromPage: vi.fn(), + mockGetNotebooklmPageState: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + listNotebooklmNotesFromPage: mockListNotebooklmNotesFromPage, + 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(); + 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 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); + }); +}); diff --git a/src/clis/notebooklm/notes-get.ts b/src/clis/notebooklm/notes-get.ts new file mode 100644 index 00000000..9e7bfbbe --- /dev/null +++ b/src/clis/notebooklm/notes-get.ts @@ -0,0 +1,67 @@ +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, + findNotebooklmNoteRow, + getNotebooklmPageState, + listNotebooklmNotesFromPage, + 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', + 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: true, + help: 'Note title or id from the current notebook', + }, + ], + columns: ['title', '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 query = typeof kwargs.note === 'string' ? kwargs.note : String(kwargs.note ?? ''); + 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/rpc.test.ts b/src/clis/notebooklm/rpc.test.ts new file mode 100644 index 00000000..ea030b84 --- /dev/null +++ b/src/clis/notebooklm/rpc.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { + buildNotebooklmRpcBody, + 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: '"SNlM0e":"csrf-123","FdrFJe":"sess-456"', + 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: 'NotebookLM', + 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: 'Loading…', + sourcePath: '/notebook/nb-demo', + readyState: 'interactive', + csrfToken: '', + sessionId: '', + }) + .mockResolvedValueOnce({ + html: '"SNlM0e":"csrf-123","FdrFJe":"sess-456"', + 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('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..fd28a30b --- /dev/null +++ b/src/clis/notebooklm/rpc.ts @@ -0,0 +1,286 @@ +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 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 { + 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 | 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 { + 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 | 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 async function fetchNotebooklmInPage( + page: IPage, + url: string, + options: { + method?: 'GET' | 'POST'; + headers?: Record; + body?: string; + } = {}, +): Promise { + 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 | 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 | null, + options: { + hl?: string; + } = {}, +): Promise { + const auth = await getNotebooklmPageAuth(page); + const requestBody = buildNotebooklmRpcBody(rpcId, params, auth.csrfToken); + const url = + `https://${NOTEBOOKLM_DOMAIN}/_/LabsTailwindUi/data/batchexecute` + + `?rpcids=${rpcId}&source-path=${encodeURIComponent(auth.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/shared.ts b/src/clis/notebooklm/shared.ts new file mode 100644 index 00000000..fab41171 --- /dev/null +++ b/src/clis/notebooklm/shared.ts @@ -0,0 +1,98 @@ +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; +} + +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 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; + 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 NotebooklmNoteDetailRow { + notebook_id: string; + id?: string | null; + title: string; + content: string; + url: string; + source: 'studio-editor'; +} 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('./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..3eb74e14 --- /dev/null +++ b/src/clis/notebooklm/source-fulltext.ts @@ -0,0 +1,69 @@ +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', + 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('./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..01893b07 --- /dev/null +++ b/src/clis/notebooklm/source-get.ts @@ -0,0 +1,60 @@ +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', + 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('./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..e688f22e --- /dev/null +++ b/src/clis/notebooklm/source-guide.ts @@ -0,0 +1,69 @@ +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', + 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..409fa987 --- /dev/null +++ b/src/clis/notebooklm/source-list.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, + listNotebooklmSourcesFromPage, + listNotebooklmSourcesViaRpc, + requireNotebooklmSession, +} from './utils.js'; + +cli({ + site: NOTEBOOKLM_SITE, + name: '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/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('./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..922a32d8 --- /dev/null +++ b/src/clis/notebooklm/utils.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from 'vitest'; +import { + buildNotebooklmRpcBody, + classifyNotebooklmPage, + extractNotebooklmHistoryPreview, + extractNotebooklmRpcResult, + getNotebooklmPageState, + normalizeNotebooklmTitle, + parseNotebooklmHistoryThreadIdsResult, + parseNotebooklmIdFromUrl, + parseNotebooklmListResult, + parseNotebooklmNoteListRawRows, + parseNotebooklmNotebookDetailResult, + parseNotebooklmSourceFulltextResult, + parseNotebooklmSourceGuideResult, + parseNotebooklmSourceListResult, +} 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('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( + [ + { + title: '新建笔记', + text: 'sticky_note_2 新建笔记 6 分钟前 more_vert', + }, + ], + 'nb-demo', + 'https://notebooklm.google.com/notebook/nb-demo', + ); + + expect(rows).toEqual([ + { + notebook_id: 'nb-demo', + 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('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: '"SNlM0e":"csrf-123","FdrFJe":"sess-456"', + 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..f3080c87 --- /dev/null +++ b/src/clis/notebooklm/utils.ts @@ -0,0 +1,893 @@ +import { AuthRequiredError, CliError } from '../../errors.js'; +import type { IPage } from '../../types.js'; +import { bindCurrentTab } from '../../browser/daemon-client.js'; +import { + NOTEBOOKLM_DOMAIN, + NOTEBOOKLM_HOME_URL, + NOTEBOOKLM_SITE, + type NotebooklmHistoryRow, + type NotebooklmNotebookDetailRow, + type NotebooklmNoteDetailRow, + type NotebooklmNoteRow, + type NotebooklmPageKind, + type NotebooklmPageState, + type NotebooklmRow, + type NotebooklmSourceFulltextRow, + type NotebooklmSourceGuideRow, + type NotebooklmSourceRow, + type NotebooklmSummaryRow, +} from './shared.js'; +import { + callNotebooklmRpc, + buildNotebooklmRpcBody, + 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_HISTORY_THREADS_RPC_ID = 'hPTbtc'; +const NOTEBOOKLM_HISTORY_DETAIL_RPC_ID = 'khqZz'; + +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 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; +} + +type NotebooklmRawNoteRow = { + title?: string | null; + text?: string | null; +}; + +type NotebooklmRawSummaryRow = { + title?: string | null; + summary?: string | null; +}; + +type NotebooklmRawVisibleNoteRow = { + title?: string | null; + content?: string | null; +}; + +function collectNotebooklmThreadIds(value: unknown, results: string[], seen: Set): 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()); +} + +export function extractNotebooklmHistoryPreview(result: unknown): string | null { + const strings = collectNotebooklmStrings(result, []); + return strings.length > 0 ? strings[0] : null; +} + +export function parseNotebooklmNoteListRawRows( + rows: NotebooklmRawNoteRow[], + notebookId: string, + url: string, +): NotebooklmNoteRow[] { + const parsed: Array = 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, + title, + created_at: suffix || null, + url, + source: 'studio-list' as const, + }; + }); + + return parsed.filter((row): row is NotebooklmNoteRow => row !== null); +} + +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: null, + 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[] { + 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]; + + return { + 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]), + }; + }) + .filter((row) => row.id); +} + +export function parseNotebooklmSourceGuideResult( + result: unknown, + source: Pick, +): 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 { + const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_LIST_RPC_ID, [null, 1, null, [2]]); + return parseNotebooklmListResult(rpc.result); +} + +export async function getNotebooklmDetailViaRpc(page: IPage): Promise { + 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], + ); + return parseNotebooklmNotebookDetailResult(rpc.result); +} + +export async function listNotebooklmSourcesViaRpc(page: IPage): Promise { + 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 listNotebooklmHistoryViaRpc(page: IPage): Promise { + 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 { + 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'); + return { + 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 { + 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 { + 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 getNotebooklmSourceFulltextViaRpc( + page: IPage, + sourceId: string, +): Promise { + 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, +): Promise { + if (!source.id) return null; + + const rpc = await callNotebooklmRpc( + page, + 'tr032e', + [[[[source.id]]]], + ); + + return parseNotebooklmSourceGuideResult(rpc.result, source); +} + +export async function readNotebooklmVisibleNoteFromPage(page: IPage): Promise { + 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 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 || ''; + } + return { + 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 { + 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); +} + +export async function ensureNotebooklmNotebookBinding(page: IPage): Promise { + if (!page.getCurrentUrl) return false; + if (process.env.OPENCLI_CDP_ENDPOINT) return false; + + 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/', + }); + return true; + } catch { + return false; + } +} + +export async function getNotebooklmPageState(page: IPage): Promise { + 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 | 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 { + 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 { + 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 { + 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 { + 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..c0d86de0 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -123,3 +123,33 @@ 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); + }); +}); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 66204848..3f7847b4 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -52,6 +52,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : ''; const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`); + if (cmd.aliases?.length) subCmd.aliases(cmd.aliases); // Register positional args first, then named options const positionalArgs: typeof cmd.args = []; @@ -293,7 +294,10 @@ export function registerAllCommands( program: Command, siteGroups: Map, ): void { + const seen = new Set(); 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.ts b/src/completion.ts index 00c031ae..86868528 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -57,9 +57,10 @@ export function getCompletions(words: string[], cursor: number): string[] { for (const [, cmd] of getRegistry()) { if (cmd.site === site) { subcommands.push(cmd.name); + if (cmd.aliases?.length) subcommands.push(...cmd.aliases); } } - return subcommands.sort(); + return [...new Set(subcommands)].sort(); } // cursor >= 3 → no further completion 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).aliases) + ? ((cliDef as Record).aliases as unknown[]).filter((value): value is string => typeof value === 'string') + : undefined, description: cliDef.description ?? '', domain: cliDef.domain, strategy, 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..32bbd040 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -33,6 +33,7 @@ export type CommandArgs = Record; export interface CliCommand { site: string; name: string; + aliases?: string[]; description: string; domain?: string; strategy?: Strategy; @@ -85,9 +86,11 @@ const _registry: Map = 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 +107,7 @@ export function cli(opts: CliOptions): CliCommand { navigateBefore: opts.navigateBefore, }; - const key = fullName(cmd); - _registry.set(key, cmd); + registerCommand(cmd); return cmd; } @@ -122,5 +124,32 @@ export function strategyLabel(cmd: CliCommand): string { } 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(); + 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..114d39ce 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -39,6 +39,7 @@ export function serializeCommand(cmd: CliCommand) { command: fullName(cmd), site: cmd.site, name: cmd.name, + aliases: cmd.aliases ?? [], description: cmd.description, strategy: strategyLabel(cmd), browser: !!cmd.browser, @@ -82,6 +83,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..a298e2f0 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,55 @@ +# 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 | pending | 扩展 ask / source add / notes save 等轻写命令 | +| 4. Long-running jobs | pending | research / artifact / generate 的提交、轮询、状态恢复 | +| 5. Download and export | pending | report/audio/video/slide 等下载导出 | +| 6. Docs / release / PR | pending | 文档、测试矩阵、面向维护者的 PR 收口 | + +## Decisions + +- 不按“命令名逐个平移”推进,按 transport 能力层推进。 +- `opencli` 维持 `site + 单层 command` 结构,不把 `notebook source list` 这类三层命令硬搬进来。 +- 与原 `notebooklm-cdp-cli` 的命令习惯对齐,优先通过 alias / wrapper 做低成本兼容层。 +- `wXbhsf` 是当前首页 notebook list 的真实 RPC,后续新命令优先从 live network 反推。 +- 浏览器内执行为主,不引入 cookies replay / `storage_state.json` 主认证模型。 +- `opencli` 只承接 browser-bridge 路线;Linux direct CDP 继续留在原仓库。 + +## 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 读链路;如需继续,优先评估是否还有值得补的 source 只读命令,而不是进入写命令 + - 保持 `get` / `metadata` 现状,暂不单独补 `notebook-get` + - 暂不进入 `generate/*` / `download/*` / `artifact/*` From a994ca5a33c9634a3c88cdd2ae40a5cdfd746d68 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 31 Mar 2026 13:09:53 +0800 Subject: [PATCH 2/2] review: trim notebooklm artifacts and sync docs --- README.md | 2 +- README.zh-CN.md | 2 +- docs/adapters/browser/notebooklm.md | 38 +++- docs/adapters/index.md | 2 +- findings.md | 309 ---------------------------- progress.md | 128 ------------ task_plan.md | 55 ----- 7 files changed, 36 insertions(+), 500 deletions(-) delete mode 100644 findings.md delete mode 100644 progress.md delete mode 100644 task_plan.md diff --git a/README.md b/README.md index 68aceea8..27a91a78 100644 --- a/README.md +++ b/README.md @@ -122,7 +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` | +| **notebooklm** | `status` `list` `current` `get` `metadata` `bind-current` `use` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | | **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 92138a95..fc610bdf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,7 +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` | 浏览器 | +| **notebooklm** | `status` `list` `current` `get` `metadata` `bind-current` `use` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 浏览器 | | **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 | | **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 | | **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 | diff --git a/docs/adapters/browser/notebooklm.md b/docs/adapters/browser/notebooklm.md index cb1960db..d13045ae 100644 --- a/docs/adapters/browser/notebooklm.md +++ b/docs/adapters/browser/notebooklm.md @@ -9,16 +9,34 @@ | `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 | +| `opencli notebooklm get` | Get richer metadata for the current notebook | +| `opencli notebooklm source-list` | List sources in the current notebook | +| `opencli notebooklm source-get ` | Resolve one source in the current notebook by id or title | +| `opencli notebooklm source-fulltext ` | Fetch extracted source fulltext through NotebookLM RPC | +| `opencli notebooklm source-guide ` | Fetch guide summary and keywords for one source | +| `opencli notebooklm history` | List conversation history threads for the current notebook | +| `opencli notebooklm note-list` | List Studio notes visible in the current notebook | +| `opencli notebooklm notes-get ` | Read the currently visible Studio note by title | +| `opencli notebooklm bind-current` | Bind the current active NotebookLM tab into the `site:notebooklm` workspace | +| `opencli notebooklm summary` | Read the current notebook summary | + +## Compatibility Aliases + +| Alias | Canonical command | +|-------|-------------------| +| `opencli notebooklm metadata` | `opencli notebooklm get` | +| `opencli notebooklm use` | `opencli notebooklm bind-current` | +| `opencli notebooklm notes-list` | `opencli notebooklm note-list` | ## Positioning -This adapter is intended to reuse the existing OpenCLI Browser Bridge runtime: +This adapter reuses 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. +The current milestone focuses on a stable NotebookLM read surface in desktop Chrome with an already logged-in Google account. ## Usage Examples @@ -26,6 +44,16 @@ The first implementation focus is desktop Chrome with an already logged-in Googl opencli notebooklm status opencli notebooklm list -f json opencli notebooklm current -f json +opencli notebooklm metadata -f json +opencli notebooklm source-list -f json +opencli notebooklm source-get "Quarterly report" -f json +opencli notebooklm source-guide "Quarterly report" -f json +opencli notebooklm source-fulltext "Quarterly report" -f json +opencli notebooklm history -f json +opencli notebooklm notes-list -f json +opencli notebooklm notes-get "Draft note" -f json +opencli notebooklm summary -f json +opencli notebooklm use -f json ``` ## Prerequisites @@ -36,6 +64,6 @@ opencli notebooklm current -f json ## 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. +- Notebook-oriented commands assume you already have the target notebook open in Chrome, or that `opencli notebooklm use` can bind an existing notebook tab into `site:notebooklm`. +- `list`, `get`, `source-list`, `history`, `source-fulltext`, and `source-guide` prefer NotebookLM RPC paths and fall back only when the richer path is unavailable. +- `notes-get` currently reads note content only from the visible Studio note editor; if the note is listed but not open, open it in NotebookLM first and then retry. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 205c5085..f9e2d806 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -29,7 +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 | +| **[notebooklm](/adapters/browser/notebooklm)** | `status` `list` `current` `get` `metadata` `bind-current` `use` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` | 🔐 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/findings.md b/findings.md deleted file mode 100644 index 4e1ba472..00000000 --- a/findings.md +++ /dev/null @@ -1,309 +0,0 @@ -# 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 后能力回退。 - -## 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 稳定性收口。 - -## 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. diff --git a/progress.md b/progress.md deleted file mode 100644 index 45f06548..00000000 --- a/progress.md +++ /dev/null @@ -1,128 +0,0 @@ -# NotebookLM OpenCLI Progress - -## 2026-03-31 - -### 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 ` -> 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 ` 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 ` 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 ` 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"`. - -### 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` - -### 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. diff --git a/task_plan.md b/task_plan.md deleted file mode 100644 index a298e2f0..00000000 --- a/task_plan.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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 | pending | 扩展 ask / source add / notes save 等轻写命令 | -| 4. Long-running jobs | pending | research / artifact / generate 的提交、轮询、状态恢复 | -| 5. Download and export | pending | report/audio/video/slide 等下载导出 | -| 6. Docs / release / PR | pending | 文档、测试矩阵、面向维护者的 PR 收口 | - -## Decisions - -- 不按“命令名逐个平移”推进,按 transport 能力层推进。 -- `opencli` 维持 `site + 单层 command` 结构,不把 `notebook source list` 这类三层命令硬搬进来。 -- 与原 `notebooklm-cdp-cli` 的命令习惯对齐,优先通过 alias / wrapper 做低成本兼容层。 -- `wXbhsf` 是当前首页 notebook list 的真实 RPC,后续新命令优先从 live network 反推。 -- 浏览器内执行为主,不引入 cookies replay / `storage_state.json` 主认证模型。 -- `opencli` 只承接 browser-bridge 路线;Linux direct CDP 继续留在原仓库。 - -## 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 读链路;如需继续,优先评估是否还有值得补的 source 只读命令,而不是进入写命令 - - 保持 `get` / `metadata` 现状,暂不单独补 `notebook-get` - - 暂不进入 `generate/*` / `download/*` / `artifact/*`