Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions clis/notebooklm/list.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion clis/notebooklm/rpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')}` +
Expand Down
19 changes: 18 additions & 1 deletion clis/notebooklm/rpc.test.js
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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: '<html>"SNlM0e":"csrf-123","FdrFJe":"sess-456"</html>',
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');
});
});
9 changes: 9 additions & 0 deletions clis/notebooklm/shared.js
Original file line number Diff line number Diff line change
@@ -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());
}
13 changes: 10 additions & 3 deletions clis/notebooklm/status.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions clis/notebooklm/status.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 8 additions & 7 deletions clis/notebooklm/utils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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/<uuid>.');
}
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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 || '';
Expand All @@ -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())
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions clis/notebooklm/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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,
});
});
});