Skip to content

Commit c051bf8

Browse files
qiaoqiao147jackwener
authored andcommitted
feat(notebooklm): add core workflows and downloads
1 parent 7f30708 commit c051bf8

86 files changed

Lines changed: 9719 additions & 135 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

findings.md

Lines changed: 869 additions & 0 deletions
Large diffs are not rendered by default.

progress.md

Lines changed: 886 additions & 0 deletions
Large diffs are not rendered by default.

src/browser/page.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,16 @@ describe('Page.evaluate', () => {
5555
expect(value).toBe(42);
5656
expect(sendCommandMock).toHaveBeenCalledTimes(2);
5757
});
58+
59+
it('retries once when the daemon reports a detached target during exec', async () => {
60+
sendCommandMock
61+
.mockRejectedValueOnce(new Error('Detached while handling command.'))
62+
.mockResolvedValueOnce(42);
63+
64+
const page = new Page('site:notebooklm');
65+
const value = await page.evaluate('21 + 21');
66+
67+
expect(value).toBe(42);
68+
expect(sendCommandMock).toHaveBeenCalledTimes(2);
69+
});
5870
});

src/browser/page.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
export function isRetryableSettleError(err: unknown): boolean {
3434
const message = err instanceof Error ? err.message : String(err);
3535
return message.includes('Inspected target navigated or closed')
36+
|| message.includes('Detached while handling command')
3637
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
3738
}
3839

src/build-manifest.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,31 @@ describe('manifest helper rules', () => {
197197
getRegistry().delete(screenKey);
198198
getRegistry().delete(statusKey);
199199
});
200+
201+
it('preserves path-like command names in manifest entries', async () => {
202+
const site = `manifest-nested-${Date.now()}`;
203+
const key = `${site}/source/list`;
204+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
205+
tempDirs.push(dir);
206+
const file = path.join(dir, `${site}.ts`);
207+
fs.writeFileSync(file, `export const nested = cli({ site: '${site}', name: 'source/list' });`);
208+
209+
const entries = await loadTsManifestEntries(file, site, async () => ({
210+
nested: cli({
211+
site,
212+
name: 'source/list',
213+
description: 'nested command',
214+
}),
215+
}));
216+
217+
expect(entries).toEqual([
218+
expect.objectContaining({
219+
site,
220+
name: 'source/list',
221+
modulePath: `${site}/${site}.js`,
222+
}),
223+
]);
224+
225+
getRegistry().delete(key);
226+
});
200227
});

src/cli.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { Command } from 'commander';
99
import chalk from 'chalk';
10-
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
10+
import { type CliCommand, formatCommandInvocation, fullName, getRegistry, strategyLabel } from './registry.js';
1111
import { serializeCommand, formatArgSummary } from './serialization.js';
1212
import { render as renderOutput } from './output.js';
1313
import { getBrowserFactory, browserSession } from './runtime.js';
@@ -46,6 +46,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
4646
? commands.map(serializeCommand)
4747
: commands.map(c => ({
4848
command: fullName(c),
49+
invocation: formatCommandInvocation(c),
4950
site: c.site,
5051
name: c.name,
5152
aliases: c.aliases?.join(', ') ?? '',
@@ -56,7 +57,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
5657
}));
5758
renderOutput(rows, {
5859
fmt,
59-
columns: ['command', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args',
60+
columns: ['command', 'invocation', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args',
6061
...(isStructured ? ['columns', 'domain'] : [])],
6162
title: 'opencli/list',
6263
source: 'opencli list',
@@ -83,7 +84,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
8384
? chalk.green('[public]')
8485
: chalk.yellow(`[${label}]`);
8586
const aliases = cmd.aliases?.length ? chalk.dim(` (aliases: ${cmd.aliases.join(', ')})`) : '';
86-
console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
87+
console.log(` ${formatCommandInvocation(cmd).slice(cmd.site.length + 1)} ${tag}${aliases}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
8788
}
8889
console.log();
8990
}

src/clis/notebooklm/binding.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,63 @@ describe('notebooklm automatic binding', () => {
5050
await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(false);
5151
expect(mockBindCurrentTab).not.toHaveBeenCalled();
5252
});
53+
54+
it('does not rebind to another notebook when the real page is already a notebook add-source url', async () => {
55+
const page = {
56+
getCurrentUrl: async () => 'https://notebooklm.google.com/',
57+
evaluate: vi.fn(async () => ({
58+
url: 'https://notebooklm.google.com/notebook/nb-demo?addSource=true',
59+
title: 'NotebookLM',
60+
hostname: 'notebooklm.google.com',
61+
kind: 'notebook',
62+
notebookId: 'nb-demo',
63+
loginRequired: false,
64+
notebookCount: 1,
65+
path: '/notebook/nb-demo',
66+
})),
67+
goto: vi.fn(async () => undefined),
68+
wait: vi.fn(async () => undefined),
69+
};
70+
71+
await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(false);
72+
expect(mockBindCurrentTab).not.toHaveBeenCalled();
73+
expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-demo');
74+
});
75+
76+
it('canonicalizes the bound notebook page after bind-current lands on add-source', async () => {
77+
const page = {
78+
getCurrentUrl: async () => 'https://notebooklm.google.com/',
79+
evaluate: vi.fn()
80+
.mockResolvedValueOnce({
81+
url: 'https://notebooklm.google.com/',
82+
title: 'NotebookLM',
83+
hostname: 'notebooklm.google.com',
84+
kind: 'home',
85+
notebookId: '',
86+
loginRequired: false,
87+
notebookCount: 1,
88+
path: '/',
89+
})
90+
.mockResolvedValueOnce({
91+
url: 'https://notebooklm.google.com/notebook/nb-live?addSource=true',
92+
title: 'NotebookLM',
93+
hostname: 'notebooklm.google.com',
94+
kind: 'notebook',
95+
notebookId: 'nb-live',
96+
loginRequired: false,
97+
notebookCount: 1,
98+
path: '/notebook/nb-live',
99+
}),
100+
goto: vi.fn(async () => undefined),
101+
wait: vi.fn(async () => undefined),
102+
};
103+
104+
mockBindCurrentTab.mockResolvedValue({});
105+
await expect(ensureNotebooklmNotebookBinding(page as any)).resolves.toBe(true);
106+
expect(mockBindCurrentTab).toHaveBeenCalledWith('site:notebooklm', {
107+
matchDomain: 'notebooklm.google.com',
108+
matchPathPrefix: '/notebook/',
109+
});
110+
expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-live');
111+
});
53112
});

src/clis/notebooklm/compat.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest';
22
import { getRegistry } from '../../registry.js';
33
import './bind-current.js';
44
import './get.js';
5+
import './language-get.js';
6+
import './language-list.js';
7+
import './language-set.js';
58
import './note-list.js';
9+
import './notes-get.js';
10+
import './source-fulltext.js';
11+
import './source-get.js';
12+
import './source-guide.js';
13+
import './source-list.js';
614

715
describe('notebooklm compatibility aliases', () => {
816
it('registers use as a compatibility alias for bind-current', () => {
@@ -16,4 +24,20 @@ describe('notebooklm compatibility aliases', () => {
1624
it('registers notes-list as a compatibility alias for note-list', () => {
1725
expect(getRegistry().get('notebooklm/notes-list')).toBe(getRegistry().get('notebooklm/note-list'));
1826
});
27+
28+
it('remounts source commands onto nested canonical paths while keeping flat aliases', () => {
29+
expect(getRegistry().get('notebooklm/source/list')).toBe(getRegistry().get('notebooklm/source-list'));
30+
expect(getRegistry().get('notebooklm/source/get')).toBe(getRegistry().get('notebooklm/source-get'));
31+
expect(getRegistry().get('notebooklm/source/fulltext')).toBe(getRegistry().get('notebooklm/source-fulltext'));
32+
expect(getRegistry().get('notebooklm/source/guide')).toBe(getRegistry().get('notebooklm/source-guide'));
33+
});
34+
35+
it('remounts note and language commands onto nested canonical paths while keeping flat aliases', () => {
36+
expect(getRegistry().get('notebooklm/notes/list')).toBe(getRegistry().get('notebooklm/note-list'));
37+
expect(getRegistry().get('notebooklm/notes/list')).toBe(getRegistry().get('notebooklm/notes-list'));
38+
expect(getRegistry().get('notebooklm/notes/get')).toBe(getRegistry().get('notebooklm/notes-get'));
39+
expect(getRegistry().get('notebooklm/language/list')).toBe(getRegistry().get('notebooklm/language-list'));
40+
expect(getRegistry().get('notebooklm/language/get')).toBe(getRegistry().get('notebooklm/language-get'));
41+
expect(getRegistry().get('notebooklm/language/set')).toBe(getRegistry().get('notebooklm/language-set'));
42+
});
1943
});

src/clis/notebooklm/create.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const {
4+
mockCreateNotebooklmNotebookViaRpc,
5+
mockEnsureNotebooklmHome,
6+
mockRequireNotebooklmSession,
7+
} = vi.hoisted(() => ({
8+
mockCreateNotebooklmNotebookViaRpc: vi.fn(),
9+
mockEnsureNotebooklmHome: vi.fn(),
10+
mockRequireNotebooklmSession: vi.fn(),
11+
}));
12+
13+
vi.mock('./utils.js', async () => {
14+
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
15+
return {
16+
...actual,
17+
createNotebooklmNotebookViaRpc: mockCreateNotebooklmNotebookViaRpc,
18+
ensureNotebooklmHome: mockEnsureNotebooklmHome,
19+
requireNotebooklmSession: mockRequireNotebooklmSession,
20+
};
21+
});
22+
23+
import { getRegistry } from '../../registry.js';
24+
import './create.js';
25+
26+
describe('notebooklm create', () => {
27+
const command = getRegistry().get('notebooklm/create');
28+
29+
beforeEach(() => {
30+
mockCreateNotebooklmNotebookViaRpc.mockReset();
31+
mockEnsureNotebooklmHome.mockReset();
32+
mockRequireNotebooklmSession.mockReset();
33+
mockEnsureNotebooklmHome.mockResolvedValue(undefined);
34+
mockRequireNotebooklmSession.mockResolvedValue(undefined);
35+
});
36+
37+
it('creates a new notebook via rpc and returns the created notebook row', async () => {
38+
mockCreateNotebooklmNotebookViaRpc.mockResolvedValue({
39+
id: 'nb-created',
40+
title: '新建 Notebook',
41+
url: 'https://notebooklm.google.com/notebook/nb-created',
42+
source: 'rpc',
43+
is_owner: true,
44+
created_at: '2026-03-31T09:12:00.000Z',
45+
updated_at: '2026-03-31T09:12:00.000Z',
46+
emoji: null,
47+
source_count: 0,
48+
});
49+
50+
const result = await command!.func!({} as any, { title: '新建 Notebook' });
51+
52+
expect(mockCreateNotebooklmNotebookViaRpc).toHaveBeenCalledWith(expect.anything(), '新建 Notebook');
53+
expect(result).toEqual([
54+
expect.objectContaining({
55+
id: 'nb-created',
56+
title: '新建 Notebook',
57+
source: 'rpc',
58+
}),
59+
]);
60+
});
61+
});

src/clis/notebooklm/create.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ArgumentError, EmptyResultError } from '../../errors.js';
2+
import { cli, Strategy } from '../../registry.js';
3+
import type { IPage } from '../../types.js';
4+
import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
5+
import {
6+
createNotebooklmNotebookViaRpc,
7+
ensureNotebooklmHome,
8+
requireNotebooklmSession,
9+
} from './utils.js';
10+
11+
cli({
12+
site: NOTEBOOKLM_SITE,
13+
name: 'create',
14+
description: 'Create a new NotebookLM notebook with the given title',
15+
domain: NOTEBOOKLM_DOMAIN,
16+
strategy: Strategy.COOKIE,
17+
browser: true,
18+
navigateBefore: false,
19+
args: [
20+
{
21+
name: 'title',
22+
positional: true,
23+
required: true,
24+
help: 'Title for the new notebook',
25+
},
26+
],
27+
columns: ['id', 'title', 'created_at', 'updated_at', 'source_count', 'url', 'source'],
28+
func: async (page: IPage, kwargs) => {
29+
await requireNotebooklmSession(page);
30+
await ensureNotebooklmHome(page);
31+
32+
const title = typeof kwargs.title === 'string' ? kwargs.title.trim() : String(kwargs.title ?? '').trim();
33+
if (!title) {
34+
throw new ArgumentError('The notebook title cannot be empty.');
35+
}
36+
37+
const notebook = await createNotebooklmNotebookViaRpc(page, title);
38+
if (notebook) return [notebook];
39+
40+
throw new EmptyResultError(
41+
'opencli notebooklm create',
42+
'NotebookLM did not return the created notebook row.',
43+
);
44+
},
45+
});

0 commit comments

Comments
 (0)