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
- 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:
```typescript
import { validate } from '../../middlewares/validate.middleware';
import { mySchema } from './my.validation';
import { validate } from "../../middlewares/validate.middleware";
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>;`.
......@@ -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`:
```typescript
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { AppError } from "../../common/errors/app-error";
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:
```json
......@@ -55,5 +55,6 @@ Route → Controller → Service → Repository → Prisma → Postgre
## 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`:
- 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`.
......@@ -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
Áp dụng chuẩn Conventional Commits:
- `feat(<module>):` Thêm chức năng mới
- `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
......
......@@ -110,14 +110,14 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
~1000 lines changed → Too large. Split it.
```
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 _total_ lines in a single file (distinct from the ~1000 _changed_-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules _first_, before piling more on. Decompose, then add.
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
**Splitting strategies when a change is too large:**
| Strategy | How | When |
|----------|-----|------|
| ----------------- | ------------------------------------------------------- | ----------------------- |
| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies |
| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns |
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
......@@ -179,8 +179,8 @@ For each file changed:
Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| *(no prefix)* | Required change | Must address before merge |
| ----------------------------- | ------------------ | ------------------------------------------------------- |
| _(no prefix)_ | Required change | Must address before merge |
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
......@@ -188,7 +188,7 @@ Label every comment with its severity so the author knows what's required vs opt
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem _is_ the review.
### Step 5: Verify the Verification
......@@ -222,6 +222,7 @@ Human makes the final call
This catches issues that a single model might miss — different models have different blind spots.
**Example prompt for a review agent:**
```
Review this code change for correctness, security, and adherence to
our project conventions. The spec says [X]. The change should [Y].
......@@ -281,6 +282,7 @@ When reviewing code — whether written by you, another agent, or a human:
Part of code review is dependency review:
**Before adding any dependency:**
1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.)
3. Is it actively maintained? (Check last commit, open issues.)
......@@ -293,11 +295,11 @@ Part of code review is dependency review:
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
3. **Let the tests decide.** The upgrade is verified by a green suite before _and_ after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade _workflow_, that one covers the security verdict.
## The Review Checklist
......@@ -305,20 +307,24 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
## Review: [PR/Change title]
### Context
- [ ] I understand what this change does and why
### Correctness
- [ ] Change matches spec/task requirements
- [ ] Edge cases handled
- [ ] Error paths handled
- [ ] Tests cover the change adequately
### Readability
- [ ] Names are clear and consistent
- [ ] Logic is straightforward
- [ ] No unnecessary complexity
### Architecture
- [ ] Follows existing patterns
- [ ] No unnecessary coupling or dependencies
- [ ] Appropriate abstraction level
......@@ -326,6 +332,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- [ ] No feature logic in shared modules; file stays within a healthy size
### Security
- [ ] No secrets in code
- [ ] Input validated at boundaries
- [ ] No injection vulnerabilities
......@@ -333,19 +340,23 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- [ ] External data sources treated as untrusted
### Performance
- [ ] No N+1 patterns
- [ ] No unbounded operations
- [ ] Pagination on list endpoints
### Verification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] Manual verification done (if applicable)
### Verdict
- [ ] **Approve** — Ready to merge
- [ ] **Request changes** — Issues must be addressed
```
## See Also
- For detailed security review guidance, see `../../references/security-checklist.md`
......@@ -354,7 +365,7 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
......
......@@ -31,6 +31,7 @@ The workflow operates **autonomously** without requiring manual user prompt paci
## Core Invariants & Safety Guardrails
### 1. General Safety Guardrails
- **DO NOT** delete data or drop database tables/schemas.
- **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.
......@@ -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.
### 2. Financial Logic Invariants
When auditing or repairing applications handling wallets, transactions, budgets, or accounting:
- **Income**: Increases 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.
......@@ -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.
### 3. Timezone Invariants (Asia/Ho_Chi_Minh — UTC+7)
- 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`.
- **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
- **P3 — Low**: Code quality, architectural convention drift, minor CPU/memory optimizations, documentation inaccuracies, or cosmetic formatting.
#### Finding Entry Structure
For every finding recorded in the backlog:
- **ID**: e.g., `BUG-P0-01`, `BUG-P1-02`
- **Severity**: `P0` / `P1` / `P2` / `P3`
- **Module**: Feature/module directory name
......@@ -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
#### Priority Order for Triage
1. Data corruption & data loss
2. Financial calculation and balance errors
3. Security vulnerabilities (SSRF, Auth/IDOR, Injection)
......@@ -164,6 +171,7 @@ Before applying any code changes, rigorously verify every **P0** and **P1** find
### Step 4 — Fix P0 Issues
Implement fixes for all `CONFIRMED` P0 findings adhering to these rules:
- **Smallest Safe Change**: Make the minimal diff necessary to fix the root cause.
- **Preserve Architecture**: Follow existing repository patterns and layered architecture.
- **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:
### Step 6 — Fix P1 Issues
Once P0 fixes are verified and green:
- Apply targeted, minimal fixes for all `CONFIRMED` P1 findings.
- 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
### Step 9 — Secondary Fixes (Convergence Loop)
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`.
2. Iterate until:
- Zero confirmed P0 issues remain.
- Zero confirmed P1 issues remain.
- All tests pass cleanly.
- 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
**Status**: [Clean / Action Required / Converged]
## Executive Summary
Concise 2–3 paragraph summary of the audit scope, critical issues discovered, fixes applied, test outcomes, and current repository health.
## Initial Findings Backlog
Summary table of all findings from Step 2 with Severity (P0, P1, P2, P3), Module, and Status (Fixed / Verified / Deferred).
## Fixed Issues Detail
### [BUG-P0-01] [Issue Title]
- **Severity**: P0
- **Module**: [module]
- **Root Cause**: [explanation]
......@@ -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 ...]
## Test Execution Summary
- **Typecheck**: PASSED
- **Lint**: PASSED
- **Unit & Integration Tests**: [X] passed, 0 failed
- **New Tests Added**: [list of new test suites]
## Re-Audit Results
Detailed checklist proving no secondary regressions, contract breakages, or timezone errors remain.
## Remaining & Deferred Issues (P2 / P3)
List of non-blocking P2 and P3 issues scheduled for future maintenance cycles with recommended remediation.
## Risk Assessment & Next Steps
- Remaining operational or infrastructure risks.
- Actionable recommendations for the development team.
```
......@@ -291,6 +308,7 @@ List of non-blocking P2 and P3 issues scheduled for future maintenance cycles wi
## Final Output Summary
Upon completion of the workflow, output a clear, concise terminal summary:
- **P0 Fixed**: Total count and IDs
- **P1 Fixed**: 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`:
```
> **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.
> - Đả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`:
"domain": "amazon.com",
"name": "Amazon Product Standard Template",
"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": "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 }
{
"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": "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`:
## 4. Xuất Dữ Liệu Sau Khi Cào
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"`.
- 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 →
```
### 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.
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).
......
......@@ -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)
### 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.
- [ ] **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/...`.
### 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.
- [ ] **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()`.
### 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`.
- [ ] **Đú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.
### D. Kiểm Thử & Kiểm Định (Testing & Verification)
- [ ] Đã 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 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
## 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 độ:
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ý.
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
- 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`:
```typescript
import { validate } from '../../middlewares/validate.middleware';
import { createCrawlJobSchema } from './crawl-job.validation';
import { validate } from "../../middlewares/validate.middleware";
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`.
......@@ -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.
- Bắt buộc kế thừa từ `AppError`:
```typescript
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
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:
......
......@@ -7,7 +7,7 @@
## 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 |
| :--- | :--- | :--- |
| :------------------ | :------------------- | :---------------------------------------------- |
| **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 |
| **Ngôn ngữ** | `TypeScript 5.7` | `strict: true`, không dùng kiểu `any` vô căn cứ |
......
......@@ -82,6 +82,7 @@ pnpm build
## 5. Quy Trình Bổ Sung Queue Worker
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`.
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`.
......
......@@ -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):**
>
> - 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.
......
......@@ -13,7 +13,8 @@ Route → Controller → Service → Repository → Prisma Client →
```
### 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>/`):**
- `<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.
......@@ -23,18 +24,41 @@ Route → Controller → Service → Repository → Prisma Client →
- `__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/`.
- **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)
### 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.
- 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.
- **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)
- 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`
- `webhook.queue.ts` / `webhook.worker.ts`
......
......@@ -583,10 +583,10 @@ src/database/prisma.client.ts
```
```ts
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: ['error', 'warn'],
log: ["error", "warn"],
});
```
......@@ -594,9 +594,10 @@ Nếu cần log query khi development:
```ts
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error', 'warn'],
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error", "warn"],
});
```
......@@ -628,17 +629,17 @@ Không để repository xử lý nghiệp vụ.
### 11.1. Route
```ts
import { Router } from 'express';
import { CrawlJobController } from './crawl-job.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { Router } from "express";
import { CrawlJobController } from "./crawl-job.controller";
import { authMiddleware } from "../../middlewares/auth.middleware";
const router = Router();
const controller = new CrawlJobController();
router.post('/', authMiddleware, controller.create);
router.get('/', authMiddleware, controller.findAll);
router.get('/:id', authMiddleware, controller.findById);
router.post('/:id/cancel', authMiddleware, controller.cancel);
router.post("/", authMiddleware, controller.create);
router.get("/", authMiddleware, controller.findAll);
router.get("/:id", authMiddleware, controller.findById);
router.post("/:id/cancel", authMiddleware, controller.cancel);
export default router;
```
......@@ -648,8 +649,8 @@ export default router;
### 11.2. Controller
```ts
import { Request, Response, NextFunction } from 'express';
import { CrawlJobService } from './crawl-job.service';
import { Request, Response, NextFunction } from "express";
import { CrawlJobService } from "./crawl-job.service";
export class CrawlJobController {
private readonly service = new CrawlJobService();
......@@ -717,9 +718,9 @@ export class CrawlJobController {
### 11.3. Service
```ts
import { CrawlJobRepository } from './crawl-job.repository';
import { AppError } from '../../common/errors/app-error';
import { crawlQueue } from '../../queues/crawl.queue';
import { CrawlJobRepository } from "./crawl-job.repository";
import { AppError } from "../../common/errors/app-error";
import { crawlQueue } from "../../queues/crawl.queue";
export class CrawlJobService {
private readonly repository = new CrawlJobRepository();
......@@ -738,7 +739,7 @@ export class CrawlJobService {
maxDepth: payload.maxDepth,
});
await crawlQueue.add('crawl-job', {
await crawlQueue.add("crawl-job", {
jobId: job.id,
});
......@@ -753,7 +754,7 @@ export class CrawlJobService {
const job = await this.repository.findById(jobId);
if (!job || job.userId !== userId) {
throw new AppError('Crawl job not found', 404);
throw new AppError("Crawl job not found", 404);
}
return job;
......@@ -762,11 +763,11 @@ export class CrawlJobService {
async cancel(userId: string, jobId: string) {
const job = await this.findById(userId, jobId);
if (job.status === 'COMPLETED') {
throw new AppError('Completed job cannot be canceled', 400);
if (job.status === "COMPLETED") {
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 {
### 11.4. Repository
```ts
import { prisma } from '../../database/prisma.client';
import { CrawlJobStatus } from '@prisma/client';
import { prisma } from "../../database/prisma.client";
import { CrawlJobStatus } from "@prisma/client";
export class CrawlJobRepository {
create(data: {
......@@ -801,7 +802,7 @@ export class CrawlJobRepository {
findAllByUser(userId: string, query: any) {
return prisma.crawlJob.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
include: {
exports: true,
},
......@@ -839,27 +840,27 @@ prisma/seed.ts
```
```ts
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { PrismaClient, UserRole } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('Admin@123456', 10);
const passwordHash = await bcrypt.hash("Admin@123456", 10);
await prisma.user.upsert({
where: { email: 'admin@crawl.local' },
where: { email: "admin@crawl.local" },
update: {},
create: {
email: 'admin@crawl.local',
email: "admin@crawl.local",
passwordHash,
fullName: 'System Admin',
fullName: "System Admin",
role: UserRole.ADMIN,
isActive: true,
},
});
console.log('Seed completed');
console.log("Seed completed");
}
main()
......
......@@ -5,7 +5,7 @@ Backend API cho hệ thống crawl dữ liệu web. Người dùng dán link, h
## Tech Stack
| Thành phần | Công nghệ |
| --------------- | -------------------------------------- |
| --------------- | ------------------------------------- |
| Runtime | Node.js + TypeScript |
| Framework | Express.js |
| ORM | Prisma (Code First Migration) |
......@@ -112,8 +112,8 @@ docker ps
Hai container cần chạy:
| Container | Service | Port |
| -------------------- | ---------- | ------ |
| `crawl_data_postgres`| PostgreSQL | `5432` |
| --------------------- | ---------- | ------ |
| `crawl_data_postgres` | PostgreSQL | `5432` |
| `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.
......@@ -129,6 +129,7 @@ pnpm db:migrate:init
```
Lệnh này sẽ:
1. Đọc `prisma/schema.prisma`
2. Tạo folder migration đầu tiên trong `prisma/migrations/`
3. Apply migration xuống PostgreSQL
......@@ -154,7 +155,7 @@ pnpm db:seed
Seed tạo 3 tài khoản mặc định để test:
| Email | Password | Role |
| --------------------- | ---------------- | ------------- |
| -------------------- | ---------------- | ------------ |
| `admin@crawl.local` | `Admin@123456` | ADMIN |
| `crawl@crawl.local` | `Crawler@123456` | CRAWLER_USER |
| `viewer@crawl.local` | `Viewer@123456` | VIEWER |
......@@ -213,7 +214,7 @@ pnpm worker
## Các lệnh hữu ích
| Lệnh | Mô tả |
| -------------------------- | ---------------------------------------------------------- |
| ------------------------ | --------------------------------------------------------- |
| `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:deploy` | Apply migration lên staging/production (không dùng dev) |
......@@ -301,7 +302,7 @@ 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)
| 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 |
| **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`) |
......@@ -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)
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.
- **`wordCount`**: Số từ tính trên `cleanText`.
- **`contentHash`**: Mã SHA-256 tính từ `cleanText` phục vụ deduplication trên Vector DB.
......@@ -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).
version: '3.8'
version: "3.8"
services:
postgres:
......
......@@ -5,7 +5,7 @@
Storage được chọn bằng `STORAGE_DRIVER`:
| Cấu hình | Nơi lưu file |
| --- | --- |
| ---------------------- | ---------------------------------------------- |
| `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 |
......
......@@ -13,8 +13,9 @@ A comprehensive, end-to-end full project audit and autonomous remediation cycle
All **29 findings** across security, authentication, database indexes, API contracts, memory efficiency, and lint quality were systematically evaluated, verified against active code, and resolved.
### Key Results
- **P0 Critical Findings**: 5 of 5 FIXED & VERIFIED
- **P1 High Findings**: 6 of 6 Actionable FIXED & VERIFIED *(2 Redis infrastructure-dependent items documented)*
- **P1 High Findings**: 6 of 6 Actionable FIXED & VERIFIED _(2 Redis infrastructure-dependent items documented)_
- **P2 Medium Findings**: 7 of 7 VERIFIED & RESOLVED (Fixed confirmed items, verified false-positives)
- **P3 Low Findings**: 7 of 7 VERIFIED & RESOLVED (Added reusable UUID validator, refined changePassword schema, fixed unused vars, configured CORS preflight)
- **Compilation (`pnpm build`)**: ✅ 0 errors
......@@ -26,19 +27,19 @@ All **29 findings** across security, authentication, database indexes, API contr
## 2. Complete Findings Verification & Resolution Matrix
| ID | Sev | Module | Description | Audit Verification | Remediation Status |
|---|---|---|---|---|---|
| ----------- | --- | ------------ | ---------------------------------------------------- | ------------------ | ------------------------------------------------- |
| **BUG-001** | P0 | Config | JWT secrets hardcoded fallback default | CONFIRMED | **FIXED** (Fail-fast startup validation) |
| **BUG-002** | P0 | Config | Webhook AES encryption key hardcoded in repo | CONFIRMED | **FIXED** (Fail-fast hex validation) |
| **BUG-003** | P0 | Auth | `resetPassword` decode-before-verify pattern | CONFIRMED | **FIXED** (Isolated `verifyResetToken`) |
| **BUG-004** | P0 | CrawlJobs | `findByIdWithPages` unbounded page queries in worker | CONFIRMED | **FIXED** (Selective diff field projection) |
| **BUG-005** | P0 | CrawlAssets | `GET /crawl-jobs/:id/assets` unbounded results | CONFIRMED | **FIXED** (Added skip/take pagination) |
| **BUG-006** | P1 | Auth | `authMiddleware` DB lookup caching | CONFIRMED | *Documented for Redis cluster rollout* |
| **BUG-006** | P1 | Auth | `authMiddleware` DB lookup caching | CONFIRMED | _Documented for Redis cluster rollout_ |
| **BUG-007** | P1 | API Keys | `apiKeyOrAuthMiddleware` 2 sequential DB queries | CONFIRMED | **FIXED** (Single query with relation join) |
| **BUG-008** | P1 | Auth | `forgotPassword` dev log leaks reset token | CONFIRMED | **FIXED** (Removed console logging of token) |
| **BUG-009** | P1 | CrawlJobs | SSE `streamEvents` interval poll & unbounded TTL | CONFIRMED | **FIXED** (30-min max TTL + 3s poll) |
| **BUG-010** | P1 | Auth | `authMiddleware` uses stale JWT role instead of DB | CONFIRMED | **FIXED** (Assigns fresh `user.role` from DB) |
| **BUG-011** | P1 | App | CORS origin coupled to mail config | CONFIRMED | **FIXED** (Dedicated multi-origin whitelist) |
| **BUG-012** | P1 | Security | Rate limiter MemoryStore in multi-instance | CONFIRMED | *Documented for Redis cluster rollout* |
| **BUG-012** | P1 | Security | Rate limiter MemoryStore in multi-instance | CONFIRMED | _Documented for Redis cluster rollout_ |
| **BUG-013** | P1 | CrawlJobs | `GET /crawl-jobs/:id/diff` naked response envelope | CONFIRMED | **FIXED** (Wrapped in `{ success, data }`) |
| **BUG-014** | P2 | Auth | Email verification token shares `accessSecret` | CONFIRMED | **FIXED** (Dedicated `emailVerificationSecret`) |
| **BUG-015** | P2 | Auth | Register email enumeration timing oracle | FALSE_POSITIVE | **VERIFIED INTENTIONAL** (Explicit design tested) |
......@@ -62,6 +63,7 @@ All **29 findings** across security, authentication, database indexes, API contr
## 3. Detailed Summary of Changes
### Security & Authentication
1. **JWT & Webhook Secret Validation**: Enforced startup checks requiring minimum 32-character strings for JWT secrets and 64-character hex strings for AES-256 webhook encryption keys in [`src/config/env.config.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/config/env.config.ts).
2. **Dedicated Email Verification Secret**: Isolated email verification signing via `jwtConfig.emailVerificationSecret` in [`src/modules/auth/auth.service.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/modules/auth/auth.service.ts).
3. **Cryptographic Reset Password Flow**: Refactored `resetPassword` to execute cryptographic signature validation in `verifyResetToken()` before any state mutation or token revocation.
......@@ -69,12 +71,14 @@ All **29 findings** across security, authentication, database indexes, API contr
5. **CORS & Preflight Optimization**: Decoupled CORS from email configuration, supporting multi-origin whitelisting via `CORS_ALLOWED_ORIGINS` and preflight caching via `maxAge: 86400` in [`src/app.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/app.ts).
### Database & Concurrency Safety
1. **Memory-Safe Diff Queries**: Replaced full page loading (`include: { pages: true }`) with selective field projections in [`src/modules/crawl-jobs/crawl-job.repository.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/modules/crawl-jobs/crawl-job.repository.ts), preventing worker heap exhaustion on large jobs.
2. **Paginated Asset Retrieval**: Added `page` and `limit` (max 500) parameters with `skip`/`take` pagination to [`src/modules/crawl-assets/crawl-asset.repository.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/modules/crawl-assets/crawl-asset.repository.ts).
3. **Single-Query API Key Auth**: Joined the `user` relation in `ApiKeyRepository.findByHash`, eliminating an extra sequential query in [`src/middlewares/api-key.middleware.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/middlewares/api-key.middleware.ts).
4. **Schema Indexing**: Added composite index `@@index([jobId, createdAt])` to `CrawlJobLog` and `@@index([ipAddress])` to `AuditLog` in [`prisma/schema.prisma`](file:///d:/NodeJS/DataCrawler/data-crawler-be/prisma/schema.prisma).
### API Contract & Validation
1. **Standardized Response Envelopes**: Wrapped `GET /crawl-jobs/:id/diff` in `{ success: true, data: diffReport }` in [`src/modules/crawl-jobs/crawl-job.controller.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/modules/crawl-jobs/crawl-job.controller.ts).
2. **Re-usable Path Params Validation**: Added `validateParams()` in [`src/middlewares/validate.middleware.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/middlewares/validate.middleware.ts).
3. **Password Validation Refinement**: Added rule ensuring `newPassword !== currentPassword` in [`src/modules/auth/auth.validation.ts`](file:///d:/NodeJS/DataCrawler/data-crawler-be/src/modules/auth/auth.validation.ts).
......
......@@ -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ử
### Cách 1: Chạy trực tiếp bằng Postman (GUI)
1. **Import vào Postman**:
- File Collection: [data-crawler.postman_collection.json](./data-crawler.postman_collection.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
- Nhấn **Run Data Crawler BE API**.
### 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:
```bash
......@@ -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ể:
```bash
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
## 📋 2. Chi tiết các Nhóm Kiểm thử (Test Suites)
### 🔐 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.
1. **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`).
- _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`).
2. **Get 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`).
- _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`).
3. **Refresh Token**:
- *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.
- _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.
4. **Logout**:
- *Endpoint*: `POST /auth/logout`
- *Test Assertions*: Thu hồi token trong database, xóa khỏi biến môi trường Postman.
- _Endpoint_: `POST /auth/logout`
- _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)**:
- *Test Assertions*: Trả về `401 Unauthorized`.
- _Test Assertions_: Trả về `401 Unauthorized`.
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
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**:
- *Endpoint*: `POST /crawl-jobs`
- *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`).
- _Endpoint_: `POST /crawl-jobs`
- _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`).
2. **Get Crawl Jobs (Paginated & Filtered)**:
- *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 } }`.
- _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 } }`.
3. **Get Crawl Job by 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`).
- _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`).
4. **Get Crawled Pages (Metadata List)**:
- *Endpoint*: `GET /crawl-jobs/:id/pages`
- *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).
- _Endpoint_: `GET /crawl-jobs/:id/pages`
- _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).
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`)
- *Test Assertions*:
- _Endpoint_: `GET /crawl-jobs/:id/pages/preview?minQualityScore=50` (hoặc `GET /crawl-jobs/:id/pages?preview=true`)
- _Test Assertions_:
- 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.
- `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.
- 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**:
- *Endpoint*: `GET /crawl-jobs/:id/assets?assetType=IMAGE`
- *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.
- _Endpoint_: `GET /crawl-jobs/:id/assets?assetType=IMAGE`
- _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.
---
### 💾 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`).
1. **Get Crawl Job 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.
- _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.
2. **Create Export for Job**:
- *Endpoint*: `POST /crawl-jobs/:id/exports`
- *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`.
- _Endpoint_: `POST /crawl-jobs/:id/exports`
- _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`.
3. **Download Export File**:
- *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`.
- _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`.
- **Đặ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 `/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
---
### 🛡️ D. Nhóm Permission & Edge Case Tests (Phân quyền & Lỗi nghiệp vụ)
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"`.
2. **Cancel Completed Job**:
......
......@@ -769,15 +769,8 @@
],
"url": {
"raw": "{{base_url}}/crawl-jobs/:id/pages/preview?minQualityScore=50",
"host": [
"{{base_url}}"
],
"path": [
"crawl-jobs",
":id",
"pages",
"preview"
],
"host": ["{{base_url}}"],
"path": ["crawl-jobs", ":id", "pages", "preview"],
"query": [
{
"key": "minQualityScore",
......@@ -825,14 +818,8 @@
],
"url": {
"raw": "{{base_url}}/crawl-jobs/:id/assets?assetType=IMAGE",
"host": [
"{{base_url}}"
],
"path": [
"crawl-jobs",
":id",
"assets"
],
"host": ["{{base_url}}"],
"path": ["crawl-jobs", ":id", "assets"],
"query": [
{
"key": "assetType",
......@@ -1311,10 +1298,21 @@
"request": {
"method": "POST",
"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": {
"raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"],
......@@ -1341,7 +1339,13 @@
],
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }],
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"],
......@@ -1370,10 +1374,21 @@
"request": {
"method": "POST",
"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": {
"raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"],
......@@ -1400,7 +1415,13 @@
],
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }],
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"],
......@@ -1429,10 +1450,21 @@
"request": {
"method": "POST",
"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": {
"raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"],
......@@ -1459,7 +1491,13 @@
],
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }],
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"],
......@@ -1488,10 +1526,21 @@
"request": {
"method": "POST",
"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": {
"raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"],
......@@ -1518,7 +1567,13 @@
],
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }],
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"],
......@@ -1547,10 +1602,21 @@
"request": {
"method": "POST",
"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": {
"raw": "{{base_url}}/crawl-jobs/:id/exports",
"host": ["{{base_url}}"],
......@@ -1576,7 +1642,13 @@
],
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}", "type": "text" }],
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/exports/:exportId/download",
"host": ["{{base_url}}"],
......
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
export default [
{
ignores: ['dist/**', 'node_modules/**', 'eslint.config.js'],
ignores: ["dist/**", "node_modules/**", "eslint.config.js"],
},
{
files: ['src/**/*.ts'],
files: ["src/**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: {
'@typescript-eslint': tsPlugin,
"@typescript-eslint": tsPlugin,
},
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
'no-console': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
"no-console": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
},
];
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
moduleFileExtensions: ["ts", "js", "json"],
moduleNameMapper: {
'node-html-parser': '<rootDir>/src/__mocks__/node-html-parser.ts',
"node-html-parser": "<rootDir>/src/__mocks__/node-html-parser.ts",
},
modulePathIgnorePatterns: ['<rootDir>/dist/'],
setupFiles: ['<rootDir>/jest.setup.ts'],
modulePathIgnorePatterns: ["<rootDir>/dist/"],
setupFiles: ["<rootDir>/jest.setup.ts"],
};
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || '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';
process.env.JWT_ACCESS_SECRET =
process.env.JWT_ACCESS_SECRET ||
"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 bcrypt from 'bcryptjs';
import { PrismaClient, UserRole } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
const adminPasswordHash = await bcrypt.hash('Admin@123456', 10);
const crawlerPasswordHash = await bcrypt.hash('Crawler@123456', 10);
const viewerPasswordHash = await bcrypt.hash('Viewer@123456', 10);
const adminPasswordHash = await bcrypt.hash("Admin@123456", 10);
const crawlerPasswordHash = await bcrypt.hash("Crawler@123456", 10);
const viewerPasswordHash = await bcrypt.hash("Viewer@123456", 10);
await prisma.user.upsert({
where: { email: 'admin@crawl.local' },
where: { email: "admin@crawl.local" },
update: {},
create: {
email: 'admin@crawl.local',
email: "admin@crawl.local",
passwordHash: adminPasswordHash,
fullName: 'System Admin',
fullName: "System Admin",
role: UserRole.ADMIN,
isActive: true,
},
});
await prisma.user.upsert({
where: { email: 'crawl@crawl.local' },
where: { email: "crawl@crawl.local" },
update: {},
create: {
email: 'crawl@crawl.local',
email: "crawl@crawl.local",
passwordHash: crawlerPasswordHash,
fullName: 'Crawl User',
fullName: "Crawl User",
role: UserRole.CRAWLER_USER,
isActive: true,
maxPagesLimit: 100,
......@@ -36,12 +36,12 @@ async function main() {
});
await prisma.user.upsert({
where: { email: 'viewer@crawl.local' },
where: { email: "viewer@crawl.local" },
update: {},
create: {
email: 'viewer@crawl.local',
email: "viewer@crawl.local",
passwordHash: viewerPasswordHash,
fullName: 'Viewer User',
fullName: "Viewer User",
role: UserRole.VIEWER,
isActive: true,
maxPagesLimit: 20,
......@@ -50,7 +50,7 @@ async function main() {
},
});
console.log('Seed completed');
console.log("Seed completed");
}
main()
......
require('dotenv').config();
const { spawn } = require('child_process');
require("dotenv").config();
const { spawn } = require("child_process");
if (!process.env.DATABASE_URL) {
const password = encodeURIComponent(process.env.DB_PASSWORD || '');
const user = encodeURIComponent(process.env.DB_USER || 'postgres');
const host = process.env.DB_HOST || 'localhost';
const port = process.env.DB_PORT || '5432';
const name = process.env.DB_NAME || 'datacrawler';
const isSupabase = host.includes('supabase.co') || host.includes('pooler.supabase.com');
const ssl = process.env.DB_SSL === 'true' || isSupabase ? '&sslmode=require' : '';
const password = encodeURIComponent(process.env.DB_PASSWORD || "");
const user = encodeURIComponent(process.env.DB_USER || "postgres");
const host = process.env.DB_HOST || "localhost";
const port = process.env.DB_PORT || "5432";
const name = process.env.DB_NAME || "datacrawler";
const isSupabase =
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}`;
}
const args = process.argv.slice(2);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(cmd, ['prisma', ...args], {
stdio: 'inherit',
const cmd = process.platform === "win32" ? "npx.cmd" : "npx";
const child = spawn(cmd, ["prisma", ...args], {
stdio: "inherit",
env: process.env,
shell: true,
});
child.on('exit', (code) => process.exit(code ?? 1));
child.on("exit", (code) => process.exit(code ?? 1));
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import swaggerDocument from './docs/swagger.json';
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';
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import cookieParser from "cookie-parser";
import swaggerUi from "swagger-ui-express";
import {
errorMiddleware,
notFoundMiddleware,
} from "./middlewares/error.middleware";
import routes from "./routes";
import swaggerDocument from "./docs/swagger.json";
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();
app.set('trust proxy', parseTrustProxy(envConfig.trustProxy));
app.set("trust proxy", parseTrustProxy(envConfig.trustProxy));
app.use(
helmet({
......@@ -27,7 +30,7 @@ app.use(
if (!origin) return callback(null, true);
if (
envConfig.cors.allowedOrigins.includes(origin) ||
envConfig.cors.allowedOrigins.includes('*')
envConfig.cors.allowedOrigins.includes("*")
) {
return callback(null, true);
}
......@@ -37,17 +40,16 @@ app.use(
maxAge: 86400,
}),
);
app.use(morgan('dev'));
app.use(morgan("dev"));
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/health', healthRoute);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use('/api/v1', rateLimitMiddleware, routes);
app.use("/health", healthRoute);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use("/api/v1", rateLimitMiddleware, routes);
app.use(notFoundMiddleware);
app.use(errorMiddleware);
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 = {
LOGIN: 'LOGIN',
REGISTER: 'REGISTER',
LOGOUT: 'LOGOUT',
UPDATE_ME: 'UPDATE_ME',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
CREATE_JOB: 'CREATE_JOB',
CANCEL_JOB: 'CANCEL_JOB',
DOWNLOAD_EXPORT: 'DOWNLOAD_EXPORT',
ADMIN_CREATE_USER: 'ADMIN_CREATE_USER',
ADMIN_UPDATE_USER: 'ADMIN_UPDATE_USER',
ADMIN_DELETE_USER: 'ADMIN_DELETE_USER',
FORGOT_PASSWORD: 'FORGOT_PASSWORD',
RESET_PASSWORD: 'RESET_PASSWORD',
VERIFY_EMAIL: 'VERIFY_EMAIL',
RESEND_VERIFICATION: 'RESEND_VERIFICATION',
CREATE_API_KEY: 'CREATE_API_KEY',
UPDATE_API_KEY_STATUS: 'UPDATE_API_KEY_STATUS',
REVOKE_API_KEY: 'REVOKE_API_KEY',
CREATE_WEBHOOK_CONFIG: 'CREATE_WEBHOOK_CONFIG',
DELETE_WEBHOOK_CONFIG: 'DELETE_WEBHOOK_CONFIG',
REDELIVER_WEBHOOK: 'REDELIVER_WEBHOOK',
LOGIN: "LOGIN",
REGISTER: "REGISTER",
LOGOUT: "LOGOUT",
UPDATE_ME: "UPDATE_ME",
CHANGE_PASSWORD: "CHANGE_PASSWORD",
CREATE_JOB: "CREATE_JOB",
CANCEL_JOB: "CANCEL_JOB",
DOWNLOAD_EXPORT: "DOWNLOAD_EXPORT",
ADMIN_CREATE_USER: "ADMIN_CREATE_USER",
ADMIN_UPDATE_USER: "ADMIN_UPDATE_USER",
ADMIN_DELETE_USER: "ADMIN_DELETE_USER",
FORGOT_PASSWORD: "FORGOT_PASSWORD",
RESET_PASSWORD: "RESET_PASSWORD",
VERIFY_EMAIL: "VERIFY_EMAIL",
RESEND_VERIFICATION: "RESEND_VERIFICATION",
CREATE_API_KEY: "CREATE_API_KEY",
UPDATE_API_KEY_STATUS: "UPDATE_API_KEY_STATUS",
REVOKE_API_KEY: "REVOKE_API_KEY",
CREATE_WEBHOOK_CONFIG: "CREATE_WEBHOOK_CONFIG",
DELETE_WEBHOOK_CONFIG: "DELETE_WEBHOOK_CONFIG",
REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK",
} 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 @@
*/
/** 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 */
export const DATA_QUALITY_MIN_WORD_COUNT = 50;
......@@ -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.
* 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 = {
JSON: 'JSON',
CSV: 'CSV',
XLSX: 'XLSX',
MARKDOWN: 'MARKDOWN',
ZIP: 'ZIP',
JSON: "JSON",
CSV: "CSV",
XLSX: "XLSX",
MARKDOWN: "MARKDOWN",
ZIP: "ZIP",
} as const;
export type ExportType = keyof typeof EXPORT_TYPE;
export const EXPORT_MIME_TYPES: Record<ExportType, string> = {
JSON: 'application/json',
CSV: 'text/csv',
XLSX: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
JSON: "application/json",
CSV: "text/csv",
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: 'application/zip',
ZIP: 'application/zip',
MARKDOWN: "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 = {
PENDING: 'PENDING',
QUEUED: 'QUEUED',
RUNNING: 'RUNNING',
PROCESSING_EXPORT: 'PROCESSING_EXPORT',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
CANCELED: 'CANCELED',
EXPIRED: 'EXPIRED',
PENDING: "PENDING",
QUEUED: "QUEUED",
RUNNING: "RUNNING",
PROCESSING_EXPORT: "PROCESSING_EXPORT",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
CANCELED: "CANCELED",
EXPIRED: "EXPIRED",
} as const;
export type JobStatus = keyof typeof JOB_STATUS;
export const ROLES = {
ADMIN: 'ADMIN',
CRAWLER_USER: 'CRAWLER_USER',
VIEWER: 'VIEWER',
ADMIN: "ADMIN",
CRAWLER_USER: "CRAWLER_USER",
VIEWER: "VIEWER",
} as const;
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 = {
DATA: 'data',
DATA_RAW: 'data/raw',
DATA_CLEAN: 'data/clean',
MARKDOWN: 'markdown',
MARKDOWN_RAW: 'markdown/raw',
MARKDOWN_CLEAN: 'markdown/clean',
RAW: 'raw',
LOGS: 'logs',
DATA: "data",
DATA_RAW: "data/raw",
DATA_CLEAN: "data/clean",
MARKDOWN: "markdown",
MARKDOWN_RAW: "markdown/raw",
MARKDOWN_CLEAN: "markdown/clean",
RAW: "raw",
LOGS: "logs",
} as const;
export const JOB_EXPORT_FILES = {
METADATA: 'metadata.json',
SUMMARY: 'summary.json',
DATA_QUALITY_JSON: 'data_quality.json',
PAGES_JSON: 'pages.json',
PAGES_RAW_JSON: 'pages.raw.json',
PAGES_CLEAN_JSON: 'pages.clean.json',
PAGES_CSV: 'pages.csv',
LINKS_CSV: 'links.csv',
IMAGES_CSV: 'images.csv',
PAGES_XLSX: 'pages.xlsx',
TABLES_XLSX: 'tables.xlsx',
ERRORS_JSON: 'errors.json',
CRAWL_LOG: 'crawl-log.txt',
STRUCTURED_JSON: 'structured.json',
DIFF_REPORT_JSON: 'diff_report.json',
METADATA: "metadata.json",
SUMMARY: "summary.json",
DATA_QUALITY_JSON: "data_quality.json",
PAGES_JSON: "pages.json",
PAGES_RAW_JSON: "pages.raw.json",
PAGES_CLEAN_JSON: "pages.clean.json",
PAGES_CSV: "pages.csv",
LINKS_CSV: "links.csv",
IMAGES_CSV: "images.csv",
PAGES_XLSX: "pages.xlsx",
TABLES_XLSX: "tables.xlsx",
ERRORS_JSON: "errors.json",
CRAWL_LOG: "crawl-log.txt",
STRUCTURED_JSON: "structured.json",
DIFF_REPORT_JSON: "diff_report.json",
} as const;
export function buildCrawlResultZipName(jobId: string): string {
......@@ -36,5 +36,5 @@ export function buildCrawlResultZipKey(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 {
public readonly statusCode: number;
......@@ -6,7 +6,12 @@ export class AppError extends Error {
public readonly details?: unknown;
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);
this.statusCode = statusCode;
this.code = code;
......@@ -19,4 +24,3 @@ export class AppError extends Error {
}
}
}
export const ERROR_CODE = {
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
USER_INACTIVE: 'USER_INACTIVE',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
MAIL_DELIVERY_FAILED: 'MAIL_DELIVERY_FAILED',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
CRAWL_JOB_NOT_COMPLETED: 'CRAWL_JOB_NOT_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
INVALID_URL: 'INVALID_URL',
QUOTA_MAX_PAGES_EXCEEDED: 'QUOTA_MAX_PAGES_EXCEEDED',
QUOTA_JOBS_PER_DAY_EXCEEDED: 'QUOTA_JOBS_PER_DAY_EXCEEDED',
QUOTA_CONCURRENT_JOBS_EXCEEDED: 'QUOTA_CONCURRENT_JOBS_EXCEEDED',
EXPORT_NOT_FOUND: 'EXPORT_NOT_FOUND',
EXPORT_FILE_MISSING: 'EXPORT_FILE_MISSING',
UNSUPPORTED_EXPORT_TYPE: 'UNSUPPORTED_EXPORT_TYPE',
API_KEY_INVALID: 'API_KEY_INVALID',
API_KEY_EXPIRED: 'API_KEY_EXPIRED',
WEBHOOK_CONFIG_NOT_FOUND: 'WEBHOOK_CONFIG_NOT_FOUND',
CRAWL_SCHEDULE_NOT_FOUND: 'CRAWL_SCHEDULE_NOT_FOUND',
DIFF_REPORT_NOT_FOUND: 'DIFF_REPORT_NOT_FOUND',
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
NOT_FOUND: "NOT_FOUND",
VALIDATION_ERROR: "VALIDATION_ERROR",
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
INVALID_CREDENTIALS: "INVALID_CREDENTIALS",
USER_INACTIVE: "USER_INACTIVE",
TOKEN_EXPIRED: "TOKEN_EXPIRED",
TOKEN_INVALID: "TOKEN_INVALID",
DUPLICATE_ENTRY: "DUPLICATE_ENTRY",
MAIL_DELIVERY_FAILED: "MAIL_DELIVERY_FAILED",
CRAWL_JOB_NOT_FOUND: "CRAWL_JOB_NOT_FOUND",
CRAWL_JOB_ALREADY_COMPLETED: "CRAWL_JOB_ALREADY_COMPLETED",
CRAWL_JOB_NOT_COMPLETED: "CRAWL_JOB_NOT_COMPLETED",
PRIVATE_IP_BLOCKED: "PRIVATE_IP_BLOCKED",
INVALID_URL: "INVALID_URL",
QUOTA_MAX_PAGES_EXCEEDED: "QUOTA_MAX_PAGES_EXCEEDED",
QUOTA_JOBS_PER_DAY_EXCEEDED: "QUOTA_JOBS_PER_DAY_EXCEEDED",
QUOTA_CONCURRENT_JOBS_EXCEEDED: "QUOTA_CONCURRENT_JOBS_EXCEEDED",
EXPORT_NOT_FOUND: "EXPORT_NOT_FOUND",
EXPORT_FILE_MISSING: "EXPORT_FILE_MISSING",
UNSUPPORTED_EXPORT_TYPE: "UNSUPPORTED_EXPORT_TYPE",
API_KEY_INVALID: "API_KEY_INVALID",
API_KEY_EXPIRED: "API_KEY_EXPIRED",
WEBHOOK_CONFIG_NOT_FOUND: "WEBHOOK_CONFIG_NOT_FOUND",
CRAWL_SCHEDULE_NOT_FOUND: "CRAWL_SCHEDULE_NOT_FOUND",
DIFF_REPORT_NOT_FOUND: "DIFF_REPORT_NOT_FOUND",
} as const;
export type ErrorCode = keyof typeof ERROR_CODE;
......@@ -4,41 +4,41 @@ import {
getTimezoneOffsetMinutes,
getZonedDateParts,
DEFAULT_TIMEZONE,
} from '../schedule-calculator.helper';
} from "../schedule-calculator.helper";
describe('schedule-calculator.helper', () => {
describe('isValidCronExpression', () => {
it('returns true for valid standard cron expressions', () => {
expect(isValidCronExpression('* * * * *')).toBe(true);
expect(isValidCronExpression('0 0 * * *')).toBe(true);
expect(isValidCronExpression('*/15 0-23 * * *')).toBe(true);
expect(isValidCronExpression('0 9 1,15 * 1-5')).toBe(true);
expect(isValidCronExpression('30 4 1 * 0')).toBe(true);
describe("schedule-calculator.helper", () => {
describe("isValidCronExpression", () => {
it("returns true for valid standard cron expressions", () => {
expect(isValidCronExpression("* * * * *")).toBe(true);
expect(isValidCronExpression("0 0 * * *")).toBe(true);
expect(isValidCronExpression("*/15 0-23 * * *")).toBe(true);
expect(isValidCronExpression("0 9 1,15 * 1-5")).toBe(true);
expect(isValidCronExpression("30 4 1 * 0")).toBe(true);
});
it('returns false for invalid cron expressions', () => {
expect(isValidCronExpression('')).toBe(false);
expect(isValidCronExpression('invalid')).toBe(false);
expect(isValidCronExpression('0 0 * *')).toBe(false); // 4 parts
expect(isValidCronExpression('0 0 * * * *')).toBe(false); // 6 parts
expect(isValidCronExpression('60 * * * *')).toBe(false); // invalid minute
expect(isValidCronExpression('* 25 * * *')).toBe(false); // invalid hour
expect(isValidCronExpression('* * 32 * *')).toBe(false); // invalid dom
expect(isValidCronExpression('* * * 13 *')).toBe(false); // invalid month
expect(isValidCronExpression('* * * * 8')).toBe(false); // invalid dow
it("returns false for invalid cron expressions", () => {
expect(isValidCronExpression("")).toBe(false);
expect(isValidCronExpression("invalid")).toBe(false);
expect(isValidCronExpression("0 0 * *")).toBe(false); // 4 parts
expect(isValidCronExpression("0 0 * * * *")).toBe(false); // 6 parts
expect(isValidCronExpression("60 * * * *")).toBe(false); // invalid minute
expect(isValidCronExpression("* 25 * * *")).toBe(false); // invalid hour
expect(isValidCronExpression("* * 32 * *")).toBe(false); // invalid dom
expect(isValidCronExpression("* * * 13 *")).toBe(false); // invalid month
expect(isValidCronExpression("* * * * 8")).toBe(false); // invalid dow
});
});
describe('Vietnam timezone (UTC+7) calculations', () => {
it('returns +420 minutes offset for Asia/Ho_Chi_Minh', () => {
expect(getTimezoneOffsetMinutes('Asia/Ho_Chi_Minh')).toBe(420);
expect(DEFAULT_TIMEZONE).toBe('Asia/Ho_Chi_Minh');
describe("Vietnam timezone (UTC+7) calculations", () => {
it("returns +420 minutes offset for Asia/Ho_Chi_Minh", () => {
expect(getTimezoneOffsetMinutes("Asia/Ho_Chi_Minh")).toBe(420);
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)
const utcDate = new Date('2026-09-02T12:15:00.000Z');
const parts = getZonedDateParts(utcDate, 'Asia/Ho_Chi_Minh');
const utcDate = new Date("2026-09-02T12:15:00.000Z");
const parts = getZonedDateParts(utcDate, "Asia/Ho_Chi_Minh");
expect(parts.year).toBe(2026);
expect(parts.month).toBe(8); // Sept (0-indexed)
......@@ -48,107 +48,107 @@ describe('schedule-calculator.helper', () => {
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)
const from = new Date('2026-09-02T12:15:00.000Z');
const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({
frequency: 'DAILY',
frequency: "DAILY",
hour: 20,
minute: 0,
timezone: 'Asia/Ho_Chi_Minh',
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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)
const from = new Date('2026-09-02T12:15:00.000Z');
const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({
frequency: 'DAILY',
frequency: "DAILY",
hour: 2,
minute: 0,
timezone: 'Asia/Ho_Chi_Minh',
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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
const from = new Date('2026-09-02T12:15:00.000Z');
const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({
frequency: 'WEEKLY',
frequency: "WEEKLY",
dayOfWeek: 5,
hour: 8,
minute: 0,
timezone: 'Asia/Ho_Chi_Minh',
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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
const from = new Date('2026-09-02T12:15:00.000Z');
const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({
frequency: 'MONTHLY',
frequency: "MONTHLY",
dayOfMonth: 15,
hour: 9,
minute: 30,
timezone: 'Asia/Ho_Chi_Minh',
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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
const from = new Date('2026-09-02T12:15:00.000Z');
const from = new Date("2026-09-02T12:15:00.000Z");
const next = calculateNextRun({
frequency: 'MONTHLY',
frequency: "MONTHLY",
dayOfMonth: 1,
hour: 9,
minute: 0,
timezone: 'Asia/Ho_Chi_Minh',
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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)
// 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({
frequency: 'CUSTOM',
cronExpression: '0 22 * * *',
timezone: 'Asia/Ho_Chi_Minh',
frequency: "CUSTOM",
cronExpression: "0 22 * * *",
timezone: "Asia/Ho_Chi_Minh",
fromDate: from,
});
// 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(() => {
calculateNextRun({
frequency: 'CUSTOM',
cronExpression: 'invalid cron',
timezone: 'Asia/Ho_Chi_Minh',
frequency: "CUSTOM",
cronExpression: "invalid cron",
timezone: "Asia/Ho_Chi_Minh",
});
}).toThrow('Invalid cron expression');
}).toThrow("Invalid cron expression");
});
});
});
This diff is collapsed.
......@@ -4,103 +4,103 @@
*/
export function mapCrawlError(rawError: string | null | undefined): string {
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();
// 1. Chặn bởi robots.txt
if (
err.includes('robots.txt') ||
err.includes('blocked by robots') ||
err.includes('robots blocked')
err.includes("robots.txt") ||
err.includes("blocked by robots") ||
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
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.';
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.";
}
// 3. Yêu cầu đăng nhập
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.';
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.";
}
// 4. 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.';
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.";
}
// 5. Lỗi API Key / Xác thực
if (
err.includes('unauthorized') ||
err.includes('api key') ||
err.includes('apikey') ||
(err.includes('forbidden') && err.includes('key'))
err.includes("unauthorized") ||
err.includes("api key") ||
err.includes("apikey") ||
(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
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.';
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.";
}
// 7. Chặn bởi Cloudflare / Security / IP Blocked
if (
err.includes('cloudflare') ||
err.includes('403') ||
err.includes('forbidden') ||
err.includes('access denied') ||
(err.includes('block') && (err.includes('ip') || err.includes('bot')))
err.includes("cloudflare") ||
err.includes("403") ||
err.includes("forbidden") ||
err.includes("access denied") ||
(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
if (
err.includes('dns') ||
err.includes('getaddrinfo') ||
err.includes('enotfound') ||
err.includes('invalid url') ||
err.includes('cannot parse url')
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
err.includes("invalid 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
if (
err.includes('rate limit') ||
err.includes('429') ||
err.includes('too many requests')
err.includes("rate limit") ||
err.includes("429") ||
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)
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).';
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).";
}
// 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
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 {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
if (typeof error === "string") {
return error;
}
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
typeof error === "object" &&
"message" in error &&
typeof (error as Record<string, unknown>).message === "string"
) {
return (error as { message: string }).message;
}
......
import path from 'path';
import fs from 'fs';
import { storageConfig } from '../../config/storage.config';
import path from "path";
import fs from "fs";
import { storageConfig } from "../../config/storage.config";
import {
JOB_EXPORT_SUBDIRS,
buildCrawlResultZipName,
buildMarkdownZipName,
} from '../constants/storage-path.constant';
import { generatePageFileName } from './slug.helper';
} from "../constants/storage-path.constant";
import { generatePageFileName } from "./slug.helper";
export function ensureDirExists(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
......@@ -76,7 +76,7 @@ export function buildJobMarkdownFilePath(
jobId: string,
index: number,
url: string,
ext = 'md',
ext = "md",
): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN);
......@@ -87,7 +87,7 @@ export function buildJobMarkdownRawFilePath(
jobId: string,
index: number,
url: string,
ext = 'md',
ext = "md",
): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_RAW);
......@@ -98,7 +98,7 @@ export function buildJobMarkdownCleanFilePath(
jobId: string,
index: number,
url: string,
ext = 'md',
ext = "md",
): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.MARKDOWN_CLEAN);
......@@ -109,7 +109,7 @@ export function buildJobRawFilePath(
jobId: string,
index: number,
url: string,
ext = 'html',
ext = "html",
): { fileName: string; filePath: string } {
const fileName = generatePageFileName(index, url, ext);
const dirPath = buildJobSubDir(jobId, JOB_EXPORT_SUBDIRS.RAW);
......
export const parseTrustProxy = (value: string): boolean | number | string => {
const lowercaseVal = value.trim().toLowerCase();
if (lowercaseVal === 'true') return true;
if (lowercaseVal === 'false') return false;
if (lowercaseVal === "true") return true;
if (lowercaseVal === "false") return false;
const parsed = parseInt(lowercaseVal, 10);
if (!isNaN(parsed) && String(parsed) === value.trim()) {
......
......@@ -2,19 +2,19 @@ export function toSlug(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function urlToPageSlug(url: string): string {
try {
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);
return slug || 'page';
return slug || "page";
} catch {
return 'page';
return "page";
}
}
......@@ -24,6 +24,6 @@ export function generatePageFileName(
ext: string,
): string {
const slug = urlToPageSlug(url);
const prefix = String(index + 1).padStart(3, '0');
const prefix = String(index + 1).padStart(3, "0");
return `${prefix}-${slug}.${ext}`;
}
This diff is collapsed.
import fs from 'fs';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import {
IStorageService,
UploadResult,
UploadStreamOptions,
} from './storage.interface';
import { storageConfig } from '../../config/storage.config';
import { ensureDirExists, getFileSizeBytes } from '../helpers/file.helper';
} from "./storage.interface";
import { storageConfig } from "../../config/storage.config";
import { ensureDirExists, getFileSizeBytes } from "../helpers/file.helper";
export class LocalStorageService implements IStorageService {
private getAbsolutePath(key: string): string {
......
This diff is collapsed.
import { IStorageService } from './storage.interface';
import { LocalStorageService } from './local-storage.service';
import { S3StorageService } from './s3-storage.service';
import { storageConfig } from '../../config/storage.config';
import { IStorageService } from "./storage.interface";
import { LocalStorageService } from "./local-storage.service";
import { S3StorageService } from "./s3-storage.service";
import { storageConfig } from "../../config/storage.config";
export class StorageFactory {
private static instance: IStorageService;
......@@ -12,9 +12,9 @@ export class StorageFactory {
!StorageFactory.instance ||
StorageFactory.activeDriver !== storageConfig.driver
) {
if (storageConfig.driver === 's3') {
if (storageConfig.driver === "s3") {
StorageFactory.instance = new S3StorageService();
} else if (storageConfig.driver === 'local') {
} else if (storageConfig.driver === "local") {
StorageFactory.instance = new LocalStorageService();
} else {
throw new Error(`Unsupported storage driver: ${storageConfig.driver}`);
......
import { Readable } from 'stream';
import { Readable } from "stream";
export interface UploadResult {
fileName: string;
......
This diff is collapsed.
import { UserRole } from '@prisma/client';
import { UserRole } from "@prisma/client";
declare global {
namespace Express {
......
import { envConfig } from './env.config';
import { envConfig } from "./env.config";
export const databaseConfig = {
url: envConfig.databaseUrl,
......
This diff is collapsed.
import { envConfig } from './env.config';
import { envConfig } from "./env.config";
export const firecrawlConfig = envConfig.firecrawl;
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