mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
903 lines
30 KiB
TypeScript
903 lines
30 KiB
TypeScript
import { AuthRequiredError, CliError } from '../../errors.js';
|
|
import type { IPage } from '../../types.js';
|
|
import {
|
|
NOTEBOOKLM_DOMAIN,
|
|
NOTEBOOKLM_HOME_URL,
|
|
type NotebooklmHistoryRow,
|
|
type NotebooklmNotebookDetailRow,
|
|
type NotebooklmNoteDetailRow,
|
|
type NotebooklmNoteRow,
|
|
type NotebooklmPageKind,
|
|
type NotebooklmPageState,
|
|
type NotebooklmRow,
|
|
type NotebooklmSourceFulltextRow,
|
|
type NotebooklmSourceGuideRow,
|
|
type NotebooklmSourceRow,
|
|
type NotebooklmSummaryRow,
|
|
} from './shared.js';
|
|
import {
|
|
callNotebooklmRpc,
|
|
buildNotebooklmRpcBody,
|
|
extractNotebooklmRpcResult,
|
|
fetchNotebooklmInPage,
|
|
getNotebooklmPageAuth,
|
|
parseNotebooklmChunkedResponse,
|
|
stripNotebooklmAntiXssi,
|
|
} 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: unknown): unknown {
|
|
let current = result;
|
|
while (Array.isArray(current) && current.length === 1 && Array.isArray(current[0])) {
|
|
current = current[0];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function parseNotebooklmIdFromUrl(url: string): string {
|
|
const match = url.match(/\/notebook\/([^/?#]+)/);
|
|
return match?.[1] ?? '';
|
|
}
|
|
|
|
export function parseNotebooklmNotebookTarget(value: string): string {
|
|
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)) {
|
|
const notebookId = parseNotebooklmIdFromUrl(normalized);
|
|
if (notebookId) return notebookId;
|
|
throw new CliError(
|
|
'NOTEBOOKLM_INVALID_NOTEBOOK',
|
|
'NotebookLM notebook URL is invalid',
|
|
'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/<id>.',
|
|
);
|
|
}
|
|
|
|
const pathMatch = normalized.match(/(?:^|\/)notebook\/([^/?#]+)/);
|
|
if (pathMatch?.[1]) return pathMatch[1];
|
|
return normalized;
|
|
}
|
|
|
|
export function buildNotebooklmNotebookUrl(notebookId: string): string {
|
|
return new URL(`/notebook/${encodeURIComponent(notebookId)}`, NOTEBOOKLM_HOME_URL).toString();
|
|
}
|
|
|
|
export function classifyNotebooklmPage(url: string): NotebooklmPageKind {
|
|
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: unknown, fallback: string = ''): string {
|
|
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: unknown): string | null {
|
|
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: unknown): string | null {
|
|
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: unknown): number | null {
|
|
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: unknown): string | null {
|
|
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: unknown): string | null {
|
|
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: string): boolean {
|
|
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: unknown, results: string[]): string[] {
|
|
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: unknown, results: string[]): string[] {
|
|
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;
|
|
}
|
|
|
|
type NotebooklmRawNoteRow = {
|
|
title?: string | null;
|
|
text?: string | null;
|
|
};
|
|
|
|
type NotebooklmRawSummaryRow = {
|
|
title?: string | null;
|
|
summary?: string | null;
|
|
};
|
|
|
|
type NotebooklmRawVisibleNoteRow = {
|
|
title?: string | null;
|
|
content?: string | null;
|
|
};
|
|
|
|
function collectNotebooklmThreadIds(value: unknown, results: string[], seen: Set<string>): string[] {
|
|
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: unknown): string[] {
|
|
return collectNotebooklmThreadIds(result, [], new Set<string>());
|
|
}
|
|
|
|
export function extractNotebooklmHistoryPreview(result: unknown): string | null {
|
|
const strings = collectNotebooklmStrings(result, []);
|
|
return strings.length > 0 ? strings[0] : null;
|
|
}
|
|
|
|
export function parseNotebooklmNoteListRawRows(
|
|
rows: NotebooklmRawNoteRow[],
|
|
notebookId: string,
|
|
url: string,
|
|
): NotebooklmNoteRow[] {
|
|
const parsed: Array<NotebooklmNoteRow | null> = 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' as const,
|
|
};
|
|
});
|
|
|
|
return parsed.filter((row): row is NotebooklmNoteRow => row !== null);
|
|
}
|
|
|
|
function parseNotebooklmSummaryRawRow(
|
|
row: NotebooklmRawSummaryRow | null | undefined,
|
|
notebookId: string,
|
|
url: string,
|
|
): NotebooklmSummaryRow | null {
|
|
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: NotebooklmRawVisibleNoteRow | null | undefined,
|
|
notebookId: string,
|
|
url: string,
|
|
): NotebooklmNoteDetailRow | null {
|
|
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: unknown): NotebooklmRow[] {
|
|
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): item is unknown[] => 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' as const,
|
|
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: unknown): NotebooklmNotebookDetailRow | null {
|
|
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: unknown): NotebooklmSourceRow[] {
|
|
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): entry is unknown[] => 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' as const,
|
|
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: unknown,
|
|
source: Pick<NotebooklmSourceRow, 'id' | 'notebook_id' | 'title' | 'type'>,
|
|
): NotebooklmSourceGuideRow | null {
|
|
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): item is string => 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: unknown,
|
|
notebookId: string,
|
|
fallbackUrl: string,
|
|
): NotebooklmSourceFulltextRow | null {
|
|
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: NotebooklmSourceRow[],
|
|
query: string,
|
|
): NotebooklmSourceRow | null {
|
|
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: NotebooklmNoteRow[],
|
|
query: string,
|
|
): NotebooklmNoteRow | null {
|
|
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: IPage): Promise<NotebooklmRow[]> {
|
|
const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_LIST_RPC_ID, [null, 1, null, [2]]);
|
|
return parseNotebooklmListResult(rpc.result);
|
|
}
|
|
|
|
export async function getNotebooklmDetailViaRpc(page: IPage): Promise<NotebooklmNotebookDetailRow | null> {
|
|
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 listNotebooklmSourcesViaRpc(page: IPage): Promise<NotebooklmSourceRow[]> {
|
|
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: IPage): Promise<NotebooklmHistoryRow[]> {
|
|
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: NotebooklmHistoryRow[] = [];
|
|
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: IPage): Promise<NotebooklmNoteRow[]> {
|
|
const state = await getNotebooklmPageState(page);
|
|
if (state.kind !== 'notebook' || !state.notebookId) return [];
|
|
|
|
const raw = 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(),
|
|
};
|
|
});
|
|
})()`) as NotebooklmRawNoteRow[] | null;
|
|
|
|
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: IPage): Promise<NotebooklmSummaryRow | null> {
|
|
const state = await getNotebooklmPageState(page);
|
|
if (state.kind !== 'notebook' || !state.notebookId) return null;
|
|
|
|
const raw = 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 };
|
|
})()`) as NotebooklmRawSummaryRow | null;
|
|
|
|
return parseNotebooklmSummaryRawRow(
|
|
raw,
|
|
state.notebookId,
|
|
state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`,
|
|
);
|
|
}
|
|
|
|
export async function getNotebooklmSummaryViaRpc(page: IPage): Promise<NotebooklmSummaryRow | null> {
|
|
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: IPage,
|
|
sourceId: string,
|
|
): Promise<NotebooklmSourceFulltextRow | null> {
|
|
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: IPage,
|
|
source: Pick<NotebooklmSourceRow, 'id' | 'notebook_id' | 'title' | 'type'>,
|
|
): Promise<NotebooklmSourceGuideRow | null> {
|
|
if (!source.id) return null;
|
|
|
|
const rpc = await callNotebooklmRpc(
|
|
page,
|
|
'tr032e',
|
|
[[[[source.id]]]],
|
|
);
|
|
|
|
return parseNotebooklmSourceGuideResult(rpc.result, source);
|
|
}
|
|
|
|
export async function readNotebooklmVisibleNoteFromPage(page: IPage): Promise<NotebooklmNoteDetailRow | null> {
|
|
const state = await getNotebooklmPageState(page);
|
|
if (state.kind !== 'notebook' || !state.notebookId) return null;
|
|
|
|
const raw = 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),
|
|
};
|
|
})()`) as NotebooklmRawVisibleNoteRow | null;
|
|
|
|
return parseNotebooklmVisibleNoteRawRow(
|
|
raw,
|
|
state.notebookId,
|
|
state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`,
|
|
);
|
|
}
|
|
|
|
export async function ensureNotebooklmHome(page: IPage): Promise<void> {
|
|
const currentUrl = page.getCurrentUrl
|
|
? await page.getCurrentUrl().catch(() => null)
|
|
: null;
|
|
const currentKind = currentUrl ? classifyNotebooklmPage(currentUrl) : 'unknown';
|
|
if (currentKind === 'home') return;
|
|
await page.goto(NOTEBOOKLM_HOME_URL);
|
|
await page.wait(2);
|
|
}
|
|
|
|
export async function getNotebooklmPageState(page: IPage): Promise<NotebooklmPageState> {
|
|
const raw = 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 };
|
|
})()`) as Partial<NotebooklmPageState> | null;
|
|
|
|
const state: NotebooklmPageState = {
|
|
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: IPage): Promise<NotebooklmRow | null> {
|
|
const raw = 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',
|
|
};
|
|
})()`) as NotebooklmRow | null;
|
|
|
|
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: IPage): Promise<NotebooklmRow[]> {
|
|
const raw = 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;
|
|
})()`) as NotebooklmRow[] | null;
|
|
|
|
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' as const,
|
|
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: IPage): Promise<NotebooklmSourceRow[]> {
|
|
const raw = 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;
|
|
})()`) as NotebooklmSourceRow[] | null;
|
|
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.filter((row) => row.id && row.title);
|
|
}
|
|
|
|
export async function requireNotebooklmSession(page: IPage): Promise<NotebooklmPageState> {
|
|
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;
|
|
}
|