1
0
Fork 0

first commit

This commit is contained in:
Joseph 2026-06-17 07:58:30 +07:00
commit 26f894c7bc
26 changed files with 9245 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,225 @@
---
name: notebooklm-api
description: Expert assistant for the notebooklm-api project — a Puppeteer-based REST/WebSocket server that automates Google NotebookLM. Use when working on browser automation, Puppeteer selectors, API routes, queue logic, or debugging DOM interactions with NotebookLM.
triggers:
- "notebooklm"
- "puppeteer"
- "selector"
- "notebook"
- "add source"
- "chat stream"
- "browser automation"
---
# NotebookLM API — Project Skill
## What this project is
A Node.js server (`src/server.js`) that drives **Google NotebookLM** (`https://notebooklm.google.com`) through a real Chrome window via Puppeteer with the Stealth plugin. It exposes a REST + WebSocket API so external systems (n8n, Python scripts, ERP, chatbots) can interact with NotebookLM programmatically.
**Key constraint:** NotebookLM has no official API. Every operation — listing notebooks, adding sources, chatting — is done by simulating real user clicks and keystrokes in the browser. The Google Angular UI changes frequently, so DOM selectors may need updating after UI changes.
## Architecture
```
src/
server.js — Express app, middleware, debug routes, graceful shutdown
browser.js — BrowserManager singleton (puppeteer-extra + stealth)
nlm.js — All NotebookLM automation logic (the core)
queue.js — AsyncQueue: serialises ALL Puppeteer ops (no concurrency)
selectors.js — CSS selectors confirmed from real DOM inspection
swagger.js — Swagger/OpenAPI spec
routes/
auth.js — GET /api/auth/status, POST /api/auth/login
notebooks.js — CRUD for notebooks + sources
chat-ws.js — WebSocket streaming chat
```
## The queue — critical invariant
`src/queue.js` is a single-lane async queue. **Every route wraps its nlm call in `queue.add(...)`**. This prevents Puppeteer race conditions — only one browser action runs at a time. Never bypass the queue. If a new route is added, it MUST use the queue.
## Selectors — how to update them
All selectors live in `src/selectors.js`. They were confirmed against the Vietnamese-language NotebookLM UI. When Google updates the UI and a selector breaks:
1. Use the debug endpoints in `server.js` to inspect the live DOM:
- `GET /debug/home` — lists all visible buttons on the home page (find create/delete buttons)
- `GET /debug/notebook-menu/:id` — hovers a card, clicks the "..." menu, returns menu items
- `GET /debug/chat/:id` — lists all textarea/input elements on a notebook page
- `GET /debug/sources/:id` — inspects the source panel DOM
- `GET /debug/source-items/:id` — full HTML of source items (buttons, icons, spans)
- `GET /debug/add-source-dialog/:id` — clicks "Add source" and snapshots the dialog
- `GET /debug/add-url-flow/:id` — step-by-step trace of the full add-URL flow
- `GET /debug/page-state/:id` — full overlay/dialog state before+after click
- `GET /debug/screenshot` — base64 PNG of current Chrome window
2. Key known selectors (confirmed 2026-06-16):
- Create notebook button: `button[aria-label="Tạo sổ ghi chú mới"], button.create-new-button`
- Card menu button: `button[aria-label="Trình đơn thao tác trong dự án"]`
- Delete menu item: find by text `/Xo[áa]/i` inside `.mat-mdc-menu-panel button.mat-mdc-menu-item`
- Add source button: `.add-source-button`
- Source items: `div.single-source-container` (NOT `.source-item-menu-button-visible` which is a child)
- Source title: `button.source-stretched-button[aria-label]` inside each container
- Source type icon: `mat-icon.source-item-source-icon` text; or "url" if `img.favicon-icon` present
- Chat textarea: `textarea.query-box-input` (NOT the source search textarea)
- AI response cards: `mat-card.to-user-message-card-content`
- User message cards: `mat-card.from-user-message-card-content`
- Thinking animation: `div.thinking-message, thinking-animation`
- Dialogs: `mat-dialog-container` (multiple can exist — emoji keyboard occupies one)
3. The UI language is **Vietnamese** — button labels like "Trang web", "Văn bản đã sao chép", "Chèn", "Tạo sổ ghi chú mới".
## Dialog handling — the tricky part
The Add Source dialog now has a **2-stage flow**:
**Stage 1 (initial):** Shows source-type buttons + a search textarea (`placeholder="Tìm nguồn mới trên web"`, class `query-box-textarea`). This textarea is always present and must be EXCLUDED from URL/text input detection.
**Stage 2 (after clicking type button):** Dialog transitions in-place to show:
- URL mode: a TEXTAREA with `aria-label="Nhập URL"` and `placeholder="Dán liên kết bất kỳ"`
- Text mode: a large textarea without the `query-box-textarea` class
**Submit button:** `"Chèn"` button with `type="button"` (NOT `type="submit"` — that's the back/close buttons).
Pattern in `nlm.addSourceUrl()` — wait for dialog to TRANSITION before finding URL input:
```javascript
// Click "Trang web" button
await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...d.querySelectorAll('button')].find(b => /Trang web/i.test(b.textContent));
btn?.click();
});
// Wait for URL input to appear (not the search textarea)
await page.waitForFunction(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
return [...d.querySelectorAll('input, textarea')].some(el =>
el.offsetParent !== null &&
(el.getAttribute('aria-label')?.toLowerCase().includes('url') ||
/liên kết|link|paste|http/i.test(el.placeholder))
);
}, { timeout: 10_000, polling: 200 });
// Click submit — use "Chèn" text, NOT type="submit"
await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...d.querySelectorAll('button')].find(b =>
b.offsetParent !== null && !b.disabled && /Ch[eè]n/i.test(b.textContent)
);
btn?.click();
});
```
## Chat streaming (WebSocket)
`src/routes/chat-ws.js` handles `WS /api/notebooks/:id/chat/stream`.
The streaming works by **polling the DOM** every 300ms during the AI response, diffing against the last known text, and emitting `{ type: "chunk", data: "..." }` messages. This is not true server-sent streaming — it's DOM polling.
Message types the server emits:
- `{ type: "connected", notebookId }` — on WS open
- `{ type: "chunk", data: string }` — incremental text
- `{ type: "done", data: { answer } }` — response complete
- `{ type: "error", data: string }` — on failure
## waitForAiResponse — response completion detection
`nlm.waitForAiResponse(page, prevCount, timeout)` in `src/nlm.js` uses a 3-step approach:
1. Wait for `div.thinking-message` / `thinking-animation` to disappear
2. Wait for a new `mat-card.to-user-message-card-content` to appear (count > prevCount)
3. Wait for the text to be **stable for 1.5s** (no changes in 4 consecutive 400ms polls)
This handles streaming responses that may still be updating after the spinner disappears.
## Browser session persistence
Chrome profile is stored at `./chrome-profile/`. Google login cookies persist between server restarts. On startup, `browser.isAuthenticated()` navigates to `https://notebooklm.google.com` and checks if the URL stays there (not redirected to `accounts.google.com`).
**Important:** Always stop the server with `Ctrl+C` (SIGTERM/SIGINT) — the graceful shutdown handler calls `browser.close()` to flush Chrome's cookie store. Using `kill -9` may corrupt the session.
## File upload (`addSourceFile`) — xap uploader quirks
NotebookLM's "Tải tệp lên" button uses Google's internal **xap scotty uploader** (`[xapscottyuploadertrigger]` attribute). Key findings from 2026-06-16 debugging:
1. **Trigger button is in the INITIAL dialog** — drop zone + "Tải tệp lên" button appear as soon as the add-source dialog opens. No need to click a secondary button to reveal the drop zone.
2. **Must use trusted (CDP) click**`trigger.click()` inside `page.evaluate()` generates `isTrusted: false`. The xap uploader silently ignores untrusted clicks. Use `page.mouse.click(x, y)` (Puppeteer's CDP method) to generate trusted clicks.
3. **Two upload paths** — depending on browser context, xap uploader may use either:
- **Native file chooser** (`input[type=file]`) → caught by `page.waitForFileChooser()` + `fileChooser.accept([path])`
- **`showOpenFilePicker()`** → override it BEFORE clicking: `window.showOpenFilePicker = async () => [{ getFile: () => file, ... }]`
4. **No "Chèn" button** — after file upload, dialog auto-closes. No confirmation click needed.
5. **Wait for network idle** after upload (the scotty upload finishes asynchronously).
```javascript
// Pattern (from nlm.addSourceFile):
const chooser = page.waitForFileChooser({ timeout: 8_000 }).catch(() => null);
await page.mouse.click(triggerX, triggerY); // trusted click
const fc = await chooser;
if (fc) await fc.accept([absPath]); // native file chooser path
// else showOpenFilePicker override handles it
await page.waitForNetworkIdle({ timeout: 60_000 }); // wait for scotty upload
```
### Debugging "add source button not found" error
The add-source button (`button[aria-label="Thêm nguồn"]`) only exists on notebooks the USER OWNS. Public/shared notebooks (shown with "Công khai" badge) don't have this button. Always test with a user-owned notebook.
## Common tasks
### Adding a new API endpoint
1. Create or edit a route file in `src/routes/`
2. Add automation logic to `src/nlm.js`
3. Register the route in `src/server.js` with `app.use()`
4. Wrap the nlm call in `queue.add(() => nlm.myFn(), 'label')`
5. Add to the Swagger spec in `src/swagger.js`
6. Update `API.md`
### Fixing a broken selector
1. Start the server: `npm start`
2. Hit the relevant `/debug/*` endpoint to see the live DOM (`/debug/home`, `/debug/source-items/:id`, `/debug/add-url-flow/:id`, etc.)
3. Update `src/selectors.js` with the corrected selector
4. If logic (not just selector) is wrong (e.g. timing, wrong element), update `src/nlm.js`
5. Test via the actual API endpoint
### Debugging "nút xác nhận không tìm thấy" in add source flow
- The Add Source dialog has a 2-stage flow. If you get "nút xác nhận", it likely means:
1. The URL was typed into the WRONG textarea (the search box `query-box-textarea`)
2. The dialog never transitioned to stage 2, so "Chèn" button never appeared
- Use `GET /debug/add-url-flow/:id` to trace each step and see what happened
- Key filter: exclude `el.placeholder?.includes('Tìm nguồn')` and `el.className?.includes('query-box-textarea')`
### Debugging a "dialog not found" error
- Use `GET /debug/notebook-menu/:id` to see what menu appears after hover+click
- Use `GET /debug/add-source-dialog/:id` to see the full overlay HTML after clicking add source
### Understanding queue state
`GET /health` returns `{ queue: { busy: boolean, pending: number } }`. If `busy` is always true, a previous operation is stuck (possibly waiting for a DOM element that never appeared). Restart the server.
## Environment variables
| Variable | Default | Notes |
|---|---|---|
| `PORT` | `3456` | HTTP/WS port |
| `HEADLESS` | `false` | Set `true` for CI (may break Google login) |
| `CHROME_PATH` | auto-detect | Prefers system Chrome over bundled Chromium |
| `API_KEY` | _(empty)_ | If set, all requests need `x-api-key` header |
## Running locally
```bash
npm install # first time only
npm start # starts at http://localhost:3456
npm run dev # same but with --watch (auto-restarts on file change)
```
Swagger UI: `http://localhost:3456/docs`
## Files NOT to modify carelessly
- `chrome-profile/` — Google session data. Do not delete. Do not gitignore the entire directory (the profile must persist).
- `src/queue.js` — Changing the queue to allow concurrency will cause race conditions in Puppeteer.
- `src/nlm.js:waitForAiResponse` — The stability detection logic is tuned for NotebookLM's streaming behaviour; don't simplify it.

4
.env Normal file
View File

@ -0,0 +1,4 @@
PORT=3456
HEADLESS=false
# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
API_KEY=

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
PORT=3456
HEADLESS=false
# Đường dẫn Chrome tuỳ chỉnh (để trống để dùng Chrome do Puppeteer tải)
# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
# API key bảo vệ endpoint (tuỳ chọn)
API_KEY=

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
chrome-profile*
data/*

460
API.md Normal file
View File

@ -0,0 +1,460 @@
# NotebookLM API — Tài liệu đầy đủ
Server Node.js điều khiển Google NotebookLM qua Puppeteer, giả lập thao tác người dùng thật.
Base URL: `http://localhost:3456`
---
## Mục lục
1. [Cài đặt & Khởi động](#cài-đặt--khởi-động)
2. [Biến môi trường](#biến-môi-trường)
3. [Xác thực](#xác-thực)
4. [Notebooks](#notebooks)
5. [Sources](#sources)
6. [Chat](#chat)
7. [Trạng thái server](#trạng-thái-server)
8. [Mã lỗi](#mã-lỗi)
9. [Tích hợp](#tích-hợp)
---
## Cài đặt & Khởi động
**Yêu cầu:** Node.js 18+, Google Chrome
```bash
bash setup.sh # cài đặt + khởi động
# Hoặc thủ công:
npm install
cp .env.example .env
npm start
```
- Server khởi động tại `http://localhost:3456`
- Swagger UI tại `http://localhost:3456/docs`
- Chrome tự mở với profile riêng tại `./chrome-profile/`
**Dừng server:** `Ctrl+C` — KHÔNG dùng `kill -9` (sẽ mất cookies Google)
---
## Biến môi trường
File `.env` (copy từ `.env.example`):
| Biến | Mặc định | Mô tả |
|------|----------|-------|
| `PORT` | `3456` | Cổng HTTP/WebSocket |
| `HEADLESS` | `false` | `true` = ẩn cửa sổ Chrome |
| `CHROME_PATH` | tự phát hiện | Đường dẫn Chrome tuỳ chỉnh |
| `API_KEY` | _(trống)_ | Nếu đặt, mọi request phải có header `x-api-key: <key>` |
---
## Xác thực
### GET /api/auth/status
Kiểm tra trạng thái đăng nhập Google.
```bash
curl http://localhost:3456/api/auth/status
```
```json
{ "ok": true, "authenticated": true }
{ "ok": true, "authenticated": false }
```
---
### POST /api/auth/login
Mở browser để đăng nhập Google. Chờ tối đa 5 phút.
```bash
curl -X POST http://localhost:3456/api/auth/login
```
```json
{ "ok": true, "authenticated": true, "message": "Đăng nhập thành công, session đã lưu" }
```
Session lưu vào `./chrome-profile/` — các lần khởi động lại server không cần đăng nhập lại.
---
## Notebooks
### GET /api/notebooks
Lấy danh sách tất cả notebooks.
```bash
curl http://localhost:3456/api/notebooks
```
```json
{
"ok": true,
"total": 3,
"notebooks": [
{
"id": "2c4f0f26-0797-4cc0-a350-48c4b70d14cc",
"title": "Dự án Q3 2026",
"url": "https://notebooklm.google.com/notebook/2c4f0f26-..."
}
]
}
```
---
### POST /api/notebooks
Tạo notebook mới.
**Body:**
```json
{ "title": "Tên notebook" }
```
```bash
curl -X POST http://localhost:3456/api/notebooks \
-H "Content-Type: application/json" \
-d '{"title": "Dự án Q3 2026"}'
```
```json
{
"ok": true,
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"title": "Dự án Q3 2026",
"url": "https://notebooklm.google.com/notebook/..."
}
```
---
### DELETE /api/notebooks/:id
Xoá notebook.
```bash
curl -X DELETE http://localhost:3456/api/notebooks/2c4f0f26-0797-4cc0-a350-48c4b70d14cc
```
```json
{ "ok": true, "deleted": true, "id": "2c4f0f26-..." }
```
---
## Sources
### GET /api/notebooks/:id/sources
Lấy danh sách sources trong notebook. Tự chờ đến khi tất cả sources convert xong.
**Query params:**
| Param | Mặc định | Mô tả |
|-------|----------|-------|
| `timeout` | `300` | Giây tối đa chờ converting xong (max 600) |
```bash
# Mặc định chờ 5 phút
curl "http://localhost:3456/api/notebooks/<id>/sources" --max-time 310
# Notebook nặng — chờ tối đa 10 phút
curl "http://localhost:3456/api/notebooks/<id>/sources?timeout=600" --max-time 610
```
```json
{
"ok": true,
"total": 3,
"stillLoading": 0,
"sources": [
{ "index": 0, "title": "Báo cáo Q2.pdf", "type": "pdf", "loading": false },
{ "index": 1, "title": "https://example.com/article", "type": "url", "loading": false },
{ "index": 2, "title": "Ghi chú cuộc họp", "type": "text", "loading": false }
]
}
```
`stillLoading > 0` — gọi lại sau vài phút để lấy kết quả đầy đủ.
---
### POST /api/notebooks/:id/sources
Thêm source vào notebook. Hỗ trợ 3 loại:
#### Loại 1: URL website
```json
{ "type": "url", "content": "https://...", "title": "Tuỳ chọn" }
```
```bash
curl -X POST http://localhost:3456/api/notebooks/<id>/sources \
-H "Content-Type: application/json" \
-d '{"type":"url","content":"https://vnexpress.net/bai-viet-abc","title":"VnExpress"}'
```
```json
{ "ok": true, "added": true, "type": "url", "url": "https://..." }
```
---
#### Loại 2: Văn bản paste
```json
{ "type": "text", "content": "Nội dung văn bản...", "title": "Tên tài liệu" }
```
```bash
curl -X POST http://localhost:3456/api/notebooks/<id>/sources \
-H "Content-Type: application/json" \
-d '{"type":"text","content":"Báo cáo doanh thu tháng 6: ...","title":"Báo cáo T6/2026"}'
```
```json
{ "ok": true, "added": true, "type": "text", "length": 1234 }
```
---
#### Loại 3: File local
Upload file từ máy đang chạy server. Đường dẫn phải là **đường dẫn tuyệt đối** (absolute path). Hỗ trợ cả URL-encoded path (có `%20` thay dấu cách).
```json
{ "type": "file", "content": "/đường/dẫn/tuyệt/đối/file.pdf" }
```
**Định dạng hỗ trợ:** `.pdf` `.txt` `.md` `.docx` `.doc` `.pptx` `.ppt` `.xlsx` `.xls` `.mp3` `.mp4` `.jpg` `.jpeg` `.png`
```bash
# File bình thường
curl -X POST http://localhost:3456/api/notebooks/<id>/sources \
-H "Content-Type: application/json" \
-d '{"type":"file","content":"/Users/me/Documents/report.pdf"}' \
--max-time 90
# Đường dẫn có dấu cách (cả 2 cách đều dùng được)
-d '{"type":"file","content":"/Users/me/Downloads/Telegram Desktop/file.md"}'
-d '{"type":"file","content":"/Users/me/Downloads/Telegram%20Desktop/file.md"}'
```
```json
{
"ok": true,
"added": true,
"type": "file",
"filename": "report.pdf",
"path": "/Users/me/Documents/report.pdf"
}
```
> **Lưu ý:** Upload có thể mất 3060 giây. Đặt `--max-time 90` khi dùng curl. Notebook phải do bạn sở hữu (không phải notebook public/shared).
---
## Chat
### POST /api/notebooks/:id/chat
Gửi câu hỏi và chờ câu trả lời đầy đủ (đồng bộ).
**Body:**
```json
{ "message": "Câu hỏi của bạn" }
```
```bash
curl -X POST http://localhost:3456/api/notebooks/<id>/chat \
-H "Content-Type: application/json" \
-d '{"message":"Tóm tắt các điểm chính trong tài liệu này"}' \
--max-time 120
```
```json
{
"ok": true,
"question": "Tóm tắt các điểm chính trong tài liệu này",
"answer": "Dựa trên các tài liệu, các điểm chính bao gồm: ..."
}
```
Timeout mặc định 90 giây. Câu hỏi phức tạp với nhiều sources có thể cần lâu hơn.
---
### GET /api/notebooks/:id/chat/history
Lấy toàn bộ lịch sử hội thoại trong notebook.
```bash
curl http://localhost:3456/api/notebooks/<id>/chat/history
```
```json
{
"ok": true,
"total": 4,
"history": [
{ "role": "user", "content": "Tóm tắt tài liệu" },
{ "role": "assistant", "content": "Tài liệu đề cập đến..." }
]
}
```
---
### WS /api/notebooks/:id/chat/stream
Chat streaming qua WebSocket. Nhận câu trả lời theo từng chunk realtime.
**Kết nối:** `ws://localhost:3456/api/notebooks/<id>/chat/stream`
**Gửi:** `{ "message": "câu hỏi" }`
**Nhận:**
```json
{ "type": "connected", "notebookId": "<id>" }
{ "type": "chunk", "data": "Dựa trên" }
{ "type": "chunk", "data": " các tài liệu..." }
{ "type": "done", "data": { "answer": "Dựa trên các tài liệu..." } }
{ "type": "error", "data": "Thông báo lỗi" }
```
**Ví dụ JavaScript:**
```javascript
const ws = new WebSocket('ws://localhost:3456/api/notebooks/<id>/chat/stream');
ws.onopen = () => ws.send(JSON.stringify({ message: 'Tóm tắt nội dung chính' }));
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'chunk') process.stdout.write(msg.data);
if (msg.type === 'done') console.log('\n[Xong]');
if (msg.type === 'error') console.error('[Lỗi]', msg.data);
};
```
**Test nhanh bằng wscat:**
```bash
npx wscat -c "ws://localhost:3456/api/notebooks/<id>/chat/stream"
# Sau khi kết nối:
{"message":"Tóm tắt tài liệu này"}
```
---
## Trạng thái server
### GET /health
```bash
curl http://localhost:3456/health
```
```json
{
"ok": true,
"status": "running",
"queue": { "busy": false, "pending": 0 }
}
```
`queue.busy = true` — đang có thao tác browser chạy; các request khác xếp hàng chờ tự động.
---
## Mã lỗi
| HTTP | `error` | Ý nghĩa |
|------|---------|---------|
| 400 | `Thiếu type hoặc content` | Body thiếu field bắt buộc |
| 401 | `NOT_AUTHENTICATED` | Chưa đăng nhập → gọi `POST /api/auth/login` |
| 401 | `Thiếu hoặc sai API key` | Header `x-api-key` sai hoặc thiếu |
| 404 | `NOTEBOOK_NOT_FOUND` | Notebook ID không tồn tại hoặc không có quyền |
| 500 | _(message)_ | Lỗi nội bộ — xem log server |
---
## Tích hợp
### n8n / Make / Zapier
- Node: **HTTP Request**
- URL: `http://<ip-máy>:3456/api/notebooks/<id>/chat`
- Method: `POST`
- Body (JSON): `{"message": "{{$json.input}}"}`
### Python
```python
import requests
BASE = "http://localhost:3456"
NB_ID = "<notebook-id>"
# Thêm file
requests.post(f"{BASE}/api/notebooks/{NB_ID}/sources",
json={"type": "file", "content": "/path/to/file.pdf"},
timeout=90)
# Thêm URL
requests.post(f"{BASE}/api/notebooks/{NB_ID}/sources",
json={"type": "url", "content": "https://example.com"},
timeout=30)
# Hỏi
resp = requests.post(f"{BASE}/api/notebooks/{NB_ID}/chat",
json={"message": "Tóm tắt tài liệu"}, timeout=120)
print(resp.json()["answer"])
# Danh sách notebooks
notebooks = requests.get(f"{BASE}/api/notebooks").json()["notebooks"]
```
### Node.js / fetch
```javascript
const BASE = 'http://localhost:3456';
const NB_ID = '<notebook-id>';
// Thêm file
await fetch(`${BASE}/api/notebooks/${NB_ID}/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'file', content: '/path/to/file.pdf' }),
signal: AbortSignal.timeout(90_000),
});
// Hỏi
const res = await fetch(`${BASE}/api/notebooks/${NB_ID}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Tóm tắt nội dung' }),
signal: AbortSignal.timeout(120_000),
});
const { answer } = await res.json();
```
---
## Lưu ý quan trọng
- **Chỉ 1 thao tác browser tại một thời điểm** — các request song song xếp hàng tự động
- **Upload file** chỉ hoạt động với notebook do bạn sở hữu (không phải public/shared)
- **Dừng server đúng cách**: `Ctrl+C` — KHÔNG dùng `kill -9`
- **Chrome crash**: xoá `chrome-profile/SingletonLock` rồi restart server
- **Google giới hạn automation**: nếu bị chặn, đợi vài phút

232
README.md Normal file
View File

@ -0,0 +1,232 @@
# notebooklm-api
REST + WebSocket API server điều khiển **Google NotebookLM** qua Puppeteer — giả lập thao tác người dùng thật trong Chrome.
Dùng để tích hợp NotebookLM vào chatbot, n8n, Make, Python scripts, ERP, hoặc bất kỳ hệ thống nào cần xử lý tài liệu bằng AI.
---
## Tính năng
- **Xác thực** — Mở Google login một lần, session lưu vĩnh viễn
- **Notebooks** — Tạo, liệt kê, xoá notebooks
- **Sources** — Thêm nguồn từ URL website, file local (.pdf, .docx, .txt, ...) hoặc văn bản paste
- **Chat đồng bộ** — Gửi câu hỏi, nhận câu trả lời đầy đủ (`POST`)
- **Chat streaming** — Nhận câu trả lời theo từng chunk realtime (`WebSocket`)
- **Queue tự động** — Các request song song được xếp hàng, không race condition
- **Swagger UI** — Tài liệu API tương tác tại `/docs`
---
## Cài đặt nhanh
```bash
git clone <repo>
cd notebooklm-api
bash setup.sh
```
Hoặc thủ công:
```bash
npm install
cp .env.example .env # tuỳ chỉnh nếu cần
npm start
```
Server khởi động tại **http://localhost:3456**
Swagger UI tại **http://localhost:3456/docs**
---
## Đăng nhập lần đầu
```bash
# Mở browser để đăng nhập Google
curl -X POST http://localhost:3456/api/auth/login
```
Chrome sẽ mở → đăng nhập Google → API tự trả về khi xong. Session lưu trong `./chrome-profile/` — các lần sau tự động đăng nhập.
**Dừng server:** luôn dùng `Ctrl+C` (không dùng `kill -9` để tránh mất cookies).
---
## Biến môi trường
| Biến | Mặc định | Mô tả |
|------|----------|-------|
| `PORT` | `3456` | Cổng HTTP/WebSocket |
| `HEADLESS` | `false` | `true` = ẩn cửa sổ Chrome |
| `CHROME_PATH` | tự phát hiện | Đường dẫn Chrome tuỳ chỉnh |
| `API_KEY` | _(trống)_ | Nếu đặt, request phải có header `x-api-key` |
---
## API nhanh
### Auth
```
GET /api/auth/status Kiểm tra đăng nhập
POST /api/auth/login Mở browser đăng nhập Google
```
### Notebooks
```
GET /api/notebooks Danh sách notebooks
POST /api/notebooks Tạo notebook { "title": "..." }
DELETE /api/notebooks/:id Xoá notebook
```
### Sources
```
GET /api/notebooks/:id/sources Danh sách sources
POST /api/notebooks/:id/sources Thêm source
```
Body thêm source:
```json
{ "type": "url", "content": "https://...", "title": "Tuỳ chọn" }
{ "type": "text", "content": "Nội dung...", "title": "Tên tài liệu" }
{ "type": "file", "content": "/đường/dẫn/tuyệt/đối/file.pdf" }
```
Định dạng file hỗ trợ: `.pdf` `.txt` `.md` `.docx` `.doc` `.pptx` `.ppt` `.xlsx` `.xls` `.mp3` `.mp4` `.jpg` `.png`
### Chat
```
POST /api/notebooks/:id/chat Hỏi đồng bộ { "message": "..." }
GET /api/notebooks/:id/chat/history Lịch sử hội thoại
WS /api/notebooks/:id/chat/stream Chat streaming (WebSocket)
```
### Tiện ích
```
GET /health Trạng thái server + queue
GET /docs Swagger UI
```
---
## Ví dụ sử dụng
### curl
```bash
NB=<notebook-id>
# Tạo notebook
curl -X POST http://localhost:3456/api/notebooks \
-H "Content-Type: application/json" \
-d '{"title": "Dự án Q3"}'
# Thêm URL
curl -X POST http://localhost:3456/api/notebooks/$NB/sources \
-H "Content-Type: application/json" \
-d '{"type":"url","content":"https://example.com/bai-viet"}'
# Thêm file local
curl -X POST http://localhost:3456/api/notebooks/$NB/sources \
-H "Content-Type: application/json" \
-d '{"type":"file","content":"/Users/me/Documents/report.pdf"}' \
--max-time 90
# Hỏi
curl -X POST http://localhost:3456/api/notebooks/$NB/chat \
-H "Content-Type: application/json" \
-d '{"message":"Tóm tắt nội dung chính"}' --max-time 120
```
### Python
```python
import requests
BASE = "http://localhost:3456"
NB_ID = "<notebook-id>"
# Thêm file
requests.post(f"{BASE}/api/notebooks/{NB_ID}/sources",
json={"type": "file", "content": "/path/to/file.pdf"},
timeout=90)
# Hỏi
answer = requests.post(f"{BASE}/api/notebooks/{NB_ID}/chat",
json={"message": "Tóm tắt tài liệu"},
timeout=120).json()["answer"]
print(answer)
```
### WebSocket streaming
```javascript
const ws = new WebSocket('ws://localhost:3456/api/notebooks/<NB_ID>/chat/stream');
ws.onopen = () => ws.send(JSON.stringify({ message: 'Tóm tắt nội dung' }));
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'chunk') process.stdout.write(msg.data);
if (msg.type === 'done') console.log('\n[Xong]');
if (msg.type === 'error') console.error('[Lỗi]', msg.data);
};
```
---
## Cấu trúc dự án
```
notebooklm-api/
├── src/
│ ├── server.js Express app + debug routes
│ ├── browser.js Puppeteer BrowserManager (singleton)
│ ├── nlm.js Logic automation NotebookLM (core)
│ ├── queue.js AsyncQueue — tuần tự hoá thao tác browser
│ ├── selectors.js CSS selectors đã xác nhận trên DOM thực
│ ├── swagger.js OpenAPI spec
│ └── routes/
│ ├── auth.js /api/auth/*
│ ├── notebooks.js /api/notebooks/*
│ └── chat-ws.js WebSocket streaming
├── chrome-profile/ Session Chrome (KHÔNG xoá)
├── .env Biến môi trường
├── setup.sh Script cài đặt nhanh
└── API.md Tài liệu API đầy đủ
```
---
## Debug endpoints
Dùng khi selector bị hỏng sau khi Google cập nhật UI:
```
GET /debug/screenshot Chụp ảnh Chrome hiện tại (base64 PNG)
GET /debug/home Liệt kê buttons trên trang chủ
GET /debug/notebook-btns/:id Liệt kê buttons trên trang notebook
GET /debug/notebook-menu/:id Hover card + click "..." menu, trả về menu items
GET /debug/sources/:id Inspect DOM source panel
GET /debug/source-items/:id HTML đầy đủ của từng source item
GET /debug/add-source-dialog/:id Click "Thêm nguồn", snapshot dialog
GET /debug/add-url-flow/:id Trace từng bước flow thêm URL
GET /debug/add-file-flow/:id Trace từng bước flow upload file
GET /debug/chat/:id Liệt kê textarea/input trên trang notebook
GET /debug/page-state/:id Trạng thái overlay trước/sau click
```
---
## Lưu ý
- **1 thao tác tại 1 thời điểm** — queue tự xếp hàng, không cần lo concurrency
- **Upload file** có thể mất 3060 giây — đặt `--max-time 90` khi dùng curl
- **Chat timeout** mặc định 90 giây — câu hỏi phức tạp có thể cần tăng lên
- **Chrome crash?** Xoá `chrome-profile/SingletonLock` rồi restart
- **Google có thể giới hạn** automation — nếu bị chặn, đợi vài phút rồi thử lại
---
## Scripts
```bash
npm start # Production
npm run dev # Development (auto-restart khi thay đổi file)
```

1072
docs/huong-dan-su-dung.html Normal file

File diff suppressed because it is too large Load Diff

3195
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "notebooklm-api",
"version": "1.0.0",
"description": "REST API server simulating NotebookLM user interactions via Puppeteer",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.18.2",
"express-ws": "^5.0.2",
"node-cron": "^4.2.1",
"puppeteer": "^22.6.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sqlite3": "^6.0.1",
"swagger-ui-express": "^5.0.1"
}
}

1216
public/index.html Normal file

File diff suppressed because it is too large Load Diff

112
setup.sh Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -e
# ── Màu terminal ──────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
ok() { echo -e "${GREEN}${NC} $1"; }
warn() { echo -e "${YELLOW}!${NC} $1"; }
fail() { echo -e "${RED}${NC} $1"; exit 1; }
hdr() { echo -e "\n${GREEN}── $1 ──${NC}"; }
echo ""
echo " NotebookLM API — Setup"
echo " ======================"
# ── 1. Node.js ────────────────────────────────────────────────────────────────
hdr "Kiểm tra Node.js"
if ! command -v node &>/dev/null; then
fail "Node.js chưa được cài. Tải tại https://nodejs.org (cần v18+)"
fi
NODE_VER=$(node -e "process.exit(parseInt(process.versions.node))")
# node -e exit trick: dùng node để lấy version number
NODE_MAJOR=$(node -e "console.log(parseInt(process.versions.node))")
if [ "$NODE_MAJOR" -lt 18 ]; then
fail "Node.js $NODE_MAJOR quá cũ. Cần v18 trở lên."
fi
ok "Node.js $(node -v)"
# ── 2. Chrome ────────────────────────────────────────────────────────────────
hdr "Kiểm tra Google Chrome"
CHROME_FOUND=false
CHROME_PATHS=(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
"/Applications/Chromium.app/Contents/MacOS/Chromium"
"/usr/bin/google-chrome"
"/usr/bin/google-chrome-stable"
"/usr/bin/chromium-browser"
"/usr/bin/chromium"
)
for p in "${CHROME_PATHS[@]}"; do
if [ -f "$p" ]; then
ok "Chrome: $p"
CHROME_FOUND=true
break
fi
done
if [ "$CHROME_FOUND" = false ]; then
warn "Không tìm thấy Chrome — Puppeteer sẽ dùng Chromium tích hợp"
warn "Khuyến nghị cài Google Chrome: https://www.google.com/chrome"
fi
# ── 3. npm install ────────────────────────────────────────────────────────────
hdr "Cài đặt dependencies"
if [ -d "node_modules" ]; then
ok "node_modules đã tồn tại — bỏ qua npm install"
warn "Chạy 'npm install' thủ công nếu cần cập nhật packages"
else
npm install
ok "npm install xong"
fi
# ── 4. .env ───────────────────────────────────────────────────────────────────
hdr "Cấu hình môi trường"
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
cp .env.example .env
ok ".env tạo từ .env.example"
else
cat > .env <<'EOF'
PORT=3456
HEADLESS=false
# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
API_KEY=
EOF
ok ".env tạo mới với cấu hình mặc định"
fi
else
ok ".env đã tồn tại"
fi
# ── 5. chrome-profile ─────────────────────────────────────────────────────────
if [ ! -d "chrome-profile" ]; then
mkdir -p chrome-profile
ok "Thư mục chrome-profile/ tạo mới"
fi
# ── 6. Kết quả ────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}Setup hoàn tất!${NC}"
echo ""
echo " Khởi động server:"
echo " npm start"
echo ""
echo " Sau khi server chạy, đăng nhập Google:"
echo " curl -X POST http://localhost:3456/api/auth/login"
echo ""
echo " Swagger UI: http://localhost:3456/docs"
echo " Tài liệu: API.md"
echo ""
# ── 7. Hỏi có muốn start ngay không ─────────────────────────────────────────
read -r -p "Khởi động server ngay bây giờ? [y/N] " REPLY
if [[ "$REPLY" =~ ^[Yy]$ ]]; then
echo ""
npm start
fi

118
src/browser.js Normal file
View File

@ -0,0 +1,118 @@
'use strict';
const puppeteerExtra = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const path = require('path');
const fs = require('fs');
const { exec } = require('child_process');
puppeteerExtra.use(StealthPlugin());
const NLM_URL = 'https://notebooklm.google.com';
const DEFAULT_PROFILE = path.join(__dirname, '..', 'chrome-profile');
// Ưu tiên Chrome hệ thống (có đầy đủ trust hơn Chromium bundled)
const SYSTEM_CHROME_PATHS = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
];
function findSystemChrome() {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
return SYSTEM_CHROME_PATHS.find(p => fs.existsSync(p)) || null;
}
class BrowserManager {
constructor(profileDir = DEFAULT_PROFILE, slotIndex = 0) {
this.browser = null;
this.page = null;
this.profileDir = profileDir;
this.slotIndex = slotIndex;
this._tag = slotIndex > 0 ? `browser-${slotIndex}` : 'browser';
}
async launch() {
if (this.browser?.isConnected()) return;
const chromePath = findSystemChrome();
console.log(`[${this._tag}] Sử dụng: ${chromePath || 'bundled Chromium'}`);
console.log(`[${this._tag}] Profile : ${this.profileDir}`);
const opts = {
headless: process.env.HEADLESS === 'true',
userDataDir: this.profileDir,
defaultViewport: null,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-infobars',
'--start-maximized',
'--no-default-browser-check',
'--no-first-run',
'--disable-session-crashed-bubble',
],
};
if (chromePath) opts.executablePath = chromePath;
this.browser = await puppeteerExtra.launch(opts);
// Đợi browser khởi động ổn định
await new Promise(r => setTimeout(r, 1500));
const existingPages = await this.browser.pages();
if (existingPages.length > 0) {
this.page = existingPages[0];
// Đóng các tab dư (restore session mở nhiều tab)
for (const p of existingPages.slice(1)) await p.close().catch(() => {});
} else {
this.page = await this.browser.newPage();
}
this.browser.on('disconnected', () => {
console.log(`[${this._tag}] Browser đóng`);
this.browser = null;
this.page = null;
});
// macOS: kéo cửa sổ lên trước (chỉ slot 0)
if (process.platform === 'darwin' && this.slotIndex === 0) {
setTimeout(() => {
exec(`osascript -e 'tell application "Google Chrome" to activate'`, () => {});
}, 1000);
}
}
async getPage() {
if (!this.browser?.isConnected()) await this.launch();
return this.page;
}
async goHome() {
const page = await this.getPage();
await page.goto(NLM_URL, { waitUntil: 'networkidle2', timeout: 40_000 });
return !page.url().includes('accounts.google.com');
}
async goNotebook(id) {
const page = await this.getPage();
await page.goto(`${NLM_URL}/notebook/${id}`, { waitUntil: 'networkidle2', timeout: 40_000 });
if (page.url().includes('accounts.google.com')) throw new Error('NOT_AUTHENTICATED');
if (!page.url().includes('/notebook/')) throw new Error('NOTEBOOK_NOT_FOUND');
}
async isAuthenticated() {
try { return await this.goHome(); }
catch { return false; }
}
async close() {
await this.browser?.close();
this.browser = null;
this.page = null;
}
}
module.exports = new BrowserManager();
module.exports.BrowserManager = BrowserManager;

124
src/cron-runner.js Normal file
View File

@ -0,0 +1,124 @@
'use strict';
const nodeCron = require('node-cron');
const db = require('./db');
const tasks = new Map(); // job.id → ScheduledTask
// ── Execute one job ────────────────────────────────────────────────────────
async function runJob(job) {
const pool = require('./pool');
const nlm = require('./nlm');
let answer = null;
let status = 'success';
let error = null;
let webhookStatus = null;
let webhookOk = null;
console.log(`[cron] Running job ${job.id} — "${job.message.slice(0, 60)}"`);
try {
const result = await pool.add(
job.notebook_id,
() => nlm.chat(job.notebook_id, job.message),
`cron.job.${job.id}`,
);
answer = result.answer;
} catch (err) {
status = 'error';
error = err.message;
console.error(`[cron] Job ${job.id} failed:`, err.message);
}
// Send webhook if configured
if (job.webhook_url) {
try {
let extraHeaders = {};
try { extraHeaders = JSON.parse(job.webhook_headers || '{}'); } catch {}
const resp = await fetch(job.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...extraHeaders },
body: JSON.stringify({
job_id: job.id,
notebook_id: job.notebook_id,
notebook_title: job.notebook_title,
label: job.label,
message: job.message,
answer,
status,
ran_at: new Date().toISOString(),
}),
signal: AbortSignal.timeout(15_000),
});
webhookStatus = resp.status;
webhookOk = resp.ok;
if (!resp.ok) console.warn(`[cron] Webhook returned ${resp.status} for job ${job.id}`);
} catch (err) {
console.error(`[cron] Webhook error for job ${job.id}:`, err.message);
webhookOk = false;
}
}
await db.addHistory({
job_id: job.id,
notebook_id: job.notebook_id,
notebook_title: job.notebook_title,
message: job.message,
answer,
status,
error,
webhook_url: job.webhook_url || null,
webhook_status: webhookStatus,
webhook_ok: webhookOk,
});
console.log(`[cron] Job ${job.id} done — status: ${status}`);
return { answer, status };
}
// ── Schedule management ────────────────────────────────────────────────────
function scheduleJob(job) {
// Clear existing task if any
if (tasks.has(job.id)) {
tasks.get(job.id).stop();
tasks.delete(job.id);
}
if (!job.enabled) return;
if (!nodeCron.validate(job.cron_expr)) {
console.warn(`[cron] Invalid expression for job ${job.id}: "${job.cron_expr}"`);
return;
}
const task = nodeCron.schedule(
job.cron_expr,
() => runJob(job).catch(console.error),
{ timezone: 'Asia/Ho_Chi_Minh' },
);
tasks.set(job.id, task);
console.log(`[cron] Scheduled job ${job.id}: ${job.cron_expr} (${job.schedule_display || ''})`);
}
function unscheduleJob(id) {
if (tasks.has(id)) {
tasks.get(id).stop();
tasks.delete(id);
console.log(`[cron] Unscheduled job ${id}`);
}
}
async function init() {
const jobs = await db.listJobs();
let active = 0;
for (const job of jobs) {
if (job.enabled) { scheduleJob(job); active++; }
}
console.log(`[cron] Initialized — ${active}/${jobs.length} jobs active`);
}
module.exports = { init, scheduleJob, unscheduleJob, runJob };

132
src/db.js Normal file
View File

@ -0,0 +1,132 @@
'use strict';
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DATA_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'scheduler.db');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new sqlite3.Database(DB_PATH);
// Helper: run a query that modifies data
const run = (sql, params = []) => new Promise((res, rej) =>
db.run(sql, params, function (err) { err ? rej(err) : res(this); })
);
// Helper: get one row
const get = (sql, params = []) => new Promise((res, rej) =>
db.get(sql, params, (err, row) => err ? rej(err) : res(row))
);
// Helper: get all rows
const all = (sql, params = []) => new Promise((res, rej) =>
db.all(sql, params, (err, rows) => err ? rej(err) : res(rows))
);
// Schema
db.serialize(() => {
db.run('PRAGMA journal_mode = WAL');
db.run(`
CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notebook_id TEXT NOT NULL,
notebook_title TEXT DEFAULT '',
label TEXT DEFAULT '',
message TEXT NOT NULL,
cron_expr TEXT NOT NULL,
schedule_display TEXT DEFAULT '',
webhook_url TEXT,
webhook_headers TEXT DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
`);
// Migration: add webhook_headers if column doesn't exist yet
db.run(`ALTER TABLE cron_jobs ADD COLUMN webhook_headers TEXT DEFAULT '{}'`, () => {});
db.run(`
CREATE TABLE IF NOT EXISTS run_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
notebook_id TEXT NOT NULL,
notebook_title TEXT DEFAULT '',
message TEXT NOT NULL,
answer TEXT,
status TEXT NOT NULL DEFAULT 'success',
error TEXT,
webhook_url TEXT,
webhook_status INTEGER,
webhook_ok INTEGER,
ran_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
`);
});
// ── Cron Jobs ──────────────────────────────────────────────────────────────
const listJobs = () =>
all('SELECT * FROM cron_jobs ORDER BY created_at DESC');
const getJob = (id) =>
get('SELECT * FROM cron_jobs WHERE id = ?', [id]);
const createJob = async ({ notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled }) => {
const r = await run(
`INSERT INTO cron_jobs (notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[notebook_id, notebook_title || '', label || '', message, cron_expr, schedule_display || '', webhook_url || null, webhook_headers || '{}', enabled ? 1 : 0],
);
return getJob(r.lastID);
};
const updateJob = async (id, { notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled }) => {
await run(
`UPDATE cron_jobs SET
notebook_id = ?, notebook_title = ?, label = ?, message = ?,
cron_expr = ?, schedule_display = ?, webhook_url = ?, webhook_headers = ?, enabled = ?,
updated_at = datetime('now','localtime')
WHERE id = ?`,
[notebook_id, notebook_title || '', label || '', message, cron_expr, schedule_display || '', webhook_url || null, webhook_headers || '{}', enabled ? 1 : 0, id],
);
return getJob(id);
};
const toggleJob = async (id) => {
const job = await getJob(id);
if (!job) throw new Error('Job không tồn tại');
await run(
`UPDATE cron_jobs SET enabled = ?, updated_at = datetime('now','localtime') WHERE id = ?`,
[job.enabled ? 0 : 1, id],
);
return getJob(id);
};
const deleteJob = (id) =>
run('DELETE FROM cron_jobs WHERE id = ?', [id]);
// ── History ────────────────────────────────────────────────────────────────
const listHistory = ({ limit = 100, job_id, notebook_id } = {}) => {
const where = [];
const params = [];
if (job_id) { where.push('job_id = ?'); params.push(job_id); }
if (notebook_id) { where.push('notebook_id = ?'); params.push(notebook_id); }
params.push(Math.min(parseInt(limit) || 100, 500));
const sql = `SELECT * FROM run_history${where.length ? ' WHERE ' + where.join(' AND ') : ''} ORDER BY ran_at DESC LIMIT ?`;
return all(sql, params);
};
const addHistory = ({ job_id, notebook_id, notebook_title, message, answer, status, error, webhook_url, webhook_status, webhook_ok }) =>
run(
`INSERT INTO run_history (job_id, notebook_id, notebook_title, message, answer, status, error, webhook_url, webhook_status, webhook_ok)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[job_id || null, notebook_id, notebook_title || '', message, answer || null, status || 'success', error || null, webhook_url || null, webhook_status || null, webhook_ok !== undefined ? (webhook_ok ? 1 : 0) : null],
);
module.exports = { listJobs, getJob, createJob, updateJob, toggleJob, deleteJob, listHistory, addHistory };

632
src/nlm.js Normal file
View File

@ -0,0 +1,632 @@
'use strict';
const browser = require('./pool'); // BrowserPool — same interface as BrowserManager
const SEL = require('./selectors');
// ── Helpers ────────────────────────────────────────────────────────────────
async function $(page, sel, timeout = 8000) {
try { return await page.waitForSelector(sel, { visible: true, timeout }); }
catch { return null; }
}
async function clickWait(page, sel, timeout = 8000) {
const el = await $(page, sel, timeout);
if (!el) throw new Error(`Selector không tìm thấy: ${sel}`);
await el.click();
}
async function typeIn(page, elOrSel, text, timeout = 8000) {
const el = typeof elOrSel === 'string' ? await $(page, elOrSel, timeout) : elOrSel;
if (!el) throw new Error(`Input không tìm thấy`);
await el.click({ clickCount: 3 });
await el.type(text, { delay: 40 });
}
function extractId(url) {
const m = url.match(/\/notebook\/([a-f0-9-]{36})/i);
return m ? m[1] : null;
}
// Đợi AI trả lời **hoàn toàn xong** — không chỉ xuất hiện mà phải ổn định
async function waitForAiResponse(page, prevCount, timeout = 120_000) {
const deadline = Date.now() + timeout;
// Bước 1: đợi thinking-message / "Consulting sources..." biến mất
await page.waitForFunction(
() => {
const el = document.querySelector('div.thinking-message, thinking-animation');
if (!el) return true;
const s = window.getComputedStyle(el);
return s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0';
},
{ timeout, polling: 300 },
).catch(() => {});
// Bước 2: đợi card AI mới xuất hiện
await page.waitForFunction(
(prev) => document.querySelectorAll('mat-card.to-user-message-card-content').length > prev,
{ timeout: Math.max(deadline - Date.now(), 10_000), polling: 300 },
prevCount,
);
// Bước 3: đợi text NGỪNG thay đổi — streaming xong khi stable >= 1.5s
const STABLE_MS = 1500;
const POLL_MS = 400;
const STABLE_TICKS = Math.ceil(STABLE_MS / POLL_MS); // = 4 ticks
let lastText = '';
let stableTicks = 0;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, POLL_MS));
const currentText = await page.evaluate(() => {
const cards = [...document.querySelectorAll('mat-card.to-user-message-card-content')];
if (!cards.length) return '';
const last = cards[cards.length - 1];
const textEl = last.querySelector('.message-text-content') || last;
return (textEl.textContent || '').trim();
}).catch(() => '');
if (currentText.length > 0 && currentText === lastText) {
stableTicks++;
if (stableTicks >= STABLE_TICKS) return; // xong!
} else {
stableTicks = 0;
lastText = currentText;
}
}
}
// ── Exports ────────────────────────────────────────────────────────────────
const nlm = {
// ── Auth ──────────────────────────────────────────────────────────────────
async authStatus() {
const ok = await browser.isAuthenticated();
return { authenticated: ok };
},
async authLogin() {
const page = await browser.getPage();
await page.goto('https://notebooklm.google.com', { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.bringToFront();
if (!page.url().includes('accounts.google.com')) {
return { authenticated: true, message: 'Đã đăng nhập sẵn' };
}
console.log('[nlm] Đang chờ user đăng nhập Google... (timeout 5 phút)');
try {
await page.waitForFunction(() => location.hostname === 'notebooklm.google.com', { timeout: 300_000 });
await page.waitForNetworkIdle({ timeout: 15_000 }).catch(() => {});
return { authenticated: true, message: 'Đăng nhập thành công, session đã lưu' };
} catch {
return { authenticated: false, message: 'Timeout hoặc đăng nhập thất bại' };
}
},
// ── Notebooks ─────────────────────────────────────────────────────────────
async listNotebooks() {
const page = await browser.getPage();
const authed = await browser.goHome();
if (!authed) throw new Error('NOT_AUTHENTICATED');
await page.waitForSelector(SEL.notebookCard, { timeout: 15_000 }).catch(() => {});
const notebooks = await page.evaluate(() => {
const anchors = [...document.querySelectorAll('a[href*="/notebook/"]')];
const seen = new Set();
const result = [];
for (const a of anchors) {
const href = a.href || a.getAttribute('href') || '';
const m = href.match(/\/notebook\/([a-f0-9-]{36})/i);
if (!m) continue;
const id = m[1];
if (seen.has(id)) continue;
// Chỉ lấy notebook do mình tạo — card phải có nút action menu
// (notebook được chia sẻ bởi người khác không có nút này)
const card = a.closest('mat-card, li, article, [class*="project"]') || a.parentElement;
const hasActionMenu = card?.querySelector('button[aria-label="Trình đơn thao tác trong dự án"]') !== null;
if (!hasActionMenu) continue;
seen.add(id);
// Title ở #project-{id}-title (anchor bên trong card là rỗng)
const titleEl = document.getElementById(`project-${id}-title`);
const title = (titleEl?.textContent || '').trim().replace(/\s+/g, ' ');
result.push({ id, title, url: `https://notebooklm.google.com/notebook/${id}` });
}
return result;
});
return { notebooks, total: notebooks.length };
},
async createNotebook(title = '') {
const page = await browser.getPage();
const authed = await browser.goHome();
if (!authed) throw new Error('NOT_AUTHENTICATED');
await clickWait(page, SEL.newNotebookBtn);
// Đợi URL chuyển sang UUID thực (bỏ qua /notebook/creating)
await page.waitForFunction(
() => /\/notebook\/[a-f0-9-]{36}/i.test(location.pathname),
{ timeout: 30_000 },
);
const id = extractId(page.url());
return { id, title: title || 'Sổ ghi chú không có tiêu đề', url: page.url() };
},
async deleteNotebook(id) {
const page = await browser.getPage();
const authed = await browser.goHome();
if (!authed) throw new Error('NOT_AUTHENTICATED');
await page.waitForSelector(SEL.notebookCard, { timeout: 15_000 }).catch(() => {});
const hovered = await page.evaluate((cardSel, targetId) => {
const anchor = [...document.querySelectorAll(cardSel)].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;
}, SEL.notebookCard, id);
if (!hovered) throw new Error('NOTEBOOK_NOT_FOUND');
await new Promise(r => setTimeout(r, 600));
await clickWait(page, SEL.cardMenuBtn, 5000);
// Đợi menu panel xuất hiện rồi click item "Xoá" theo text
await page.waitForSelector('.mat-mdc-menu-panel', { visible: true, timeout: 5000 });
const menuClicked = await page.evaluate(() => {
const items = [...document.querySelectorAll('.mat-mdc-menu-panel button.mat-mdc-menu-item')];
const btn = items.find(b => /Xo[áa]/i.test(b.textContent));
if (btn) { btn.click(); return true; }
// Fallback: first item nếu không tìm được text
if (items[0]) { items[0].click(); return true; }
return false;
});
if (!menuClicked) throw new Error('Không tìm thấy menu item Xoá');
// Đợi confirm dialog (nếu có) rồi click nút xác nhận
await new Promise(r => setTimeout(r, 600));
const confirmed = await page.evaluate(() => {
const dialogs = [...document.querySelectorAll('mat-dialog-container')];
for (const dlg of dialogs) {
if (!dlg.offsetParent && dlg.children.length === 0) continue;
const btns = [...dlg.querySelectorAll('button')].filter(b => b.offsetParent !== null);
const btn = btns.find(b =>
/Xo[áa]/i.test(b.textContent) ||
/X[oó]a/i.test(b.textContent) ||
/delete/i.test(b.textContent) ||
/confirm/i.test(b.textContent)
) || btns[btns.length - 1]; // fallback: nút cuối (thường là confirm)
if (btn) { btn.click(); return true; }
}
return false; // không có confirm dialog — đã xóa ngay
});
await new Promise(r => setTimeout(r, 1000));
return { deleted: true, id };
},
// ── Sources ───────────────────────────────────────────────────────────────
async listSources(notebookId, { timeoutMs = 300_000 } = {}) {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
console.log(`[nlm] Đợi sources load (timeout ${timeoutMs / 1000}s)...`);
// Đợi source panel xuất hiện
await $(page, 'section.source-panel', 20_000);
// Đợi tất cả mat-spinner bên trong source panel biến mất
await page.waitForFunction(() => {
const panel = document.querySelector('.source-panel-content');
if (!panel) return true;
const spinner = panel.querySelector('mat-spinner');
if (!spinner) return true;
return window.getComputedStyle(spinner).display === 'none';
}, { timeout: timeoutMs, polling: 1000 }).catch(() => {
console.warn('[nlm] Timeout đợi loading — đọc sources hiện tại');
});
await new Promise(r => setTimeout(r, 600));
// Trích xuất source items — mỗi item là div.single-source-container
const sources = await page.evaluate(() => {
const items = [...document.querySelectorAll('div.single-source-container')];
return items.map((el, i) => {
// Title: từ aria-label của button.source-stretched-button
const btn = el.querySelector('button.source-stretched-button');
const title = btn?.getAttribute('aria-label') ||
el.querySelector('span')?.textContent?.trim() || '';
// Type: từ mat-icon.source-item-source-icon text, hoặc "url" nếu có favicon img
const iconEl = el.querySelector('mat-icon.source-item-source-icon');
const favicon = el.querySelector('img.favicon-icon, img.source-item-source-icon');
let type = iconEl?.textContent?.trim() || '';
if (!type && favicon) type = 'url';
if (!type) type = 'unknown';
const isLoading = !!el.querySelector('mat-spinner');
return { index: i, title, type, loading: isLoading };
});
});
const stillLoading = sources.filter(s => s.loading).length;
return { sources, total: sources.length, stillLoading };
},
async addSourceFile(notebookId, filePath) {
const fs = require('fs');
const path = require('path');
const absPath = path.resolve(decodeURIComponent(filePath));
if (!fs.existsSync(absPath)) throw new Error(`File không tồn tại: ${absPath}`);
const filename = path.basename(absPath);
const ext = path.extname(absPath).toLowerCase();
const MIME_MAP = {
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.doc': 'application/msword',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.ppt': 'application/vnd.ms-powerpoint',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
};
const mimeType = MIME_MAP[ext] || 'application/octet-stream';
const base64Data = fs.readFileSync(absPath).toString('base64');
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
const prevCount = await page.evaluate(() =>
document.querySelectorAll('mat-dialog-container').length
);
await clickWait(page, SEL.addSourceBtn);
await page.waitForFunction(
(prev) => document.querySelectorAll('mat-dialog-container').length > prev,
{ timeout: 10_000, polling: 200 },
prevCount,
).catch(() => console.warn('[addSourceFile] timeout chờ dialog'));
await new Promise(r => setTimeout(r, 800));
// Dialog mới: "Tải tệp lên" (= [xapscottyuploadertrigger]) cần được click
// bằng Puppeteer page.click() để sinh TRUSTED event.
// Untrusted clicks (trigger.click() trong evaluate) bị xap uploader bỏ qua.
// Lấy tọa độ trigger button để click với CDP (trusted event)
const triggerRect = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const t = d?.querySelector('[xapscottyuploadertrigger]');
if (!t) return null;
const r = t.getBoundingClientRect();
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
});
if (!triggerRect) throw new Error('Không tìm thấy nút Tải tệp lên trong dialog');
// Intercept file chooser TRƯỚC khi click (Puppeteer bắt CDP event).
// xap uploader cần trusted click — page.mouse.click() dùng CDP.
// Nếu xap dùng showOpenFilePicker() thay vì input[type=file],
// chooserPromise trả về null nhưng override đã cài sẽ xử lý.
const chooserPromise = page.waitForFileChooser({ timeout: 8_000 }).catch(() => null);
// Trusted CDP click
await page.mouse.click(triggerRect.x, triggerRect.y);
const fileChooser = await chooserPromise;
if (fileChooser) {
await fileChooser.accept([absPath]);
console.log('[addSourceFile] accepted via native file chooser');
} else {
console.log('[addSourceFile] showOpenFilePicker flow — waiting for upload');
}
// Sau khi accept/override, xap tự upload lên scotty và dialog đóng.
// Đợi network idle (upload xong) thay vì đợi dialog đóng (có thể đã đóng rồi).
await page.waitForNetworkIdle({ timeout: 60_000, idleTime: 1000 }).catch(() => {});
console.log('[addSourceFile] upload complete');
return { added: true, type: 'file', filename, path: absPath };
},
async addSourceUrl(notebookId, url, sourceTitle = '') {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
const prevCount = await page.evaluate(() =>
document.querySelectorAll('mat-dialog-container').length
);
await clickWait(page, SEL.addSourceBtn);
// Đợi dialog add-source xuất hiện
await page.waitForFunction(
(prev) => document.querySelectorAll('mat-dialog-container').length > prev,
{ timeout: 10_000, polling: 200 },
prevCount,
).catch(() => console.warn('[addSourceUrl] timeout chờ dialog'));
await new Promise(r => setTimeout(r, 800));
// Luôn dùng flow "Trang web" cho mọi URL (kể cả Google Drive)
await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...(d?.querySelectorAll('button') || [])]
.filter(b => b.offsetParent !== null)
.find(b => /Trang web/i.test(b.textContent) || /website/i.test(b.textContent?.toLowerCase()));
btn?.click();
});
// Đợi dialog chuyển sang state nhập URL
// Dấu hiệu: xuất hiện input có aria-label "Nhập URL" hoặc placeholder về "liên kết"
await page.waitForFunction(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return false;
return [...d.querySelectorAll('input, textarea')].some(el =>
el.offsetParent !== null && !el.disabled &&
(el.getAttribute('aria-label')?.toLowerCase().includes('url') ||
/liên kết|link|paste|http/i.test(el.placeholder))
);
}, { timeout: 10_000, polling: 200 }).catch(() =>
console.warn('[addSourceUrl] timeout đợi URL input')
);
// Lấy URL input (loại trừ search box "Tìm nguồn mới trên web")
const inputEl = await page.evaluateHandle(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return null;
return [...d.querySelectorAll('input, textarea')].find(el =>
el.offsetParent !== null && !el.disabled && !el.readOnly &&
(el.getAttribute('aria-label')?.toLowerCase().includes('url') ||
/liên kết|link|paste|http/i.test(el.placeholder))
) || null;
});
if (!inputEl.asElement()) throw new Error('Không tìm thấy URL input trong dialog');
await inputEl.asElement().click({ clickCount: 3 });
await inputEl.asElement().type(url, { delay: 30 });
// Đợi và click nút "Chèn" (type="button", KHÔNG phải type="submit")
await page.waitForFunction(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return false;
return [...d.querySelectorAll('button')].some(b =>
b.offsetParent !== null && !b.disabled && /Ch[eè]n/i.test(b.textContent)
);
}, { timeout: 8_000, polling: 300 }).catch(() => {});
const submitted = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...(d?.querySelectorAll('button') || [])].find(b =>
b.offsetParent !== null && !b.disabled &&
(/Ch[eè]n/i.test(b.textContent) || /insert/i.test(b.textContent?.toLowerCase()))
);
if (btn) { btn.click(); return true; }
return false;
});
if (!submitted) throw new Error('Không tìm thấy nút xác nhận trong dialog');
await page.waitForNetworkIdle({ timeout: 30_000 }).catch(() => {});
return { added: true, type: 'url', url };
},
async addSourceText(notebookId, text, sourceTitle = '') {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
const prevCount = await page.evaluate(() =>
document.querySelectorAll('mat-dialog-container').length
);
await clickWait(page, SEL.addSourceBtn);
await page.waitForFunction(
(prev) => document.querySelectorAll('mat-dialog-container').length > prev,
{ timeout: 10_000, polling: 200 },
prevCount,
).catch(() => console.warn('[addSourceText] timeout chờ dialog'));
await new Promise(r => setTimeout(r, 800));
// Click "Văn bản đã sao chép"
await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...(d?.querySelectorAll('button') || [])].find(b =>
/Văn bản|sao chép|paste/i.test(b.textContent)
);
btn?.click();
});
// Đợi dialog chuyển state — có textarea nội dung (không phải search box)
await page.waitForFunction(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return false;
return [...d.querySelectorAll('textarea')].some(el =>
el.offsetParent !== null && !el.disabled &&
!el.className?.includes('query-box-textarea') &&
!el.placeholder?.includes('Tìm nguồn')
);
}, { timeout: 10_000, polling: 200 }).catch(() =>
console.warn('[addSourceText] timeout đợi text area')
);
// Lấy textarea nội dung (loại trừ search box)
const textEl = await page.evaluateHandle(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return null;
return [...d.querySelectorAll('textarea')].find(el =>
el.offsetParent !== null && !el.disabled && !el.readOnly &&
!el.className?.includes('query-box-textarea') &&
!el.placeholder?.includes('Tìm nguồn')
) || null;
});
if (!textEl.asElement()) throw new Error('Không tìm thấy textarea trong dialog');
await textEl.asElement().click({ clickCount: 3 });
await textEl.asElement().type(text, { delay: 20 });
// Title input (nếu có)
if (sourceTitle) {
const titleEl = await page.evaluateHandle(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
if (!d) return null;
return [...d.querySelectorAll('input')].find(i =>
/tiêu đề|title/i.test(i.placeholder)
) || null;
});
if (titleEl.asElement()) {
await titleEl.asElement().click({ clickCount: 3 });
await titleEl.asElement().type(sourceTitle, { delay: 30 });
}
}
const submitted = await page.evaluate(() => {
const d = [...document.querySelectorAll('mat-dialog-container')].pop();
const btn = [...(d?.querySelectorAll('button') || [])].find(b =>
b.offsetParent !== null && !b.disabled &&
(/Ch[eè]n/i.test(b.textContent) || /Thêm/i.test(b.textContent) || /insert/i.test(b.textContent?.toLowerCase()))
);
if (btn) { btn.click(); return true; }
return false;
});
if (!submitted) throw new Error('Không tìm thấy nút xác nhận trong dialog');
await page.waitForNetworkIdle({ timeout: 30_000 }).catch(() => {});
return { added: true, type: 'text', length: text.length };
},
// ── Chat ──────────────────────────────────────────────────────────────────
async chat(notebookId, message) {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
const inputEl = await $(page, 'textarea.query-box-input', 15_000);
if (!inputEl) throw new Error('Không tìm thấy chat input (textarea.query-box-input)');
// Đếm AI messages trước khi gửi
const prevCount = await page.$$eval('mat-card.to-user-message-card-content', els => els.length).catch(() => 0);
await inputEl.click({ clickCount: 3 });
await inputEl.type(message, { delay: 30 });
await page.keyboard.press('Enter');
await waitForAiResponse(page, prevCount);
const answer = await page.evaluate(() => {
const cards = [...document.querySelectorAll('mat-card.to-user-message-card-content')];
if (!cards.length) return '';
const last = cards[cards.length - 1];
const textEl = last.querySelector('.message-text-content') || last;
return (textEl.textContent || '').trim().replace(/\s+/g, ' ');
});
return { question: message, answer };
},
async chatHistory(notebookId) {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
await $(page, 'div.chat-message-pair', 10_000);
const history = await page.evaluate(() => {
const pairs = [...document.querySelectorAll('div.chat-message-pair')];
const turns = [];
for (const pair of pairs) {
const userCard = pair.querySelector('mat-card.from-user-message-card-content');
const aiCard = pair.querySelector('mat-card.to-user-message-card-content');
if (userCard) {
const t = userCard.querySelector('.message-text-content') || userCard;
turns.push({ role: 'user', content: t.textContent?.trim().replace(/\s+/g, ' ') || '' });
}
if (aiCard) {
const t = aiCard.querySelector('.message-text-content') || aiCard;
turns.push({ role: 'assistant', content: t.textContent?.trim().replace(/\s+/g, ' ') || '' });
}
}
return turns;
});
return { notebookId, history, total: history.length };
},
async chatStream(notebookId, message, onChunk, onDone) {
const page = await browser.getPage(notebookId);
await browser.goNotebook(notebookId);
const inputEl = await $(page, 'textarea.query-box-input', 15_000);
if (!inputEl) throw new Error('Không tìm thấy chat input');
const prevCount = await page.$$eval('mat-card.to-user-message-card-content', els => els.length).catch(() => 0);
await inputEl.click({ clickCount: 3 });
await inputEl.type(message, { delay: 30 });
await page.keyboard.press('Enter');
let lastText = '';
const interval = setInterval(async () => {
try {
const current = await page.evaluate(() => {
const cards = [...document.querySelectorAll('mat-card.to-user-message-card-content')];
if (!cards.length) return '';
const last = cards[cards.length - 1];
const textEl = last.querySelector('.message-text-content') || last;
return (textEl.textContent || '').trim();
});
if (current && current !== lastText) {
onChunk(current.slice(lastText.length));
lastText = current;
}
} catch {}
}, 300);
try {
await waitForAiResponse(page, prevCount, 120_000);
} finally {
clearInterval(interval);
const final = await page.evaluate(() => {
const cards = [...document.querySelectorAll('mat-card.to-user-message-card-content')];
const last = cards[cards.length - 1];
const textEl = last?.querySelector('.message-text-content') || last;
return (textEl?.textContent || '').trim();
}).catch(() => lastText);
if (final && final !== lastText) onChunk(final.slice(lastText.length));
onDone({ answer: final || lastText });
}
},
};
module.exports = nlm;

174
src/pool.js Normal file
View File

@ -0,0 +1,174 @@
'use strict';
/**
* BrowserPool quản N Chrome instances song song
*
* Routing: hash(notebookId) % poolSize cùng notebook luôn vào cùng slot
* Mỗi slot BrowserManager riêng + AsyncQueue riêng parallel giữa slots,
* tuần tự trong từng slot.
*
* Env: BROWSER_POOL_SIZE=2 (mặc định 2)
*/
const path = require('path');
const fs = require('fs');
const { BrowserManager } = require('./browser');
const { AsyncQueue } = require('./queue');
const POOL_SIZE = Math.max(1, parseInt(process.env.BROWSER_POOL_SIZE || '2', 10));
const BASE_PROFILE = path.join(__dirname, '..', 'chrome-profile');
// Simple hash để route nhất quán: cùng notebookId → cùng slot
function hashSlot(notebookId, size) {
if (!notebookId || size === 1) return 0;
let h = 0;
for (let i = 0; i < notebookId.length; i++) {
h = Math.imul(31, h) + notebookId.charCodeAt(i) | 0;
}
return Math.abs(h) % size;
}
class BrowserPool {
constructor(size) {
this.size = size;
this.slots = []; // [{ browser: BrowserManager, queue: AsyncQueue, index }]
}
// Gọi 1 lần khi server khởi động — tạo slots (browser chưa launch, lazy)
async init() {
for (let i = 0; i < this.size; i++) {
const profileDir = i === 0
? BASE_PROFILE
: path.join(__dirname, '..', `chrome-profile-${i}`);
// Copy profile từ slot 0 cho các slot mới (để có sẵn Google cookies)
if (i > 0 && !fs.existsSync(profileDir) && fs.existsSync(BASE_PROFILE)) {
console.log(`[pool] Slot ${i}: copy profile từ chrome-profile...`);
try {
fs.cpSync(BASE_PROFILE, profileDir, { recursive: true, errorOnExist: false });
// Xoá Singleton files để Chrome không nghĩ instance kia đang chạy
for (const f of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
try { fs.unlinkSync(path.join(profileDir, f)); } catch {}
}
console.log(`[pool] Slot ${i}: profile ready tại ${profileDir}`);
} catch (err) {
console.warn(`[pool] Slot ${i}: copy profile thất bại (${err.message}), dùng profile mới`);
fs.mkdirSync(profileDir, { recursive: true });
}
} else {
fs.mkdirSync(profileDir, { recursive: true });
}
this.slots.push({
index: i,
browser: new BrowserManager(profileDir, i),
queue: new AsyncQueue(`slot-${i}`),
});
}
console.log(`[pool] Khởi tạo ${this.size} browser slot(s) — BROWSER_POOL_SIZE=${this.size}`);
}
// ── Routing ────────────────────────────────────────────────────────────────
_slot(notebookId) {
return this.slots[hashSlot(notebookId, this.size)];
}
// ── BrowserManager interface (dùng trong nlm.js) ──────────────────────────
// getPage(notebookId?) — trả về page của slot phù hợp
async getPage(notebookId) {
return this._slot(notebookId).browser.getPage();
}
// goNotebook — điều hướng đến notebook trong slot phù hợp
async goNotebook(notebookId) {
return this._slot(notebookId).browser.goNotebook(notebookId);
}
// goHome — luôn dùng slot 0 (trang chủ không cần routing theo notebook)
async goHome() {
return this.slots[0].browser.goHome();
}
async isAuthenticated() {
return this.slots[0].browser.isAuthenticated();
}
async close() {
await Promise.all(this.slots.map(s => s.browser.close().catch(() => {})));
console.log('[pool] Tất cả browser đã đóng');
}
// ── Queue interface ────────────────────────────────────────────────────────
// Notebook-specific: route đến đúng slot
add(notebookId, fn, label) {
return this._slot(notebookId).queue.add(fn, label);
}
// Global ops (auth, listNotebooks, createNotebook, deleteNotebook): dùng slot 0
addGlobal(fn, label) {
return this.slots[0].queue.add(fn, label);
}
// ── Monitoring ─────────────────────────────────────────────────────────────
status() {
return this.slots.map(s => ({
slot: s.index,
connected: s.browser.browser?.isConnected() ?? false,
busy: s.queue.busy,
pending: s.queue.size,
profile: s.browser.profileDir,
}));
}
// ── Auth sync — copy cookies từ slot 0 sang các slot khác ─────────────────
async syncAuth() {
if (this.size === 1) return { synced: 0, message: 'Pool chỉ có 1 slot' };
const page0 = await this.slots[0].browser.getPage();
// Thu thập cookies từ tất cả Google domains
const cookieMap = new Map();
const domains = [
'https://accounts.google.com',
'https://google.com',
'https://notebooklm.google.com',
];
for (const url of domains) {
try {
await page0.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
const cookies = await page0.cookies();
for (const c of cookies) cookieMap.set(`${c.name}||${c.domain}`, c);
} catch (err) {
console.warn(`[pool] syncAuth: không thể lấy cookies từ ${url}:`, err.message);
}
}
const allCookies = [...cookieMap.values()];
if (!allCookies.length) return { synced: 0, message: 'Không có cookies để sync (chưa đăng nhập?)' };
// Áp cookies lên các slot còn lại
let synced = 0;
for (let i = 1; i < this.slots.length; i++) {
try {
const page = await this.slots[i].browser.getPage();
await page.setCookie(...allCookies);
synced++;
console.log(`[pool] syncAuth: slot ${i}${allCookies.length} cookies applied`);
} catch (err) {
console.error(`[pool] syncAuth: slot ${i} thất bại:`, err.message);
}
}
return { synced, cookieCount: allCookies.length, message: `Đã sync ${synced} slot` };
}
}
module.exports = new BrowserPool(POOL_SIZE);

40
src/queue.js Normal file
View File

@ -0,0 +1,40 @@
'use strict';
// Queue tuần tự hoá thao tác Puppeteer — tránh race condition trong 1 browser slot
class AsyncQueue {
constructor(name = 'default') {
this._name = name;
this._running = false;
this._queue = [];
}
add(fn, label = 'task') {
return new Promise((resolve, reject) => {
this._queue.push({ fn, resolve, reject, label });
this._next();
});
}
async _next() {
if (this._running || this._queue.length === 0) return;
this._running = true;
const { fn, resolve, reject, label } = this._queue.shift();
try {
console.log(`[queue:${this._name}] → ${label}`);
resolve(await fn());
} catch (err) {
console.error(`[queue:${this._name}] ✗ ${label}:`, err.message);
reject(err);
} finally {
this._running = false;
this._next();
}
}
get size() { return this._queue.length; }
get busy() { return this._running; }
}
module.exports = new AsyncQueue('global');
module.exports.AsyncQueue = AsyncQueue;

33
src/routes/auth.js Normal file
View File

@ -0,0 +1,33 @@
'use strict';
const { Router } = require('express');
const pool = require('../pool');
const nlm = require('../nlm');
const router = Router();
// GET /api/auth/status
router.get('/status', async (req, res) => {
try {
const result = await pool.addGlobal(() => nlm.authStatus(), 'auth.status');
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// POST /api/auth/login
// Mở browser slot 0, chờ user đăng nhập (blocking tối đa 5 phút)
router.post('/login', async (req, res) => {
req.socket.setTimeout(310_000);
res.setTimeout(310_000);
try {
const result = await pool.addGlobal(() => nlm.authLogin(), 'auth.login');
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;

67
src/routes/chat-ws.js Normal file
View File

@ -0,0 +1,67 @@
'use strict';
/**
* WebSocket endpoint cho streaming chat:
* ws://localhost:3456/api/notebooks/:id/chat/stream
*
* Client gửi JSON: { "message": "câu hỏi..." }
* Server push:
* { type: "chunk", data: "...text..." }
* { type: "done", data: { answer: "full answer" } }
* { type: "error", data: "error message" }
*/
const pool = require('../pool');
const nlm = require('../nlm');
function registerChatWs(app) {
app.ws('/api/notebooks/:id/chat/stream', (ws, req) => {
const notebookId = req.params.id;
ws.send(JSON.stringify({ type: 'connected', notebookId }));
ws.on('message', async (raw) => {
let payload;
try {
payload = JSON.parse(raw);
} catch {
ws.send(JSON.stringify({ type: 'error', data: 'JSON không hợp lệ' }));
return;
}
const { message } = payload;
if (!message) {
ws.send(JSON.stringify({ type: 'error', data: 'Thiếu "message"' }));
return;
}
try {
await pool.add(
notebookId,
() => nlm.chatStream(
notebookId,
message,
(chunk) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'chunk', data: chunk }));
}
},
(done) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'done', data: done }));
}
},
),
`chat.stream.${notebookId}`,
);
} catch (err) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'error', data: err.message }));
}
}
});
ws.on('close', () => console.log(`[ws] client disconnected (${notebookId})`));
});
}
module.exports = { registerChatWs };

116
src/routes/cron.js Normal file
View File

@ -0,0 +1,116 @@
'use strict';
const { Router } = require('express');
const nodeCron = require('node-cron');
const pool = require('../pool');
const db = require('../db');
const runner = require('../cron-runner');
const router = Router();
// GET /api/cron
router.get('/', async (req, res) => {
try {
const jobs = await db.listJobs();
res.json({ ok: true, total: jobs.length, jobs });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// POST /api/cron
router.post('/', async (req, res) => {
try {
const { notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled = true } = req.body || {};
if (!notebook_id || !message || !cron_expr) {
return res.status(400).json({ ok: false, error: 'Thiếu notebook_id, message hoặc cron_expr' });
}
if (!nodeCron.validate(cron_expr)) {
return res.status(400).json({ ok: false, error: `Cron expression không hợp lệ: "${cron_expr}"` });
}
// webhook_headers có thể là object hoặc JSON string
const headersJson = webhook_headers
? (typeof webhook_headers === 'string' ? webhook_headers : JSON.stringify(webhook_headers))
: '{}';
const job = await db.createJob({ notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers: headersJson, enabled });
runner.scheduleJob(job);
res.status(201).json({ ok: true, job });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// PUT /api/cron/:id
router.put('/:id', async (req, res) => {
try {
const existing = await db.getJob(req.params.id);
if (!existing) return res.status(404).json({ ok: false, error: 'Job không tồn tại' });
const { notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled } = req.body || {};
let headersJson = existing.webhook_headers || '{}';
if (webhook_headers !== undefined) {
headersJson = typeof webhook_headers === 'string' ? webhook_headers : JSON.stringify(webhook_headers || {});
}
const updated = await db.updateJob(req.params.id, {
notebook_id: notebook_id ?? existing.notebook_id,
notebook_title: notebook_title ?? existing.notebook_title,
label: label ?? existing.label,
message: message ?? existing.message,
cron_expr: cron_expr ?? existing.cron_expr,
schedule_display: schedule_display ?? existing.schedule_display,
webhook_url: webhook_url !== undefined ? (webhook_url || null) : existing.webhook_url,
webhook_headers: headersJson,
enabled: enabled !== undefined ? enabled : !!existing.enabled,
});
runner.scheduleJob(updated);
res.json({ ok: true, job: updated });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// PATCH /api/cron/:id/toggle
router.patch('/:id/toggle', async (req, res) => {
try {
const job = await db.toggleJob(req.params.id);
if (job.enabled) runner.scheduleJob(job);
else runner.unscheduleJob(job.id);
res.json({ ok: true, job });
} catch (err) {
res.status(err.message === 'Job không tồn tại' ? 404 : 500).json({ ok: false, error: err.message });
}
});
// POST /api/cron/:id/run — manual trigger
router.post('/:id/run', async (req, res) => {
try {
const job = await db.getJob(req.params.id);
if (!job) return res.status(404).json({ ok: false, error: 'Job không tồn tại' });
// Fire in pool queue for this notebook's slot
pool.add(job.notebook_id, () => runner.runJob(job), `cron.manual.${job.id}`).catch(console.error);
res.json({ ok: true, message: 'Đang chạy job...' });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// DELETE /api/cron/:id
router.delete('/:id', async (req, res) => {
try {
const job = await db.getJob(req.params.id);
if (!job) return res.status(404).json({ ok: false, error: 'Job không tồn tại' });
runner.unscheduleJob(job.id);
await db.deleteJob(req.params.id);
res.json({ ok: true, deleted: true });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;

19
src/routes/history.js Normal file
View File

@ -0,0 +1,19 @@
'use strict';
const { Router } = require('express');
const db = require('../db');
const router = Router();
// GET /api/history?limit=100&job_id=1&notebook_id=xxx
router.get('/', async (req, res) => {
try {
const { limit, job_id, notebook_id } = req.query;
const history = await db.listHistory({ limit, job_id, notebook_id });
res.json({ ok: true, total: history.length, history });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;

124
src/routes/notebooks.js Normal file
View File

@ -0,0 +1,124 @@
'use strict';
const { Router } = require('express');
const pool = require('../pool');
const nlm = require('../nlm');
const router = Router();
// GET /api/notebooks
router.get('/', async (req, res) => {
try {
const result = await pool.addGlobal(() => nlm.listNotebooks(), 'notebooks.list');
res.json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// POST /api/notebooks
router.post('/', async (req, res) => {
try {
const { title = '' } = req.body || {};
const result = await pool.addGlobal(() => nlm.createNotebook(title), 'notebooks.create');
res.status(201).json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// DELETE /api/notebooks/:id
router.delete('/:id', async (req, res) => {
try {
const result = await pool.addGlobal(() => nlm.deleteNotebook(req.params.id), 'notebooks.delete');
res.json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOTEBOOK_NOT_FOUND' ? 404
: err.message === 'NOT_AUTHENTICATED' ? 401 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// ── Sources ───────────────────────────────────────────────────────────────
// GET /api/notebooks/:id/sources?timeout=300
router.get('/:id/sources', async (req, res) => {
const timeoutMs = Math.min(
parseInt(req.query.timeout || '300', 10) * 1000,
600_000,
);
req.socket.setTimeout(timeoutMs + 10_000);
res.setTimeout(timeoutMs + 10_000);
try {
const result = await pool.add(
req.params.id,
() => nlm.listSources(req.params.id, { timeoutMs }),
'sources.list',
);
res.json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401
: err.message === 'NOTEBOOK_NOT_FOUND' ? 404 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// POST /api/notebooks/:id/sources
router.post('/:id/sources', async (req, res) => {
try {
const { type, content, title = '' } = req.body || {};
if (!type || !content) {
return res.status(400).json({ ok: false, error: 'Thiếu type hoặc content' });
}
let result;
if (type === 'url') {
result = await pool.add(req.params.id, () => nlm.addSourceUrl(req.params.id, content, title), 'sources.add.url');
} else if (type === 'text') {
result = await pool.add(req.params.id, () => nlm.addSourceText(req.params.id, content, title), 'sources.add.text');
} else if (type === 'file') {
result = await pool.add(req.params.id, () => nlm.addSourceFile(req.params.id, content), 'sources.add.file');
} else {
return res.status(400).json({ ok: false, error: 'type phải là "url", "text" hoặc "file"' });
}
res.status(201).json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401
: err.message === 'NOTEBOOK_NOT_FOUND' ? 404 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /api/notebooks/:id/chat/history
router.get('/:id/chat/history', async (req, res) => {
try {
const result = await pool.add(req.params.id, () => nlm.chatHistory(req.params.id), 'chat.history');
res.json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401
: err.message === 'NOTEBOOK_NOT_FOUND' ? 404 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// POST /api/notebooks/:id/chat
router.post('/:id/chat', async (req, res) => {
try {
const { message } = req.body || {};
if (!message) return res.status(400).json({ ok: false, error: 'Thiếu message' });
const result = await pool.add(req.params.id, () => nlm.chat(req.params.id, message), 'chat.send');
res.json({ ok: true, ...result });
} catch (err) {
const status = err.message === 'NOT_AUTHENTICATED' ? 401
: err.message === 'NOTEBOOK_NOT_FOUND' ? 404 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
module.exports = router;

93
src/selectors.js Normal file
View File

@ -0,0 +1,93 @@
'use strict';
/**
* Selectors xác nhận từ debug DOM thực tế (15/06/2026)
* Ngôn ngữ UI: Tiếng Việt
*/
const SEL = {
// ── Trang chủ ─────────────────────────────────────────────────────────────
notebookCard: 'a[href*="/notebook/"]',
// Title: #project-{id}-title (không nằm trong <a>)
newNotebookBtn: 'button[aria-label="Tạo sổ ghi chú mới"], button.create-new-button',
cardMenuBtn: 'button[aria-label="Trình đơn thao tác trong dự án"]',
// ── Delete notebook flow ───────────────────────────────────────────────────
// Menu item "Xoá" trong .mat-mdc-menu-panel (không có aria-label, dùng text)
deleteMenuItem: '.mat-mdc-menu-panel button.mat-mdc-menu-item:first-of-type',
// Confirm dialog: nút xác nhận xóa (text "Xoá" hoặc "Xóa")
confirmDeleteBtn: 'mat-dialog-container button.mat-mdc-button:last-of-type, mat-dialog-container button[class*="primary"], mat-dialog-container button[class*="warn"]',
// ── Source panel (trái) ────────────────────────────────────────────────────
sourcePanel: 'section.source-panel',
sourcePanelContent: '.source-panel-content',
// Source items — mỗi item là div.source-item-menu-button-visible
sourceItem: '.source-item-menu-button-visible',
sourceItemTitle: '.source-item-icon-container ~ span, .source-item-icon-container ~ div, [class*="source-title"], [class*="title"]',
// Loading khi source đang convert (mat-spinner bên trong source panel)
sourceLoading: '.source-panel-content mat-spinner, .source-panel-content [class*="loading"]:not(.emoji-keyboard__loading-message)',
// ── Add Source Button ──────────────────────────────────────────────────────
addSourceBtn: 'button[aria-label="Thêm nguồn"], button.add-source-button',
// ── Add Source Dialog (add-sources-dialog bên trong mat-dialog-container) ──
// Dialog KHÔNG có tabs — dùng các icon-button để chọn loại nguồn
sourceDialog: 'mat-dialog-container',
sourceDialogClose:'mat-dialog-container button[aria-label="Đóng"]',
// Nút chọn loại nguồn (class cố định: drop-zone-icon-button)
sourceTypeBtnUrl: 'mat-dialog-container button.drop-zone-icon-button:nth-of-type(2)', // "Trang web"
sourceTypeBtnText: 'mat-dialog-container button.drop-zone-icon-button:last-of-type', // "Văn bản đã sao chép"
// Inputs bên trong dialog sau khi chọn loại
sourceUrlInput: 'mat-dialog-container input[type="url"], mat-dialog-container input[placeholder*="URL" i], mat-dialog-container input[placeholder*="http" i], mat-dialog-container input[type="text"]:not([aria-label*="emoji" i])',
sourceTextArea: 'mat-dialog-container textarea:not([aria-label*="truy vấn" i]):not([aria-label*="query" i]):not([placeholder*="Tìm nguồn" i])',
sourceTitleInput: 'mat-dialog-container input[placeholder*="tiêu đề" i], mat-dialog-container input[placeholder*="title" i]',
insertSourceBtn: 'mat-dialog-container button[type="submit"]:not([aria-label="Gửi"]):not([aria-label="Đóng"]), mat-dialog-container button[aria-label*="Chèn" i], mat-dialog-container button[aria-label*="Tải" i]',
// ── Chat panel (phải) — component: chat-panel / query-box ─────────────────
// Textarea chat: class "query-box-input", aria-label "Hộp truy vấn", x≈545
// KHÔNG dùng "query-box-textarea" (textarea source search ở trái, x≈48)
chatInput: [
'textarea.query-box-input',
'chat-panel textarea',
'query-box textarea',
].join(', '),
// Send button chat: class "submit-button" — KHÁC với "actions-enter-button" (source search)
sendBtn: [
'button.submit-button[aria-label="Gửi"]',
'chat-panel button[aria-label="Gửi"]',
'query-box button.submit-button',
].join(', '),
// ── Chat messages ──────────────────────────────────────────────────────────
// Cặp message: div.chat-message-pair (chứa 1 user + 1 AI)
chatMessagePair: 'div.chat-message-pair',
// User message: mat-card.from-user-message-card-content
userMessage: [
'mat-card.from-user-message-card-content .message-text-content',
'.from-user-message-card-content .message-text-content',
'mat-card.from-user-message-card-content',
].join(', '),
// AI response: mat-card.to-user-message-card-content
aiMessage: [
'mat-card.to-user-message-card-content .message-text-content',
'.to-user-message-card-content .message-text-content',
'mat-card.to-user-message-card-content',
].join(', '),
// Đang typing/thinking: div.thinking-message hoặc thinking-animation component
aiTyping: [
'div.thinking-message',
'thinking-animation',
'chat-panel mat-spinner',
].join(', '),
};
module.exports = SEL;

736
src/server.js Normal file
View File

@ -0,0 +1,736 @@
'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`);
});

290
src/swagger.js Normal file
View File

@ -0,0 +1,290 @@
'use strict';
const spec = {
openapi: '3.0.0',
info: {
title: 'NotebookLM API',
version: '1.0.0',
description: 'API điều khiển NotebookLM qua Puppeteer — giả lập thao tác người dùng thật',
},
servers: [{ url: 'http://localhost:3456', description: 'Local server' }],
tags: [
{ name: 'Auth', description: 'Xác thực Google' },
{ name: 'Notebooks', description: 'Quản lý notebooks' },
{ name: 'Sources', description: 'Quản lý tài liệu trong notebook' },
{ name: 'Chat', description: 'Hỏi đáp với notebook' },
{ name: 'System', description: 'Trạng thái server' },
],
paths: {
// ── System ──────────────────────────────────────────────────────────────
'/health': {
get: {
tags: ['System'],
summary: 'Trạng thái server & queue',
responses: {
200: {
description: 'OK',
content: { 'application/json': { example: {
ok: true, status: 'running',
queue: { busy: false, pending: 0 },
}}},
},
},
},
},
// ── Auth ────────────────────────────────────────────────────────────────
'/api/auth/status': {
get: {
tags: ['Auth'],
summary: 'Kiểm tra đã đăng nhập chưa',
responses: {
200: {
description: 'Trạng thái đăng nhập',
content: { 'application/json': { example: { ok: true, authenticated: true } } },
},
},
},
},
'/api/auth/login': {
post: {
tags: ['Auth'],
summary: 'Mở browser để đăng nhập Google (chờ đến khi login xong)',
description: 'Mở Chrome, điều hướng đến Google login. **Chờ tối đa 5 phút** để user đăng nhập. Request sẽ trả về sau khi đăng nhập thành công.',
responses: {
200: {
description: 'Kết quả đăng nhập',
content: { 'application/json': { example: {
ok: true, authenticated: true,
message: 'Đăng nhập thành công, session đã lưu',
}}},
},
},
},
},
// ── Notebooks ────────────────────────────────────────────────────────────
'/api/notebooks': {
get: {
tags: ['Notebooks'],
summary: 'Lấy danh sách tất cả notebooks',
responses: {
200: {
description: 'Danh sách notebooks',
content: { 'application/json': { example: {
ok: true, total: 2,
notebooks: [
{ id: '2c4f0f26-0797-4cc0-a350-48c4b70d14cc', title: 'Toàn tập Sherlock Holmes', url: 'https://notebooklm.google.com/notebook/2c4f0f26-...' },
{ id: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869', title: 'Prology_Net eBay Correspondence', url: 'https://notebooklm.google.com/notebook/b1b0eb8c-...' },
],
}}},
},
401: { description: 'Chưa đăng nhập' },
},
},
post: {
tags: ['Notebooks'],
summary: 'Tạo notebook mới',
requestBody: {
content: { 'application/json': {
schema: {
type: 'object',
properties: { title: { type: 'string', example: 'Dự án Q3 2026' } },
},
}},
},
responses: {
201: {
description: 'Notebook đã tạo',
content: { 'application/json': { example: {
ok: true,
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
title: 'Dự án Q3 2026',
url: 'https://notebooklm.google.com/notebook/...',
}}},
},
401: { description: 'Chưa đăng nhập' },
},
},
},
'/api/notebooks/{id}': {
delete: {
tags: ['Notebooks'],
summary: 'Xoá notebook',
parameters: [{
name: 'id', in: 'path', required: true,
schema: { type: 'string' },
example: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869',
}],
responses: {
200: {
description: 'Đã xoá',
content: { 'application/json': { example: {
ok: true, deleted: true, id: 'b1b0eb8c-...',
}}},
},
404: { description: 'Notebook không tồn tại' },
},
},
},
// ── Sources ──────────────────────────────────────────────────────────────
'/api/notebooks/{id}/sources': {
get: {
tags: ['Sources'],
summary: 'Lấy danh sách sources (chờ converting xong)',
description: 'Tự động đợi tất cả sources hoàn tất quá trình convert trước khi trả kết quả. Timeout mặc định 5 phút.',
parameters: [
{
name: 'id', in: 'path', required: true,
schema: { type: 'string' },
example: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869',
},
{
name: 'timeout', in: 'query', required: false,
description: 'Giây tối đa chờ converting (mặc định 300, tối đa 600)',
schema: { type: 'integer', default: 300, minimum: 10, maximum: 600 },
},
],
responses: {
200: {
description: 'Danh sách sources',
content: { 'application/json': { example: {
ok: true, total: 3, stillLoading: 0,
sources: [
{ index: 0, title: 'Báo cáo Q2.pdf', type: 'pdf', loading: false },
{ index: 1, title: 'https://example.com/article', type: 'url', loading: false },
{ index: 2, title: 'Ghi chú cuộc họp tháng 6', type: 'text', loading: false },
],
}}},
},
401: { description: 'Chưa đăng nhập' },
404: { description: 'Notebook không tồn tại' },
},
},
post: {
tags: ['Sources'],
summary: 'Thêm source vào notebook',
parameters: [{
name: 'id', in: 'path', required: true,
schema: { type: 'string' },
example: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869',
}],
requestBody: {
required: true,
content: { 'application/json': {
schema: {
type: 'object',
required: ['type', 'content'],
properties: {
type: {
type: 'string', enum: ['url', 'text'],
description: '"url" = website, "text" = dán văn bản',
},
content: {
type: 'string',
description: 'URL hoặc nội dung văn bản',
example: 'https://vnexpress.net/bai-viet',
},
title: {
type: 'string',
description: 'Tên hiển thị (tuỳ chọn)',
example: 'VnExpress - Bài viết tháng 6',
},
},
},
examples: {
url: { summary: 'Thêm URL', value: { type: 'url', content: 'https://example.com/article', title: 'Bài viết mẫu' } },
text: { summary: 'Dán văn bản', value: { type: 'text', content: 'Nội dung báo cáo tháng 6...', title: 'Báo cáo T6' } },
},
}},
},
responses: {
201: {
description: 'Source đã thêm',
content: { 'application/json': { example: { ok: true, added: true, type: 'url', url: 'https://example.com' } } },
},
400: { description: 'Thiếu type hoặc content' },
401: { description: 'Chưa đăng nhập' },
},
},
},
// ── Chat ────────────────────────────────────────────────────────────────
'/api/notebooks/{id}/chat': {
post: {
tags: ['Chat'],
summary: 'Gửi câu hỏi và nhận trả lời (đồng bộ)',
description: 'Gửi message đến notebook, chờ AI trả lời xong (tối đa 90 giây). Dùng WebSocket `/api/notebooks/{id}/chat/stream` nếu muốn streaming.',
parameters: [{
name: 'id', in: 'path', required: true,
schema: { type: 'string' },
example: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869',
}],
requestBody: {
required: true,
content: { 'application/json': {
schema: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', example: 'Tóm tắt các điểm chính trong tài liệu này' },
},
},
}},
},
responses: {
200: {
description: 'Câu trả lời từ NotebookLM',
content: { 'application/json': { example: {
ok: true,
question: 'Tóm tắt các điểm chính trong tài liệu này',
answer: 'Dựa trên các tài liệu, các điểm chính bao gồm: ...',
}}},
},
400: { description: 'Thiếu message' },
401: { description: 'Chưa đăng nhập' },
},
},
},
'/api/notebooks/{id}/chat/history': {
get: {
tags: ['Chat'],
summary: 'Lấy lịch sử chat của notebook',
parameters: [{
name: 'id', in: 'path', required: true,
schema: { type: 'string' },
example: 'b1b0eb8c-6549-4ef4-9384-eddfea3cc869',
}],
responses: {
200: {
description: 'Lịch sử chat',
content: { 'application/json': { example: {
ok: true,
total: 4,
history: [
{ role: 'user', content: 'Tóm tắt tài liệu' },
{ role: 'assistant', content: 'Tài liệu đề cập đến...' },
],
}}},
},
},
},
},
},
};
module.exports = spec;