import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError } from '@jackwener/opencli/errors'; import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, } 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'; const NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID = 'rLM1Ne'; const NOTEBOOKLM_HISTORY_THREADS_RPC_ID = 'hPTbtc'; const NOTEBOOKLM_HISTORY_DETAIL_RPC_ID = 'khqZz'; function unwrapNotebooklmSingletonResult(result) { let current = result; while (Array.isArray(current) && current.length === 1 && Array.isArray(current[0])) { current = current[0]; } return current; } export function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } export function parseNotebooklmIdFromUrl(url) { const match = url.match(/\/notebook\/([^/?#]+)/); return match?.[1] ?? ''; } const NOTEBOOK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function ensureNotebookUuid(candidate) { if (!NOTEBOOK_UUID_RE.test(candidate)) { throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', `NotebookLM notebook id "${candidate}" is not a valid UUID`, 'Pass a notebook id from `opencli notebooklm list` or a full notebook URL like https://notebooklm.google.com/notebook/.'); } return candidate; } export function parseNotebooklmNotebookTarget(value) { const normalized = value.trim(); if (!normalized) { throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook id is required', 'Pass a notebook id from `opencli notebooklm list` or a full notebook URL.'); } if (/^https?:\/\//i.test(normalized)) { let parsed; try { parsed = new URL(normalized); } 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.'); } const notebookId = parseNotebooklmIdFromUrl(normalized); if (!notebookId) { throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL is invalid', 'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/.'); } return ensureNotebookUuid(notebookId); } const pathMatch = normalized.match(/(?:^|\/)notebook\/([^/?#]+)/); if (pathMatch?.[1]) return ensureNotebookUuid(pathMatch[1]); return ensureNotebookUuid(normalized); } export function getNotebooklmAuthuser() { const v = process.env.OPENCLI_NOTEBOOKLM_AUTHUSER; return typeof v === 'string' && /^\d+$/.test(v) ? v : ''; } export function requireNotebooklmExecute(value, action) { if (value !== true) { throw new ArgumentError(`Refusing to ${action}: pass --execute to perform this NotebookLM write`); } } export function buildNotebooklmNotebookUrl(notebookId) { const u = new URL(`/notebook/${encodeURIComponent(notebookId)}`, NOTEBOOKLM_HOME_URL); const authuser = getNotebooklmAuthuser(); if (authuser) u.searchParams.set('authuser', authuser); return u.toString(); } export function classifyNotebooklmPage(url) { 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, fallback = '') { 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) { 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) { 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) { 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) { 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) { 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) { 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, results) { 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, results) { 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; } function collectNotebooklmThreadIds(value, results, seen) { 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) { return collectNotebooklmThreadIds(result, [], new Set()); } export function extractNotebooklmHistoryPreview(result) { const strings = collectNotebooklmStrings(result, []); return strings.length > 0 ? strings[0] : null; } export function parseNotebooklmNoteListRawRows(rows, notebookId, url) { const parsed = 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', }; }); return parsed.filter((row) => row !== null); } function parseNotebooklmSummaryRawRow(row, notebookId, url) { 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, notebookId, url) { 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) { 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) => 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', 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) { 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) { 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) => 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', 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, source) { 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) => 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, notebookId, fallbackUrl) { 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, query) { 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, query) { 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) { const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_LIST_RPC_ID, [null, 1, null, [2]]); return parseNotebooklmListResult(rpc.result); } export async function getNotebooklmDetailViaRpc(page) { 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 getNotebooklmNotebookDetailById(page, notebookId) { const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, [notebookId, null, [2], null, 0]); return { detail: parseNotebooklmNotebookDetailResult(rpc.result), sources: parseNotebooklmSourceListResult(rpc.result) }; } export async function verifyNotebooklmNotebookExists(page, notebookId, action) { try { const { detail } = await getNotebooklmNotebookDetailById(page, notebookId); if (!detail || detail.id !== notebookId) { throw new CommandExecutionError(`NotebookLM ${action} succeeded but the notebook ${notebookId} was not found in the post-write verification`); } return detail; } catch (error) { if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) throw error; throw new CommandExecutionError(`NotebookLM ${action} post-write verification failed: ${error?.message || error}`); } } export async function verifyNotebooklmSourceAdded(page, notebookId, sourceId, action) { try { const { detail, sources } = await getNotebooklmNotebookDetailById(page, notebookId); if (!detail || detail.id !== notebookId) { throw new CommandExecutionError(`NotebookLM ${action} succeeded but the notebook ${notebookId} was not found in the post-write verification`); } const matched = sources.find((s) => s.id === sourceId); if (!matched) { throw new CommandExecutionError(`NotebookLM ${action} succeeded but source ${sourceId} did not appear in the notebook's source list`); } return matched; } catch (error) { if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) throw error; throw new CommandExecutionError(`NotebookLM ${action} post-write verification failed: ${error?.message || error}`); } } export async function listNotebooklmSourcesViaRpc(page) { 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) { 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 = []; 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) { const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook' || !state.notebookId) return []; const raw = unwrapNotebooklmEvaluateResult(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(), }; }); })()`)); 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) { const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook' || !state.notebookId) return null; const raw = unwrapNotebooklmEvaluateResult(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 }; })()`)); return parseNotebooklmSummaryRawRow(raw, state.notebookId, state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`); } export async function getNotebooklmSummaryViaRpc(page) { 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, sourceId) { 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, source) { if (!source.id) return null; const rpc = await callNotebooklmRpc(page, 'tr032e', [[[[source.id]]]]); return parseNotebooklmSourceGuideResult(rpc.result, source); } export async function readNotebooklmVisibleNoteFromPage(page) { const state = await getNotebooklmPageState(page); if (state.kind !== 'notebook' || !state.notebookId) return null; const raw = unwrapNotebooklmEvaluateResult(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), }; })()`)); return parseNotebooklmVisibleNoteRawRow(raw, state.notebookId, state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`); } export async function ensureNotebooklmHome(page) { const currentUrl = page.getCurrentUrl ? await page.getCurrentUrl().catch(() => null) : null; const currentKind = currentUrl ? classifyNotebooklmPage(currentUrl) : 'unknown'; if (currentKind === 'home') return; const authuser = getNotebooklmAuthuser(); const target = authuser ? `${NOTEBOOKLM_HOME_URL}?authuser=${encodeURIComponent(authuser)}` : NOTEBOOKLM_HOME_URL; try { await page.goto(target); await page.wait(2); } catch (error) { throw new CommandExecutionError(`Failed to open NotebookLM home: ${error?.message || error}`); } } export async function getNotebooklmPageState(page) { const raw = unwrapNotebooklmEvaluateResult(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 }; })()`)); const state = { 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) { const raw = unwrapNotebooklmEvaluateResult(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', }; })()`)); 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) { const raw = unwrapNotebooklmEvaluateResult(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; })()`)); 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', 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) { const raw = unwrapNotebooklmEvaluateResult(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; })()`)); if (!Array.isArray(raw)) return []; return raw.filter((row) => row.id && row.title); } export async function requireNotebooklmSession(page) { 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; }