'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`); });