forked from joseph/notebooklm-api
first commit
This commit is contained in:
commit
26f894c7bc
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
PORT=3456
|
||||
HEADLESS=false
|
||||
# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
|
||||
API_KEY=
|
||||
|
|
@ -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=
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
chrome-profile*
|
||||
data/*
|
||||
|
|
@ -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 30–60 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
|
||||
|
|
@ -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 30–60 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)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* BrowserPool — quản lý N Chrome instances song song
|
||||
*
|
||||
* Routing: hash(notebookId) % poolSize → cùng notebook luôn vào cùng slot
|
||||
* Mỗi slot có 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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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¬ebook_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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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`);
|
||||
});
|
||||
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue