From 26f894c7bc9397569436627008c57c75d7d6dffb Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 17 Jun 2026 07:58:30 +0700 Subject: [PATCH] first commit --- .DS_Store | Bin 0 -> 8196 bytes .claude/skills/notebooklm-api.md | 225 +++ .env | 4 + .env.example | 6 + .gitignore | 3 + API.md | 460 +++++ README.md | 232 +++ docs/huong-dan-su-dung.html | 1072 ++++++++++ package-lock.json | 3195 ++++++++++++++++++++++++++++++ package.json | 22 + public/index.html | 1216 ++++++++++++ setup.sh | 112 ++ src/browser.js | 118 ++ src/cron-runner.js | 124 ++ src/db.js | 132 ++ src/nlm.js | 632 ++++++ src/pool.js | 174 ++ src/queue.js | 40 + src/routes/auth.js | 33 + src/routes/chat-ws.js | 67 + src/routes/cron.js | 116 ++ src/routes/history.js | 19 + src/routes/notebooks.js | 124 ++ src/selectors.js | 93 + src/server.js | 736 +++++++ src/swagger.js | 290 +++ 26 files changed, 9245 insertions(+) create mode 100644 .DS_Store create mode 100644 .claude/skills/notebooklm-api.md create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 API.md create mode 100644 README.md create mode 100644 docs/huong-dan-su-dung.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100755 setup.sh create mode 100644 src/browser.js create mode 100644 src/cron-runner.js create mode 100644 src/db.js create mode 100644 src/nlm.js create mode 100644 src/pool.js create mode 100644 src/queue.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/chat-ws.js create mode 100644 src/routes/cron.js create mode 100644 src/routes/history.js create mode 100644 src/routes/notebooks.js create mode 100644 src/selectors.js create mode 100644 src/server.js create mode 100644 src/swagger.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3bd1f3699c5156f6dfa054dd6bf3ff2d1465acce GIT binary patch literal 8196 zcmeHMU2GIp6u#fIz>JJ^S}T;oWVdc91dDC4DnEkTKcLbI+Lmrh6?b=rc4RtJcV@RO ziDrrMFEP>hm-s~deQeZd)EE?fGVw2(#zYc{Q4{gSCtffCJ$LRbX$yTbrjfbH-0z-q z&pEUA%y;JI&N9Z(o;O+;t6_}EbaB*HQg@Zc=lOL-lR`}yA;_QEUZEpnyBWsH`mV5! z6j27E3`7}-G7x1T%D_^{0PWel$&2j!Vm9id3`7~YCNsd_4{^FUCPOR=3|<}71xEnF za#9D4#u^_G#zRboSQJPz)F505g)71%28285sG4Q>w~GGVRa;B zWnHJwI&L1(D!egwvYW(%IkRAylY?2;&W}6Z5v?+twzGLV?VD}3&5cP{;bn8KZ69 zsAcAqN;E3{PI}y&t*TjDyKcR%FSIU2N#!%v!+Fb}))KNT?op>F6zzR^#GoJ9>l!?w z5zOdy`bJqk%=H~PlX{_6H|vdxmX`ZHN{|TAq_!xUr}Q7QoY1vZy-%JR*c(gd z%mb5-nJanp$a3&tNzYxn49j3RalRDY(W#6(SjY=iCx%@{piO44k3eMu;IXkk4Nw*ia3EM z@GPFg^LPQT;5EFCH}Mu`@Bu!=NBA6H;wyZEZ*dmC;~f6Ld0fOL{4G^VwNkybL28hi zq-JTWbgQ&S+AAHDhNO%%DvgO1NZ}5}?w`LhX`1A@r8Xfxl}R&KFSXd-ecgAfJxjjS zeq6xSwZSFee0*xb~*ZCBf63JS)SNIIAN0pI6UNx=7j6AQU2s!)v$P(heeixs1` zf)}nl%dP8fQe)aODj6X*Y$S0xm5UH>Zj8k=iKjD<{&Sx>s&t-9;FXz~*t$C(h|-9-}|Z6@biDy;@|%Rqo|8A z5M|*1nE{k{_I0+?+7^3<^RsrGt_SJjjoTFk1}@YE$8o~oI8J!^4?`Nq$&~vfLo5m; W4YmLLLqP4iX#bD)|A2efa`z8z(ekYT literal 0 HcmV?d00001 diff --git a/.claude/skills/notebooklm-api.md b/.claude/skills/notebooklm-api.md new file mode 100644 index 0000000..524dc08 --- /dev/null +++ b/.claude/skills/notebooklm-api.md @@ -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. diff --git a/.env b/.env new file mode 100644 index 0000000..7fef238 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +PORT=3456 +HEADLESS=false +# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome +API_KEY= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb0d7ef --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bc520b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +chrome-profile* +data/* \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..ee8f066 --- /dev/null +++ b/API.md @@ -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: ` | + +--- + +## 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//sources" --max-time 310 + +# Notebook nặng — chờ tối đa 10 phút +curl "http://localhost:3456/api/notebooks//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//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//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//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//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//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//chat/stream` + +**Gửi:** `{ "message": "câu hỏi" }` + +**Nhận:** +```json +{ "type": "connected", "notebookId": "" } +{ "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//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//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://:3456/api/notebooks//chat` +- Method: `POST` +- Body (JSON): `{"message": "{{$json.input}}"}` + +### Python + +```python +import requests + +BASE = "http://localhost:3456" +NB_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 = ''; + +// 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b750e9 --- /dev/null +++ b/README.md @@ -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 +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= + +# 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 = "" + +# 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//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) +``` diff --git a/docs/huong-dan-su-dung.html b/docs/huong-dan-su-dung.html new file mode 100644 index 0000000..1b53264 --- /dev/null +++ b/docs/huong-dan-su-dung.html @@ -0,0 +1,1072 @@ + + + + + +Hướng dẫn sử dụng — NotebookLM Scheduler + + + + + + + + +
+ + +
+
Tài liệu hướng dẫn
+

NotebookLM Scheduler

+

Hệ thống tự động hóa Google NotebookLM — đặt lịch chat, lưu kết quả và gửi webhook.

+
+ v1.0 + Tiếng Việt +
+
+ + +

Giới thiệu

+

NotebookLM Scheduler cho phép bạn tự động hóa việc hỏi đáp trên Google NotebookLM theo lịch định sẵn — không cần ngồi chờ hay thao tác thủ công.

+ + + + + + + + + + + + + + + + + + + + + + +
Tính năngMô tả
Xem Notebooks & SourcesXem danh sách notebook và tài liệu nguồn của bạn trên NotebookLM
Lịch chạy tự độngĐặt câu hỏi tự động vào khung giờ nhất định (hàng ngày, các ngày trong tuần...)
Lịch sử chạyXem toàn bộ câu trả lời từ các lần chạy trước
WebhookTự động gửi kết quả đến hệ thống khác (Slack, Telegram, CRM...)
+ + +

Truy cập hệ thống

+

Mở trình duyệt và truy cập vào địa chỉ mà admin cung cấp, thường có dạng:

+
+ 🌐 +

http://địa-chỉ-server:3456
Ví dụ: http://192.168.1.100:3456 hoặc http://my-server.com:3456

+
+ + +
+
+
+
+
+
http://my-server.com:3456
+
+ +
+ ↑ Giao diện chính — 3 tab điều hướng ở trên cùng +
+
+ +

Giao diện gồm 3 tab chính ở thanh điều hướng phía trên:

+
    +
  • Notebooks — xem danh sách notebook và tài liệu nguồn
  • +
  • Lịch chạy — quản lý lịch tự động chat
  • +
  • Lịch sử — xem kết quả các lần đã chạy
  • +
+ +

Góc trên bên phải hiển thị trạng thái đăng nhập:

+
    +
  • Đã đăng nhập — sẵn sàng hoạt động
  • +
  • Chưa đăng nhập — cần liên hệ admin
  • +
+ + +

Đăng nhập

+

Việc đăng nhập Google được admin thực hiện một lần duy nhất khi cài đặt hệ thống. Bạn không cần đăng nhập lại. Hệ thống tự duy trì phiên đăng nhập.

+ +
+ ⚠️ +

Nếu thấy chấm đỏ "Chưa đăng nhập" ở góc trên phải, hãy báo ngay cho admin để họ đăng nhập lại vào tài khoản Google NotebookLM.

+
+ + +

Notebooks & Sources

+

Tab Notebooks cho phép bạn xem toàn bộ notebook và tài liệu nguồn của mình trên Google NotebookLM.

+ + +
+
+
+
http://my-server.com:3456
+
+ +
+
+ +
+
+ Notebooks + +
+ +
+
+ Báo cáo tài chính Q2 + 4 +
+
+
+ Nghiên cứu thị trường 2026 + 7 +
+
+
+ Tài liệu sản phẩm + 12 +
+
+
+ Hợp đồng khách hàng + 3 +
+
+ +
+
+ Sources — Nghiên cứu thị trường 2026 + +
+
+ PDF + Bao-cao-thi-truong-2026.pdf +
+
+ URL + https://statista.com/vietnam-market-2026 +
+
+ TEXT + Ghi chú phân tích đối thủ +
+
+ URL + https://vnexpress.net/kinh-te/... +
+
+ AUDIO + Phỏng vấn chuyên gia tháng 5.mp3 +
+
+
+
+
+ +

Cách sử dụng

+
    +
  1. +
    Nhấp vào một notebook ở danh sách bên trái để chọn nó
    Notebook đang chọn sẽ được tô nền tím với đường kẻ dọc bên trái.
    +
  2. +
  3. +
    Xem danh sách tài liệu nguồn hiện ra ở bên phải
    Mỗi source có nhãn màu phân loại: PDF URL TEXT AUDIO
    +
  4. +
  5. +
    Nhấn "+ Tạo lịch chạy" để đặt lịch tự động hỏi notebook đã chọn
    +
  6. +
+ +
+ 💡 +

Số badge (ví dụ 7) trên mỗi notebook cho biết số lượng tài liệu nguồn đang có trong notebook đó.

+
+ + +

Lịch chạy tự động

+

Tab Lịch chạy là nơi bạn cài đặt các tác vụ tự động — hệ thống sẽ tự hỏi NotebookLM theo lịch bạn đặt và lưu lại kết quả.

+ + +
+
+
+
http://my-server.com:3456
+
+ +
+
+
+ Lịch chạy + 3 lịch +
+ +
+ +
+
+
📓 Nghiên cứu thị trường 2026
+
Báo cáo hàng ngày
+
Tóm tắt những điểm nổi bật nhất từ các tài liệu nghiên cứu thị trường. Liệt kê top 3 xu hướng quan trọng.
+
+ 🕗 07:30 · Hàng ngày + ✓ Lần cuối: thành công +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
📓 Báo cáo tài chính Q2
+
Phân tích cuối tuần
+
Phân tích tình hình tài chính tuần này so với kế hoạch đề ra. Có điểm nào cần chú ý?
+
+ 🕖 18:00 · Thứ 6 + ⏸ Đang tắt +
+
+
+
+
+
+
+
+
+
+
+
+
+ +

Tạo lịch chạy mới

+

Nhấn nút "+ Thêm lịch chạy" (hoặc "+ Tạo lịch chạy" từ tab Notebooks). Một cửa sổ sẽ xuất hiện:

+ + +
+
+
+
http://my-server.com:3456
+
+
+
+
+ Tạo lịch chạy mới + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
T2
+
T3
+
T4
+
T5
+
T6
+
T7
+
CN
+
+
+
+ +
+
+ Đang bật +
+
+
+ +
+
+
+ +

Các trường cần điền

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TrườngMô tảBắt buộc?
NotebookChọn notebook muốn hỏi. Danh sách lấy từ tài khoản NotebookLM của bạn.
Tên lịch chạyĐặt tên dễ nhớ để phân biệt với các lịch khác. Ví dụ: "Báo cáo sáng thứ 2".
Câu hỏiNội dung sẽ được gửi tới NotebookLM. Viết rõ ràng, cụ thể để nhận kết quả chính xác.
Giờ & PhútThời điểm tự động chạy trong ngày. Ví dụ: 07:30 sáng.
Lặp lạiHàng ngày / Ngày trong tuần (T2–T6) / Cuối tuần / Tùy chọn ngày.
Webhook URLĐịa chỉ để gửi kết quả đến hệ thống khác. Để trống nếu không cần.
+ +

Các nút thao tác trên mỗi lịch

+
+
+
+
+ Bật / Tắt lịch
+ Toggle tím = đang bật. Toggle xám = đang tắt (lịch không chạy nhưng vẫn được lưu). +
+
+
+
+ +
+
+ Chạy ngay
+ Chạy lịch này ngay lập tức, không cần chờ đến giờ đã đặt. Kết quả sẽ hiện trong tab Lịch sử. +
+
+
+
+ +
+
+ Chỉnh sửa
+ Thay đổi câu hỏi, giờ chạy, ngày lặp hoặc webhook. +
+
+
+
+ +
+
+ Xóa lịch
+ Xóa vĩnh viễn lịch chạy này. Lịch sử đã chạy vẫn được giữ lại. +
+
+
+ + +

Lịch sử chạy

+

Tab Lịch sử lưu toàn bộ kết quả của các lần chạy — cả thủ công lẫn tự động theo lịch. Bạn có thể xem câu hỏi đã gửi, câu trả lời nhận được và trạng thái gửi webhook.

+ + +
+
+
+
http://my-server.com:3456
+
+ +
+
+
Lịch sử chạy
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Thời gianLịch chạyNotebookTrạng tháiWebhook
17/06 07:30Báo cáo hàng ngàyNghiên cứu thị trường 2026✓ Thành công✓ 200
+
Dựa trên tài liệu nghiên cứu thị trường 2026, 3 xu hướng nổi bật nhất tuần này là:

1. Thương mại điện tử mobile tăng trưởng 34% so với cùng kỳ, tập trung nhóm 18-34 tuổi...
2. Thanh toán không tiền mặt đạt 78% giao dịch bán lẻ tại Hà Nội và TP.HCM...
3. Phân khúc mid-range đang là khu vực tăng trưởng nhanh nhất với biên lợi nhuận cải thiện 12%...
+
16/06 18:00Phân tích cuối tuầnBáo cáo tài chính Q2✓ Thành công
15/06 07:30Báo cáo hàng ngàyNghiên cứu thị trường 2026✗ Lỗi✗ 500
+
+
Nhấp vào hàng để xem toàn bộ câu trả lời ↑
+
+
+ +

Cách đọc bảng lịch sử

+
    +
  • Nhấp vào bất kỳ hàng nào để xem toàn bộ câu trả lời từ NotebookLM
  • +
  • ✓ Thành công — NotebookLM đã trả lời thành công
  • +
  • ✗ Lỗi — xảy ra lỗi khi chạy (session hết hạn, timeout...)
  • +
  • Cột Webhook hiển thị mã phản hồi HTTP (200 = gửi thành công, 5xx = lỗi server nhận)
  • +
+ +

Lọc kết quả

+

Dùng hai bộ lọc phía trên bên phải để tìm kiếm:

+
    +
  • Tất cả notebooks → chọn notebook cụ thể để xem lịch sử riêng
  • +
  • Tất cả trạng thái → lọc chỉ "Thành công" hoặc "Lỗi"
  • +
+ + +

Gửi kết quả ra ngoài (Webhook)

+

Webhook cho phép hệ thống tự động gửi câu trả lời đến một ứng dụng khác — như Slack, Telegram bot, CRM, Zapier, hoặc bất kỳ hệ thống nào có thể nhận HTTP request.

+ +
+ 📡 +

Webhook là tùy chọn. Nếu không điền, kết quả vẫn được lưu bình thường trong tab Lịch sử.

+
+ + +
+
+
+
http://my-server.com:3456
+
+
+
+
+ Tạo lịch chạy mới + +
+
+
+ ... (Notebook, Tên, Câu hỏi, Giờ chạy, Lặp lại) ... +
+
+
+ + +
Hệ thống sẽ POST kết quả đến URL này sau mỗi lần chạy
+
+
+
Headers xác thực
+
+ + +
✓ JSON hợp lệ
+
+
+
+ +
+
+
+ +

Dữ liệu được gửi đi

+

Mỗi lần chạy, hệ thống sẽ gửi một POST request với dữ liệu dạng JSON như sau:

+
+{
+  "job_id": 1,
+  "notebook_id": "abc-123-...",
+  "label": "Báo cáo hàng ngày",
+  "message": "Tóm tắt điểm nổi bật...",
+  "answer": "Dựa trên tài liệu, 3 xu hướng...",
+  "status": "success",
+  "ran_at": "2026-06-17T07:30:00.000Z"
+} +
+ +

Ví dụ tích hợp phổ biến

+ + + + + + + + + + + + + + + + + + + + + + +
Hệ thốngWebhook URLHeader cần thêm
Slackhttps://hooks.slack.com/services/xxxKhông cần thêm header
Telegram BotURL webhook của botKhông cần thêm header
API riêngURL endpoint của hệ thống bạnAuthorization: Bearer <token>
Make / ZapierWebhook URL từ scenario/zapKhông cần thêm header
+ + +

Câu hỏi thường gặp

+ +

Tôi không thấy notebook nào trong danh sách?

+

Hệ thống chỉ hiển thị các notebook do bạn tạo trên tài khoản Google NotebookLM đã đăng nhập. Nếu danh sách trống, nhấn nút "Làm mới" và kiểm tra lại trạng thái đăng nhập ở góc trên phải.

+ +

Lịch chạy bị bỏ qua nếu server đang tắt không?

+

Đúng vậy. Nếu server tắt đúng giờ đã đặt, lần chạy đó sẽ bị bỏ qua và không chạy bù. Hệ thống chỉ chạy đúng giờ theo lịch thực tế.

+ +

Câu trả lời bị rỗng hoặc thấy lỗi "NOT_AUTHENTICATED"?

+

Session Google đã hết hạn. Liên hệ admin để đăng nhập lại vào tài khoản Google trên server.

+ +

Tôi có thể đặt nhiều lịch cho cùng một notebook không?

+

Có thể. Một notebook có thể có nhiều lịch chạy với câu hỏi và thời điểm khác nhau — ví dụ báo cáo buổi sáng và tóm tắt buổi chiều.

+ +

Webhook gửi thất bại thì có retry không?

+

Không retry. Trạng thái webhook (thành công hay thất bại) được ghi lại trong Lịch sử để bạn theo dõi. Nếu thất bại, bạn có thể nhấn "Chạy ngay" để gửi lại thủ công.

+ +

Kết quả trong Lịch sử được lưu bao lâu?

+

Kết quả được lưu vĩnh viễn trong database của hệ thống, cho đến khi admin xóa. Không có giới hạn thời gian tự động xóa.

+ + +
+

NotebookLM Scheduler · Hướng dẫn sử dụng · Phiên bản 1.0

+
+ +
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8abd78d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3195 @@ +{ + "name": "notebooklm-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notebooklm-api", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", + "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "optional": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-bidi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", + "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1312386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", + "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "optional": true + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-ws": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", + "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", + "dependencies": { + "ws": "^7.4.6" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "optional": true, + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", + "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "deprecated": "< 24.15.0 is no longer supported", + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1312386", + "puppeteer-core": "22.15.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", + "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "chromium-bidi": "0.6.3", + "debug": "^4.3.6", + "devtools-protocol": "0.0.1312386", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/puppeteer-extra-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/puppeteer-extra/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sqlite3": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" + }, + "optionalDependencies": { + "node-gyp": "12.x" + }, + "peerDependencies": { + "node-gyp": "12.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", + "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "optional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "optional": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "optional": true, + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b11c008 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8ff13f3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,1216 @@ + + + + + +NotebookLM Scheduler + + + + + +
+ + +
+ + Đang kết nối... +
+
+ + +
+ + +
+
+ +
+
+ Notebooks + +
+
    +
    +
    +
    +
    +
    +
+
+ + +
+
+
+ +

Chọn một notebook để xem sources

+
+
+
+
+
+ + +
+
+
+ Lịch chạy tự động + +
+ +
+
+
+
+
+
+
+ + +
+
+
+ Lịch sử chạy + +
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + +
Thời gianNotebookCâu hỏiTrạng tháiWebhook
Đang tải...
+
+
+
+ +
+ + + + + +
+ + + + diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..ee8742d --- /dev/null +++ b/setup.sh @@ -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 diff --git a/src/browser.js b/src/browser.js new file mode 100644 index 0000000..d5d65ed --- /dev/null +++ b/src/browser.js @@ -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; diff --git a/src/cron-runner.js b/src/cron-runner.js new file mode 100644 index 0000000..25db884 --- /dev/null +++ b/src/cron-runner.js @@ -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 }; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..239f517 --- /dev/null +++ b/src/db.js @@ -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 }; diff --git a/src/nlm.js b/src/nlm.js new file mode 100644 index 0000000..2087047 --- /dev/null +++ b/src/nlm.js @@ -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; diff --git a/src/pool.js b/src/pool.js new file mode 100644 index 0000000..e7fc681 --- /dev/null +++ b/src/pool.js @@ -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); diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..b931d8a --- /dev/null +++ b/src/queue.js @@ -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; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..13797fc --- /dev/null +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/chat-ws.js b/src/routes/chat-ws.js new file mode 100644 index 0000000..4bd09ce --- /dev/null +++ b/src/routes/chat-ws.js @@ -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 }; diff --git a/src/routes/cron.js b/src/routes/cron.js new file mode 100644 index 0000000..f7df673 --- /dev/null +++ b/src/routes/cron.js @@ -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; diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..65d1751 --- /dev/null +++ b/src/routes/history.js @@ -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; diff --git a/src/routes/notebooks.js b/src/routes/notebooks.js new file mode 100644 index 0000000..002abf5 --- /dev/null +++ b/src/routes/notebooks.js @@ -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; diff --git a/src/selectors.js b/src/selectors.js new file mode 100644 index 0000000..e8b3612 --- /dev/null +++ b/src/selectors.js @@ -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 ) + 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; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..bf46246 --- /dev/null +++ b/src/server.js @@ -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`); +}); + diff --git a/src/swagger.js b/src/swagger.js new file mode 100644 index 0000000..eb2aab9 --- /dev/null +++ b/src/swagger.js @@ -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;