737 lines
32 KiB
JavaScript
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`);
|
|
});
|
|
|