Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
U
upgrade-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
upgrade-data-crawler-be
Commits
ffc94a49
Commit
ffc94a49
authored
Sep 08, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(be): exempt failed connection & provider quota jobs from daily usage quota
parent
85f64564
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
246 additions
and
5 deletions
+246
-5
inspect-error.js
scripts/inspect-error.js
+33
-0
test-firecrawl.js
scripts/test-firecrawl.js
+32
-0
error-mapping.helper.ts
src/common/helpers/error-mapping.helper.ts
+64
-1
crawl-job.soft-delete-quota.test.ts
.../crawl-jobs/__tests__/crawl-job.soft-delete-quota.test.ts
+28
-0
crawl-job.repository.ts
src/modules/crawl-jobs/crawl-job.repository.ts
+53
-3
crawl-page-processor.service.test.ts
...rawl-pages/__tests__/crawl-page-processor.service.test.ts
+36
-1
No files found.
scripts/inspect-error.js
0 → 100644
View file @
ffc94a49
require
(
"dotenv"
).
config
();
const
{
PrismaClient
}
=
require
(
"@prisma/client"
);
const
prisma
=
new
PrismaClient
();
async
function
main
()
{
const
jobs
=
await
prisma
.
crawlJob
.
findMany
({
orderBy
:
{
createdAt
:
"desc"
},
take
:
3
,
select
:
{
id
:
true
,
startUrl
:
true
,
status
:
true
,
errorMessage
:
true
,
createdAt
:
true
,
},
});
for
(
const
job
of
jobs
)
{
console
.
log
(
"-----------------------------------------"
);
console
.
log
(
"JOB:"
,
job
.
id
,
job
.
startUrl
,
job
.
status
);
console
.
log
(
"ERROR MESSAGE:"
,
job
.
errorMessage
);
const
logs
=
await
prisma
.
crawlJobLog
.
findMany
({
where
:
{
jobId
:
job
.
id
},
orderBy
:
{
createdAt
:
"desc"
},
take
:
5
,
});
console
.
log
(
"LOGS:"
,
logs
.
map
(
l
=>
({
level
:
l
.
level
,
message
:
l
.
message
})));
}
}
main
()
.
catch
(
console
.
error
)
.
finally
(()
=>
prisma
.
$disconnect
());
scripts/test-firecrawl.js
0 → 100644
View file @
ffc94a49
require
(
"dotenv"
).
config
();
const
axios
=
require
(
"axios"
);
async
function
testFirecrawl
()
{
const
apiKey
=
process
.
env
.
FIRECRAWL_API_KEY
;
const
baseUrl
=
process
.
env
.
FIRECRAWL_BASE_URL
||
"https://api.firecrawl.dev"
;
console
.
log
(
"Using API Key:"
,
apiKey
?
apiKey
.
slice
(
0
,
8
)
+
"..."
:
"NONE"
);
console
.
log
(
"Using Base URL:"
,
baseUrl
);
try
{
const
res
=
await
axios
.
post
(
`
${
baseUrl
}
/v1/scrape`
,
{
url
:
"https://example.com"
},
{
headers
:
{
Authorization
:
`Bearer
${
apiKey
}
`
,
"Content-Type"
:
"application/json"
,
},
}
);
console
.
log
(
"SUCCESS:"
,
res
.
status
,
res
.
data
);
}
catch
(
err
)
{
if
(
err
.
response
)
{
console
.
log
(
"RESPONSE ERROR STATUS:"
,
err
.
response
.
status
);
console
.
log
(
"RESPONSE ERROR DATA:"
,
JSON
.
stringify
(
err
.
response
.
data
));
}
else
{
console
.
log
(
"NETWORK/OTHER ERROR:"
,
err
.
message
);
}
}
}
testFirecrawl
();
src/common/helpers/error-mapping.helper.ts
View file @
ffc94a49
...
@@ -33,11 +33,13 @@ export function mapCrawlError(rawError: string | null | undefined): string {
...
@@ -33,11 +33,13 @@ export function mapCrawlError(rawError: string | null | undefined): string {
return
"Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED."
;
return
"Trang web đích được bảo vệ bởi paywall (nội dung trả phí). Hệ thống không thể truy cập — trang được đánh dấu PAYWALL_DETECTED."
;
}
}
// 5. Lỗi API Key / Xác thực
// 5. Lỗi API Key / Xác thực
/ Hạn mức Provider
if
(
if
(
err
.
includes
(
"unauthorized"
)
||
err
.
includes
(
"unauthorized"
)
||
err
.
includes
(
"api key"
)
||
err
.
includes
(
"api key"
)
||
err
.
includes
(
"apikey"
)
||
err
.
includes
(
"apikey"
)
||
err
.
includes
(
"credit"
)
||
err
.
includes
(
"402"
)
||
(
err
.
includes
(
"forbidden"
)
&&
err
.
includes
(
"key"
))
(
err
.
includes
(
"forbidden"
)
&&
err
.
includes
(
"key"
))
)
{
)
{
return
"Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ)."
;
return
"Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ)."
;
...
@@ -106,3 +108,64 @@ export function getErrorMessage(error: unknown): string {
...
@@ -106,3 +108,64 @@ export function getErrorMessage(error: unknown): string {
}
}
return
String
(
error
);
return
String
(
error
);
}
}
/**
* Kiểm tra xem lỗi cào dữ liệu có phải do mất kết nối mạng, timeout hoặc không thể truy cập host hay không.
* Các tác vụ thất bại do lỗi kết nối sẽ được miễn trừ và không tính vào hạn mức sử dụng (Quota) của người dùng.
*/
export
function
isConnectionLossError
(
rawError
:
string
|
null
|
undefined
,
):
boolean
{
if
(
!
rawError
)
{
return
false
;
}
const
err
=
rawError
.
toLowerCase
();
return
(
// Timeout / quá thời gian chờ kết nối
err
.
includes
(
"timeout"
)
||
err
.
includes
(
"timed out"
)
||
err
.
includes
(
"etimedout"
)
||
err
.
includes
(
"esockettimedout"
)
||
// Lỗi đứt kết nối mạng, reset hoặc từ chối kết nối
err
.
includes
(
"econnrefused"
)
||
err
.
includes
(
"econnreset"
)
||
err
.
includes
(
"econnaborted"
)
||
err
.
includes
(
"network error"
)
||
err
.
includes
(
"network_error"
)
||
err
.
includes
(
"connection reset"
)
||
err
.
includes
(
"connection refused"
)
||
err
.
includes
(
"connection lost"
)
||
err
.
includes
(
"connection closed"
)
||
err
.
includes
(
"connection error"
)
||
err
.
includes
(
"socket hang up"
)
||
err
.
includes
(
"network is unreachable"
)
||
err
.
includes
(
"host unreachable"
)
||
err
.
includes
(
"offline"
)
||
// Lỗi DNS / không tìm thấy host
err
.
includes
(
"dns"
)
||
err
.
includes
(
"getaddrinfo"
)
||
err
.
includes
(
"enotfound"
)
||
// Lỗi từ nhà cung cấp cào / proxy / gateway (402, 500, 502, 503, 504, credit, bad gateway)
err
.
includes
(
"402"
)
||
err
.
includes
(
"credit"
)
||
err
.
includes
(
"500"
)
||
err
.
includes
(
"502"
)
||
err
.
includes
(
"503"
)
||
err
.
includes
(
"504"
)
||
err
.
includes
(
"bad gateway"
)
||
err
.
includes
(
"gateway"
)
||
err
.
includes
(
"service unavailable"
)
||
// Các thông báo tiếng Việt tương ứng
err
.
includes
(
"mất kết nối"
)
||
err
.
includes
(
"lỗi kết nối"
)
||
err
.
includes
(
"không thể kết nối"
)
||
err
.
includes
(
"kết nối đến trang web đích bị quá thời gian"
)
||
err
.
includes
(
"không thể phân giải tên miền"
)
||
err
.
includes
(
"đã xảy ra lỗi trong quá trình cào dữ liệu"
)
||
err
.
includes
(
"lỗi không xác định"
)
||
err
.
includes
(
"lỗi xác thực hệ thống cào dữ liệu"
)
);
}
src/modules/crawl-jobs/__tests__/crawl-job.soft-delete-quota.test.ts
View file @
ffc94a49
...
@@ -122,4 +122,32 @@ describe("CrawlJobRepository soft-delete and quota retention", () => {
...
@@ -122,4 +122,32 @@ describe("CrawlJobRepository soft-delete and quota retention", () => {
}),
}),
);
);
});
});
it
(
"countJobsSince() waives jobs that failed due to connection loss from quota count"
,
async
()
=>
{
const
sinceDate
=
new
Date
(
"2026-09-08T00:00:00Z"
);
(
prisma
.
crawlJob
.
count
as
jest
.
Mock
).
mockResolvedValue
(
4
);
(
prisma
.
crawlJob
.
findMany
as
jest
.
Mock
).
mockResolvedValue
([
{
id
:
"job-failed-timeout"
,
errorMessage
:
"Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm."
},
{
id
:
"job-failed-connreset"
,
errorMessage
:
"connect ECONNRESET 192.168.1.1"
},
]);
const
billableCount
=
await
repository
.
countJobsSince
(
"user-1"
,
sinceDate
);
// 4 total minus 2 connection loss failed jobs = 2 billable jobs
expect
(
billableCount
).
toBe
(
2
);
});
it
(
"sumPagesCrawledByUser() waives pages from jobs that failed due to connection loss"
,
async
()
=>
{
(
prisma
.
crawlJob
.
aggregate
as
jest
.
Mock
).
mockResolvedValue
({
_sum
:
{
totalPages
:
100
},
});
(
prisma
.
crawlJob
.
findMany
as
jest
.
Mock
).
mockResolvedValue
([
{
totalPages
:
20
,
errorMessage
:
"getaddrinfo ENOTFOUND invalid-domain.xyz"
},
]);
const
pages
=
await
repository
.
sumPagesCrawledByUser
(
"user-1"
);
// 100 total minus 20 waived pages from DNS connection failure = 80 pages
expect
(
pages
).
toBe
(
80
);
});
});
});
src/modules/crawl-jobs/crawl-job.repository.ts
View file @
ffc94a49
...
@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client";
...
@@ -2,6 +2,7 @@ import { prisma } from "../../database/prisma.client";
import
{
CrawlJobStatus
,
CrawlMode
,
LogLevel
,
Prisma
}
from
"@prisma/client"
;
import
{
CrawlJobStatus
,
CrawlMode
,
LogLevel
,
Prisma
}
from
"@prisma/client"
;
import
{
CrawlJobQueryDto
}
from
"./crawl-job.dto"
;
import
{
CrawlJobQueryDto
}
from
"./crawl-job.dto"
;
import
{
JOB_STATUS
}
from
"../../common/constants/job-status.constant"
;
import
{
JOB_STATUS
}
from
"../../common/constants/job-status.constant"
;
import
{
isConnectionLossError
}
from
"../../common/helpers/error-mapping.helper"
;
export
class
CrawlJobRepository
{
export
class
CrawlJobRepository
{
create
(
data
:
{
create
(
data
:
{
...
@@ -332,13 +333,36 @@ export class CrawlJobRepository {
...
@@ -332,13 +333,36 @@ export class CrawlJobRepository {
]);
]);
}
}
countJobsSince
(
userId
:
string
,
sinceDate
:
Date
):
Promise
<
number
>
{
async
countJobsSince
(
userId
:
string
,
sinceDate
:
Date
):
Promise
<
number
>
{
return
prisma
.
crawlJob
.
count
({
const
totalCount
=
await
prisma
.
crawlJob
.
count
({
where
:
{
where
:
{
userId
,
userId
,
createdAt
:
{
gte
:
sinceDate
},
createdAt
:
{
gte
:
sinceDate
},
},
},
});
});
try
{
// Find jobs that failed due to connection loss with 0 successful pages
const
failedJobs
=
await
prisma
.
crawlJob
.
findMany
({
where
:
{
userId
,
status
:
JOB_STATUS
.
FAILED
,
createdAt
:
{
gte
:
sinceDate
},
successPages
:
0
,
},
select
:
{
errorMessage
:
true
,
},
});
const
waivedCount
=
failedJobs
.
filter
(
(
job
)
=>
!
job
.
errorMessage
||
isConnectionLossError
(
job
.
errorMessage
),
).
length
;
return
Math
.
max
(
0
,
totalCount
-
waivedCount
);
}
catch
{
return
totalCount
;
}
}
}
countConcurrentJobs
(
countConcurrentJobs
(
...
@@ -361,7 +385,33 @@ export class CrawlJobRepository {
...
@@ -361,7 +385,33 @@ export class CrawlJobRepository {
where
:
{
userId
},
where
:
{
userId
},
_sum
:
{
totalPages
:
true
},
_sum
:
{
totalPages
:
true
},
});
});
return
aggregate
.
_sum
.
totalPages
??
0
;
const
totalPages
=
aggregate
.
_sum
.
totalPages
??
0
;
try
{
// Deduct un-crawled totalPages for jobs that failed due to connection error with 0 success pages
const
failedJobs
=
await
prisma
.
crawlJob
.
findMany
({
where
:
{
userId
,
status
:
JOB_STATUS
.
FAILED
,
successPages
:
0
,
},
select
:
{
totalPages
:
true
,
errorMessage
:
true
,
},
});
const
waivedPages
=
failedJobs
.
filter
(
(
job
)
=>
!
job
.
errorMessage
||
isConnectionLossError
(
job
.
errorMessage
),
)
.
reduce
((
sum
,
job
)
=>
sum
+
(
job
.
totalPages
??
0
),
0
);
return
Math
.
max
(
0
,
totalPages
-
waivedPages
);
}
catch
{
return
totalPages
;
}
}
}
async
delete
(
id
:
string
,
deletedBy
?:
string
)
{
async
delete
(
id
:
string
,
deletedBy
?:
string
)
{
...
...
src/modules/crawl-pages/__tests__/crawl-page-processor.service.test.ts
View file @
ffc94a49
import
{
CrawlPageProcessorService
}
from
"../crawl-page-processor.service"
;
import
{
CrawlPageProcessorService
}
from
"../crawl-page-processor.service"
;
import
{
mapCrawlError
}
from
"../../../common/helpers/error-mapping.helper"
;
import
{
mapCrawlError
,
isConnectionLossError
,
}
from
"../../../common/helpers/error-mapping.helper"
;
import
{
import
{
FirecrawlPageResult
,
FirecrawlPageResult
,
CrawlErrorItem
,
CrawlErrorItem
,
...
@@ -233,6 +236,38 @@ describe("mapCrawlError()", () => {
...
@@ -233,6 +236,38 @@ describe("mapCrawlError()", () => {
});
});
});
});
describe
(
"isConnectionLossError()"
,
()
=>
{
it
(
"detects timeout errors"
,
()
=>
{
expect
(
isConnectionLossError
(
"request timed out"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"ETIMEDOUT 10.0.0.1"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"ESOCKETTIMEDOUT"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm."
)).
toBe
(
true
);
});
it
(
"detects connection resets and network errors"
,
()
=>
{
expect
(
isConnectionLossError
(
"read ECONNRESET"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"connect ECONNREFUSED 127.0.0.1"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"socket hang up"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"network error occurred"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"Mất kết nối với máy chủ đích"
)).
toBe
(
true
);
});
it
(
"detects DNS lookup errors"
,
()
=>
{
expect
(
isConnectionLossError
(
"getaddrinfo ENOTFOUND api.example.com"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"dns lookup failure"
)).
toBe
(
true
);
expect
(
isConnectionLossError
(
"không thể phân giải tên miền"
)).
toBe
(
true
);
});
it
(
"returns false for non-connection errors"
,
()
=>
{
expect
(
isConnectionLossError
(
null
)).
toBe
(
false
);
expect
(
isConnectionLossError
(
undefined
)).
toBe
(
false
);
expect
(
isConnectionLossError
(
""
)).
toBe
(
false
);
expect
(
isConnectionLossError
(
"blocked by robots.txt"
)).
toBe
(
false
);
expect
(
isConnectionLossError
(
"captcha required"
)).
toBe
(
false
);
expect
(
isConnectionLossError
(
"paywall detected"
)).
toBe
(
false
);
});
});
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// normalize() — status/errorMessage consistency
// normalize() — status/errorMessage consistency
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment