Commit c168a47c authored by ThinhNC's avatar ThinhNC

refactor(common): standardize hardcoded constants and enforce zero-hardcode architecture

parent 65fc55f1
...@@ -22,10 +22,10 @@ Route → Controller → Service → Repository → Prisma → Postgre ...@@ -22,10 +22,10 @@ Route → Controller → Service → Repository → Prisma → Postgre
- Mọi endpoint nhận dữ liệu từ client (`body`, `query`, `params`) phải có schema kiểm thực tương ứng bằng Zod. - Mọi endpoint nhận dữ liệu từ client (`body`, `query`, `params`) phải có schema kiểm thực tương ứng bằng Zod.
- Sử dụng middleware dùng chung: - Sử dụng middleware dùng chung:
```typescript ```typescript
import { validate } from '../../middlewares/validate.middleware'; import { validate } from "../../middlewares/validate.middleware";
import { mySchema } from './my.validation'; import { mySchema } from "./my.validation";
router.post('/', validate(mySchema), myController.create); router.post("/", validate(mySchema), myController.create);
``` ```
- DTO type được suy diễn trực tiếp từ schema: `type MyDto = z.infer<typeof mySchema>;`. - DTO type được suy diễn trực tiếp từ schema: `type MyDto = z.infer<typeof mySchema>;`.
...@@ -35,10 +35,10 @@ Route → Controller → Service → Repository → Prisma → Postgre ...@@ -35,10 +35,10 @@ Route → Controller → Service → Repository → Prisma → Postgre
- Bắt buộc dùng `AppError` kèm HTTP status code và mã `ERROR_CODE`: - Bắt buộc dùng `AppError` kèm HTTP status code và mã `ERROR_CODE`:
```typescript ```typescript
import { AppError } from '../../common/errors/app-error'; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from "../../common/errors/error-code";
throw new AppError('Resource not found', 404, ERROR_CODE.NOT_FOUND); throw new AppError("Resource not found", 404, ERROR_CODE.NOT_FOUND);
``` ```
- Định dạng response lỗi chuẩn: - Định dạng response lỗi chuẩn:
```json ```json
...@@ -55,5 +55,6 @@ Route → Controller → Service → Repository → Prisma → Postgre ...@@ -55,5 +55,6 @@ Route → Controller → Service → Repository → Prisma → Postgre
## 4. Hợp Đồng Dữ Liệu Cào (Data Contract V1) ## 4. Hợp Đồng Dữ Liệu Cào (Data Contract V1)
Bảo toàn hợp đồng dữ liệu quy định tại `docs/DATA_CONTRACT_V1.md`: Bảo toàn hợp đồng dữ liệu quy định tại `docs/DATA_CONTRACT_V1.md`:
- Dữ liệu thô (`raw`): Nguyên bản HTML từ Firecrawl. - Dữ liệu thô (`raw`): Nguyên bản HTML từ Firecrawl.
- Dữ liệu sạch (`clean`): Markdown chuẩn hóa qua Turndown, lọc bỏ script/ads/styles, tính toán `dataQualityScore``contentHash`. - Dữ liệu sạch (`clean`): Markdown chuẩn hóa qua Turndown, lọc bỏ script/ads/styles, tính toán `dataQualityScore``contentHash`.
...@@ -20,6 +20,7 @@ Khi xây dựng hoặc sửa đổi tính năng trong `data-crawler-be`, thực ...@@ -20,6 +20,7 @@ Khi xây dựng hoặc sửa đổi tính năng trong `data-crawler-be`, thực
## 2. Quy Chuẩn Commit Git ## 2. Quy Chuẩn Commit Git
Áp dụng chuẩn Conventional Commits: Áp dụng chuẩn Conventional Commits:
- `feat(<module>):` Thêm chức năng mới - `feat(<module>):` Thêm chức năng mới
- `fix(<module>):` Sửa lỗi nghiệp vụ hoặc kỹ thuật - `fix(<module>):` Sửa lỗi nghiệp vụ hoặc kỹ thuật
- `refactor(<module>):` Tối ưu hóa code mà không thay đổi tính năng - `refactor(<module>):` Tối ưu hóa code mà không thay đổi tính năng
......
...@@ -31,6 +31,7 @@ The workflow operates **autonomously** without requiring manual user prompt paci ...@@ -31,6 +31,7 @@ The workflow operates **autonomously** without requiring manual user prompt paci
## Core Invariants & Safety Guardrails ## Core Invariants & Safety Guardrails
### 1. General Safety Guardrails ### 1. General Safety Guardrails
- **DO NOT** delete data or drop database tables/schemas. - **DO NOT** delete data or drop database tables/schemas.
- **DO NOT** execute destructive migrations (`pnpm db:migrate:reset` or manual `DROP TABLE`). - **DO NOT** execute destructive migrations (`pnpm db:migrate:reset` or manual `DROP TABLE`).
- **DO NOT** remove authentication checks, disable authorization middleware, or weaken Zod validation schemas. - **DO NOT** remove authentication checks, disable authorization middleware, or weaken Zod validation schemas.
...@@ -38,7 +39,9 @@ The workflow operates **autonomously** without requiring manual user prompt paci ...@@ -38,7 +39,9 @@ The workflow operates **autonomously** without requiring manual user prompt paci
- **DO NOT** weaken security controls or mock out security middleware merely to make test suites pass. - **DO NOT** weaken security controls or mock out security middleware merely to make test suites pass.
### 2. Financial Logic Invariants ### 2. Financial Logic Invariants
When auditing or repairing applications handling wallets, transactions, budgets, or accounting: When auditing or repairing applications handling wallets, transactions, budgets, or accounting:
- **Income**: Increases target wallet balance. - **Income**: Increases target wallet balance.
- **Expense**: Decreases target wallet balance. - **Expense**: Decreases target wallet balance.
- **Transfer**: Decreases source wallet balance, increases destination wallet balance. A transfer **must never** be counted as income or expense in revenue/spending analytics. - **Transfer**: Decreases source wallet balance, increases destination wallet balance. A transfer **must never** be counted as income or expense in revenue/spending analytics.
...@@ -49,6 +52,7 @@ When auditing or repairing applications handling wallets, transactions, budgets, ...@@ -49,6 +52,7 @@ When auditing or repairing applications handling wallets, transactions, budgets,
- **Ownership & Tenant Isolation**: Never trust `userId` or `walletId` from client body or query params. Always verify that the authenticated user owns the resource being accessed or modified. - **Ownership & Tenant Isolation**: Never trust `userId` or `walletId` from client body or query params. Always verify that the authenticated user owns the resource being accessed or modified.
### 3. Timezone Invariants (Asia/Ho_Chi_Minh — UTC+7) ### 3. Timezone Invariants (Asia/Ho_Chi_Minh — UTC+7)
- The official business timezone is **`Asia/Ho_Chi_Minh` (UTC+7, +07:00)**. - The official business timezone is **`Asia/Ho_Chi_Minh` (UTC+7, +07:00)**.
- **Boundary Auditing**: All date filters, `startOfDay`, `endOfDay`, monthly aggregations, budget periods, reports, reminders, cron schedules, and daily quotas must be calculated in `Asia/Ho_Chi_Minh`. - **Boundary Auditing**: All date filters, `startOfDay`, `endOfDay`, monthly aggregations, budget periods, reports, reminders, cron schedules, and daily quotas must be calculated in `Asia/Ho_Chi_Minh`.
- **Near-Midnight Invariant**: A transaction occurring at `23:59:59` or `00:00:01` Vietnam time must strictly belong to the correct Vietnam business calendar date, regardless of whether the server or database runs in UTC (`+00:00`). - **Near-Midnight Invariant**: A transaction occurring at `23:59:59` or `00:00:01` Vietnam time must strictly belong to the correct Vietnam business calendar date, regardless of whether the server or database runs in UTC (`+00:00`).
...@@ -123,7 +127,9 @@ Convert all audit findings into a structured, prioritized backlog using the foll ...@@ -123,7 +127,9 @@ Convert all audit findings into a structured, prioritized backlog using the foll
- **P3 — Low**: Code quality, architectural convention drift, minor CPU/memory optimizations, documentation inaccuracies, or cosmetic formatting. - **P3 — Low**: Code quality, architectural convention drift, minor CPU/memory optimizations, documentation inaccuracies, or cosmetic formatting.
#### Finding Entry Structure #### Finding Entry Structure
For every finding recorded in the backlog: For every finding recorded in the backlog:
- **ID**: e.g., `BUG-P0-01`, `BUG-P1-02` - **ID**: e.g., `BUG-P0-01`, `BUG-P1-02`
- **Severity**: `P0` / `P1` / `P2` / `P3` - **Severity**: `P0` / `P1` / `P2` / `P3`
- **Module**: Feature/module directory name - **Module**: Feature/module directory name
...@@ -136,6 +142,7 @@ For every finding recorded in the backlog: ...@@ -136,6 +142,7 @@ For every finding recorded in the backlog:
- **Required Tests**: Specific test cases to prove the bug is resolved and prevent regressions - **Required Tests**: Specific test cases to prove the bug is resolved and prevent regressions
#### Priority Order for Triage #### Priority Order for Triage
1. Data corruption & data loss 1. Data corruption & data loss
2. Financial calculation and balance errors 2. Financial calculation and balance errors
3. Security vulnerabilities (SSRF, Auth/IDOR, Injection) 3. Security vulnerabilities (SSRF, Auth/IDOR, Injection)
...@@ -164,6 +171,7 @@ Before applying any code changes, rigorously verify every **P0** and **P1** find ...@@ -164,6 +171,7 @@ Before applying any code changes, rigorously verify every **P0** and **P1** find
### Step 4 — Fix P0 Issues ### Step 4 — Fix P0 Issues
Implement fixes for all `CONFIRMED` P0 findings adhering to these rules: Implement fixes for all `CONFIRMED` P0 findings adhering to these rules:
- **Smallest Safe Change**: Make the minimal diff necessary to fix the root cause. - **Smallest Safe Change**: Make the minimal diff necessary to fix the root cause.
- **Preserve Architecture**: Follow existing repository patterns and layered architecture. - **Preserve Architecture**: Follow existing repository patterns and layered architecture.
- **No Unrelated Refactors**: Do not reformat or clean up unrelated code in the same change. - **No Unrelated Refactors**: Do not reformat or clean up unrelated code in the same change.
...@@ -197,6 +205,7 @@ Validate that all P0 fixes are working and introduce no regressions: ...@@ -197,6 +205,7 @@ Validate that all P0 fixes are working and introduce no regressions:
### Step 6 — Fix P1 Issues ### Step 6 — Fix P1 Issues
Once P0 fixes are verified and green: Once P0 fixes are verified and green:
- Apply targeted, minimal fixes for all `CONFIRMED` P1 findings. - Apply targeted, minimal fixes for all `CONFIRMED` P1 findings.
- Maintain the same strict standards: no architectural disruption, no breaking contract changes, minimal clean diff. - Maintain the same strict standards: no architectural disruption, no breaking contract changes, minimal clean diff.
...@@ -226,13 +235,14 @@ Perform a second full audit pass over the entire codebase to verify resolution a ...@@ -226,13 +235,14 @@ Perform a second full audit pass over the entire codebase to verify resolution a
### Step 9 — Secondary Fixes (Convergence Loop) ### Step 9 — Secondary Fixes (Convergence Loop)
If the re-audit uncovers new `CONFIRMED` P0 or P1 issues caused by recent edits: If the re-audit uncovers new `CONFIRMED` P0 or P1 issues caused by recent edits:
1. Re-enter the loop: `VERIFY -> FIX -> TEST -> RE-AUDIT`. 1. Re-enter the loop: `VERIFY -> FIX -> TEST -> RE-AUDIT`.
2. Iterate until: 2. Iterate until:
- Zero confirmed P0 issues remain. - Zero confirmed P0 issues remain.
- Zero confirmed P1 issues remain. - Zero confirmed P1 issues remain.
- All tests pass cleanly. - All tests pass cleanly.
- Lint and typecheck pass with zero errors. - Lint and typecheck pass with zero errors.
3. *Convergence limit*: If an issue cannot be resolved within 3 iterations without major architectural redesign, document it clearly in the report as `Deferred` and stop the loop. 3. _Convergence limit_: If an issue cannot be resolved within 3 iterations without major architectural redesign, document it clearly in the report as `Deferred` and stop the loop.
--- ---
...@@ -251,14 +261,17 @@ Create directory `docs/audits/` (if it does not exist) and write the final repor ...@@ -251,14 +261,17 @@ Create directory `docs/audits/` (if it does not exist) and write the final repor
**Status**: [Clean / Action Required / Converged] **Status**: [Clean / Action Required / Converged]
## Executive Summary ## Executive Summary
Concise 2–3 paragraph summary of the audit scope, critical issues discovered, fixes applied, test outcomes, and current repository health. Concise 2–3 paragraph summary of the audit scope, critical issues discovered, fixes applied, test outcomes, and current repository health.
## Initial Findings Backlog ## Initial Findings Backlog
Summary table of all findings from Step 2 with Severity (P0, P1, P2, P3), Module, and Status (Fixed / Verified / Deferred). Summary table of all findings from Step 2 with Severity (P0, P1, P2, P3), Module, and Status (Fixed / Verified / Deferred).
## Fixed Issues Detail ## Fixed Issues Detail
### [BUG-P0-01] [Issue Title] ### [BUG-P0-01] [Issue Title]
- **Severity**: P0 - **Severity**: P0
- **Module**: [module] - **Module**: [module]
- **Root Cause**: [explanation] - **Root Cause**: [explanation]
...@@ -270,18 +283,22 @@ Summary table of all findings from Step 2 with Severity (P0, P1, P2, P3), Module ...@@ -270,18 +283,22 @@ Summary table of all findings from Step 2 with Severity (P0, P1, P2, P3), Module
[... repeat for all fixed P0 and P1 issues ...] [... repeat for all fixed P0 and P1 issues ...]
## Test Execution Summary ## Test Execution Summary
- **Typecheck**: PASSED - **Typecheck**: PASSED
- **Lint**: PASSED - **Lint**: PASSED
- **Unit & Integration Tests**: [X] passed, 0 failed - **Unit & Integration Tests**: [X] passed, 0 failed
- **New Tests Added**: [list of new test suites] - **New Tests Added**: [list of new test suites]
## Re-Audit Results ## Re-Audit Results
Detailed checklist proving no secondary regressions, contract breakages, or timezone errors remain. Detailed checklist proving no secondary regressions, contract breakages, or timezone errors remain.
## Remaining & Deferred Issues (P2 / P3) ## Remaining & Deferred Issues (P2 / P3)
List of non-blocking P2 and P3 issues scheduled for future maintenance cycles with recommended remediation. List of non-blocking P2 and P3 issues scheduled for future maintenance cycles with recommended remediation.
## Risk Assessment & Next Steps ## Risk Assessment & Next Steps
- Remaining operational or infrastructure risks. - Remaining operational or infrastructure risks.
- Actionable recommendations for the development team. - Actionable recommendations for the development team.
``` ```
...@@ -291,6 +308,7 @@ List of non-blocking P2 and P3 issues scheduled for future maintenance cycles wi ...@@ -291,6 +308,7 @@ List of non-blocking P2 and P3 issues scheduled for future maintenance cycles wi
## Final Output Summary ## Final Output Summary
Upon completion of the workflow, output a clear, concise terminal summary: Upon completion of the workflow, output a clear, concise terminal summary:
- **P0 Fixed**: Total count and IDs - **P0 Fixed**: Total count and IDs
- **P1 Fixed**: Total count and IDs - **P1 Fixed**: Total count and IDs
- **P2 / P3 Remaining**: Total count and IDs - **P2 / P3 Remaining**: Total count and IDs
......
...@@ -35,6 +35,7 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`: ...@@ -35,6 +35,7 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`:
``` ```
> **Quy tắc chống chặn (Anti-bot):** > **Quy tắc chống chặn (Anti-bot):**
>
> - Amazon áp dụng cơ chế phát hiện bot rất mạnh. Luôn đặt `delayMs >= 2000`ms. > - Amazon áp dụng cơ chế phát hiện bot rất mạnh. Luôn đặt `delayMs >= 2000`ms.
> - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` hoặc `BLOCKED` trong bảng `crawl_pages` để cảnh báo kịp thời. > - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` hoặc `BLOCKED` trong bảng `crawl_pages` để cảnh báo kịp thời.
...@@ -49,12 +50,43 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`: ...@@ -49,12 +50,43 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`:
"domain": "amazon.com", "domain": "amazon.com",
"name": "Amazon Product Standard Template", "name": "Amazon Product Standard Template",
"fields": [ "fields": [
{ "name": "title", "selector": "#productTitle, h2 a.a-link-normal span", "type": "text", "required": true }, {
{ "name": "price", "selector": ".a-price .a-offscreen, span.a-price-whole", "type": "text", "required": false }, "name": "title",
{ "name": "rating", "selector": "span[data-hook='rating-out-of-text'], span.a-icon-alt", "type": "text", "required": false }, "selector": "#productTitle, h2 a.a-link-normal span",
{ "name": "reviewCount", "selector": "#acrCustomerReviewText, span[data-hook='total-review-count']", "type": "number", "required": false }, "type": "text",
{ "name": "mainImage", "selector": "#landingImage, .s-image", "type": "attribute", "attributeName": "src", "required": false }, "required": true
{ "name": "availability", "selector": "#availability span", "type": "text", "required": false } },
{
"name": "price",
"selector": ".a-price .a-offscreen, span.a-price-whole",
"type": "text",
"required": false
},
{
"name": "rating",
"selector": "span[data-hook='rating-out-of-text'], span.a-icon-alt",
"type": "text",
"required": false
},
{
"name": "reviewCount",
"selector": "#acrCustomerReviewText, span[data-hook='total-review-count']",
"type": "number",
"required": false
},
{
"name": "mainImage",
"selector": "#landingImage, .s-image",
"type": "attribute",
"attributeName": "src",
"required": false
},
{
"name": "availability",
"selector": "#availability span",
"type": "text",
"required": false
}
] ]
} }
``` ```
...@@ -64,5 +96,6 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`: ...@@ -64,5 +96,6 @@ Gửi yêu cầu tới `POST /api/v1/crawl-jobs`:
## 4. Xuất Dữ Liệu Sau Khi Cào ## 4. Xuất Dữ Liệu Sau Khi Cào
Sau khi Job đạt trạng thái `COMPLETED`: Sau khi Job đạt trạng thái `COMPLETED`:
- Kích hoạt export sang Excel qua `POST /api/v1/exports` với `exportType: "XLSX"`. - Kích hoạt export sang Excel qua `POST /api/v1/exports` với `exportType: "XLSX"`.
- Báo cáo kết quả và đường dẫn tải file xuất cho người dùng. - Báo cáo kết quả và đường dẫn tải file xuất cho người dùng.
...@@ -22,6 +22,7 @@ Route → Controller → Service → Repository → Prisma Client → ...@@ -22,6 +22,7 @@ Route → Controller → Service → Repository → Prisma Client →
``` ```
### Quy tắc bất di bất dịch: ### Quy tắc bất di bất dịch:
1. **Chỉ Repository được gọi Prisma:** Tuyệt đối **chỉ có** các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Service, Controller, Worker hay Helper **không bao giờ** được gọi Prisma trực tiếp. 1. **Chỉ Repository được gọi Prisma:** Tuyệt đối **chỉ có** các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Service, Controller, Worker hay Helper **không bao giờ** được gọi Prisma trực tiếp.
2. **Cấu trúc Module chuẩn:** Mọi tính năng nghiệp vụ đặt tại `src/modules/<feature>/` với đầy đủ các file quy chuẩn: 2. **Cấu trúc Module chuẩn:** Mọi tính năng nghiệp vụ đặt tại `src/modules/<feature>/` với đầy đủ các file quy chuẩn:
- `<feature>.route.ts`: Định nghĩa endpoint, gắn middleware (auth, validate, rate-limit). - `<feature>.route.ts`: Định nghĩa endpoint, gắn middleware (auth, validate, rate-limit).
......
...@@ -16,21 +16,25 @@ Bạn là **Reviewer Sub-Agent** chịu trách nhiệm kiểm duyệt mọi thay ...@@ -16,21 +16,25 @@ Bạn là **Reviewer Sub-Agent** chịu trách nhiệm kiểm duyệt mọi thay
## 1. Danh Sách Kiểm Tra Bắt Buộc (Review Checklist) ## 1. Danh Sách Kiểm Tra Bắt Buộc (Review Checklist)
### A. Tính Tuân Thủ Kiến Trúc (Architectural Compliance) ### A. Tính Tuân Thủ Kiến Trúc (Architectural Compliance)
- [ ] **Quy tắc Prisma độc quyền:** Chỉ duy nhất các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Tuyệt đối không chấp nhận Prisma query trong Controller, Service, Worker hay Middleware. - [ ] **Quy tắc Prisma độc quyền:** Chỉ duy nhất các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Tuyệt đối không chấp nhận Prisma query trong Controller, Service, Worker hay Middleware.
- [ ] **Phân tách tầng rõ ràng:** Controller không chứa logic tính toán nghiệp vụ; Service không can thiệp vào định dạng response HTTP (`res.status()`). - [ ] **Phân tách tầng rõ ràng:** Controller không chứa logic tính toán nghiệp vụ; Service không can thiệp vào định dạng response HTTP (`res.status()`).
- [ ] **Đăng ký Route:** Route mới đã được đăng ký vào `src/routes/index.ts` và có prefix hợp lệ `/api/v1/...`. - [ ] **Đăng ký Route:** Route mới đã được đăng ký vào `src/routes/index.ts` và có prefix hợp lệ `/api/v1/...`.
### B. Tính Toàn Vẹn Dữ Liệu & Xác Thực (Validation & Type Safety) ### B. Tính Toàn Vẹn Dữ Liệu & Xác Thực (Validation & Type Safety)
- [ ] **Xác thực Zod:** Toàn bộ Body, Query và Params phải đi qua `validate(schema)` middleware. - [ ] **Xác thực Zod:** Toàn bộ Body, Query và Params phải đi qua `validate(schema)` middleware.
- [ ] **Kiểu dữ liệu TypeScript:** Không dùng `any` bừa bãi. Sử dụng `z.infer<typeof schema>` cho các DTO. - [ ] **Kiểu dữ liệu TypeScript:** Không dùng `any` bừa bãi. Sử dụng `z.infer<typeof schema>` cho các DTO.
- [ ] **Xử lý lỗi:** Lỗi phải được ném ra qua `AppError` với `statusCode``ERROR_CODE` chuẩn mực, không dùng `throw new Error()`. - [ ] **Xử lý lỗi:** Lỗi phải được ném ra qua `AppError` với `statusCode``ERROR_CODE` chuẩn mực, không dùng `throw new Error()`.
### C. Hiệu Năng & Cơ Sở Dữ Liệu (Performance & Database) ### C. Hiệu Năng & Cơ Sở Dữ Liệu (Performance & Database)
- [ ] **Tránh N+1 Query:** Khi truy vấn dữ liệu liên kết, phải sử dụng `include` hoặc `select` hợp lý thay vì gọi lặp lại trong vòng lặp `for`/`forEach`. - [ ] **Tránh N+1 Query:** Khi truy vấn dữ liệu liên kết, phải sử dụng `include` hoặc `select` hợp lý thay vì gọi lặp lại trong vòng lặp `for`/`forEach`.
- [ ] **Đúng chỉ mục (Index):** Các trường thường xuyên filter, sort (`status`, `createdAt`, `userId`, `domain`) phải có index trong `schema.prisma`. - [ ] **Đúng chỉ mục (Index):** Các trường thường xuyên filter, sort (`status`, `createdAt`, `userId`, `domain`) phải có index trong `schema.prisma`.
- [ ] **Xử lý Stream:** Các tác vụ export file dung lượng lớn bắt buộc phải dùng luồng (Stream) thay vì dồn toàn bộ vào RAM. - [ ] **Xử lý Stream:** Các tác vụ export file dung lượng lớn bắt buộc phải dùng luồng (Stream) thay vì dồn toàn bộ vào RAM.
### D. Kiểm Thử & Kiểm Định (Testing & Verification) ### D. Kiểm Thử & Kiểm Định (Testing & Verification)
- [ ] Đã bổ sung unit test tương ứng trong thư mục `__tests__/` liền kề. - [ ] Đã bổ sung unit test tương ứng trong thư mục `__tests__/` liền kề.
- [ ] Lệnh `pnpm lint` chạy không có cảnh báo nghiêm trọng hoặc lỗi cú pháp. - [ ] Lệnh `pnpm lint` chạy không có cảnh báo nghiêm trọng hoặc lỗi cú pháp.
- [ ] Lệnh `pnpm test -- --runInBand` chạy thành công 100%. - [ ] Lệnh `pnpm test -- --runInBand` chạy thành công 100%.
...@@ -40,6 +44,7 @@ Bạn là **Reviewer Sub-Agent** chịu trách nhiệm kiểm duyệt mọi thay ...@@ -40,6 +44,7 @@ Bạn là **Reviewer Sub-Agent** chịu trách nhiệm kiểm duyệt mọi thay
## 2. Tiêu Chuẩn Phản Hồi Khi Review ## 2. Tiêu Chuẩn Phản Hồi Khi Review
Khi đưa ra nhận xét, Reviewer phải phân loại theo 3 mức độ: Khi đưa ra nhận xét, Reviewer phải phân loại theo 3 mức độ:
1. 🔴 **[BLOCKER]**: Vi phạm nghiêm trọng kiến trúc (ví dụ: Service gọi Prisma), lỗ hổng bảo mật, làm gãy test. Yêu cầu sửa ngay lập tức. 1. 🔴 **[BLOCKER]**: Vi phạm nghiêm trọng kiến trúc (ví dụ: Service gọi Prisma), lỗ hổng bảo mật, làm gãy test. Yêu cầu sửa ngay lập tức.
2. 🟡 **[WARNING]**: Chưa tối ưu hiệu năng, thiếu test case biên hoặc chưa cập nhật Swagger. Cần cân nhắc xử lý. 2. 🟡 **[WARNING]**: Chưa tối ưu hiệu năng, thiếu test case biên hoặc chưa cập nhật Swagger. Cần cân nhắc xử lý.
3. 🟢 **[SUGGESTION]**: Góp ý làm gọn code, đặt tên biến rõ nghĩa hơn hoặc cải thiện comment. 3. 🟢 **[SUGGESTION]**: Góp ý làm gọn code, đặt tên biến rõ nghĩa hơn hoặc cải thiện comment.
...@@ -46,10 +46,10 @@ Hệ thống áp dụng kiến trúc phân lớp hướng dịch vụ (Layered C ...@@ -46,10 +46,10 @@ Hệ thống áp dụng kiến trúc phân lớp hướng dịch vụ (Layered C
- Mọi dữ liệu đầu vào từ người dùng (Body, Query, Params) **bắt buộc** phải được định nghĩa Schema bằng **Zod** trong `<feature>.validation.ts`. - Mọi dữ liệu đầu vào từ người dùng (Body, Query, Params) **bắt buộc** phải được định nghĩa Schema bằng **Zod** trong `<feature>.validation.ts`.
- Sử dụng middleware dùng chung `validateMiddleware`: - Sử dụng middleware dùng chung `validateMiddleware`:
```typescript ```typescript
import { validate } from '../../middlewares/validate.middleware'; import { validate } from "../../middlewares/validate.middleware";
import { createCrawlJobSchema } from './crawl-job.validation'; import { createCrawlJobSchema } from "./crawl-job.validation";
router.post('/', validate(createCrawlJobSchema), crawlJobController.create); router.post("/", validate(createCrawlJobSchema), crawlJobController.create);
``` ```
- Định nghĩa kiểu TypeScript tương ứng (`DTO`) bằng `z.infer<typeof schema>` trong `<feature>.dto.ts`. - Định nghĩa kiểu TypeScript tương ứng (`DTO`) bằng `z.infer<typeof schema>` trong `<feature>.dto.ts`.
...@@ -60,11 +60,11 @@ Hệ thống áp dụng kiến trúc phân lớp hướng dịch vụ (Layered C ...@@ -60,11 +60,11 @@ Hệ thống áp dụng kiến trúc phân lớp hướng dịch vụ (Layered C
- Không dùng `throw new Error("...")` một cách tùy tiện. - Không dùng `throw new Error("...")` một cách tùy tiện.
- Bắt buộc kế thừa từ `AppError`: - Bắt buộc kế thừa từ `AppError`:
```typescript ```typescript
import { AppError } from '../../common/errors/app-error'; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from "../../common/errors/error-code";
if (!job) { if (!job) {
throw new AppError('Crawl job not found', 404, ERROR_CODE.NOT_FOUND); throw new AppError("Crawl job not found", 404, ERROR_CODE.NOT_FOUND);
} }
``` ```
- Cấu trúc response trả về cho client luôn thống nhất: - Cấu trúc response trả về cho client luôn thống nhất:
......
...@@ -6,12 +6,12 @@ ...@@ -6,12 +6,12 @@
## 1. Môi Trường & Phiên Bản Chuẩn (Core Runtime) ## 1. Môi Trường & Phiên Bản Chuẩn (Core Runtime)
| Thành phần | Phiên bản / Thư viện | Ghi chú quy ước | | Thành phần | Phiên bản / Thư viện | Ghi chú quy ước |
| :--- | :--- | :--- | | :------------------ | :------------------- | :---------------------------------------------- |
| **Node.js** | `>= 20.x` | Sử dụng cú pháp ES2022+ hiện đại | | **Node.js** | `>= 20.x` | Sử dụng cú pháp ES2022+ hiện đại |
| **Package Manager** | `pnpm@9.15.0` | Không dùng npm hay yarn để tránh lệch pnpm-lock | | **Package Manager** | `pnpm@9.15.0` | Không dùng npm hay yarn để tránh lệch pnpm-lock |
| **Ngôn ngữ** | `TypeScript 5.7` | `strict: true`, không dùng kiểu `any` vô căn cứ | | **Ngôn ngữ** | `TypeScript 5.7` | `strict: true`, không dùng kiểu `any` vô căn cứ |
| **Web Framework** | `Express.js 4.21` | Tách rời App configuration và Server listener | | **Web Framework** | `Express.js 4.21` | Tách rời App configuration và Server listener |
--- ---
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
Khi xây dựng một module hoặc tính năng mới, Agent cần thực hiện tuần tự theo 6 bước: Khi xây dựng một module hoặc tính năng mới, Agent cần thực hiện tuần tự theo 6 bước:
``` ```
[1. Khảo sát Schema & Yêu Cầu] [1. Khảo sát Schema & Yêu Cầu]
[2. Viết Repository (Prisma Query)] [2. Viết Repository (Prisma Query)]
...@@ -82,6 +82,7 @@ pnpm build ...@@ -82,6 +82,7 @@ pnpm build
## 5. Quy Trình Bổ Sung Queue Worker ## 5. Quy Trình Bổ Sung Queue Worker
Khi tạo thêm Worker xử lý tác vụ nền: Khi tạo thêm Worker xử lý tác vụ nền:
1. Tạo Queue tại `src/queues/<job-name>.queue.ts`. 1. Tạo Queue tại `src/queues/<job-name>.queue.ts`.
2. Tạo Processor xử lý logic tại `src/queues/<job-name>.worker.processor.ts`. 2. Tạo Processor xử lý logic tại `src/queues/<job-name>.worker.processor.ts`.
3. Khởi tạo Worker file tại `src/queues/<job-name>.worker.ts`. 3. Khởi tạo Worker file tại `src/queues/<job-name>.worker.ts`.
......
...@@ -31,6 +31,7 @@ Khi khởi tạo `CrawlJob` qua API `POST /api/v1/crawl-jobs`, sử dụng paylo ...@@ -31,6 +31,7 @@ Khi khởi tạo `CrawlJob` qua API `POST /api/v1/crawl-jobs`, sử dụng paylo
``` ```
> **Lưu ý chống chặn (Anti-bot):** > **Lưu ý chống chặn (Anti-bot):**
>
> - Luôn thiết lập `delayMs` tối thiểu `2000`ms để tránh bị hệ thống Amazon chặn IP hoặc hiển thị CAPTCHA. > - Luôn thiết lập `delayMs` tối thiểu `2000`ms để tránh bị hệ thống Amazon chặn IP hoặc hiển thị CAPTCHA.
> - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` và `BLOCKED` trong bảng `crawl_pages` để cảnh báo người dùng. > - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` và `BLOCKED` trong bảng `crawl_pages` để cảnh báo người dùng.
......
...@@ -13,7 +13,8 @@ Route → Controller → Service → Repository → Prisma Client → ...@@ -13,7 +13,8 @@ Route → Controller → Service → Repository → Prisma Client →
``` ```
### Quy tắc bất biến: ### Quy tắc bất biến:
- **Độc quyền Prisma:** Chỉ duy nhất các file `*.repository.ts` được phép import và gọi `prisma` hoặc `PrismaClient`. Service, Controller, Worker, Helper và Middleware **tuyệt đối không** được gọi Prisma trực tiếp.
- **Độc quyền Prisma:** Chỉ duy nhất các file `*.repository.ts` được phép import và gọi `prisma` hoặc `PrismaClient`. Service, Controller, Worker, Helper và Middleware **tuyệt đối không** được gọi Prisma trực tiếp. Đồng thời, các tầng ngoài Repository (DTO, Service, Controller, Middleware) **không import enum từ `@prisma/client`** (ví dụ: `UserRole`, `CrawlJobStatus`, `CrawlMode`, `ExportType`, `AssetType`), mà phải sử dụng types từ `src/common/constants/`.
- **Tổ chức Module chuẩn (`src/modules/<feature>/`):** - **Tổ chức Module chuẩn (`src/modules/<feature>/`):**
- `<feature>.route.ts`: Khai báo endpoints, gắn middleware (`auth`, `role`, `validate`, `rateLimit`). - `<feature>.route.ts`: Khai báo endpoints, gắn middleware (`auth`, `role`, `validate`, `rateLimit`).
- `<feature>.controller.ts`: Nhận HTTP request, trích xuất parameters, gọi Service, trả response HTTP chuẩn. - `<feature>.controller.ts`: Nhận HTTP request, trích xuất parameters, gọi Service, trả response HTTP chuẩn.
...@@ -23,18 +24,41 @@ Route → Controller → Service → Repository → Prisma Client → ...@@ -23,18 +24,41 @@ Route → Controller → Service → Repository → Prisma Client →
- `__tests__/`: Chứa colocated unit/integration tests cho module. - `__tests__/`: Chứa colocated unit/integration tests cho module.
- **Routing:** Mọi router module mới phải được mount tập trung trong `src/routes/index.ts` với prefix `/api/v1/`. - **Routing:** Mọi router module mới phải được mount tập trung trong `src/routes/index.ts` với prefix `/api/v1/`.
- **Mã dùng chung (`src/common/`):** Chỉ đặt vào `src/common/` (errors, helpers, constants, types, storage) khi code thực sự được tái sử dụng qua ít nhất 2 modules. - **Mã dùng chung (`src/common/`):** Chỉ đặt vào `src/common/` (errors, helpers, constants, types, storage) khi code thực sự được tái sử dụng qua ít nhất 2 modules.
- **Chuẩn Hóa Constants & Cấm Tuyệt Đối Hardcode (Zero Hardcode Principle):**
- **Tập trung tại `src/common/constants/`:** Mọi chuỗi trạng thái (`JOB_STATUS`), vai trò người dùng (`ROLES`), chế độ thu thập (`CRAWL_MODE`), tần suất lịch (`SCHEDULE_FREQUENCY`), kiểu xuất dữ liệu (`EXPORT_TYPE`), loại tài nguyên (`ASSET_TYPE`), múi giờ (`DEFAULT_TIMEZONE`), v.v. **bắt buộc** phải được định nghĩa trong `src/common/constants/*.constant.ts` dưới dạng object `as const` và export type `keyof typeof CONSTANT`. Barrel export tập trung tại `src/common/constants/index.ts`.
- **Cấm hardcode chuỗi / mảng trong code:** Tuyệt đối không viết trực tiếp string literal (ví dụ: `'COMPLETED'`, `'SCRAPE'`, `'ADMIN'`, `'Asia/Ho_Chi_Minh'`) trong Controllers, Services, Workers, DTOs, Repositories, Helpers hoặc Schemas. Luôn dùng `CONSTANT.KEY` hoặc `Object.values(CONSTANT)`.
- **Sử dụng trong Zod Validation (`*.validation.ts`):** Luôn dùng `z.nativeEnum(CONSTANT)` thay vì khai báo mảng chuỗi `z.enum(['VAL1', 'VAL2'])`.
- **Pattern chuẩn:**
```typescript
// 1. Khai báo (src/common/constants/crawl-mode.constant.ts):
export const CRAWL_MODE = {
SCRAPE: 'SCRAPE',
CRAWL: 'CRAWL',
SITEMAP: 'SITEMAP',
URL_LIST: 'URL_LIST',
} as const;
export type CrawlMode = keyof typeof CRAWL_MODE;
// 2. Validation (crawl-job.validation.ts):
mode: z.nativeEnum(CRAWL_MODE).optional().default(CRAWL_MODE.SCRAPE)
// 3. Logic nghiệp vụ (crawl-job.service.ts / crawl.worker.processor.ts):
if (job.mode === CRAWL_MODE.URL_LIST) { ... }
```
--- ---
## 2. Dữ Liệu & Tích Hợp (Data, Prisma & Workers) ## 2. Dữ Liệu & Tích Hợp (Data, Prisma & Workers)
### A. Cơ sở dữ liệu & Prisma Migrations ### A. Cơ sở dữ liệu & Prisma Migrations
- `prisma/schema.prisma` là nguồn chân lý duy nhất (Single Source of Truth) của database schema. - `prisma/schema.prisma` là nguồn chân lý duy nhất (Single Source of Truth) của database schema.
- Thao tác Prisma thông qua script runner: `node scripts/prisma-run.js <cmd>`. Runner tự động tổng hợp `DATABASE_URL` từ các biến môi trường cấu hình (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSL`). - Thao tác Prisma thông qua script runner: `node scripts/prisma-run.js <cmd>`. Runner tự động tổng hợp `DATABASE_URL` từ các biến môi trường cấu hình (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSL`).
- Mọi thay đổi schema phải sinh migration tương ứng bằng `pnpm db:migrate` và commit đồng thời cả `schema.prisma` lẫn thư mục migration. - Mọi thay đổi schema phải sinh migration tương ứng bằng `pnpm db:migrate` và commit đồng thời cả `schema.prisma` lẫn thư mục migration.
- **CẤM:** Không bao giờ chạy `pnpm db:migrate:reset` trừ khi người dùng yêu cầu rõ ràng việc xóa trắng dữ liệu. - **CẤM:** Không bao giờ chạy `pnpm db:migrate:reset` trừ khi người dùng yêu cầu rõ ràng việc xóa trắng dữ liệu.
### B. Hàng đợi bất đồng bộ & Worker (BullMQ + Redis) ### B. Hàng đợi bất đồng bộ & Worker (BullMQ + Redis)
- Các tác vụ nặng (thu thập web, gửi webhook, chạy lịch cron) phải chuyển qua hàng đợi BullMQ: - Các tác vụ nặng (thu thập web, gửi webhook, chạy lịch cron) phải chuyển qua hàng đợi BullMQ:
- `crawl.queue.ts` / `crawl.worker.ts` / `crawl.worker.processor.ts` - `crawl.queue.ts` / `crawl.worker.ts` / `crawl.worker.processor.ts`
- `webhook.queue.ts` / `webhook.worker.ts` - `webhook.queue.ts` / `webhook.worker.ts`
......
...@@ -583,10 +583,10 @@ src/database/prisma.client.ts ...@@ -583,10 +583,10 @@ src/database/prisma.client.ts
``` ```
```ts ```ts
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({ export const prisma = new PrismaClient({
log: ['error', 'warn'], log: ["error", "warn"],
}); });
``` ```
...@@ -594,9 +594,10 @@ Nếu cần log query khi development: ...@@ -594,9 +594,10 @@ Nếu cần log query khi development:
```ts ```ts
export const prisma = new PrismaClient({ export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' log:
? ['query', 'error', 'warn'] process.env.NODE_ENV === "development"
: ['error', 'warn'], ? ["query", "error", "warn"]
: ["error", "warn"],
}); });
``` ```
...@@ -628,17 +629,17 @@ Không để repository xử lý nghiệp vụ. ...@@ -628,17 +629,17 @@ Không để repository xử lý nghiệp vụ.
### 11.1. Route ### 11.1. Route
```ts ```ts
import { Router } from 'express'; import { Router } from "express";
import { CrawlJobController } from './crawl-job.controller'; import { CrawlJobController } from "./crawl-job.controller";
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from "../../middlewares/auth.middleware";
const router = Router(); const router = Router();
const controller = new CrawlJobController(); const controller = new CrawlJobController();
router.post('/', authMiddleware, controller.create); router.post("/", authMiddleware, controller.create);
router.get('/', authMiddleware, controller.findAll); router.get("/", authMiddleware, controller.findAll);
router.get('/:id', authMiddleware, controller.findById); router.get("/:id", authMiddleware, controller.findById);
router.post('/:id/cancel', authMiddleware, controller.cancel); router.post("/:id/cancel", authMiddleware, controller.cancel);
export default router; export default router;
``` ```
...@@ -648,8 +649,8 @@ export default router; ...@@ -648,8 +649,8 @@ export default router;
### 11.2. Controller ### 11.2. Controller
```ts ```ts
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { CrawlJobService } from './crawl-job.service'; import { CrawlJobService } from "./crawl-job.service";
export class CrawlJobController { export class CrawlJobController {
private readonly service = new CrawlJobService(); private readonly service = new CrawlJobService();
...@@ -717,9 +718,9 @@ export class CrawlJobController { ...@@ -717,9 +718,9 @@ export class CrawlJobController {
### 11.3. Service ### 11.3. Service
```ts ```ts
import { CrawlJobRepository } from './crawl-job.repository'; import { CrawlJobRepository } from "./crawl-job.repository";
import { AppError } from '../../common/errors/app-error'; import { AppError } from "../../common/errors/app-error";
import { crawlQueue } from '../../queues/crawl.queue'; import { crawlQueue } from "../../queues/crawl.queue";
export class CrawlJobService { export class CrawlJobService {
private readonly repository = new CrawlJobRepository(); private readonly repository = new CrawlJobRepository();
...@@ -738,7 +739,7 @@ export class CrawlJobService { ...@@ -738,7 +739,7 @@ export class CrawlJobService {
maxDepth: payload.maxDepth, maxDepth: payload.maxDepth,
}); });
await crawlQueue.add('crawl-job', { await crawlQueue.add("crawl-job", {
jobId: job.id, jobId: job.id,
}); });
...@@ -753,7 +754,7 @@ export class CrawlJobService { ...@@ -753,7 +754,7 @@ export class CrawlJobService {
const job = await this.repository.findById(jobId); const job = await this.repository.findById(jobId);
if (!job || job.userId !== userId) { if (!job || job.userId !== userId) {
throw new AppError('Crawl job not found', 404); throw new AppError("Crawl job not found", 404);
} }
return job; return job;
...@@ -762,11 +763,11 @@ export class CrawlJobService { ...@@ -762,11 +763,11 @@ export class CrawlJobService {
async cancel(userId: string, jobId: string) { async cancel(userId: string, jobId: string) {
const job = await this.findById(userId, jobId); const job = await this.findById(userId, jobId);
if (job.status === 'COMPLETED') { if (job.status === "COMPLETED") {
throw new AppError('Completed job cannot be canceled', 400); throw new AppError("Completed job cannot be canceled", 400);
} }
return this.repository.updateStatus(jobId, 'CANCELED'); return this.repository.updateStatus(jobId, "CANCELED");
} }
} }
``` ```
...@@ -776,8 +777,8 @@ export class CrawlJobService { ...@@ -776,8 +777,8 @@ export class CrawlJobService {
### 11.4. Repository ### 11.4. Repository
```ts ```ts
import { prisma } from '../../database/prisma.client'; import { prisma } from "../../database/prisma.client";
import { CrawlJobStatus } from '@prisma/client'; import { CrawlJobStatus } from "@prisma/client";
export class CrawlJobRepository { export class CrawlJobRepository {
create(data: { create(data: {
...@@ -801,7 +802,7 @@ export class CrawlJobRepository { ...@@ -801,7 +802,7 @@ export class CrawlJobRepository {
findAllByUser(userId: string, query: any) { findAllByUser(userId: string, query: any) {
return prisma.crawlJob.findMany({ return prisma.crawlJob.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
include: { include: {
exports: true, exports: true,
}, },
...@@ -839,27 +840,27 @@ prisma/seed.ts ...@@ -839,27 +840,27 @@ prisma/seed.ts
``` ```
```ts ```ts
import { PrismaClient, UserRole } from '@prisma/client'; import { PrismaClient, UserRole } from "@prisma/client";
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
const passwordHash = await bcrypt.hash('Admin@123456', 10); const passwordHash = await bcrypt.hash("Admin@123456", 10);
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'admin@crawl.local' }, where: { email: "admin@crawl.local" },
update: {}, update: {},
create: { create: {
email: 'admin@crawl.local', email: "admin@crawl.local",
passwordHash, passwordHash,
fullName: 'System Admin', fullName: "System Admin",
role: UserRole.ADMIN, role: UserRole.ADMIN,
isActive: true, isActive: true,
}, },
}); });
console.log('Seed completed'); console.log("Seed completed");
} }
main() main()
......
...@@ -4,16 +4,16 @@ Backend API cho hệ thống crawl dữ liệu web. Người dùng dán link, h ...@@ -4,16 +4,16 @@ Backend API cho hệ thống crawl dữ liệu web. Người dùng dán link, h
## Tech Stack ## Tech Stack
| Thành phần | Công nghệ | | Thành phần | Công nghệ |
| --------------- | -------------------------------------- | | --------------- | ------------------------------------- |
| Runtime | Node.js + TypeScript | | Runtime | Node.js + TypeScript |
| Framework | Express.js | | Framework | Express.js |
| ORM | Prisma (Code First Migration) | | ORM | Prisma (Code First Migration) |
| Database | PostgreSQL | | Database | PostgreSQL |
| Queue / Cache | Redis + BullMQ | | Queue / Cache | Redis + BullMQ |
| Crawl Engine | Firecrawl API | | Crawl Engine | Firecrawl API |
| Export | Archiver, ExcelJS, json2csv, Turndown | | Export | Archiver, ExcelJS, json2csv, Turndown |
| Package Manager | pnpm@9.15.0 | | Package Manager | pnpm@9.15.0 |
## Kiến trúc Service Layer ## Kiến trúc Service Layer
...@@ -111,10 +111,10 @@ docker ps ...@@ -111,10 +111,10 @@ docker ps
Hai container cần chạy: Hai container cần chạy:
| Container | Service | Port | | Container | Service | Port |
| -------------------- | ---------- | ------ | | --------------------- | ---------- | ------ |
| `crawl_data_postgres`| PostgreSQL | `5432` | | `crawl_data_postgres` | PostgreSQL | `5432` |
| `crawl_data_redis` | Redis | `6379` | | `crawl_data_redis` | Redis | `6379` |
> DB name mặc định trong Docker là `crawl_data_db` — đảm bảo `DB_NAME` trong `.env` khớp với giá trị này. > DB name mặc định trong Docker là `crawl_data_db` — đảm bảo `DB_NAME` trong `.env` khớp với giá trị này.
...@@ -129,6 +129,7 @@ pnpm db:migrate:init ...@@ -129,6 +129,7 @@ pnpm db:migrate:init
``` ```
Lệnh này sẽ: Lệnh này sẽ:
1. Đọc `prisma/schema.prisma` 1. Đọc `prisma/schema.prisma`
2. Tạo folder migration đầu tiên trong `prisma/migrations/` 2. Tạo folder migration đầu tiên trong `prisma/migrations/`
3. Apply migration xuống PostgreSQL 3. Apply migration xuống PostgreSQL
...@@ -153,11 +154,11 @@ pnpm db:seed ...@@ -153,11 +154,11 @@ pnpm db:seed
Seed tạo 3 tài khoản mặc định để test: Seed tạo 3 tài khoản mặc định để test:
| Email | Password | Role | | Email | Password | Role |
| --------------------- | ---------------- | ------------- | | -------------------- | ---------------- | ------------ |
| `admin@crawl.local` | `Admin@123456` | ADMIN | | `admin@crawl.local` | `Admin@123456` | ADMIN |
| `crawl@crawl.local` | `Crawler@123456` | CRAWLER_USER | | `crawl@crawl.local` | `Crawler@123456` | CRAWLER_USER |
| `viewer@crawl.local` | `Viewer@123456` | VIEWER | | `viewer@crawl.local` | `Viewer@123456` | VIEWER |
--- ---
...@@ -212,20 +213,20 @@ pnpm worker ...@@ -212,20 +213,20 @@ pnpm worker
## Các lệnh hữu ích ## Các lệnh hữu ích
| Lệnh | Mô tả | | Lệnh | Mô tả |
| -------------------------- | ---------------------------------------------------------- | | ------------------------ | --------------------------------------------------------- |
| `pnpm db:migrate:init` | Tạo migration lần đầu (`--name init`) | | `pnpm db:migrate:init` | Tạo migration lần đầu (`--name init`) |
| `pnpm db:migrate` | Tạo migration mới sau khi sửa `schema.prisma` | | `pnpm db:migrate` | Tạo migration mới sau khi sửa `schema.prisma` |
| `pnpm db:migrate:deploy` | Apply migration lên staging/production (không dùng dev) | | `pnpm db:migrate:deploy` | Apply migration lên staging/production (không dùng dev) |
| `pnpm db:migrate:reset` | Xóa toàn bộ DB và chạy lại migration — **chỉ dùng local** | | `pnpm db:migrate:reset` | Xóa toàn bộ DB và chạy lại migration — **chỉ dùng local** |
| `pnpm db:migrate:status` | Xem trạng thái các migration đã apply | | `pnpm db:migrate:status` | Xem trạng thái các migration đã apply |
| `pnpm prisma:generate` | Regenerate Prisma Client sau khi sửa schema thủ công | | `pnpm prisma:generate` | Regenerate Prisma Client sau khi sửa schema thủ công |
| `pnpm prisma:studio` | Mở Prisma Studio — GUI quản lý dữ liệu trực quan | | `pnpm prisma:studio` | Mở Prisma Studio — GUI quản lý dữ liệu trực quan |
| `pnpm db:seed` | Chạy seed tạo dữ liệu mẫu | | `pnpm db:seed` | Chạy seed tạo dữ liệu mẫu |
| `pnpm swagger` | Regenerate file `src/docs/swagger.json` | | `pnpm swagger` | Regenerate file `src/docs/swagger.json` |
| `pnpm build` | Build production bundle ra thư mục `dist/` | | `pnpm build` | Build production bundle ra thư mục `dist/` |
| `pnpm lint` | Kiểm tra lỗi ESLint | | `pnpm lint` | Kiểm tra lỗi ESLint |
| `pnpm format` | Format code bằng Prettier | | `pnpm format` | Format code bằng Prettier |
--- ---
...@@ -300,11 +301,11 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**, ...@@ -300,11 +301,11 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**,
### 8.1 Mô hình Hai Lớp Output (Clean vs. Raw Output Model) ### 8.1 Mô hình Hai Lớp Output (Clean vs. Raw Output Model)
| Lớp Output | Trường Dữ Liệu | Đặc Điểm & Mô Tả | Đối Tượng Sử Dụng | | Lớp Output | Trường Dữ Liệu | Đặc Điểm & Mô Tả | Đối Tượng Sử Dụng |
| :--- | :--- | :--- | :--- | | :--------------- | :------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------ |
| **Raw Output** | `rawMarkdown` | Nội dung Markdown thô nguyên bản thu thập từ crawler, giữ nguyên menu, header, sidebar và footer. | FE Debug / Reconstruct trang gốc | | **Raw Output** | `rawMarkdown` | Nội dung Markdown thô nguyên bản thu thập từ crawler, giữ nguyên menu, header, sidebar và footer. | FE Debug / Reconstruct trang gốc |
| **Clean Output** | `mainContent` | Thân bài chính đã qua thuật toán lọc nhiễu tự động (`extractMainContent`), loại bỏ menu nav, liên kết mạng xã hội, bài viết liên quan và copyright footer. Vẫn giữ cú pháp Markdown. | **AI Agent / LLM Prompt Context / RAG** | | **Clean Output** | `mainContent` | Thân bài chính đã qua thuật toán lọc nhiễu tự động (`extractMainContent`), loại bỏ menu nav, liên kết mạng xã hội, bài viết liên quan và copyright footer. Vẫn giữ cú pháp Markdown. | **AI Agent / LLM Prompt Context / RAG** |
| **Clean Text** | `cleanText` | Plain text thuần túy đã xóa sạch toàn bộ ký tự định dạng Markdown (`stripMarkdown`). | Đếm từ (`wordCount`) & Hash (`contentHash`) | | **Clean Text** | `cleanText` | Plain text thuần túy đã xóa sạch toàn bộ ký tự định dạng Markdown (`stripMarkdown`). | Đếm từ (`wordCount`) & Hash (`contentHash`) |
### 8.2 API Endpoints Preview & Assets ### 8.2 API Endpoints Preview & Assets
...@@ -321,6 +322,7 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**, ...@@ -321,6 +322,7 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**,
### 8.3 Chỉ số Chất lượng Dữ liệu & Cảnh báo (Quality Metrics & Warnings) ### 8.3 Chỉ số Chất lượng Dữ liệu & Cảnh báo (Quality Metrics & Warnings)
Mỗi bản ghi trang đã crawl trả về đầy đủ các trường đo lường chất lượng: Mỗi bản ghi trang đã crawl trả về đầy đủ các trường đo lường chất lượng:
- **`normalizedUrl`**: URL đã loại bỏ các tham số tracking (`utm_*`, `fbclid`, `gclid`), loại bỏ fragment và chuẩn hóa host/scheme để tránh trùng lặp. - **`normalizedUrl`**: URL đã loại bỏ các tham số tracking (`utm_*`, `fbclid`, `gclid`), loại bỏ fragment và chuẩn hóa host/scheme để tránh trùng lặp.
- **`wordCount`**: Số từ tính trên `cleanText`. - **`wordCount`**: Số từ tính trên `cleanText`.
- **`contentHash`**: Mã SHA-256 tính từ `cleanText` phục vụ deduplication trên Vector DB. - **`contentHash`**: Mã SHA-256 tính từ `cleanText` phục vụ deduplication trên Vector DB.
...@@ -348,5 +350,3 @@ export-job-c4b8e21a.zip ...@@ -348,5 +350,3 @@ export-job-c4b8e21a.zip
``` ```
Xem chi tiết Data Contract đầy đủ tại [DATA_CONTRACT_V1.md](docs/DATA_CONTRACT_V1.md). Xem chi tiết Data Contract đầy đủ tại [DATA_CONTRACT_V1.md](docs/DATA_CONTRACT_V1.md).
version: '3.8' version: "3.8"
services: services:
postgres: postgres:
......
...@@ -4,10 +4,10 @@ ...@@ -4,10 +4,10 @@
Storage được chọn bằng `STORAGE_DRIVER`: Storage được chọn bằng `STORAGE_DRIVER`:
| Cấu hình | Nơi lưu file | | Cấu hình | Nơi lưu file |
| --- | --- | | ---------------------- | ---------------------------------------------- |
| `STORAGE_DRIVER=local` | Lưu trực tiếp trong `STORAGE_EXPORT_DIR` | | `STORAGE_DRIVER=local` | Lưu trực tiếp trong `STORAGE_EXPORT_DIR` |
| `STORAGE_DRIVER=s3` | Dùng AWS S3, MinIO hoặc dịch vụ tương thích S3 | | `STORAGE_DRIVER=s3` | Dùng AWS S3, MinIO hoặc dịch vụ tương thích S3 |
Với JSON, CSV, XLSX và Markdown, hệ thống tạo file staging rồi upload lên storage. ZIP được stream trực tiếp lên storage, không tạo file ZIP local. Với JSON, CSV, XLSX và Markdown, hệ thống tạo file staging rồi upload lên storage. ZIP được stream trực tiếp lên storage, không tạo file ZIP local.
......
This diff is collapsed.
...@@ -7,6 +7,7 @@ Tài liệu này hướng dẫn cách vận hành, cấu trúc và danh sách c ...@@ -7,6 +7,7 @@ Tài liệu này hướng dẫn cách vận hành, cấu trúc và danh sách c
## 🚀 1. Cách chạy kiểm thử ## 🚀 1. Cách chạy kiểm thử
### Cách 1: Chạy trực tiếp bằng Postman (GUI) ### Cách 1: Chạy trực tiếp bằng Postman (GUI)
1. **Import vào Postman**: 1. **Import vào Postman**:
- File Collection: [data-crawler.postman_collection.json](./data-crawler.postman_collection.json) - File Collection: [data-crawler.postman_collection.json](./data-crawler.postman_collection.json)
- File Environment: [data-crawler.postman_environment.json](./data-crawler.postman_environment.json) - File Environment: [data-crawler.postman_environment.json](./data-crawler.postman_environment.json)
...@@ -17,6 +18,7 @@ Tài liệu này hướng dẫn cách vận hành, cấu trúc và danh sách c ...@@ -17,6 +18,7 @@ Tài liệu này hướng dẫn cách vận hành, cấu trúc và danh sách c
- Nhấn **Run Data Crawler BE API**. - Nhấn **Run Data Crawler BE API**.
### Cách 2: Chạy tự động bằng Newman (CLI) ### Cách 2: Chạy tự động bằng Newman (CLI)
Yêu cầu đã khởi chạy Server Backend tại `http://localhost:3000`. Chạy lệnh sau tại thư mục gốc của dự án: Yêu cầu đã khởi chạy Server Backend tại `http://localhost:3000`. Chạy lệnh sau tại thư mục gốc của dự án:
```bash ```bash
...@@ -24,6 +26,7 @@ npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/post ...@@ -24,6 +26,7 @@ npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/post
``` ```
Chạy từng folder kịch bản cụ thể: Chạy từng folder kịch bản cụ thể:
```bash ```bash
npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/postman/data-crawler.postman_environment.json" --folder "Crawl Jobs" --reporters cli npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/postman/data-crawler.postman_environment.json" --folder "Crawl Jobs" --reporters cli
``` ```
...@@ -33,72 +36,75 @@ npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/post ...@@ -33,72 +36,75 @@ npx newman run "docs/postman/data-crawler.postman_collection.json" -e "docs/post
## 📋 2. Chi tiết các Nhóm Kiểm thử (Test Suites) ## 📋 2. Chi tiết các Nhóm Kiểm thử (Test Suites)
### 🔐 A. Nhóm Auth (Xác thực & Ủy quyền) ### 🔐 A. Nhóm Auth (Xác thực & Ủy quyền)
Kiểm tra luồng đăng nhập, lấy thông tin cá nhân, cập nhật tài khoản và cơ chế refresh token. Kiểm tra luồng đăng nhập, lấy thông tin cá nhân, cập nhật tài khoản và cơ chế refresh token.
1. **Login**: 1. **Login**:
- *Endpoint*: `POST /auth/login` - _Endpoint_: `POST /auth/login`
- *Test Assertions*: Trả về HTTP 200, `success: true`, sinh ra `accessToken``refreshToken`, tự động lưu vào môi trường Postman (`token`, `refreshToken`). - _Test Assertions_: Trả về HTTP 200, `success: true`, sinh ra `accessToken``refreshToken`, tự động lưu vào môi trường Postman (`token`, `refreshToken`).
2. **Get Me**: 2. **Get Me**:
- *Endpoint*: `GET /auth/me` - _Endpoint_: `GET /auth/me`
- *Test Assertions*: Đính kèm Bearer token. Trả về chính xác thông tin User (`id`, `email`, `role`, `isActive`). - _Test Assertions_: Đính kèm Bearer token. Trả về chính xác thông tin User (`id`, `email`, `role`, `isActive`).
3. **Refresh Token**: 3. **Refresh Token**:
- *Endpoint*: `POST /auth/refresh` - _Endpoint_: `POST /auth/refresh`
- *Test Assertions*: Nhận `refreshToken`, cấp lại `accessToken` mới, cập nhật lại biến môi trường. - _Test Assertions_: Nhận `refreshToken`, cấp lại `accessToken` mới, cập nhật lại biến môi trường.
4. **Logout**: 4. **Logout**:
- *Endpoint*: `POST /auth/logout` - _Endpoint_: `POST /auth/logout`
- *Test Assertions*: Thu hồi token trong database, xóa khỏi biến môi trường Postman. - _Test Assertions_: Thu hồi token trong database, xóa khỏi biến môi trường Postman.
5. **Login with Invalid Credentials (Edge Case)**: 5. **Login with Invalid Credentials (Edge Case)**:
- *Test Assertions*: Trả về `401 Unauthorized`. - _Test Assertions_: Trả về `401 Unauthorized`.
6. **Get Me without Token (Edge Case)**: 6. **Get Me without Token (Edge Case)**:
- *Test Assertions*: Trả về `401 Unauthorized`. - _Test Assertions_: Trả về `401 Unauthorized`.
--- ---
### ⚙️ B. Nhóm Crawl Jobs & Clean/Raw Preview ### ⚙️ B. Nhóm Crawl Jobs & Clean/Raw Preview
Kiểm tra các hoạt động tạo tác vụ crawl, lấy danh sách trang, xem trước dữ liệu sạch/thô và lọc chất lượng. Kiểm tra các hoạt động tạo tác vụ crawl, lấy danh sách trang, xem trước dữ liệu sạch/thô và lọc chất lượng.
1. **Create Crawl Job**: 1. **Create Crawl Job**:
- *Endpoint*: `POST /crawl-jobs` - _Endpoint_: `POST /crawl-jobs`
- *Body Payload*: `{ "startUrl": "https://example.com", "mode": "CRAWL", "maxPages": 50, "maxDepth": 2 }` - _Body Payload_: `{ "startUrl": "https://example.com", "mode": "CRAWL", "maxPages": 50, "maxDepth": 2 }`
- *Test Assertions*: HTTP 201 Created, trả về job ID mới (`job_id`). - _Test Assertions_: HTTP 201 Created, trả về job ID mới (`job_id`).
2. **Get Crawl Jobs (Paginated & Filtered)**: 2. **Get Crawl Jobs (Paginated & Filtered)**:
- *Endpoint*: `GET /crawl-jobs?status=PENDING&page=1&limit=10` - _Endpoint_: `GET /crawl-jobs?status=PENDING&page=1&limit=10`
- *Test Assertions*: Trả về danh sách phân trang `{ items: Array, meta: { total, page, limit, totalPages } }`. - _Test Assertions_: Trả về danh sách phân trang `{ items: Array, meta: { total, page, limit, totalPages } }`.
3. **Get Crawl Job by ID**: 3. **Get Crawl Job by ID**:
- *Endpoint*: `GET /crawl-jobs/:id` - _Endpoint_: `GET /crawl-jobs/:id`
- *Test Assertions*: Trả về chi tiết các thông số của Job (`startUrl`, `mode`, `status`, `totalPages`, `successPages`, `failedPages`). - _Test Assertions_: Trả về chi tiết các thông số của Job (`startUrl`, `mode`, `status`, `totalPages`, `successPages`, `failedPages`).
4. **Get Crawled Pages (Metadata List)**: 4. **Get Crawled Pages (Metadata List)**:
- *Endpoint*: `GET /crawl-jobs/:id/pages` - _Endpoint_: `GET /crawl-jobs/:id/pages`
- *Query Params*: Supports `status`, `statusCode`, `search`, `dataQualityScore`, `hasTables`, `hasImages`, `hasLinks`, `wordCount`, `sortBy`, `order`. - _Query Params_: Supports `status`, `statusCode`, `search`, `dataQualityScore`, `hasTables`, `hasImages`, `hasLinks`, `wordCount`, `sortBy`, `order`.
- *Test Assertions*: Trả về mảng danh sách trang kèm theo `normalizedUrl`, `dataQualityScore`, `wordCount`, `warnings`, `hasSensitiveData` (không chứa payload markdown dài để tối ưu băng thông). - _Test Assertions_: Trả về mảng danh sách trang kèm theo `normalizedUrl`, `dataQualityScore`, `wordCount`, `warnings`, `hasSensitiveData` (không chứa payload markdown dài để tối ưu băng thông).
5. **Get Crawled Pages Preview (Clean vs. Raw Output)**: 5. **Get Crawled Pages Preview (Clean vs. Raw Output)**:
- *Endpoint*: `GET /crawl-jobs/:id/pages/preview?minQualityScore=50` (hoặc `GET /crawl-jobs/:id/pages?preview=true`) - _Endpoint_: `GET /crawl-jobs/:id/pages/preview?minQualityScore=50` (hoặc `GET /crawl-jobs/:id/pages?preview=true`)
- *Test Assertions*: - _Test Assertions_:
- Phải chứa đủ 3 trường nội dung đại diện cho hai lớp Output: - Phải chứa đủ 3 trường nội dung đại diện cho hai lớp Output:
- `rawMarkdown`: Markdown thô nguyên bản thu thập được. - `rawMarkdown`: Markdown thô nguyên bản thu thập được.
- `mainContent`: Thân bài chính đã qua lọc bỏ nhiễu nav/footer/sidebar **(Khuyến nghị cho AI Agents / LLM)**. - `mainContent`: Thân bài chính đã qua lọc bỏ nhiễu nav/footer/sidebar **(Khuyến nghị cho AI Agents / LLM)**.
- `cleanText`: Văn bản thuần túy đã xóa sạch ký tự định dạng Markdown. - `cleanText`: Văn bản thuần túy đã xóa sạch ký tự định dạng Markdown.
- Hỗ trợ kiểm tra các chỉ số chất lượng: `dataQualityScore` (0-100), `wordCount`, `contentHash` (SHA-256), `warnings` (`NAV_NOISE`, `TOO_SHORT`, `DUPLICATE_CONTENT`, ...). - Hỗ trợ kiểm tra các chỉ số chất lượng: `dataQualityScore` (0-100), `wordCount`, `contentHash` (SHA-256), `warnings` (`NAV_NOISE`, `TOO_SHORT`, `DUPLICATE_CONTENT`, ...).
6. **Get Job Assets**: 6. **Get Job Assets**:
- *Endpoint*: `GET /crawl-jobs/:id/assets?assetType=IMAGE` - _Endpoint_: `GET /crawl-jobs/:id/assets?assetType=IMAGE`
- *Query Params*: `assetType` (enum: `IMAGE`, `LINK`, `PDF`, `FILE`, `VIDEO`, `OTHER`). - _Query Params_: `assetType` (enum: `IMAGE`, `LINK`, `PDF`, `FILE`, `VIDEO`, `OTHER`).
- *Test Assertions*: Trả về danh sách tài nguyên hình ảnh/liên kết thu thập được từ các trang. - _Test Assertions_: Trả về danh sách tài nguyên hình ảnh/liên kết thu thập được từ các trang.
--- ---
### 💾 C. Nhóm Export & Download ### 💾 C. Nhóm Export & Download
Kiểm tra luồng khởi tạo và tải về các tập tin xuất bản cho Crawl Job đã hoàn thành (`COMPLETED`). Kiểm tra luồng khởi tạo và tải về các tập tin xuất bản cho Crawl Job đã hoàn thành (`COMPLETED`).
1. **Get Crawl Job Exports**: 1. **Get Crawl Job Exports**:
- *Endpoint*: `GET /crawl-jobs/:id/exports` - _Endpoint_: `GET /crawl-jobs/:id/exports`
- *Test Assertions*: Trả về danh sách các tệp tin xuất bản đã tạo của Job. - _Test Assertions_: Trả về danh sách các tệp tin xuất bản đã tạo của Job.
2. **Create Export for Job**: 2. **Create Export for Job**:
- *Endpoint*: `POST /crawl-jobs/:id/exports` - _Endpoint_: `POST /crawl-jobs/:id/exports`
- *Body Payload*: `{ "exportType": "ZIP" }` (Các định dạng hỗ trợ: `JSON`, `CSV`, `XLSX`, `MARKDOWN`, `ZIP`). - _Body Payload_: `{ "exportType": "ZIP" }` (Các định dạng hỗ trợ: `JSON`, `CSV`, `XLSX`, `MARKDOWN`, `ZIP`).
- *Test Assertions*: HTTP 201 Created, khởi tạo bản export thành công và lưu `export_id`. - _Test Assertions_: HTTP 201 Created, khởi tạo bản export thành công và lưu `export_id`.
3. **Download Export File**: 3. **Download Export File**:
- *Endpoint*: `GET /exports/:exportId/download` (hoặc `GET /crawl-jobs/:id/download`) - _Endpoint_: `GET /exports/:exportId/download` (hoặc `GET /crawl-jobs/:id/download`)
- *Test Assertions*: Trả về stream binary tệp tin kèm theo đúng Header `Content-Disposition`. - _Test Assertions_: Trả về stream binary tệp tin kèm theo đúng Header `Content-Disposition`.
- **Đặc quyền cấu trúc ZIP Output**: - **Đặc quyền cấu trúc ZIP Output**:
- Thư mục `/data/raw/pages.raw.json` & `/data/clean/pages.clean.json`. - Thư mục `/data/raw/pages.raw.json` & `/data/clean/pages.clean.json`.
- Thư mục `/markdown/raw/` & `/markdown/clean/`. - Thư mục `/markdown/raw/` & `/markdown/clean/`.
...@@ -107,6 +113,7 @@ Kiểm tra luồng khởi tạo và tải về các tập tin xuất bản cho C ...@@ -107,6 +113,7 @@ Kiểm tra luồng khởi tạo và tải về các tập tin xuất bản cho C
--- ---
### 🛡️ D. Nhóm Permission & Edge Case Tests (Phân quyền & Lỗi nghiệp vụ) ### 🛡️ D. Nhóm Permission & Edge Case Tests (Phân quyền & Lỗi nghiệp vụ)
1. **Get Non-Existent Job**: 1. **Get Non-Existent Job**:
- Gửi ID UUID không tồn tại -> Kiểm tra phản hồi `404 Not Found``code: "CRAWL_JOB_NOT_FOUND"`. - Gửi ID UUID không tồn tại -> Kiểm tra phản hồi `404 Not Found``code: "CRAWL_JOB_NOT_FOUND"`.
2. **Cancel Completed Job**: 2. **Cancel Completed Job**:
......
...@@ -769,15 +769,8 @@ ...@@ -769,15 +769,8 @@
], ],
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/pages/preview?minQualityScore=50", "raw": "{{base_url}}/crawl-jobs/:id/pages/preview?minQualityScore=50",
"host": [ "host": ["{{base_url}}"],
"{{base_url}}" "path": ["crawl-jobs", ":id", "pages", "preview"],
],
"path": [
"crawl-jobs",
":id",
"pages",
"preview"
],
"query": [ "query": [
{ {
"key": "minQualityScore", "key": "minQualityScore",
...@@ -825,14 +818,8 @@ ...@@ -825,14 +818,8 @@
], ],
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/assets?assetType=IMAGE", "raw": "{{base_url}}/crawl-jobs/:id/assets?assetType=IMAGE",
"host": [ "host": ["{{base_url}}"],
"{{base_url}}" "path": ["crawl-jobs", ":id", "assets"],
],
"path": [
"crawl-jobs",
":id",
"assets"
],
"query": [ "query": [
{ {
"key": "assetType", "key": "assetType",
...@@ -1311,10 +1298,21 @@ ...@@ -1311,10 +1298,21 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }, {
{ "key": "Content-Type", "value": "application/json", "type": "text" } "key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
], ],
"body": { "mode": "raw", "raw": "{\n \"exportType\": \"ZIP\"\n}" }, "body": {
"mode": "raw",
"raw": "{\n \"exportType\": \"ZIP\"\n}"
},
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/exports", "raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1341,7 +1339,13 @@ ...@@ -1341,7 +1339,13 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }], "header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{base_url}}/exports/:exportId/download", "raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1370,10 +1374,21 @@ ...@@ -1370,10 +1374,21 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }, {
{ "key": "Content-Type", "value": "application/json", "type": "text" } "key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
], ],
"body": { "mode": "raw", "raw": "{\n \"exportType\": \"JSON\"\n}" }, "body": {
"mode": "raw",
"raw": "{\n \"exportType\": \"JSON\"\n}"
},
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/exports", "raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1400,7 +1415,13 @@ ...@@ -1400,7 +1415,13 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }], "header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{base_url}}/exports/:exportId/download", "raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1429,10 +1450,21 @@ ...@@ -1429,10 +1450,21 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }, {
{ "key": "Content-Type", "value": "application/json", "type": "text" } "key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
], ],
"body": { "mode": "raw", "raw": "{\n \"exportType\": \"CSV\"\n}" }, "body": {
"mode": "raw",
"raw": "{\n \"exportType\": \"CSV\"\n}"
},
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/exports", "raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1459,7 +1491,13 @@ ...@@ -1459,7 +1491,13 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }], "header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{base_url}}/exports/:exportId/download", "raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1488,10 +1526,21 @@ ...@@ -1488,10 +1526,21 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }, {
{ "key": "Content-Type", "value": "application/json", "type": "text" } "key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
], ],
"body": { "mode": "raw", "raw": "{\n \"exportType\": \"XLSX\"\n}" }, "body": {
"mode": "raw",
"raw": "{\n \"exportType\": \"XLSX\"\n}"
},
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/exports", "raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1518,7 +1567,13 @@ ...@@ -1518,7 +1567,13 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }], "header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{base_url}}/exports/:exportId/download", "raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1547,10 +1602,21 @@ ...@@ -1547,10 +1602,21 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }, {
{ "key": "Content-Type", "value": "application/json", "type": "text" } "key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
], ],
"body": { "mode": "raw", "raw": "{\n \"exportType\": \"MARKDOWN\"\n}" }, "body": {
"mode": "raw",
"raw": "{\n \"exportType\": \"MARKDOWN\"\n}"
},
"url": { "url": {
"raw": "{{base_url}}/crawl-jobs/:id/exports", "raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
...@@ -1576,7 +1642,13 @@ ...@@ -1576,7 +1642,13 @@
], ],
"request": { "request": {
"method": "GET", "method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }], "header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": { "url": {
"raw": "{{base_url}}/exports/:exportId/download", "raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
......
import tsParser from '@typescript-eslint/parser'; import tsParser from "@typescript-eslint/parser";
import tsPlugin from '@typescript-eslint/eslint-plugin'; import tsPlugin from "@typescript-eslint/eslint-plugin";
export default [ export default [
{ {
ignores: ['dist/**', 'node_modules/**', 'eslint.config.js'], ignores: ["dist/**", "node_modules/**", "eslint.config.js"],
}, },
{ {
files: ['src/**/*.ts'], files: ["src/**/*.ts"],
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
ecmaVersion: 'latest', ecmaVersion: "latest",
sourceType: 'module', sourceType: "module",
}, },
}, },
plugins: { plugins: {
'@typescript-eslint': tsPlugin, "@typescript-eslint": tsPlugin,
}, },
rules: { rules: {
'no-unused-vars': 'off', "no-unused-vars": "off",
'@typescript-eslint/no-unused-vars': [ "@typescript-eslint/no-unused-vars": [
'warn', "warn",
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
], ],
'no-console': 'off', "no-console": "off",
'@typescript-eslint/no-explicit-any': 'warn', "@typescript-eslint/no-explicit-any": "warn",
}, },
}, },
]; ];
module.exports = { module.exports = {
preset: 'ts-jest', preset: "ts-jest",
testEnvironment: 'node', testEnvironment: "node",
testMatch: ['**/*.test.ts'], testMatch: ["**/*.test.ts"],
moduleFileExtensions: ['ts', 'js', 'json'], moduleFileExtensions: ["ts", "js", "json"],
moduleNameMapper: { moduleNameMapper: {
'node-html-parser': '<rootDir>/src/__mocks__/node-html-parser.ts', "node-html-parser": "<rootDir>/src/__mocks__/node-html-parser.ts",
}, },
modulePathIgnorePatterns: ['<rootDir>/dist/'], modulePathIgnorePatterns: ["<rootDir>/dist/"],
setupFiles: ['<rootDir>/jest.setup.ts'], setupFiles: ["<rootDir>/jest.setup.ts"],
}; };
\ No newline at end of file
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret-for-jest-must-be-32-chars-long'; process.env.JWT_ACCESS_SECRET =
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret-for-jest-must-be-32-chars-long'; process.env.JWT_ACCESS_SECRET ||
process.env.WEBHOOK_ENCRYPTION_KEY = process.env.WEBHOOK_ENCRYPTION_KEY || 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; "test-access-secret-for-jest-must-be-32-chars-long";
process.env.JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET ||
"test-refresh-secret-for-jest-must-be-32-chars-long";
process.env.WEBHOOK_ENCRYPTION_KEY =
process.env.WEBHOOK_ENCRYPTION_KEY ||
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
This diff is collapsed.
import { PrismaClient, UserRole } from '@prisma/client'; import { PrismaClient, UserRole } from "@prisma/client";
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
const adminPasswordHash = await bcrypt.hash('Admin@123456', 10); const adminPasswordHash = await bcrypt.hash("Admin@123456", 10);
const crawlerPasswordHash = await bcrypt.hash('Crawler@123456', 10); const crawlerPasswordHash = await bcrypt.hash("Crawler@123456", 10);
const viewerPasswordHash = await bcrypt.hash('Viewer@123456', 10); const viewerPasswordHash = await bcrypt.hash("Viewer@123456", 10);
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'admin@crawl.local' }, where: { email: "admin@crawl.local" },
update: {}, update: {},
create: { create: {
email: 'admin@crawl.local', email: "admin@crawl.local",
passwordHash: adminPasswordHash, passwordHash: adminPasswordHash,
fullName: 'System Admin', fullName: "System Admin",
role: UserRole.ADMIN, role: UserRole.ADMIN,
isActive: true, isActive: true,
}, },
}); });
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'crawl@crawl.local' }, where: { email: "crawl@crawl.local" },
update: {}, update: {},
create: { create: {
email: 'crawl@crawl.local', email: "crawl@crawl.local",
passwordHash: crawlerPasswordHash, passwordHash: crawlerPasswordHash,
fullName: 'Crawl User', fullName: "Crawl User",
role: UserRole.CRAWLER_USER, role: UserRole.CRAWLER_USER,
isActive: true, isActive: true,
maxPagesLimit: 100, maxPagesLimit: 100,
...@@ -36,12 +36,12 @@ async function main() { ...@@ -36,12 +36,12 @@ async function main() {
}); });
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'viewer@crawl.local' }, where: { email: "viewer@crawl.local" },
update: {}, update: {},
create: { create: {
email: 'viewer@crawl.local', email: "viewer@crawl.local",
passwordHash: viewerPasswordHash, passwordHash: viewerPasswordHash,
fullName: 'Viewer User', fullName: "Viewer User",
role: UserRole.VIEWER, role: UserRole.VIEWER,
isActive: true, isActive: true,
maxPagesLimit: 20, maxPagesLimit: 20,
...@@ -50,7 +50,7 @@ async function main() { ...@@ -50,7 +50,7 @@ async function main() {
}, },
}); });
console.log('Seed completed'); console.log("Seed completed");
} }
main() main()
......
require('dotenv').config(); require("dotenv").config();
const { spawn } = require('child_process'); const { spawn } = require("child_process");
if (!process.env.DATABASE_URL) { if (!process.env.DATABASE_URL) {
const password = encodeURIComponent(process.env.DB_PASSWORD || ''); const password = encodeURIComponent(process.env.DB_PASSWORD || "");
const user = encodeURIComponent(process.env.DB_USER || 'postgres'); const user = encodeURIComponent(process.env.DB_USER || "postgres");
const host = process.env.DB_HOST || 'localhost'; const host = process.env.DB_HOST || "localhost";
const port = process.env.DB_PORT || '5432'; const port = process.env.DB_PORT || "5432";
const name = process.env.DB_NAME || 'datacrawler'; const name = process.env.DB_NAME || "datacrawler";
const isSupabase = host.includes('supabase.co') || host.includes('pooler.supabase.com'); const isSupabase =
const ssl = process.env.DB_SSL === 'true' || isSupabase ? '&sslmode=require' : ''; host.includes("supabase.co") || host.includes("pooler.supabase.com");
const ssl =
process.env.DB_SSL === "true" || isSupabase ? "&sslmode=require" : "";
process.env.DATABASE_URL = `postgresql://${user}:${password}@${host}:${port}/${name}?schema=public${ssl}`; process.env.DATABASE_URL = `postgresql://${user}:${password}@${host}:${port}/${name}?schema=public${ssl}`;
} }
const args = process.argv.slice(2); const args = process.argv.slice(2);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const cmd = process.platform === "win32" ? "npx.cmd" : "npx";
const child = spawn(cmd, ['prisma', ...args], { const child = spawn(cmd, ["prisma", ...args], {
stdio: 'inherit', stdio: "inherit",
env: process.env, env: process.env,
shell: true, shell: true,
}); });
child.on('exit', (code) => process.exit(code ?? 1)); child.on("exit", (code) => process.exit(code ?? 1));
export const parse = () => ({ export const parse = () => ({
querySelectorAll: () => [], querySelectorAll: () => [],
}); });
\ No newline at end of file
import express from 'express'; import express from "express";
import cors from 'cors'; import cors from "cors";
import helmet from 'helmet'; import helmet from "helmet";
import morgan from 'morgan'; import morgan from "morgan";
import cookieParser from 'cookie-parser'; import cookieParser from "cookie-parser";
import swaggerUi from 'swagger-ui-express'; import swaggerUi from "swagger-ui-express";
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware'; import {
import routes from './routes'; errorMiddleware,
import swaggerDocument from './docs/swagger.json'; notFoundMiddleware,
import healthRoute from './modules/health/health.route'; } from "./middlewares/error.middleware";
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware'; import routes from "./routes";
import { envConfig } from './config/env.config'; import swaggerDocument from "./docs/swagger.json";
import { parseTrustProxy } from './common/helpers/proxy.helper'; import healthRoute from "./modules/health/health.route";
import { rateLimitMiddleware } from "./middlewares/rate-limit.middleware";
import { envConfig } from "./config/env.config";
import { parseTrustProxy } from "./common/helpers/proxy.helper";
const app = express(); const app = express();
app.set('trust proxy', parseTrustProxy(envConfig.trustProxy)); app.set("trust proxy", parseTrustProxy(envConfig.trustProxy));
app.use( app.use(
helmet({ helmet({
...@@ -27,7 +30,7 @@ app.use( ...@@ -27,7 +30,7 @@ app.use(
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
if ( if (
envConfig.cors.allowedOrigins.includes(origin) || envConfig.cors.allowedOrigins.includes(origin) ||
envConfig.cors.allowedOrigins.includes('*') envConfig.cors.allowedOrigins.includes("*")
) { ) {
return callback(null, true); return callback(null, true);
} }
...@@ -37,17 +40,16 @@ app.use( ...@@ -37,17 +40,16 @@ app.use(
maxAge: 86400, maxAge: 86400,
}), }),
); );
app.use(morgan('dev')); app.use(morgan("dev"));
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use('/health', healthRoute); app.use("/health", healthRoute);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use('/api/v1', rateLimitMiddleware, routes); app.use("/api/v1", rateLimitMiddleware, routes);
app.use(notFoundMiddleware); app.use(notFoundMiddleware);
app.use(errorMiddleware); app.use(errorMiddleware);
export default app; export default app;
export const ASSET_TYPE = {
IMAGE: "IMAGE",
LINK: "LINK",
PDF: "PDF",
FILE: "FILE",
VIDEO: "VIDEO",
OTHER: "OTHER",
} as const;
export const ASSET_TYPES = ASSET_TYPE;
export type AssetType = keyof typeof ASSET_TYPE;
export const AUDIT_ACTIONS = { export const AUDIT_ACTIONS = {
LOGIN: 'LOGIN', LOGIN: "LOGIN",
REGISTER: 'REGISTER', REGISTER: "REGISTER",
LOGOUT: 'LOGOUT', LOGOUT: "LOGOUT",
UPDATE_ME: 'UPDATE_ME', UPDATE_ME: "UPDATE_ME",
CHANGE_PASSWORD: 'CHANGE_PASSWORD', CHANGE_PASSWORD: "CHANGE_PASSWORD",
CREATE_JOB: 'CREATE_JOB', CREATE_JOB: "CREATE_JOB",
CANCEL_JOB: 'CANCEL_JOB', CANCEL_JOB: "CANCEL_JOB",
DOWNLOAD_EXPORT: 'DOWNLOAD_EXPORT', DOWNLOAD_EXPORT: "DOWNLOAD_EXPORT",
ADMIN_CREATE_USER: 'ADMIN_CREATE_USER', ADMIN_CREATE_USER: "ADMIN_CREATE_USER",
ADMIN_UPDATE_USER: 'ADMIN_UPDATE_USER', ADMIN_UPDATE_USER: "ADMIN_UPDATE_USER",
ADMIN_DELETE_USER: 'ADMIN_DELETE_USER', ADMIN_DELETE_USER: "ADMIN_DELETE_USER",
FORGOT_PASSWORD: 'FORGOT_PASSWORD', FORGOT_PASSWORD: "FORGOT_PASSWORD",
RESET_PASSWORD: 'RESET_PASSWORD', RESET_PASSWORD: "RESET_PASSWORD",
VERIFY_EMAIL: 'VERIFY_EMAIL', VERIFY_EMAIL: "VERIFY_EMAIL",
RESEND_VERIFICATION: 'RESEND_VERIFICATION', RESEND_VERIFICATION: "RESEND_VERIFICATION",
CREATE_API_KEY: 'CREATE_API_KEY', CREATE_API_KEY: "CREATE_API_KEY",
UPDATE_API_KEY_STATUS: 'UPDATE_API_KEY_STATUS', UPDATE_API_KEY_STATUS: "UPDATE_API_KEY_STATUS",
REVOKE_API_KEY: 'REVOKE_API_KEY', REVOKE_API_KEY: "REVOKE_API_KEY",
CREATE_WEBHOOK_CONFIG: 'CREATE_WEBHOOK_CONFIG', CREATE_WEBHOOK_CONFIG: "CREATE_WEBHOOK_CONFIG",
DELETE_WEBHOOK_CONFIG: 'DELETE_WEBHOOK_CONFIG', DELETE_WEBHOOK_CONFIG: "DELETE_WEBHOOK_CONFIG",
REDELIVER_WEBHOOK: 'REDELIVER_WEBHOOK', REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK",
} as const; } as const;
export type AuditAction = typeof AUDIT_ACTIONS[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
export const CRAWL_MODE = {
SCRAPE: "SCRAPE",
CRAWL: "CRAWL",
SITEMAP: "SITEMAP",
URL_LIST: "URL_LIST",
} as const;
export const CRAWL_MODES = CRAWL_MODE;
export type CrawlMode = keyof typeof CRAWL_MODE;
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
*/ */
/** Phiên bản hiện tại của Data Contract schema */ /** Phiên bản hiện tại của Data Contract schema */
export const DATA_CONTRACT_SCHEMA_VERSION = '1.0.0'; export const DATA_CONTRACT_SCHEMA_VERSION = "1.0.0";
/** Ngưỡng số từ tối thiểu; dưới ngưỡng này page bị gắn cảnh báo TOO_SHORT */ /** Ngưỡng số từ tối thiểu; dưới ngưỡng này page bị gắn cảnh báo TOO_SHORT */
export const DATA_QUALITY_MIN_WORD_COUNT = 50; export const DATA_QUALITY_MIN_WORD_COUNT = 50;
...@@ -16,4 +16,4 @@ export const DATA_QUALITY_MIN_SCORE = 30; ...@@ -16,4 +16,4 @@ export const DATA_QUALITY_MIN_SCORE = 30;
* Thuật toán hash dùng để tạo contentHash cho nội dung page. * Thuật toán hash dùng để tạo contentHash cho nội dung page.
* Dùng 'sha256' để đảm bảo đủ entropy cho việc phát hiện duplicate. * Dùng 'sha256' để đảm bảo đủ entropy cho việc phát hiện duplicate.
*/ */
export const DATA_CONTRACT_HASH_ALGORITHM = 'sha256'; export const DATA_CONTRACT_HASH_ALGORITHM = "sha256";
export const EXPORT_TYPE = { export const EXPORT_TYPE = {
JSON: 'JSON', JSON: "JSON",
CSV: 'CSV', CSV: "CSV",
XLSX: 'XLSX', XLSX: "XLSX",
MARKDOWN: 'MARKDOWN', MARKDOWN: "MARKDOWN",
ZIP: 'ZIP', ZIP: "ZIP",
} as const; } as const;
export type ExportType = keyof typeof EXPORT_TYPE; export type ExportType = keyof typeof EXPORT_TYPE;
export const EXPORT_MIME_TYPES: Record<ExportType, string> = { export const EXPORT_MIME_TYPES: Record<ExportType, string> = {
JSON: 'application/json', JSON: "application/json",
CSV: 'text/csv', CSV: "text/csv",
XLSX: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
// MARKDOWN export tạo file .zip chứa nhiều file markdown — nên dùng application/zip // MARKDOWN export tạo file .zip chứa nhiều file markdown — nên dùng application/zip
MARKDOWN: 'application/zip', MARKDOWN: "application/zip",
ZIP: 'application/zip', ZIP: "application/zip",
}; };
export * from "./role.constant";
export * from "./job-status.constant";
export * from "./export-type.constant";
export * from "./audit-action.constant";
export * from "./storage-path.constant";
export * from "./data-contract.constant";
export * from "./timezone.constant";
export * from "./crawl-mode.constant";
export * from "./schedule-frequency.constant";
export * from "./asset-type.constant";
export const JOB_STATUS = { export const JOB_STATUS = {
PENDING: 'PENDING', PENDING: "PENDING",
QUEUED: 'QUEUED', QUEUED: "QUEUED",
RUNNING: 'RUNNING', RUNNING: "RUNNING",
PROCESSING_EXPORT: 'PROCESSING_EXPORT', PROCESSING_EXPORT: "PROCESSING_EXPORT",
COMPLETED: 'COMPLETED', COMPLETED: "COMPLETED",
FAILED: 'FAILED', FAILED: "FAILED",
CANCELED: 'CANCELED', CANCELED: "CANCELED",
EXPIRED: 'EXPIRED', EXPIRED: "EXPIRED",
} as const; } as const;
export type JobStatus = keyof typeof JOB_STATUS; export type JobStatus = keyof typeof JOB_STATUS;
export const ROLES = { export const ROLES = {
ADMIN: 'ADMIN', ADMIN: "ADMIN",
CRAWLER_USER: 'CRAWLER_USER', CRAWLER_USER: "CRAWLER_USER",
VIEWER: 'VIEWER', VIEWER: "VIEWER",
} as const; } as const;
export type Role = keyof typeof ROLES; export type Role = keyof typeof ROLES;
export const SCHEDULE_FREQUENCY = {
DAILY: "DAILY",
WEEKLY: "WEEKLY",
MONTHLY: "MONTHLY",
CUSTOM: "CUSTOM",
} as const;
export const SCHEDULE_FREQUENCIES = SCHEDULE_FREQUENCY;
export type ScheduleFrequency = keyof typeof SCHEDULE_FREQUENCY;
export const JOB_EXPORT_SUBDIRS = { export const JOB_EXPORT_SUBDIRS = {
DATA: 'data', DATA: "data",
DATA_RAW: 'data/raw', DATA_RAW: "data/raw",
DATA_CLEAN: 'data/clean', DATA_CLEAN: "data/clean",
MARKDOWN: 'markdown', MARKDOWN: "markdown",
MARKDOWN_RAW: 'markdown/raw', MARKDOWN_RAW: "markdown/raw",
MARKDOWN_CLEAN: 'markdown/clean', MARKDOWN_CLEAN: "markdown/clean",
RAW: 'raw', RAW: "raw",
LOGS: 'logs', LOGS: "logs",
} as const; } as const;
export const JOB_EXPORT_FILES = { export const JOB_EXPORT_FILES = {
METADATA: 'metadata.json', METADATA: "metadata.json",
SUMMARY: 'summary.json', SUMMARY: "summary.json",
DATA_QUALITY_JSON: 'data_quality.json', DATA_QUALITY_JSON: "data_quality.json",
PAGES_JSON: 'pages.json', PAGES_JSON: "pages.json",
PAGES_RAW_JSON: 'pages.raw.json', PAGES_RAW_JSON: "pages.raw.json",
PAGES_CLEAN_JSON: 'pages.clean.json', PAGES_CLEAN_JSON: "pages.clean.json",
PAGES_CSV: 'pages.csv', PAGES_CSV: "pages.csv",
LINKS_CSV: 'links.csv', LINKS_CSV: "links.csv",
IMAGES_CSV: 'images.csv', IMAGES_CSV: "images.csv",
PAGES_XLSX: 'pages.xlsx', PAGES_XLSX: "pages.xlsx",
TABLES_XLSX: 'tables.xlsx', TABLES_XLSX: "tables.xlsx",
ERRORS_JSON: 'errors.json', ERRORS_JSON: "errors.json",
CRAWL_LOG: 'crawl-log.txt', CRAWL_LOG: "crawl-log.txt",
STRUCTURED_JSON: 'structured.json', STRUCTURED_JSON: "structured.json",
DIFF_REPORT_JSON: 'diff_report.json', DIFF_REPORT_JSON: "diff_report.json",
} as const; } as const;
export function buildCrawlResultZipName(jobId: string): string { export function buildCrawlResultZipName(jobId: string): string {
...@@ -36,5 +36,5 @@ export function buildCrawlResultZipKey(jobId: string): string { ...@@ -36,5 +36,5 @@ export function buildCrawlResultZipKey(jobId: string): string {
} }
export function buildMarkdownZipName(jobId: string): string { export function buildMarkdownZipName(jobId: string): string {
return 'markdown.zip'; return "markdown.zip";
} }
export const DEFAULT_TIMEZONE = "Asia/Ho_Chi_Minh"; // Vietnam UTC+7
import { ErrorCode } from './error-code'; import { ErrorCode } from "./error-code";
export class AppError extends Error { export class AppError extends Error {
public readonly statusCode: number; public readonly statusCode: number;
...@@ -6,7 +6,12 @@ export class AppError extends Error { ...@@ -6,7 +6,12 @@ export class AppError extends Error {
public readonly details?: unknown; public readonly details?: unknown;
public readonly isOperational: boolean; public readonly isOperational: boolean;
constructor(message: string, statusCode: number = 500, code?: ErrorCode, details?: unknown) { constructor(
message: string,
statusCode: number = 500,
code?: ErrorCode,
details?: unknown,
) {
super(message); super(message);
this.statusCode = statusCode; this.statusCode = statusCode;
this.code = code; this.code = code;
...@@ -19,4 +24,3 @@ export class AppError extends Error { ...@@ -19,4 +24,3 @@ export class AppError extends Error {
} }
} }
} }
export const ERROR_CODE = { export const ERROR_CODE = {
UNAUTHORIZED: 'UNAUTHORIZED', UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: 'FORBIDDEN', FORBIDDEN: "FORBIDDEN",
NOT_FOUND: 'NOT_FOUND', NOT_FOUND: "NOT_FOUND",
VALIDATION_ERROR: 'VALIDATION_ERROR', VALIDATION_ERROR: "VALIDATION_ERROR",
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', INVALID_CREDENTIALS: "INVALID_CREDENTIALS",
USER_INACTIVE: 'USER_INACTIVE', USER_INACTIVE: "USER_INACTIVE",
TOKEN_EXPIRED: 'TOKEN_EXPIRED', TOKEN_EXPIRED: "TOKEN_EXPIRED",
TOKEN_INVALID: 'TOKEN_INVALID', TOKEN_INVALID: "TOKEN_INVALID",
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY', DUPLICATE_ENTRY: "DUPLICATE_ENTRY",
MAIL_DELIVERY_FAILED: 'MAIL_DELIVERY_FAILED', MAIL_DELIVERY_FAILED: "MAIL_DELIVERY_FAILED",
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND', CRAWL_JOB_NOT_FOUND: "CRAWL_JOB_NOT_FOUND",
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED', CRAWL_JOB_ALREADY_COMPLETED: "CRAWL_JOB_ALREADY_COMPLETED",
CRAWL_JOB_NOT_COMPLETED: 'CRAWL_JOB_NOT_COMPLETED', CRAWL_JOB_NOT_COMPLETED: "CRAWL_JOB_NOT_COMPLETED",
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED', PRIVATE_IP_BLOCKED: "PRIVATE_IP_BLOCKED",
INVALID_URL: 'INVALID_URL', INVALID_URL: "INVALID_URL",
QUOTA_MAX_PAGES_EXCEEDED: 'QUOTA_MAX_PAGES_EXCEEDED', QUOTA_MAX_PAGES_EXCEEDED: "QUOTA_MAX_PAGES_EXCEEDED",
QUOTA_JOBS_PER_DAY_EXCEEDED: 'QUOTA_JOBS_PER_DAY_EXCEEDED', QUOTA_JOBS_PER_DAY_EXCEEDED: "QUOTA_JOBS_PER_DAY_EXCEEDED",
QUOTA_CONCURRENT_JOBS_EXCEEDED: 'QUOTA_CONCURRENT_JOBS_EXCEEDED', QUOTA_CONCURRENT_JOBS_EXCEEDED: "QUOTA_CONCURRENT_JOBS_EXCEEDED",
EXPORT_NOT_FOUND: 'EXPORT_NOT_FOUND', EXPORT_NOT_FOUND: "EXPORT_NOT_FOUND",
EXPORT_FILE_MISSING: 'EXPORT_FILE_MISSING', EXPORT_FILE_MISSING: "EXPORT_FILE_MISSING",
UNSUPPORTED_EXPORT_TYPE: 'UNSUPPORTED_EXPORT_TYPE', UNSUPPORTED_EXPORT_TYPE: "UNSUPPORTED_EXPORT_TYPE",
API_KEY_INVALID: 'API_KEY_INVALID', API_KEY_INVALID: "API_KEY_INVALID",
API_KEY_EXPIRED: 'API_KEY_EXPIRED', API_KEY_EXPIRED: "API_KEY_EXPIRED",
WEBHOOK_CONFIG_NOT_FOUND: 'WEBHOOK_CONFIG_NOT_FOUND', WEBHOOK_CONFIG_NOT_FOUND: "WEBHOOK_CONFIG_NOT_FOUND",
CRAWL_SCHEDULE_NOT_FOUND: 'CRAWL_SCHEDULE_NOT_FOUND', CRAWL_SCHEDULE_NOT_FOUND: "CRAWL_SCHEDULE_NOT_FOUND",
DIFF_REPORT_NOT_FOUND: 'DIFF_REPORT_NOT_FOUND', DIFF_REPORT_NOT_FOUND: "DIFF_REPORT_NOT_FOUND",
} as const; } as const;
export type ErrorCode = keyof typeof ERROR_CODE; export type ErrorCode = keyof typeof ERROR_CODE;
...@@ -4,41 +4,41 @@ import { ...@@ -4,41 +4,41 @@ import {
getTimezoneOffsetMinutes, getTimezoneOffsetMinutes,
getZonedDateParts, getZonedDateParts,
DEFAULT_TIMEZONE, DEFAULT_TIMEZONE,
} from '../schedule-calculator.helper'; } from "../schedule-calculator.helper";
describe('schedule-calculator.helper', () => { describe("schedule-calculator.helper", () => {
describe('isValidCronExpression', () => { describe("isValidCronExpression", () => {
it('returns true for valid standard cron expressions', () => { it("returns true for valid standard cron expressions", () => {
expect(isValidCronExpression('* * * * *')).toBe(true); expect(isValidCronExpression("* * * * *")).toBe(true);
expect(isValidCronExpression('0 0 * * *')).toBe(true); expect(isValidCronExpression("0 0 * * *")).toBe(true);
expect(isValidCronExpression('*/15 0-23 * * *')).toBe(true); expect(isValidCronExpression("*/15 0-23 * * *")).toBe(true);
expect(isValidCronExpression('0 9 1,15 * 1-5')).toBe(true); expect(isValidCronExpression("0 9 1,15 * 1-5")).toBe(true);
expect(isValidCronExpression('30 4 1 * 0')).toBe(true); expect(isValidCronExpression("30 4 1 * 0")).toBe(true);
}); });
it('returns false for invalid cron expressions', () => { it("returns false for invalid cron expressions", () => {
expect(isValidCronExpression('')).toBe(false); expect(isValidCronExpression("")).toBe(false);
expect(isValidCronExpression('invalid')).toBe(false); expect(isValidCronExpression("invalid")).toBe(false);
expect(isValidCronExpression('0 0 * *')).toBe(false); // 4 parts expect(isValidCronExpression("0 0 * *")).toBe(false); // 4 parts
expect(isValidCronExpression('0 0 * * * *')).toBe(false); // 6 parts expect(isValidCronExpression("0 0 * * * *")).toBe(false); // 6 parts
expect(isValidCronExpression('60 * * * *')).toBe(false); // invalid minute expect(isValidCronExpression("60 * * * *")).toBe(false); // invalid minute
expect(isValidCronExpression('* 25 * * *')).toBe(false); // invalid hour expect(isValidCronExpression("* 25 * * *")).toBe(false); // invalid hour
expect(isValidCronExpression('* * 32 * *')).toBe(false); // invalid dom expect(isValidCronExpression("* * 32 * *")).toBe(false); // invalid dom
expect(isValidCronExpression('* * * 13 *')).toBe(false); // invalid month expect(isValidCronExpression("* * * 13 *")).toBe(false); // invalid month
expect(isValidCronExpression('* * * * 8')).toBe(false); // invalid dow expect(isValidCronExpression("* * * * 8")).toBe(false); // invalid dow
}); });
}); });
describe('Vietnam timezone (UTC+7) calculations', () => { describe("Vietnam timezone (UTC+7) calculations", () => {
it('returns +420 minutes offset for Asia/Ho_Chi_Minh', () => { it("returns +420 minutes offset for Asia/Ho_Chi_Minh", () => {
expect(getTimezoneOffsetMinutes('Asia/Ho_Chi_Minh')).toBe(420); expect(getTimezoneOffsetMinutes("Asia/Ho_Chi_Minh")).toBe(420);
expect(DEFAULT_TIMEZONE).toBe('Asia/Ho_Chi_Minh'); expect(DEFAULT_TIMEZONE).toBe("Asia/Ho_Chi_Minh");
}); });
it('extracts Vietnam zoned date parts correctly', () => { it("extracts Vietnam zoned date parts correctly", () => {
// 12:15 UTC is 19:15 Vietnam time (+7 hours) // 12:15 UTC is 19:15 Vietnam time (+7 hours)
const utcDate = new Date('2026-09-02T12:15:00.000Z'); const utcDate = new Date("2026-09-02T12:15:00.000Z");
const parts = getZonedDateParts(utcDate, 'Asia/Ho_Chi_Minh'); const parts = getZonedDateParts(utcDate, "Asia/Ho_Chi_Minh");
expect(parts.year).toBe(2026); expect(parts.year).toBe(2026);
expect(parts.month).toBe(8); // Sept (0-indexed) expect(parts.month).toBe(8); // Sept (0-indexed)
...@@ -48,107 +48,107 @@ describe('schedule-calculator.helper', () => { ...@@ -48,107 +48,107 @@ describe('schedule-calculator.helper', () => {
expect(parts.dayOfWeek).toBe(3); // Wednesday expect(parts.dayOfWeek).toBe(3); // Wednesday
}); });
it('calculates DAILY crawl at 20:00 VN time (same day in VN)', () => { it("calculates DAILY crawl at 20:00 VN time (same day in VN)", () => {
// Current time: 19:15 VN time (12:15 UTC) // Current time: 19:15 VN time (12:15 UTC)
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'DAILY', frequency: "DAILY",
hour: 20, hour: 20,
minute: 0, minute: 0,
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// 20:00 VN time on Sept 2 is 13:00 UTC on Sept 2 // 20:00 VN time on Sept 2 is 13:00 UTC on Sept 2
expect(next.toISOString()).toBe('2026-09-02T13:00:00.000Z'); expect(next.toISOString()).toBe("2026-09-02T13:00:00.000Z");
}); });
it('calculates DAILY crawl at 02:00 VN time (next day in VN)', () => { it("calculates DAILY crawl at 02:00 VN time (next day in VN)", () => {
// Current time: 19:15 VN time on Sept 2 (12:15 UTC) // Current time: 19:15 VN time on Sept 2 (12:15 UTC)
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'DAILY', frequency: "DAILY",
hour: 2, hour: 2,
minute: 0, minute: 0,
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// 02:00 VN time on Sept 3 is 19:00 UTC on Sept 2 // 02:00 VN time on Sept 3 is 19:00 UTC on Sept 2
expect(next.toISOString()).toBe('2026-09-02T19:00:00.000Z'); expect(next.toISOString()).toBe("2026-09-02T19:00:00.000Z");
}); });
it('calculates WEEKLY crawl on Friday (day 5) at 08:00 VN time', () => { it("calculates WEEKLY crawl on Friday (day 5) at 08:00 VN time", () => {
// Current time: Wednesday Sept 2, 19:15 VN time // Current time: Wednesday Sept 2, 19:15 VN time
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'WEEKLY', frequency: "WEEKLY",
dayOfWeek: 5, dayOfWeek: 5,
hour: 8, hour: 8,
minute: 0, minute: 0,
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// Friday Sept 4, 08:00 VN time is Sept 4, 01:00 UTC // Friday Sept 4, 08:00 VN time is Sept 4, 01:00 UTC
expect(next.toISOString()).toBe('2026-09-04T01:00:00.000Z'); expect(next.toISOString()).toBe("2026-09-04T01:00:00.000Z");
}); });
it('calculates MONTHLY crawl on 15th at 09:30 VN time', () => { it("calculates MONTHLY crawl on 15th at 09:30 VN time", () => {
// Current time: Sept 2, 19:15 VN time // Current time: Sept 2, 19:15 VN time
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'MONTHLY', frequency: "MONTHLY",
dayOfMonth: 15, dayOfMonth: 15,
hour: 9, hour: 9,
minute: 30, minute: 30,
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// Sept 15, 09:30 VN time is Sept 15, 02:30 UTC // Sept 15, 09:30 VN time is Sept 15, 02:30 UTC
expect(next.toISOString()).toBe('2026-09-15T02:30:00.000Z'); expect(next.toISOString()).toBe("2026-09-15T02:30:00.000Z");
}); });
it('calculates MONTHLY crawl for next month if day has passed in VN time', () => { it("calculates MONTHLY crawl for next month if day has passed in VN time", () => {
// Current time: Sept 2, 19:15 VN time // Current time: Sept 2, 19:15 VN time
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'MONTHLY', frequency: "MONTHLY",
dayOfMonth: 1, dayOfMonth: 1,
hour: 9, hour: 9,
minute: 0, minute: 0,
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// Oct 1, 09:00 VN time is Oct 1, 02:00 UTC // Oct 1, 09:00 VN time is Oct 1, 02:00 UTC
expect(next.toISOString()).toBe('2026-10-01T02:00:00.000Z'); expect(next.toISOString()).toBe("2026-10-01T02:00:00.000Z");
}); });
it('calculates CUSTOM cron in Vietnam time', () => { it("calculates CUSTOM cron in Vietnam time", () => {
// Current time: Sept 2, 19:15 VN time (12:15 UTC) // Current time: Sept 2, 19:15 VN time (12:15 UTC)
// Cron: "0 22 * * *" (22:00 VN time every day) // Cron: "0 22 * * *" (22:00 VN time every day)
const from = new Date('2026-09-02T12:15:00.000Z'); const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({ const next = calculateNextRun({
frequency: 'CUSTOM', frequency: "CUSTOM",
cronExpression: '0 22 * * *', cronExpression: "0 22 * * *",
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
fromDate: from, fromDate: from,
}); });
// 22:00 VN time on Sept 2 is 15:00 UTC on Sept 2 // 22:00 VN time on Sept 2 is 15:00 UTC on Sept 2
expect(next.toISOString()).toBe('2026-09-02T15:00:00.000Z'); expect(next.toISOString()).toBe("2026-09-02T15:00:00.000Z");
}); });
it('throws error on invalid cron expression', () => { it("throws error on invalid cron expression", () => {
expect(() => { expect(() => {
calculateNextRun({ calculateNextRun({
frequency: 'CUSTOM', frequency: "CUSTOM",
cronExpression: 'invalid cron', cronExpression: "invalid cron",
timezone: 'Asia/Ho_Chi_Minh', timezone: "Asia/Ho_Chi_Minh",
}); });
}).toThrow('Invalid cron expression'); }).toThrow("Invalid cron expression");
}); });
}); });
}); });
This diff is collapsed.
...@@ -4,103 +4,103 @@ ...@@ -4,103 +4,103 @@
*/ */
export function mapCrawlError(rawError: string | null | undefined): string { export function mapCrawlError(rawError: string | null | undefined): string {
if (!rawError) { if (!rawError) {
return 'Lỗi không xác định trong quá trình cào dữ liệu.'; return "Lỗi không xác định trong quá trình cào dữ liệu.";
} }
const err = rawError.toLowerCase(); const err = rawError.toLowerCase();
// 1. Chặn bởi robots.txt // 1. Chặn bởi robots.txt
if ( if (
err.includes('robots.txt') || err.includes("robots.txt") ||
err.includes('blocked by robots') || err.includes("blocked by robots") ||
err.includes('robots blocked') err.includes("robots blocked")
) { ) {
return 'Trang web đích ngăn chặn việc cào dữ liệu thông qua tệp robots.txt.'; return "Trang web đích ngăn chặn việc cào dữ liệu thông qua tệp robots.txt.";
} }
// 2. CAPTCHA // 2. CAPTCHA
if (err.includes('captcha')) { if (err.includes("captcha")) {
return 'Trang web đích yêu cầu xác minh CAPTCHA. Hệ thống không thể bypass — trang được đánh dấu CAPTCHA_DETECTED.'; return "Trang web đích yêu cầu xác minh CAPTCHA. Hệ thống không thể bypass — trang được đánh dấu CAPTCHA_DETECTED.";
} }
// 3. Yêu cầu đăng nhập // 3. Yêu cầu đăng nhập
if (err.includes('requires login') || err.includes('login required')) { if (err.includes("requires login") || err.includes("login required")) {
return 'Trang web đích yêu cầu đăng nhập. Hệ thống không thể truy cập nội dung — trang được đánh dấu REQUIRES_LOGIN.'; return "Trang web đích yêu cầu đăng nhập. Hệ thống không thể truy cập nội dung — trang được đánh dấu REQUIRES_LOGIN.";
} }
// 4. Paywall // 4. Paywall
if (err.includes('paywall')) { if (err.includes("paywall")) {
return 'Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED.'; return "Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED.";
} }
// 5. Lỗi API Key / Xác thực // 5. Lỗi API Key / Xác thực
if ( if (
err.includes('unauthorized') || err.includes("unauthorized") ||
err.includes('api key') || err.includes("api key") ||
err.includes('apikey') || err.includes("apikey") ||
(err.includes('forbidden') && err.includes('key')) (err.includes("forbidden") && err.includes("key"))
) { ) {
return 'Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ).'; return "Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ).";
} }
// 6. Timeout // 6. Timeout
if (err.includes('timeout') || err.includes('timed out')) { if (err.includes("timeout") || err.includes("timed out")) {
return 'Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm.'; return "Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm.";
} }
// 7. Chặn bởi Cloudflare / Security / IP Blocked // 7. Chặn bởi Cloudflare / Security / IP Blocked
if ( if (
err.includes('cloudflare') || err.includes("cloudflare") ||
err.includes('403') || err.includes("403") ||
err.includes('forbidden') || err.includes("forbidden") ||
err.includes('access denied') || err.includes("access denied") ||
(err.includes('block') && (err.includes('ip') || err.includes('bot'))) (err.includes("block") && (err.includes("ip") || err.includes("bot")))
) { ) {
return 'Yêu cầu bị từ chối. Trang web đích chặn kết nối cào dữ liệu (chặn IP, phát hiện bot hoặc bảo vệ bởi Cloudflare/WAF).'; return "Yêu cầu bị từ chối. Trang web đích chặn kết nối cào dữ liệu (chặn IP, phát hiện bot hoặc bảo vệ bởi Cloudflare/WAF).";
} }
// 8. DNS / URL không đúng / Không tìm thấy host // 8. DNS / URL không đúng / Không tìm thấy host
if ( if (
err.includes('dns') || err.includes("dns") ||
err.includes('getaddrinfo') || err.includes("getaddrinfo") ||
err.includes('enotfound') || err.includes("enotfound") ||
err.includes('invalid url') || err.includes("invalid url") ||
err.includes('cannot parse url') err.includes("cannot parse url")
) { ) {
return 'Địa chỉ URL không hợp lệ hoặc không thể phân giải tên miền (trang web không tồn tại hoặc sai đường dẫn).'; return "Địa chỉ URL không hợp lệ hoặc không thể phân giải tên miền (trang web không tồn tại hoặc sai đường dẫn).";
} }
// 9. Rate limit / 429 // 9. Rate limit / 429
if ( if (
err.includes('rate limit') || err.includes("rate limit") ||
err.includes('429') || err.includes("429") ||
err.includes('too many requests') err.includes("too many requests")
) { ) {
return 'Yêu cầu bị từ chối do tần suất truy cập vượt quá giới hạn cho phép (Rate Limit). Vui lòng thử lại sau.'; return "Yêu cầu bị từ chối do tần suất truy cập vượt quá giới hạn cho phép (Rate Limit). Vui lòng thử lại sau.";
} }
// 10. Chặn IP Private (SSRF Protection) // 10. Chặn IP Private (SSRF Protection)
if (err.includes('private ip') || err.includes('private_ip_blocked')) { if (err.includes("private ip") || err.includes("private_ip_blocked")) {
return 'Không được phép cào dữ liệu từ địa chỉ IP nội bộ hoặc mạng nội bộ (SSRF Protection).'; return "Không được phép cào dữ liệu từ địa chỉ IP nội bộ hoặc mạng nội bộ (SSRF Protection).";
} }
// Fallback: không khớp bất kỳ pattern nào — trả về message tổng quát // Fallback: không khớp bất kỳ pattern nào — trả về message tổng quát
// Raw error được log ở tầng worker, không expose technical detail lên frontend // Raw error được log ở tầng worker, không expose technical detail lên frontend
return 'Đã xảy ra lỗi trong quá trình cào dữ liệu. Vui lòng thử lại hoặc kiểm tra URL đích.'; return "Đã xảy ra lỗi trong quá trình cào dữ liệu. Vui lòng thử lại hoặc kiểm tra URL đích.";
} }
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
} }
if (typeof error === 'string') { if (typeof error === "string") {
return error; return error;
} }
if ( if (
error && error &&
typeof error === 'object' && typeof error === "object" &&
'message' in error && "message" in error &&
typeof (error as Record<string, unknown>).message === 'string' typeof (error as Record<string, unknown>).message === "string"
) { ) {
return (error as { message: string }).message; return (error as { message: string }).message;
} }
......
import path from 'path'; import path from "path";
import fs from 'fs'; import fs from "fs";
import { storageConfig } from '../../config/storage.config'; import { storageConfig } from "../../config/storage.config";
import { import {
JOB_EXPORT_SUBDIRS, JOB_EXPORT_SUBDIRS,
buildCrawlResultZipName, buildCrawlResultZipName,
buildMarkdownZipName, buildMarkdownZipName,
} from '../constants/storage-path.constant'; } from "../constants/storage-path.constant";
import { generatePageFileName } from './slug.helper'; import { generatePageFileName } from "./slug.helper";
export function ensureDirExists(dirPath: string): void { export function ensureDirExists(dirPath: string): void {
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
...@@ -76,7 +76,7 @@ export function buildJobMarkdownFilePath( ...@@ -76,7 +76,7 @@ export function buildJobMarkdownFilePath(
jobId: string, jobId: string,
index: number, index: number,
url: string, url: string,
ext = 'md', ext = "md",
): { fileName: string; filePath: string } { ): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext); const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN); const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN);
...@@ -87,7 +87,7 @@ export function buildJobMarkdownRawFilePath( ...@@ -87,7 +87,7 @@ export function buildJobMarkdownRawFilePath(
jobId: string, jobId: string,
index: number, index: number,
url: string, url: string,
ext = 'md', ext = "md",
): { fileName: string; filePath: string } { ): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext); const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_RAW); const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_RAW);
...@@ -98,7 +98,7 @@ export function buildJobMarkdownCleanFilePath( ...@@ -98,7 +98,7 @@ export function buildJobMarkdownCleanFilePath(
jobId: string, jobId: string,
index: number, index: number,
url: string, url: string,
ext = 'md', ext = "md",
): { fileName: string; filePath: string } { ): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext); const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_CLEAN); const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_CLEAN);
...@@ -109,7 +109,7 @@ export function buildJobRawFilePath( ...@@ -109,7 +109,7 @@ export function buildJobRawFilePath(
jobId: string, jobId: string,
index: number, index: number,
url: string, url: string,
ext = 'html', ext = "html",
): { fileName: string; filePath: string } { ): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext); const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.RAW); const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.RAW);
......
export const parseTrustProxy = (value: string): boolean | number | string => { export const parseTrustProxy = (value: string): boolean | number | string => {
const lowercaseVal = value.trim().toLowerCase(); const lowercaseVal = value.trim().toLowerCase();
if (lowercaseVal === 'true') return true; if (lowercaseVal === "true") return true;
if (lowercaseVal === 'false') return false; if (lowercaseVal === "false") return false;
const parsed = parseInt(lowercaseVal, 10); const parsed = parseInt(lowercaseVal, 10);
if (!isNaN(parsed) && String(parsed) === value.trim()) { if (!isNaN(parsed) && String(parsed) === value.trim()) {
......
...@@ -2,19 +2,19 @@ export function toSlug(text: string): string { ...@@ -2,19 +2,19 @@ export function toSlug(text: string): string {
return text return text
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/[^\w\s-]/g, '') .replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, '-') .replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, "");
} }
export function urlToPageSlug(url: string): string { export function urlToPageSlug(url: string): string {
try { try {
const pathname = new URL(url).pathname; const pathname = new URL(url).pathname;
const segment = pathname.split('/').filter(Boolean).pop() || 'home'; const segment = pathname.split("/").filter(Boolean).pop() || "home";
const slug = toSlug(segment); const slug = toSlug(segment);
return slug || 'page'; return slug || "page";
} catch { } catch {
return 'page'; return "page";
} }
} }
...@@ -24,6 +24,6 @@ export function generatePageFileName( ...@@ -24,6 +24,6 @@ export function generatePageFileName(
ext: string, ext: string,
): string { ): string {
const slug = urlToPageSlug(url); const slug = urlToPageSlug(url);
const prefix = String(index + 1).padStart(3, '0'); const prefix = String(index + 1).padStart(3, "0");
return `${prefix}-${slug}.${ext}`; return `${prefix}-${slug}.${ext}`;
} }
This diff is collapsed.
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { Readable } from 'stream'; import { Readable } from "stream";
import { pipeline } from 'stream/promises'; import { pipeline } from "stream/promises";
import { import {
IStorageService, IStorageService,
UploadResult, UploadResult,
UploadStreamOptions, UploadStreamOptions,
} from './storage.interface'; } from "./storage.interface";
import { storageConfig } from '../../config/storage.config'; import { storageConfig } from "../../config/storage.config";
import { ensureDirExists, getFileSizeBytes } from '../helpers/file.helper'; import { ensureDirExists, getFileSizeBytes } from "../helpers/file.helper";
export class LocalStorageService implements IStorageService { export class LocalStorageService implements IStorageService {
private getAbsolutePath(key: string): string { private getAbsolutePath(key: string): string {
......
This diff is collapsed.
import { Response } from 'express'; import { Response } from "express";
import { pipeline } from 'stream/promises'; import { pipeline } from "stream/promises";
import { AppError } from '../errors/app-error'; import { AppError } from "../errors/app-error";
import { ERROR_CODE } from '../errors/error-code'; import { ERROR_CODE } from "../errors/error-code";
import { StorageFactory } from './storage.factory'; import { StorageFactory } from "./storage.factory";
interface StoredDownload { interface StoredDownload {
fileName: string; fileName: string;
...@@ -19,26 +19,26 @@ export async function streamStorageDownload( ...@@ -19,26 +19,26 @@ export async function streamStorageDownload(
if (!(await storage.exists(file.filePath))) { if (!(await storage.exists(file.filePath))) {
throw new AppError( throw new AppError(
'Export file not found in storage', "Export file not found in storage",
404, 404,
ERROR_CODE.EXPORT_FILE_MISSING, ERROR_CODE.EXPORT_FILE_MISSING,
); );
} }
const source = await storage.getReadStream(file.filePath); const source = await storage.getReadStream(file.filePath);
const safeFileName = file.fileName.replace(/[\r\n"]/g, '_'); const safeFileName = file.fileName.replace(/[\r\n"]/g, "_");
const encodedFileName = encodeURIComponent(file.fileName); const encodedFileName = encodeURIComponent(file.fileName);
response.setHeader( response.setHeader(
'Content-Disposition', "Content-Disposition",
`attachment; filename="${safeFileName}"; filename*=UTF-8''${encodedFileName}`, `attachment; filename="${safeFileName}"; filename*=UTF-8''${encodedFileName}`,
); );
response.setHeader( response.setHeader(
'Content-Type', "Content-Type",
file.mimeType || 'application/octet-stream', file.mimeType || "application/octet-stream",
); );
if (file.fileSize !== null && file.fileSize !== undefined) { if (file.fileSize !== null && file.fileSize !== undefined) {
response.setHeader('Content-Length', String(file.fileSize)); response.setHeader("Content-Length", String(file.fileSize));
} }
await pipeline(source, response); await pipeline(source, response);
......
This diff is collapsed.
import { Readable } from 'stream'; import { Readable } from "stream";
export interface UploadResult { export interface UploadResult {
fileName: string; fileName: string;
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment