diff --git a/README.md b/README.md index 71e92f67..27a91a78 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` `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 2c4de509..fc610bdf 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` `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/.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..d13045ae --- /dev/null +++ b/docs/adapters/browser/notebooklm.md @@ -0,0 +1,69 @@ +# 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 | +| `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 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 current milestone focuses on a stable NotebookLM read surface in 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 +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 + +- Chrome running and logged into Google / NotebookLM +- [Browser Bridge extension](/guide/browser-bridge) installed +- NotebookLM accessible in the current browser session + +## Notes + +- 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 ae3d4ea4..f9e2d806 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` `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/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/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';