notebooklm-api/src/server.js

737 lines
32 KiB
JavaScript

'use strict';
require('dotenv').config();
const express = require('express');
const expressWs = require('express-ws');
const cors = require('cors');
const path = require('path');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger');
const pool = require('./pool');
const app = express();
expressWs(app); // phải gọi trước khi đăng ký routes
// ── Middleware ─────────────────────────────────────────────────────────────
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Phục vụ UI tĩnh — không cần xác thực API key
app.use(express.static(path.join(__dirname, '..', 'public')));
// Tuỳ chọn: bảo vệ /api/* bằng API key
if (process.env.API_KEY) {
app.use('/api', (req, res, next) => {
const key = req.headers['x-api-key'] || req.query.api_key;
if (key !== process.env.API_KEY) {
return res.status(401).json({ ok: false, error: 'Thiếu hoặc sai API key' });
}
next();
});
}
// ── Swagger UI tại /docs ───────────────────────────────────────────────────
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customSiteTitle: 'NotebookLM API Docs',
swaggerOptions: { persistAuthorization: true, tryItOutEnabled: true },
}));
app.get('/docs.json', (req, res) => res.json(swaggerSpec));
// ── Routes ─────────────────────────────────────────────────────────────────
const { registerChatWs } = require('./routes/chat-ws');
registerChatWs(app); // WebSocket trước
app.use('/api/auth', require('./routes/auth'));
app.use('/api/notebooks', require('./routes/notebooks'));
app.use('/api/cron', require('./routes/cron'));
app.use('/api/history', require('./routes/history'));
// Debug DOM chat input — tìm đúng textarea cho chat
app.get('/debug/chat/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3000));
const dom = await page.evaluate(() => {
// Liệt kê TẤT CẢ textarea + input trên trang
const allTextareas = [...document.querySelectorAll('textarea, input[type="text"], input:not([type])')];
const textareaInfo = allTextareas.map(el => ({
tag: el.tagName,
type: el.type || '',
placeholder: el.placeholder || '',
cls: el.className?.slice(0, 100),
parentTag: el.parentElement?.tagName,
parentCls: el.parentElement?.className?.slice(0, 100),
ancestor3: el.closest('[class]')?.className?.slice(0, 100),
inDialog: !!el.closest('dialog, [role="dialog"], mat-dialog-container'),
rect: (() => { const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width) }; })(),
visible: el.offsetParent !== null,
}));
// Custom Angular tags quanh vùng chat
const chatArea = [...document.querySelectorAll('*')]
.filter(el => el.tagName.includes('-') && /chat|query|message|input|conversation/i.test(el.tagName))
.map(el => ({ tag: el.tagName, cls: el.className?.slice(0, 80) }));
return { textareas: textareaInfo, chatComponents: chatArea };
});
res.json(dom);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Debug: inspect "add source" dialog DOM — scan toàn bộ overlay
app.get('/debug/add-source-dialog/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3000));
// Snapshot TRƯỚC khi click (baseline)
const before = await page.evaluate(() => {
const overlay = document.querySelector('.cdk-overlay-container');
return {
customTags: [...new Set([...(overlay || document).querySelectorAll('*')]
.map(e => e.tagName.toLowerCase()).filter(t => t.includes('-')))],
visibleDialogs: [...document.querySelectorAll('[role="dialog"], mat-dialog-container, [cdkdialog]')]
.filter(e => e.offsetParent !== null)
.map(e => ({ tag: e.tagName, cls: e.className?.slice(0,80) })),
addSourceBtn: !!document.querySelector('.add-source-button'),
};
});
// Click nút "Thêm nguồn"
await page.click('.add-source-button').catch(e => console.warn('click fail:', e.message));
await new Promise(r => setTimeout(r, 2500));
// Snapshot SAU khi click — quét rộng
const after = await page.evaluate(() => {
// Tất cả dialogs/overlays có thể xuất hiện
const SELS = [
'[role="dialog"]',
'mat-dialog-container',
'.cdk-overlay-pane',
'add-sources-dialog',
'[class*="add-source"]',
'[class*="source-dialog"]',
'[class*="upload"]',
'[aria-label*="source" i]',
'[aria-modal="true"]',
];
const found = [];
for (const sel of SELS) {
for (const el of document.querySelectorAll(sel)) {
if (el.offsetParent === null && !el.querySelector('button[class*="drop-zone"]')) continue;
const btns = [...el.querySelectorAll('button')].filter(b => b.offsetParent !== null || b.textContent?.trim());
const inputs = [...el.querySelectorAll('input,textarea')];
found.push({
sel, tag: el.tagName, cls: el.className?.slice(0,100),
visible: el.offsetParent !== null,
btns: btns.map(b => ({ txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,50), cls: b.className?.slice(0,60), visible: b.offsetParent !== null })),
inputs: inputs.map(i => ({ tag: i.tagName, type: i.type, placeholder: i.placeholder, cls: i.className?.slice(0,80), visible: i.offsetParent !== null })),
html: el.innerHTML.trim().slice(0, 800),
});
}
}
// Custom Angular tags trong overlay
const overlay = document.querySelector('.cdk-overlay-container');
const overlayHTML = overlay?.innerHTML?.slice(0, 1500) || '';
const customInOverlay = [...new Set([...(overlay || document).querySelectorAll('*')]
.map(e => e.tagName.toLowerCase()).filter(t => t.includes('-')))];
return { found, overlayHTML, customInOverlay };
});
await page.keyboard.press('Escape').catch(() => {});
res.json({ ok: true, before, after });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Debug: tất cả buttons trên notebook page (tìm add-source button)
app.get('/debug/notebook-btns/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3500));
const data = await page.evaluate(() => {
const all = [...document.querySelectorAll('button, [role="button"], a[role="button"]')]
.filter(b => b.offsetParent !== null)
.map(b => ({
text: b.textContent?.trim()?.replace(/\s+/g,' ')?.slice(0, 60),
ariaLabel: b.getAttribute('aria-label'),
cls: b.className?.slice(0, 100),
id: b.id || undefined,
tag: b.tagName,
}));
const addSource = [...document.querySelectorAll('[class*="add-source"], [aria-label*="Thêm nguồn"], [aria-label*="source" i]')]
.map(e => ({ tag: e.tagName, cls: e.className?.slice(0, 100), aria: e.getAttribute('aria-label'), txt: e.textContent?.trim()?.slice(0,50) }));
return { url: location.pathname, totalBtns: all.length, buttons: all, addSourceCandidates: addSource };
});
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Debug: full HTML của source items để understand selector mới
app.get('/debug/source-items/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 4000));
const data = await page.evaluate(() => {
// Thử tất cả selector có thể
const containers = [
...document.querySelectorAll('.single-source-container, .source-item-menu-button-visible'),
];
return {
count: containers.length,
items: containers.slice(0, 5).map(el => ({
cls: el.className?.slice(0, 100),
outerHTML: el.outerHTML?.slice(0, 1000),
// Các text nodes và aria-labels
buttons: [...el.querySelectorAll('button')].map(b => ({
aria: b.getAttribute('aria-label'),
cls: b.className?.slice(0,60),
txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,60),
})),
icons: [...el.querySelectorAll('mat-icon')].map(i => ({
cls: i.className?.slice(0,80),
txt: i.textContent?.trim(),
})),
spanTexts: [...el.querySelectorAll('span, div')].filter(s =>
s.childNodes.length === 1 && s.childNodes[0].nodeType === 3 &&
s.textContent?.trim().length > 2
).map(s => ({ tag: s.tagName, cls: s.className?.slice(0,60), txt: s.textContent?.trim().slice(0,80) })),
})),
// Tìm loading indicator hiện tại
loadingEls: [...document.querySelectorAll('mat-spinner, [class*="loading"], [class*="spinner"]')]
.filter(e => e.offsetParent !== null)
.map(e => ({ sel: e.tagName, cls: e.className?.slice(0,60), parent: e.parentElement?.className?.slice(0,60) }))
.slice(0, 5),
};
});
res.json(data);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Debug DOM sources panel — gọi sau khi notebook đã load xong
app.get('/debug/sources/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 4000));
const dom = await page.evaluate(() => {
// Thử nhiều selector để tìm source panel
const PANEL_SELS = [
'sources-list', 'source-panel', 'sources-panel',
'[class*="sources-list"]', '[class*="source-list"]',
'[aria-label*="source" i]', 'mat-nav-list', 'mat-list',
'nav[class*="source"]',
];
let panel = null;
let panelSel = '';
for (const s of PANEL_SELS) {
panel = document.querySelector(s);
if (panel) { panelSel = s; break; }
}
// Tìm loading indicators
const LOAD_SELS = [
'mat-spinner', '.loading', '[class*="loading"]', '[class*="spinner"]',
'[class*="progress"]', '[aria-busy="true"]', '[class*="converting"]',
'[class*="processing"]',
];
const loaders = LOAD_SELS.flatMap(s =>
[...document.querySelectorAll(s)].map(el => ({
sel: s, tag: el.tagName, cls: el.className?.slice(0, 80),
txt: el.textContent?.trim().slice(0, 50),
}))
);
// Sample tất cả elements có text liên quan đến source
const allWithSource = [...document.querySelectorAll('[class]')]
.filter(el => /source/i.test(el.className))
.slice(0, 8)
.map(el => ({
tag: el.tagName, cls: el.className.slice(0, 100),
html: el.innerHTML.trim().slice(0, 400),
}));
// Lấy HTML của panel nếu tìm thấy
return {
foundPanel: panelSel,
panelHTML: panel?.innerHTML.trim().slice(0, 1200),
loadingEls: loaders.slice(0, 6),
sourceClsEls: allWithSource,
// Toàn bộ custom tags (Angular components)
customTags: [...new Set([...document.querySelectorAll('*')]
.map(el => el.tagName.toLowerCase())
.filter(t => t.includes('-')))],
};
});
res.json(dom);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Screenshot endpoint — chụp ảnh màn hình Chrome hiện tại
app.get('/debug/screenshot', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
const shot = await page.screenshot({ encoding: 'base64', type: 'png' });
res.json({ ok: true, url: page.url(), png_base64: shot.slice(0, 100) + '...(truncated)', fullLength: shot.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Debug page state — kiểm tra trạng thái trang hiện tại
app.get('/debug/page-state/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3000));
const state = await page.evaluate(() => {
const addBtn = document.querySelector('.add-source-button');
const allOverlays = [...document.querySelectorAll('.cdk-overlay-container, .cdk-overlay-backdrop, .cdk-overlay-pane, [cdkportaloutlet]')]
.map(e => ({ tag: e.tagName, cls: e.className?.slice(0,80), children: e.children.length, visible: e.offsetParent !== null }));
return {
url: location.href,
addBtnExists: !!addBtn,
addBtnText: addBtn?.textContent?.trim(),
addBtnRect: addBtn ? { x: Math.round(addBtn.getBoundingClientRect().x), y: Math.round(addBtn.getBoundingClientRect().y), w: Math.round(addBtn.getBoundingClientRect().width) } : null,
addBtnVisible: addBtn ? addBtn.offsetParent !== null : false,
overlays: allOverlays,
customTags: [...new Set([...document.querySelectorAll('*')].map(e => e.tagName.toLowerCase()).filter(t => t.includes('-')))].slice(0, 30),
bodyClasses: document.body.className.slice(0,100),
};
});
// Click add source và chờ
if (state.addBtnExists) {
await page.click('.add-source-button');
await new Promise(r => setTimeout(r, 2500));
state.afterClick = await page.evaluate(() => {
const allEls = [...document.querySelectorAll('*')].filter(e => e.offsetParent !== null);
const dialogs = allEls.filter(e =>
e.getAttribute('role') === 'dialog' ||
e.tagName.toLowerCase().includes('dialog') ||
e.className?.toLowerCase().includes('dialog') ||
e.className?.toLowerCase().includes('modal') ||
e.className?.toLowerCase().includes('overlay-pane')
);
const overlay = document.querySelector('.cdk-overlay-container');
return {
dialogsFound: dialogs.map(e => ({ tag: e.tagName, cls: e.className?.slice(0,80), role: e.getAttribute('role'), html: e.innerHTML?.slice(0,200) })),
overlayInnerHTML: overlay?.innerHTML?.slice(0, 2000) || '(empty)',
overlayChildren: overlay ? [...overlay.children].map(c => ({ tag: c.tagName, cls: c.className?.slice(0,80), visible: c.offsetParent !== null })) : [],
};
});
await page.keyboard.press('Escape').catch(() => {});
}
res.json(state);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Debug: click "Tải tệp lên" và trace DOM sau đó (không upload thật)
app.get('/debug/add-file-flow/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3000));
const steps = [];
// Mở dialog
await page.click('.add-source-button');
await page.waitForFunction(
() => document.querySelectorAll('mat-dialog-container').length > 0,
{ timeout: 10_000 },
);
await new Promise(r => setTimeout(r, 800));
// Click "Tải tệp lên"
const clicked = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...(d?.querySelectorAll('button') || [])]
.filter(b => b.offsetParent !== null)
.find(b => /Tải tệp|upload/i.test(b.textContent));
if (btn) { btn.click(); return btn.textContent?.trim(); }
return null;
});
steps.push({ step: 'click-upload-btn', clicked });
await new Promise(r => setTimeout(r, 1500));
// Snapshot sau click: tìm input[type=file], drop zone, overlay mới
const snap = await page.evaluate(() => {
// Tất cả file inputs (kể cả hidden)
const fileInputs = [...document.querySelectorAll('input[type="file"]')].map(i => ({
id: i.id, cls: i.className?.slice(0,80), accept: i.accept, multiple: i.multiple,
hidden: i.type === 'hidden' || i.offsetParent === null || window.getComputedStyle(i).display === 'none',
}));
// Dialog state sau click
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btns = d ? [...d.querySelectorAll('button')].filter(b => b.offsetParent !== null)
.map(b => ({ txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,50), cls: b.className?.slice(0,50), type: b.type })) : [];
const inputs = d ? [...d.querySelectorAll('input,textarea')].filter(i => i.offsetParent !== null)
.map(i => ({ tag: i.tagName, type: i.type, ph: i.placeholder?.slice(0,40), cls: i.className?.slice(0,60) })) : [];
// Drop zones
const dropZones = [...document.querySelectorAll('[class*="drop-zone"], [class*="dropzone"], [class*="file-drop"]')]
.map(e => ({ tag: e.tagName, cls: e.className?.slice(0,80), html: e.innerHTML?.slice(0,200) }));
return { fileInputs, dialogBtns: btns, dialogInputs: inputs, dropZones };
});
steps.push({ step: 'after-click', snap });
await page.keyboard.press('Escape').catch(() => {});
res.json({ ok: true, steps });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Debug: toàn bộ add-source URL flow step-by-step
app.get('/debug/add-url-flow/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goNotebook(req.params.id);
await new Promise(r => setTimeout(r, 3000));
const steps = [];
// Step 1: snapshot trước khi click
const before = await page.evaluate(() => ({
dialogs: document.querySelectorAll('mat-dialog-container').length,
}));
steps.push({ step: 'before', ...before });
// Step 2: click add-source button
await page.click('.add-source-button').catch(e => steps.push({ step: 'click-fail', err: e.message }));
await new Promise(r => setTimeout(r, 1500));
// Step 3: snapshot dialog ban đầu
const dialogSnap1 = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return null;
return {
btns: [...d.querySelectorAll('button')].filter(b => b.offsetParent !== null)
.map(b => ({ txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,60), cls: b.className?.slice(0,50) })),
inputs: [...d.querySelectorAll('input,textarea')].filter(e => e.offsetParent !== null)
.map(i => ({ tag: i.tagName, ph: i.placeholder, cls: i.className?.slice(0,60), readOnly: i.readOnly })),
};
});
steps.push({ step: 'dialog-initial', snap: dialogSnap1 });
// Step 4: click "Trang web" button
const webBtnClicked = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btns = [...(d?.querySelectorAll('button') || [])].filter(b => b.offsetParent !== null);
const btn = btns.find(b => /Trang web/i.test(b.textContent) || /website/i.test(b.textContent));
if (btn) { btn.click(); return { clicked: true, txt: btn.textContent?.trim().slice(0,50) }; }
return { clicked: false, available: btns.map(b => b.textContent?.trim().slice(0,30)) };
});
steps.push({ step: 'click-trang-web', result: webBtnClicked });
await new Promise(r => setTimeout(r, 1500));
// Step 5: snapshot sau khi click Trang web
const dialogSnap2 = await page.evaluate(() => {
// Quét TẤT CẢ dialogs
const allDialogs = [...document.querySelectorAll('mat-dialog-container')];
return allDialogs.map((d, idx) => ({
idx,
btns: [...d.querySelectorAll('button')].filter(b => b.offsetParent !== null)
.map(b => ({ txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,60), cls: b.className?.slice(0,50), type: b.type })),
inputs: [...d.querySelectorAll('input,textarea')].filter(e => e.offsetParent !== null)
.map(i => ({ tag: i.tagName, ph: i.placeholder, cls: i.className?.slice(0,80), type: i.type, readOnly: i.readOnly, ariaLabel: i.getAttribute('aria-label') })),
html: d.innerHTML?.slice(0,600),
}));
});
steps.push({ step: 'after-trang-web-click', dialogs: dialogSnap2 });
// Step 6: type test URL vào input đầu tiên phù hợp
const inputEl = await page.evaluateHandle(() => {
const allDialogs = [...document.querySelectorAll('mat-dialog-container')];
for (let i = allDialogs.length - 1; i >= 0; i--) {
const inputs = [...allDialogs[i].querySelectorAll('input,textarea')].filter(el =>
el.offsetParent !== null && !el.disabled && !el.readOnly &&
!el.getAttribute('aria-label')?.includes('cảm xúc') &&
!el.placeholder?.includes('Tìm nguồn') // BỎ QUA search box
);
if (inputs.length) return inputs[0];
}
return null;
});
if (inputEl.asElement()) {
await inputEl.asElement().click({ clickCount: 3 });
await inputEl.asElement().type('https://en.wikipedia.org/wiki/Node.js', { delay: 20 });
await new Promise(r => setTimeout(r, 1000));
// Step 7: snapshot sau khi type URL — tìm submit button
const dialogSnap3 = await page.evaluate(() => {
const allDialogs = [...document.querySelectorAll('mat-dialog-container')];
return allDialogs.map((d, idx) => ({
idx,
btns: [...d.querySelectorAll('button')].filter(b => b.offsetParent !== null)
.map(b => ({ txt: b.textContent?.trim().replace(/\s+/g,' ').slice(0,60), cls: b.className?.slice(0,60), type: b.type, disabled: b.disabled })),
inputs: [...d.querySelectorAll('input,textarea')].filter(e => e.offsetParent !== null)
.map(i => ({ tag: i.tagName, val: i.value?.slice(0,80), ph: i.placeholder?.slice(0,40) })),
}));
});
steps.push({ step: 'after-type-url', dialogs: dialogSnap3 });
} else {
steps.push({ step: 'no-suitable-input-found' });
}
await page.keyboard.press('Escape').catch(() => {});
res.json({ ok: true, steps });
} catch (e) { res.status(500).json({ error: e.message, steps: [] }); }
});
// Debug: hover notebook card và click menu — tìm menu items xóa
app.get('/debug/notebook-menu/:id', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goHome();
await new Promise(r => setTimeout(r, 3000));
// Hover card của notebook id được chỉ định
const hovered = await page.evaluate((targetId) => {
const anchor = [...document.querySelectorAll('a[href*="/notebook/"]')]
.find(a => a.href?.includes(targetId));
if (!anchor) return false;
const card = anchor.closest('mat-card, li, article, [class*="project"]') || anchor;
card.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
card.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
return true;
}, req.params.id);
if (!hovered) return res.status(404).json({ error: 'Notebook không tìm thấy trên trang' });
await new Promise(r => setTimeout(r, 1000));
// Tìm và click menu button hiện ra sau hover
const menuBtnClicked = await page.evaluate(() => {
const SELS = [
'button[aria-label="Trình đơn thao tác trong dự án"]',
'button[aria-label="Xem thêm"]',
'button.mat-mdc-menu-trigger[aria-label*="thao tác"]',
'project-action-button button',
'[class*="action-button"] button',
'mat-card button[class*="menu"]',
];
for (const sel of SELS) {
const els = [...document.querySelectorAll(sel)].filter(e => e.offsetParent !== null);
if (els.length) { els[0].click(); return { clicked: true, sel }; }
}
// Fallback: click bất kỳ button visible có text "more_vert"
const more = [...document.querySelectorAll('button')].find(b =>
b.offsetParent !== null && b.textContent?.trim() === 'more_vert'
);
if (more) { more.click(); return { clicked: true, sel: 'more_vert fallback' }; }
return { clicked: false };
});
await new Promise(r => setTimeout(r, 1200));
// Đọc menu items xuất hiện
const menuItems = await page.evaluate(() => {
const MENU_SELS = [
'.mat-mdc-menu-panel',
'mat-menu',
'[class*="mat-menu"]',
'.cdk-overlay-container [role="menu"]',
'.cdk-overlay-container [role="menuitem"]',
];
const found = [];
for (const sel of MENU_SELS) {
for (const el of document.querySelectorAll(sel)) {
if (!el.offsetParent && el.children.length === 0) continue;
const items = [...el.querySelectorAll('[role="menuitem"], button, a')].map(i => ({
tag: i.tagName,
text: i.textContent?.trim().replace(/\s+/g, ' ').slice(0, 60),
aria: i.getAttribute('aria-label'),
cls: i.className?.slice(0, 80),
}));
found.push({ sel, items, html: el.innerHTML?.slice(0, 500) });
}
}
return found;
});
await page.keyboard.press('Escape').catch(() => {});
res.json({ hovered, menuBtnClicked, menuItems });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Debug: inspect home page DOM — tìm nút tạo notebook và menu options
app.get('/debug/home', async (req, res) => {
try {
const b = require('./browser');
const page = await b.getPage();
await b.goHome();
await new Promise(r => setTimeout(r, 3000));
const dom = await page.evaluate(() => {
// Tất cả buttons có aria-label
const btns = [...document.querySelectorAll('button')].map(b => ({
ariaLabel: b.getAttribute('aria-label') || '',
text: b.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80),
cls: b.className?.slice(0, 80),
visible: b.offsetParent !== null,
tagName: b.tagName,
})).filter(b => b.visible);
// Mat-fab / create buttons
const fabs = [...document.querySelectorAll('button[mat-fab], button[mat-mini-fab], [class*="create"], [class*="new-notebook"], [class*="fab"]')]
.filter(e => e.offsetParent !== null)
.map(e => ({ tag: e.tagName, cls: e.className?.slice(0,80), aria: e.getAttribute('aria-label'), txt: e.textContent?.trim().slice(0,60) }));
// Custom Angular tags
const customTags = [...new Set([...document.querySelectorAll('*')]
.map(el => el.tagName.toLowerCase()).filter(t => t.includes('-')))];
// Tất cả anchors/links đến notebook
const notebookLinks = [...document.querySelectorAll('a[href*="/notebook/"]')]
.slice(0, 3)
.map(a => ({ href: a.href?.slice(0,80), cls: a.className?.slice(0,60) }));
return { buttons: btns, fabs, customTags: customTags.slice(0,30), notebookLinks };
});
res.json(dom);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Health check
app.get('/health', (req, res) => {
const slots = pool.status();
res.json({
ok: true,
status: 'running',
pool_size: pool.size,
slots,
// backward compat: tổng hợp
queue: {
busy: slots.some(s => s.busy),
pending: slots.reduce((sum, s) => sum + s.pending, 0),
},
});
});
// Pool status & auth sync
app.get('/api/pool/status', (req, res) => {
res.json({ ok: true, size: pool.size, slots: pool.status() });
});
app.post('/api/pool/sync-auth', async (req, res) => {
try {
const result = await pool.syncAuth();
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// API map
app.get('/api', (req, res) => {
res.json({
name: 'notebooklm-api',
version: '1.0.0',
ui: 'http://localhost:' + (process.env.PORT || 3456),
endpoints: {
'GET /health': 'Trạng thái server',
'GET /api/auth/status': 'Kiểm tra đăng nhập',
'POST /api/auth/login': 'Mở browser đăng nhập Google',
'GET /api/notebooks': 'Danh sách notebooks',
'POST /api/notebooks': 'Tạo notebook mới { title }',
'DELETE /api/notebooks/:id': 'Xoá notebook',
'GET /api/notebooks/:id/sources': 'Danh sách sources',
'POST /api/notebooks/:id/sources': 'Thêm source { type, content, title }',
'GET /api/notebooks/:id/chat/history': 'Lịch sử chat',
'POST /api/notebooks/:id/chat': 'Gửi câu hỏi { message }',
'WS /api/notebooks/:id/chat/stream': 'Streaming chat qua WebSocket',
},
});
});
// ── Graceful shutdown: đóng browser trước khi tắt để Chrome kịp lưu cookies ──
async function shutdown(signal) {
console.log(`\n[server] ${signal} nhận được — đang đóng ${pool.size} browser(s)...`);
try { await pool.close(); } catch {}
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ── Start ──────────────────────────────────────────────────────────────────
const PORT = process.env.PORT || 3456;
app.listen(PORT, async () => {
console.log(`\n[server] NotebookLM API : http://localhost:${PORT}`);
console.log(`[server] UI : http://localhost:${PORT}`);
console.log(`[server] Swagger UI : http://localhost:${PORT}/docs`);
console.log(`[server] Browser pool : ${pool.size} slot(s)`);
console.log(`[server] Chrome profile : ${path.join(__dirname, '..', 'chrome-profile')}\n`);
// Khởi tạo browser pool
try {
await pool.init();
const authed = await pool.isAuthenticated();
if (authed) {
console.log('[server] ✅ Đã đăng nhập sẵn — sẵn sàng dùng API');
} else {
console.log('[server] ⚠️ Chưa đăng nhập. Gọi POST /api/auth/login để mở trang đăng nhập Google');
console.log('[server] Hoặc dùng: curl -X POST http://localhost:' + PORT + '/api/auth/login');
}
} catch (err) {
console.error('[server] ❌ Không khởi tạo được browser pool:', err.message);
}
// Khởi động cron scheduler
require('./cron-runner').init().catch(console.error);
// Keepalive: navigate đến NotebookLM định kỳ để giữ session Google không bị logout
const KEEPALIVE_MS = parseInt(process.env.KEEPALIVE_INTERVAL_HOURS || '4', 10) * 60 * 60 * 1000;
setInterval(async () => {
try {
const authed = await pool.isAuthenticated();
if (authed) {
console.log('[keepalive] Session còn hiệu lực ✅');
} else {
console.warn('[keepalive] ⚠️ Session hết hạn — gọi POST /api/auth/login để đăng nhập lại');
}
} catch (err) {
console.warn('[keepalive] ⚠️ Lỗi kiểm tra session:', err.message);
}
}, KEEPALIVE_MS);
console.log(`[server] Keepalive mỗi ${process.env.KEEPALIVE_INTERVAL_HOURS || 4}h để giữ session`);
});