/ clis / notebooklm / utils.test.js
utils.test.js
  1  import { describe, expect, it } from 'vitest';
  2  import { buildNotebooklmRpcBody, classifyNotebooklmPage, extractNotebooklmHistoryPreview, extractNotebooklmRpcResult, getNotebooklmPageState, normalizeNotebooklmTitle, parseNotebooklmHistoryThreadIdsResult, parseNotebooklmIdFromUrl, parseNotebooklmListResult, parseNotebooklmNoteListRawRows, parseNotebooklmNotebookDetailResult, parseNotebooklmSourceFulltextResult, parseNotebooklmSourceGuideResult, parseNotebooklmSourceListResult, } from './utils.js';
  3  describe('notebooklm utils', () => {
  4      it('parses notebook id from a notebook url', () => {
  5          expect(parseNotebooklmIdFromUrl('https://notebooklm.google.com/notebook/abc-123')).toBe('abc-123');
  6      });
  7      it('returns empty string when notebook id is absent', () => {
  8          expect(parseNotebooklmIdFromUrl('https://notebooklm.google.com/')).toBe('');
  9      });
 10      it('classifies notebook pages correctly', () => {
 11          expect(classifyNotebooklmPage('https://notebooklm.google.com/notebook/demo-id')).toBe('notebook');
 12          expect(classifyNotebooklmPage('https://notebooklm.google.com/')).toBe('home');
 13          expect(classifyNotebooklmPage('https://example.com/notebook/demo-id')).toBe('unknown');
 14      });
 15      it('normalizes notebook titles', () => {
 16          expect(normalizeNotebooklmTitle('  Demo   Notebook  ')).toBe('Demo Notebook');
 17          expect(normalizeNotebooklmTitle('', 'Untitled')).toBe('Untitled');
 18      });
 19      it('builds the notebooklm rpc request body with csrf token', () => {
 20          const body = buildNotebooklmRpcBody('wXbhsf', [null, 1, null, [2]], 'csrf123');
 21          expect(body).toContain('f.req=');
 22          expect(body).toContain('at=csrf123');
 23          expect(body.endsWith('&')).toBe(true);
 24          expect(decodeURIComponent(body)).toContain('"[null,1,null,[2]]"');
 25      });
 26      it('extracts notebooklm rpc payload from chunked batchexecute response', () => {
 27          const raw = ')]}\'\n107\n[["wrb.fr","wXbhsf","[[[\\"Notebook One\\",null,\\"nb1\\",null,null,[null,false,null,null,null,[1704067200]]]]]"]]';
 28          const result = extractNotebooklmRpcResult(raw, 'wXbhsf');
 29          expect(Array.isArray(result)).toBe(true);
 30          expect(result[0]).toBeDefined();
 31      });
 32      it('parses notebook rows from notebooklm rpc payload', () => {
 33          const rows = parseNotebooklmListResult([
 34              [
 35                  ['Notebook One', null, 'nb1', null, null, [null, false, null, null, null, [1704067200]]],
 36              ],
 37          ]);
 38          expect(rows).toEqual([
 39              {
 40                  id: 'nb1',
 41                  title: 'Notebook One',
 42                  url: 'https://notebooklm.google.com/notebook/nb1',
 43                  source: 'rpc',
 44                  is_owner: true,
 45                  created_at: '2024-01-01T00:00:00.000Z',
 46              },
 47          ]);
 48      });
 49      it('parses notebook metadata from notebook detail rpc payload', () => {
 50          const notebook = parseNotebooklmNotebookDetailResult([
 51              'Browser Automation',
 52              [
 53                  [
 54                      [['src1']],
 55                      'Pasted text',
 56                      [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
 57                      [null, 2],
 58                  ],
 59              ],
 60              'nb-demo',
 61              '🕸️',
 62              null,
 63              [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
 64          ]);
 65          expect(notebook).toEqual({
 66              id: 'nb-demo',
 67              title: 'Browser Automation',
 68              url: 'https://notebooklm.google.com/notebook/nb-demo',
 69              source: 'rpc',
 70              emoji: '🕸️',
 71              source_count: 1,
 72              is_owner: true,
 73              created_at: '2026-03-30T12:02:41.361Z',
 74              updated_at: '2026-03-30T16:52:38.348Z',
 75          });
 76      });
 77      it('parses notebook metadata when detail rpc wraps the payload in a singleton envelope', () => {
 78          const notebook = parseNotebooklmNotebookDetailResult([
 79              [
 80                  'Browser Automation',
 81                  [
 82                      [
 83                          [['src1']],
 84                          'Pasted text',
 85                          [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
 86                          [null, 2],
 87                      ],
 88                  ],
 89                  'nb-demo',
 90                  '🕸️',
 91                  null,
 92                  [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
 93              ],
 94          ]);
 95          expect(notebook).toEqual({
 96              id: 'nb-demo',
 97              title: 'Browser Automation',
 98              url: 'https://notebooklm.google.com/notebook/nb-demo',
 99              source: 'rpc',
100              emoji: '🕸️',
101              source_count: 1,
102              is_owner: true,
103              created_at: '2026-03-30T12:02:41.361Z',
104              updated_at: '2026-03-30T16:52:38.348Z',
105          });
106      });
107      it('parses sources from notebook detail rpc payload', () => {
108          const rows = parseNotebooklmSourceListResult([
109              'Browser Automation',
110              [
111                  [
112                      [['src1']],
113                      'Pasted text',
114                      [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
115                      [null, 2],
116                  ],
117              ],
118              'nb-demo',
119              '🕸️',
120              null,
121              [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
122          ]);
123          expect(rows).toEqual([
124              {
125                  id: 'src1',
126                  notebook_id: 'nb-demo',
127                  title: 'Pasted text',
128                  type: 'pasted-text',
129                  type_code: 8,
130                  size: 359,
131                  created_at: '2026-03-30T12:03:03.855Z',
132                  updated_at: '2026-03-30T12:03:05.395Z',
133                  url: 'https://notebooklm.google.com/notebook/nb-demo',
134                  source: 'rpc',
135              },
136          ]);
137      });
138      it('parses sources when detail rpc wraps the payload in a singleton envelope', () => {
139          const rows = parseNotebooklmSourceListResult([
140              [
141                  'Browser Automation',
142                  [
143                      [
144                          [['src1']],
145                          'Pasted text',
146                          [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
147                          [null, 2],
148                      ],
149                  ],
150                  'nb-demo',
151                  '🕸️',
152                  null,
153                  [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
154              ],
155          ]);
156          expect(rows).toEqual([
157              {
158                  id: 'src1',
159                  notebook_id: 'nb-demo',
160                  title: 'Pasted text',
161                  type: 'pasted-text',
162                  type_code: 8,
163                  size: 359,
164                  created_at: '2026-03-30T12:03:03.855Z',
165                  updated_at: '2026-03-30T12:03:05.395Z',
166                  url: 'https://notebooklm.google.com/notebook/nb-demo',
167                  source: 'rpc',
168              },
169          ]);
170      });
171      it('parses sources when the source id container is only wrapped once', () => {
172          const rows = parseNotebooklmSourceListResult([
173              [
174                  'Browser Automation',
175                  [
176                      [
177                          ['src-live'],
178                          'Pasted text',
179                          [null, 359, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 8, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
180                          [null, 2],
181                      ],
182                  ],
183                  'nb-demo',
184                  '🕸️',
185                  null,
186                  [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
187              ],
188          ]);
189          expect(rows).toEqual([
190              {
191                  id: 'src-live',
192                  notebook_id: 'nb-demo',
193                  title: 'Pasted text',
194                  type: 'pasted-text',
195                  type_code: 8,
196                  size: 359,
197                  created_at: '2026-03-30T12:03:03.855Z',
198                  updated_at: '2026-03-30T12:03:05.395Z',
199                  url: 'https://notebooklm.google.com/notebook/nb-demo',
200                  source: 'rpc',
201              },
202          ]);
203      });
204      it('parses source type from metadata slot instead of the stale entry[3] envelope', () => {
205          const rows = parseNotebooklmSourceListResult([
206              [
207                  'Browser Automation',
208                  [
209                      [
210                          ['src-pdf'],
211                          'Manual.pdf',
212                          [null, 18940, [1774872183, 855096000], ['doc1', [1774872183, 356519000]], 3, null, 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
213                          [null, 2],
214                      ],
215                      [
216                          ['src-web'],
217                          'Example Site',
218                          [null, 131, [1774872183, 855096000], ['doc2', [1774872183, 356519000]], 5, ['https://example.com'], 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
219                          [null, 2],
220                      ],
221                      [
222                          ['src-yt'],
223                          'Video Source',
224                          [null, 11958, [1774872183, 855096000], ['doc3', [1774872183, 356519000]], 9, ['https://youtu.be/demo', 'demo', 'Uploader'], 1, null, null, null, null, null, null, null, [1774872185, 395271000]],
225                          [null, 2],
226                      ],
227                  ],
228                  'nb-demo',
229                  '🕸️',
230                  null,
231                  [1, false, true, null, null, [1774889558, 348721000], 1, false, [1774872161, 361922000], null, null, null, false, true, 1, false, null, true, 1],
232              ],
233          ]);
234          expect(rows).toEqual([
235              expect.objectContaining({
236                  id: 'src-pdf',
237                  type: 'pdf',
238                  type_code: 3,
239              }),
240              expect.objectContaining({
241                  id: 'src-web',
242                  type: 'web',
243                  type_code: 5,
244              }),
245              expect.objectContaining({
246                  id: 'src-yt',
247                  type: 'youtube',
248                  type_code: 9,
249              }),
250          ]);
251      });
252      it('parses notebook history thread ids from hPTbtc payload', () => {
253          const threadIds = parseNotebooklmHistoryThreadIdsResult([
254              [[['28e0f2cb-4591-45a3-a661-7653666f7c78']]],
255          ]);
256          expect(threadIds).toEqual(['28e0f2cb-4591-45a3-a661-7653666f7c78']);
257      });
258      it('extracts a notebook history preview from khqZz payload', () => {
259          const preview = extractNotebooklmHistoryPreview([
260              [
261                  ['28e0f2cb-4591-45a3-a661-7653666f7c78'],
262                  [null, 'Summarize this notebook'],
263              ],
264          ]);
265          expect(preview).toBe('Summarize this notebook');
266      });
267      it('parses notebook notes from studio note rows', () => {
268          const rows = parseNotebooklmNoteListRawRows([
269              {
270                  title: '新建笔记',
271                  text: 'sticky_note_2 新建笔记 6 分钟前 more_vert',
272              },
273          ], 'nb-demo', 'https://notebooklm.google.com/notebook/nb-demo');
274          expect(rows).toEqual([
275              {
276                  notebook_id: 'nb-demo',
277                  title: '新建笔记',
278                  created_at: '6 分钟前',
279                  url: 'https://notebooklm.google.com/notebook/nb-demo',
280                  source: 'studio-list',
281              },
282          ]);
283      });
284      it('parses source fulltext from hizoJc payload', () => {
285          const row = parseNotebooklmSourceFulltextResult([
286              [
287                  [['src-1']],
288                  '粘贴的文字',
289                  [null, 359, [1774872183, 855096000], null, 8, null, 1, ['https://example.com/source']],
290                  [null, 2],
291              ],
292              null,
293              null,
294              [
295                  [
296                      [
297                          [0, 5, [[[0, 5, ['第一段']]]]],
298                          [5, 10, [[[5, 10, ['第二段']]]]],
299                      ],
300                  ],
301              ],
302          ], 'nb-demo', 'https://notebooklm.google.com/notebook/nb-demo');
303          expect(row).toEqual({
304              source_id: 'src-1',
305              notebook_id: 'nb-demo',
306              title: '粘贴的文字',
307              kind: 'pasted-text',
308              content: '第一段\n第二段',
309              char_count: 7,
310              url: 'https://example.com/source',
311              source: 'rpc',
312          });
313      });
314      it('parses source guide from tr032e payloads with either null or source-id envelope in slot 0', () => {
315          const source = {
316              id: 'src-yt',
317              notebook_id: 'nb-demo',
318              title: 'Video Source',
319              type: 'youtube',
320          };
321          expect(parseNotebooklmSourceGuideResult([
322              [
323                  [
324                      null,
325                      ['Guide summary'],
326                      [['AI', 'agents']],
327                      [],
328                  ],
329              ],
330          ], source)).toEqual({
331              source_id: 'src-yt',
332              notebook_id: 'nb-demo',
333              title: 'Video Source',
334              type: 'youtube',
335              summary: 'Guide summary',
336              keywords: ['AI', 'agents'],
337              source: 'rpc',
338          });
339          expect(parseNotebooklmSourceGuideResult([
340              [
341                  [
342                      [['src-yt']],
343                      ['Guide summary'],
344                      [['AI', 'agents']],
345                      [],
346                  ],
347              ],
348          ], source)).toEqual({
349              source_id: 'src-yt',
350              notebook_id: 'nb-demo',
351              title: 'Video Source',
352              type: 'youtube',
353              summary: 'Guide summary',
354              keywords: ['AI', 'agents'],
355              source: 'rpc',
356          });
357      });
358      it('prefers real NotebookLM page tokens over login text heuristics', async () => {
359          let call = 0;
360          const page = {
361              evaluate: async () => {
362                  call += 1;
363                  if (call === 1) {
364                      return {
365                          url: 'https://notebooklm.google.com/notebook/nb-demo',
366                          title: 'Demo Notebook - NotebookLM',
367                          hostname: 'notebooklm.google.com',
368                          kind: 'notebook',
369                          notebookId: 'nb-demo',
370                          loginRequired: true,
371                          notebookCount: 0,
372                      };
373                  }
374                  return {
375                      html: '<html>"SNlM0e":"csrf-123","FdrFJe":"sess-456"</html>',
376                      sourcePath: '/notebook/nb-demo',
377                  };
378              },
379          };
380          await expect(getNotebooklmPageState(page)).resolves.toEqual({
381              url: 'https://notebooklm.google.com/notebook/nb-demo',
382              title: 'Demo Notebook - NotebookLM',
383              hostname: 'notebooklm.google.com',
384              kind: 'notebook',
385              notebookId: 'nb-demo',
386              loginRequired: false,
387              notebookCount: 0,
388          });
389      });
390  });