diff --git a/clis/notebooklm/list.test.js b/clis/notebooklm/list.test.js new file mode 100644 index 000000000..dc4295df5 --- /dev/null +++ b/clis/notebooklm/list.test.js @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const { mockEnsureNotebooklmHome, mockListNotebooklmLinks, mockListNotebooklmViaRpc, mockReadCurrentNotebooklm, mockRequireNotebooklmSession, } = vi.hoisted(() => ({ + mockEnsureNotebooklmHome: vi.fn(), + mockListNotebooklmLinks: vi.fn(), + mockListNotebooklmViaRpc: vi.fn(), + mockReadCurrentNotebooklm: vi.fn(), + mockRequireNotebooklmSession: vi.fn(), +})); +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + ensureNotebooklmHome: mockEnsureNotebooklmHome, + listNotebooklmLinks: mockListNotebooklmLinks, + listNotebooklmViaRpc: mockListNotebooklmViaRpc, + readCurrentNotebooklm: mockReadCurrentNotebooklm, + requireNotebooklmSession: mockRequireNotebooklmSession, + }; +}); +import { getRegistry } from '@jackwener/opencli/registry'; +import './list.js'; +describe('notebooklm list', () => { + const command = getRegistry().get('notebooklm/list'); + beforeEach(() => { + mockEnsureNotebooklmHome.mockReset().mockResolvedValue(undefined); + mockListNotebooklmLinks.mockReset().mockResolvedValue([]); + mockListNotebooklmViaRpc.mockReset().mockResolvedValue([]); + mockReadCurrentNotebooklm.mockReset().mockResolvedValue(null); + mockRequireNotebooklmSession.mockReset().mockResolvedValue(undefined); + }); + it('returns RPC rows before considering DOM fallback', async () => { + const rpcRows = [{ + id: 'nb-rpc', + title: 'RPC notebook', + url: 'https://notebooklm.google.com/notebook/nb-rpc', + source: 'rpc', + is_owner: true, + created_at: null, + }]; + mockListNotebooklmViaRpc.mockResolvedValueOnce(rpcRows); + mockListNotebooklmLinks.mockResolvedValueOnce([{ + id: 'nb-dom', + title: 'DOM notebook', + url: 'https://notebooklm.google.com/notebook/nb-dom', + source: 'home-links', + is_owner: true, + created_at: null, + }]); + const result = await command.func({}, {}); + expect(result).toEqual(rpcRows); + expect(mockListNotebooklmViaRpc).toHaveBeenCalledTimes(1); + expect(mockListNotebooklmLinks).not.toHaveBeenCalled(); + expect(mockRequireNotebooklmSession).toHaveBeenCalledTimes(1); + }); + it('falls back to DOM rows after an RPC failure on the redirected host', async () => { + const rows = [{ + id: 'nb-demo', + title: 'Browser Automation', + url: 'https://notebook.google.com/notebook/nb-demo', + source: 'home-links', + is_owner: true, + created_at: null, + }]; + mockRequireNotebooklmSession.mockResolvedValueOnce({ + hostname: 'notebook.google.com', + kind: 'home', + }); + mockListNotebooklmViaRpc.mockRejectedValueOnce(new Error('RPC unavailable on redirected host')); + mockListNotebooklmLinks.mockResolvedValueOnce(rows); + const result = await command.func({}, {}); + expect(result).toEqual(rows); + expect(mockListNotebooklmViaRpc).toHaveBeenCalledTimes(1); + expect(mockListNotebooklmLinks).toHaveBeenCalledTimes(1); + expect(mockRequireNotebooklmSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clis/notebooklm/rpc.js b/clis/notebooklm/rpc.js index 84d004007..91083f02b 100644 --- a/clis/notebooklm/rpc.js +++ b/clis/notebooklm/rpc.js @@ -175,7 +175,8 @@ export async function callNotebooklmRpc(page, rpcId, params, options = {}) { const auth = await getNotebooklmPageAuth(page); const requestBody = buildNotebooklmRpcBody(rpcId, params, auth.csrfToken); const authuser = auth.authuser || ''; - const url = `https://${NOTEBOOKLM_DOMAIN}/_/LabsTailwindUi/data/batchexecute` + + // Use the current page origin so redirected NotebookLM pages stay same-origin. + const url = `/_/LabsTailwindUi/data/batchexecute` + `?rpcids=${rpcId}&source-path=${encodeURIComponent(auth.sourcePath)}` + (authuser ? `&authuser=${encodeURIComponent(authuser)}` : '') + `&hl=${encodeURIComponent(options.hl ?? 'en')}` + diff --git a/clis/notebooklm/rpc.test.js b/clis/notebooklm/rpc.test.js index 808986413..fad10bbd1 100644 --- a/clis/notebooklm/rpc.test.js +++ b/clis/notebooklm/rpc.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { AuthRequiredError } from '@jackwener/opencli/errors'; -import { buildNotebooklmRpcBody, extractNotebooklmRpcResult, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, unwrapNotebooklmEvaluateResult, } from './rpc.js'; +import { buildNotebooklmRpcBody, callNotebooklmRpc, extractNotebooklmRpcResult, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, unwrapNotebooklmEvaluateResult, } from './rpc.js'; describe('notebooklm rpc transport', () => { it('unwraps Browser Bridge evaluate envelopes', () => { const data = { ok: true }; @@ -128,4 +128,21 @@ describe('notebooklm rpc transport', () => { expect(error.code).toBe('AUTH_REQUIRED'); } }); + it('uses a relative batchexecute URL for redirected NotebookLM pages', async () => { + const page = { + evaluate: vi.fn(async (script) => { + if (script.includes('document.documentElement.innerHTML')) { + return { + html: '"SNlM0e":"csrf-123","FdrFJe":"sess-456"', + sourcePath: '/', + }; + } + expect(script).toContain('url: "/_/LabsTailwindUi/data/batchexecute'); + expect(script).not.toContain('https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute'); + return { ok: true, status: 200, body: `)]}'\\n0\\n[]` }; + }), + }; + const result = await callNotebooklmRpc(page, 'wXbhsf', [null, 1, null, [2]]); + expect(result.url).toContain('/_/LabsTailwindUi/data/batchexecute'); + }); }); diff --git a/clis/notebooklm/shared.js b/clis/notebooklm/shared.js index 231c04a0e..c1fa83e71 100644 --- a/clis/notebooklm/shared.js +++ b/clis/notebooklm/shared.js @@ -1,3 +1,12 @@ export const NOTEBOOKLM_SITE = 'notebooklm'; export const NOTEBOOKLM_DOMAIN = 'notebooklm.google.com'; +export const NOTEBOOKLM_REDIRECTED_DOMAIN = 'notebook.google.com'; +export const NOTEBOOKLM_HOSTS = Object.freeze([ + NOTEBOOKLM_DOMAIN, + NOTEBOOKLM_REDIRECTED_DOMAIN, +]); export const NOTEBOOKLM_HOME_URL = 'https://notebooklm.google.com/'; + +export function isNotebooklmHost(hostname) { + return NOTEBOOKLM_HOSTS.includes(String(hostname ?? '').toLowerCase()); +} diff --git a/clis/notebooklm/status.js b/clis/notebooklm/status.js index 2736489b9..e86d96aa2 100644 --- a/clis/notebooklm/status.js +++ b/clis/notebooklm/status.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_SITE } from './shared.js'; +import { isNotebooklmHost, NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_SITE } from './shared.js'; import { getNotebooklmPageState } from './utils.js'; cli({ site: NOTEBOOKLM_SITE, @@ -14,13 +14,20 @@ cli({ columns: ['status', 'login', 'page', 'url', 'title', 'notebooks'], func: async (page) => { const currentUrl = await page.getCurrentUrl?.().catch(() => null); - if (!currentUrl || !currentUrl.includes(NOTEBOOKLM_DOMAIN)) { + let currentHost = ''; + try { + currentHost = currentUrl ? new URL(currentUrl).hostname : ''; + } + catch { + currentHost = ''; + } + if (!currentUrl || !isNotebooklmHost(currentHost)) { await page.goto(NOTEBOOKLM_HOME_URL); await page.wait(2); } const state = await getNotebooklmPageState(page); return [{ - status: state.hostname === NOTEBOOKLM_DOMAIN ? 'Connected' : 'Unavailable', + status: isNotebooklmHost(state.hostname) ? 'Connected' : 'Unavailable', login: state.loginRequired ? 'Required' : 'OK', page: state.kind, url: state.url, diff --git a/clis/notebooklm/status.test.js b/clis/notebooklm/status.test.js new file mode 100644 index 000000000..6ccc9158c --- /dev/null +++ b/clis/notebooklm/status.test.js @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const { mockGetNotebooklmPageState } = vi.hoisted(() => ({ + mockGetNotebooklmPageState: vi.fn(), +})); +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getNotebooklmPageState: mockGetNotebooklmPageState, + }; +}); +import { getRegistry } from '@jackwener/opencli/registry'; +import './status.js'; +describe('notebooklm status', () => { + const command = getRegistry().get('notebooklm/status'); + beforeEach(() => { + mockGetNotebooklmPageState.mockReset().mockResolvedValue({ + url: 'https://notebook.google.com/?pli=1', + title: 'NotebookLM', + hostname: 'notebook.google.com', + kind: 'home', + notebookId: '', + loginRequired: false, + notebookCount: 2, + }); + }); + it('keeps redirected NotebookLM home pages connected without re-navigation', async () => { + const page = { + getCurrentUrl: vi.fn().mockResolvedValue('https://notebook.google.com/?pli=1'), + goto: vi.fn(), + wait: vi.fn(), + }; + await expect(command.func(page, {})).resolves.toEqual([{ + status: 'Connected', + login: 'OK', + page: 'home', + url: 'https://notebook.google.com/?pli=1', + title: 'NotebookLM', + notebooks: 2, + }]); + expect(page.goto).not.toHaveBeenCalled(); + expect(mockGetNotebooklmPageState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clis/notebooklm/utils.js b/clis/notebooklm/utils.js index 79749be1b..df18cd6bc 100644 --- a/clis/notebooklm/utils.js +++ b/clis/notebooklm/utils.js @@ -1,5 +1,5 @@ import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError } from '@jackwener/opencli/errors'; -import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, } from './shared.js'; +import { isNotebooklmHost, NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_HOSTS, } from './shared.js'; import { callNotebooklmRpc, getNotebooklmPageAuth, unwrapNotebooklmEvaluateResult, } from './rpc.js'; export { buildNotebooklmRpcBody, extractNotebooklmRpcResult, fetchNotebooklmInPage, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, stripNotebooklmAntiXssi, } from './rpc.js'; const NOTEBOOKLM_LIST_RPC_ID = 'wXbhsf'; @@ -40,8 +40,8 @@ export function parseNotebooklmNotebookTarget(value) { catch { throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL is invalid', 'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/.'); } - if (parsed.protocol !== 'https:' || parsed.hostname !== NOTEBOOKLM_DOMAIN || parsed.username || parsed.password || parsed.port) { - throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL must be a canonical https://notebooklm.google.com URL', 'Pass a notebook id from `opencli notebooklm list` or a full NotebookLM notebook URL.'); + if (parsed.protocol !== 'https:' || !isNotebooklmHost(parsed.hostname) || parsed.username || parsed.password || parsed.port) { + throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', `NotebookLM notebook URL must use https://${NOTEBOOKLM_HOSTS.join(' or https://')}`, 'Pass a notebook id from `opencli notebooklm list` or a full NotebookLM notebook URL.'); } const notebookId = parseNotebooklmIdFromUrl(normalized); if (!notebookId) { @@ -72,7 +72,7 @@ export function buildNotebooklmNotebookUrl(notebookId) { export function classifyNotebooklmPage(url) { try { const parsed = new URL(url); - if (parsed.hostname !== NOTEBOOKLM_DOMAIN) + if (!isNotebooklmHost(parsed.hostname)) return 'unknown'; if (/\/notebook\/[^/?#]+/.test(parsed.pathname)) return 'notebook'; @@ -606,6 +606,7 @@ export async function ensureNotebooklmHome(page) { } } export async function getNotebooklmPageState(page) { + const notebooklmHosts = JSON.stringify(NOTEBOOKLM_HOSTS); const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => { const url = window.location.href; const title = document.title || ''; @@ -615,7 +616,7 @@ export async function getNotebooklmPageState(page) { const path = window.location.pathname || '/'; const kind = notebookId ? 'notebook' - : (hostname === 'notebooklm.google.com' ? 'home' : 'unknown'); + : (${notebooklmHosts}.includes(hostname) ? 'home' : 'unknown'); const textNodes = Array.from(document.querySelectorAll('a, button, [role="button"], h1, h2')) .map(node => (node.textContent || '').trim().toLowerCase()) @@ -646,7 +647,7 @@ export async function getNotebooklmPageState(page) { // 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) { + if (isNotebooklmHost(state.hostname) && state.loginRequired) { try { await getNotebooklmPageAuth(page); state.loginRequired = false; @@ -804,7 +805,7 @@ export async function listNotebooklmSourcesFromPage(page) { } export async function requireNotebooklmSession(page) { const state = await getNotebooklmPageState(page); - if (state.hostname !== NOTEBOOKLM_DOMAIN) { + if (!isNotebooklmHost(state.hostname)) { 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) { diff --git a/clis/notebooklm/utils.test.js b/clis/notebooklm/utils.test.js index 13adf2204..0cad882e1 100644 --- a/clis/notebooklm/utils.test.js +++ b/clis/notebooklm/utils.test.js @@ -18,6 +18,10 @@ describe('notebooklm utils', () => { const id = '17e2b882-aaaa-bbbb-cccc-abcdef012345'; expect(parseNotebooklmNotebookTarget(`https://notebooklm.google.com/notebook/${id}?pli=1`)).toBe(id); }); + it('parseNotebooklmNotebookTarget accepts the redirected NotebookLM host', () => { + const id = '17e2b882-aaaa-bbbb-cccc-abcdef012345'; + expect(parseNotebooklmNotebookTarget(`https://notebook.google.com/notebook/${id}`)).toBe(id); + }); it('parseNotebooklmNotebookTarget rejects non-uuid bare ids', () => { expect(() => parseNotebooklmNotebookTarget('nb-demo')).toThrow(CliError); }); @@ -44,6 +48,8 @@ describe('notebooklm utils', () => { 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://notebook.google.com/notebook/demo-id')).toBe('notebook'); + expect(classifyNotebooklmPage('https://notebook.google.com/')).toBe('home'); expect(classifyNotebooklmPage('https://example.com/notebook/demo-id')).toBe('unknown'); }); it('normalizes notebook titles', () => { @@ -446,4 +452,30 @@ describe('notebooklm utils', () => { notebookCount: 0, }); }); + it('recognizes the redirected host in the Browser Bridge page-state script', async () => { + const page = { + evaluate: async (script) => { + expect(script).toContain('notebook.google.com'); + expect(script).toContain('.includes(hostname)'); + return { + url: 'https://notebook.google.com/', + title: 'NotebookLM', + hostname: 'notebook.google.com', + kind: 'home', + notebookId: '', + loginRequired: false, + notebookCount: 2, + }; + }, + }; + await expect(getNotebooklmPageState(page)).resolves.toEqual({ + url: 'https://notebook.google.com/', + title: 'NotebookLM', + hostname: 'notebook.google.com', + kind: 'home', + notebookId: '', + loginRequired: false, + notebookCount: 2, + }); + }); });