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
......
......@@ -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:
......
......@@ -6,12 +6,12 @@
## 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ứ |
| **Web Framework** | `Express.js 4.21` | Tách rời App configuration và Server listener |
| 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ứ |
| **Web Framework** | `Express.js 4.21` | Tách rời App configuration và Server listener |
---
......
......@@ -9,7 +9,7 @@
Khi xây dựng một module hoặc tính năng mới, Agent cần thực hiện tuần tự theo 6 bước:
```
[1. Khảo sát Schema & Yêu Cầu]
[1. Khảo sát Schema & Yêu Cầu]
[2. Viết Repository (Prisma Query)]
......@@ -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()
......
......@@ -4,16 +4,16 @@ Backend API cho hệ thống crawl dữ liệu web. Người dùng dán link, h
## Tech Stack
| Thành phần | Công nghệ |
| --------------- | -------------------------------------- |
| Runtime | Node.js + TypeScript |
| Framework | Express.js |
| ORM | Prisma (Code First Migration) |
| Database | PostgreSQL |
| Queue / Cache | Redis + BullMQ |
| Crawl Engine | Firecrawl API |
| Export | Archiver, ExcelJS, json2csv, Turndown |
| Package Manager | pnpm@9.15.0 |
| Thành phần | Công nghệ |
| --------------- | ------------------------------------- |
| Runtime | Node.js + TypeScript |
| Framework | Express.js |
| ORM | Prisma (Code First Migration) |
| Database | PostgreSQL |
| Queue / Cache | Redis + BullMQ |
| Crawl Engine | Firecrawl API |
| Export | Archiver, ExcelJS, json2csv, Turndown |
| Package Manager | pnpm@9.15.0 |
## Kiến trúc Service Layer
......@@ -111,10 +111,10 @@ docker ps
Hai container cần chạy:
| Container | Service | Port |
| -------------------- | ---------- | ------ |
| `crawl_data_postgres`| PostgreSQL | `5432` |
| `crawl_data_redis` | Redis | `6379` |
| Container | Service | Port |
| --------------------- | ---------- | ------ |
| `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
......@@ -153,11 +154,11 @@ 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 |
| Email | Password | Role |
| -------------------- | ---------------- | ------------ |
| `admin@crawl.local` | `Admin@123456` | ADMIN |
| `crawl@crawl.local` | `Crawler@123456` | CRAWLER_USER |
| `viewer@crawl.local` | `Viewer@123456` | VIEWER |
---
......@@ -212,20 +213,20 @@ 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) |
| `pnpm db:migrate:reset` | Xóa toàn bộ DB và chạy lại migration — **chỉ dùng local** |
| `pnpm db:migrate:status` | Xem trạng thái các migration đã apply |
| `pnpm prisma:generate` | Regenerate Prisma Client sau khi sửa schema thủ công |
| `pnpm prisma:studio` | Mở Prisma Studio — GUI quản lý dữ liệu trực quan |
| `pnpm db:seed` | Chạy seed tạo dữ liệu mẫu |
| `pnpm swagger` | Regenerate file `src/docs/swagger.json` |
| `pnpm build` | Build production bundle ra thư mục `dist/` |
| `pnpm lint` | Kiểm tra lỗi ESLint |
| `pnpm format` | Format code bằng Prettier |
| 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) |
| `pnpm db:migrate:reset` | Xóa toàn bộ DB và chạy lại migration — **chỉ dùng local** |
| `pnpm db:migrate:status` | Xem trạng thái các migration đã apply |
| `pnpm prisma:generate` | Regenerate Prisma Client sau khi sửa schema thủ công |
| `pnpm prisma:studio` | Mở Prisma Studio — GUI quản lý dữ liệu trực quan |
| `pnpm db:seed` | Chạy seed tạo dữ liệu mẫu |
| `pnpm swagger` | Regenerate file `src/docs/swagger.json` |
| `pnpm build` | Build production bundle ra thư mục `dist/` |
| `pnpm lint` | Kiểm tra lỗi ESLint |
| `pnpm format` | Format code bằng Prettier |
---
......@@ -300,11 +301,11 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**,
### 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`) |
| 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`) |
### 8.2 API Endpoints Preview & Assets
......@@ -321,6 +322,7 @@ Hệ thống hỗ trợ chuẩn hóa dữ liệu đầu ra **Data Contract v1**,
### 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:
......
......@@ -4,10 +4,10 @@
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 |
| 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 |
Với JSON, CSV, XLSX và Markdown, hệ thống tạo file staging rồi upload lên storage. ZIP được stream trực tiếp lên storage, không tạo file ZIP local.
......
This diff is collapsed.
......@@ -7,6 +7,7 @@ Tài liệu này hướng dẫn cách vận hành, cấu trúc và danh sách c
## 🚀 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'],
};
\ No newline at end of file
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));
export const parse = () => ({
querySelectorAll: () => [],
});
\ No newline at end of file
});
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';
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);
} 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);
});
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 { Response } from 'express';
import { pipeline } from 'stream/promises';
import { AppError } from '../errors/app-error';
import { ERROR_CODE } from '../errors/error-code';
import { StorageFactory } from './storage.factory';
import { Response } from "express";
import { pipeline } from "stream/promises";
import { AppError } from "../errors/app-error";
import { ERROR_CODE } from "../errors/error-code";
import { StorageFactory } from "./storage.factory";
interface StoredDownload {
fileName: string;
......@@ -19,26 +19,26 @@ export async function streamStorageDownload(
if (!(await storage.exists(file.filePath))) {
throw new AppError(
'Export file not found in storage',
"Export file not found in storage",
404,
ERROR_CODE.EXPORT_FILE_MISSING,
);
}
const source = await storage.getReadStream(file.filePath);
const safeFileName = file.fileName.replace(/[\r\n"]/g, '_');
const safeFileName = file.fileName.replace(/[\r\n"]/g, "_");
const encodedFileName = encodeURIComponent(file.fileName);
response.setHeader(
'Content-Disposition',
"Content-Disposition",
`attachment; filename="${safeFileName}"; filename*=UTF-8''${encodedFileName}`,
);
response.setHeader(
'Content-Type',
file.mimeType || 'application/octet-stream',
"Content-Type",
file.mimeType || "application/octet-stream",
);
if (file.fileSize !== null && file.fileSize !== undefined) {
response.setHeader('Content-Length', String(file.fileSize));
response.setHeader("Content-Length", String(file.fileSize));
}
await pipeline(source, response);
......
This diff is collapsed.
import { Readable } from 'stream';
import { Readable } from "stream";
export interface UploadResult {
fileName: string;
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment