9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
815 lines
32 KiB
JavaScript
815 lines
32 KiB
JavaScript
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/<uuid>.');
|
|
}
|
|
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/<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.');
|
|
}
|
|
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/<uuid>.');
|
|
}
|
|
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;
|
|
}
|