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
24ac3314
Commit
24ac3314
authored
Sep 07, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(crawler): support real-time page persistence, partial export and prevent duplicate rerun
parent
c917a34b
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
511 additions
and
83 deletions
+511
-83
docker-compose.yml
docker-compose.yml
+11
-11
crawl-export.flow.test.ts
...modules/crawl-exports/__tests__/crawl-export.flow.test.ts
+53
-1
crawl-export.service.ts
src/modules/crawl-exports/crawl-export.service.ts
+20
-3
crawl-job.events.test.ts
src/modules/crawl-jobs/__tests__/crawl-job.events.test.ts
+1
-1
crawl-job.repository.ts
src/modules/crawl-jobs/crawl-job.repository.ts
+15
-0
crawl-job.service.ts
src/modules/crawl-jobs/crawl-job.service.ts
+75
-8
firecrawl.service.ts
src/modules/firecrawl/firecrawl.service.ts
+32
-4
crawl.worker.test.ts
src/queues/__tests__/crawl.worker.test.ts
+92
-0
crawl.worker.processor.ts
src/queues/crawl.worker.processor.ts
+212
-55
No files found.
docker-compose.yml
View file @
24ac3314
version
:
"
3.8"
version
:
"
3.8"
services
:
services
:
postgres
:
#
postgres:
image
:
postgres:16-alpine
#
image: postgres:16-alpine
container_name
:
crawl_data_postgres
#
container_name: crawl_data_postgres
environment
:
#
environment:
POSTGRES_USER
:
${DB_USER:-postgres}
#
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD
:
${DB_PASSWORD:-postgres}
#
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB
:
${DB_NAME:-crawl_data_db}
#
POSTGRES_DB: ${DB_NAME:-crawl_data_db}
ports
:
#
ports:
-
"
${DB_PORT:-5432}:5432"
#
- "${DB_PORT:-5432}:5432"
volumes
:
#
volumes:
-
postgres_data:/var/lib/postgresql/data
#
- postgres_data:/var/lib/postgresql/data
redis
:
redis
:
image
:
redis:7-alpine
image
:
redis:7-alpine
...
...
src/modules/crawl-exports/__tests__/crawl-export.flow.test.ts
View file @
24ac3314
...
@@ -94,7 +94,7 @@ describe("CrawlExportService", () => {
...
@@ -94,7 +94,7 @@ describe("CrawlExportService", () => {
expect
(
result
).
toEqual
(
exportRecord
);
expect
(
result
).
toEqual
(
exportRecord
);
});
});
it
(
"throws 400 when job is
not COMPLETED
"
,
async
()
=>
{
it
(
"throws 400 when job is
still RUNNING
"
,
async
()
=>
{
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
makeJob
({
status
:
"RUNNING"
}),
makeJob
({
status
:
"RUNNING"
}),
);
);
...
@@ -104,6 +104,58 @@ describe("CrawlExportService", () => {
...
@@ -104,6 +104,58 @@ describe("CrawlExportService", () => {
).
rejects
.
toMatchObject
({
statusCode
:
400
});
).
rejects
.
toMatchObject
({
statusCode
:
400
});
});
});
it
(
"allows exporting a CANCELED job when successPages > 0"
,
async
()
=>
{
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
makeJob
({
status
:
"CANCELED"
,
successPages
:
5
}),
);
const
exportRecord
=
makeExport
();
mockExportService
.
generate
.
mockResolvedValue
(
exportRecord
as
any
);
const
result
=
await
service
.
createExport
(
"user-1"
,
"CRAWLER_USER"
,
"job-1"
,
"JSON"
,
);
expect
(
mockExportService
.
generate
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
id
:
"job-1"
}),
"JSON"
,
);
expect
(
result
).
toEqual
(
exportRecord
);
});
it
(
"throws 400 when CANCELED job has 0 successPages"
,
async
()
=>
{
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
makeJob
({
status
:
"CANCELED"
,
successPages
:
0
}),
);
await
expect
(
service
.
createExport
(
"user-1"
,
"CRAWLER_USER"
,
"job-1"
,
"JSON"
),
).
rejects
.
toMatchObject
({
statusCode
:
400
});
});
it
(
"allows exporting a FAILED job when successPages > 0"
,
async
()
=>
{
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
makeJob
({
status
:
"FAILED"
,
successPages
:
10
}),
);
const
exportRecord
=
makeExport
();
mockExportService
.
generate
.
mockResolvedValue
(
exportRecord
as
any
);
const
result
=
await
service
.
createExport
(
"user-1"
,
"CRAWLER_USER"
,
"job-1"
,
"CSV"
,
);
expect
(
mockExportService
.
generate
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
id
:
"job-1"
}),
"CSV"
,
);
expect
(
result
).
toEqual
(
exportRecord
);
});
it
(
"throws 404 when job does not exist"
,
async
()
=>
{
it
(
"throws 404 when job does not exist"
,
async
()
=>
{
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
null
);
(
prisma
.
crawlJob
.
findUnique
as
jest
.
Mock
).
mockResolvedValue
(
null
);
...
...
src/modules/crawl-exports/crawl-export.service.ts
View file @
24ac3314
...
@@ -61,11 +61,28 @@ export class CrawlExportService {
...
@@ -61,11 +61,28 @@ export class CrawlExportService {
);
);
}
}
if
(
job
.
status
!==
JOB_STATUS
.
COMPLETED
)
{
const
isExportable
=
job
.
status
===
JOB_STATUS
.
COMPLETED
||
(
job
.
status
===
JOB_STATUS
.
CANCELED
&&
(
job
.
successPages
??
0
)
>
0
)
||
(
job
.
status
===
JOB_STATUS
.
FAILED
&&
(
job
.
successPages
??
0
)
>
0
);
if
(
!
isExportable
)
{
if
(
job
.
status
===
JOB_STATUS
.
RUNNING
||
job
.
status
===
JOB_STATUS
.
PENDING
||
job
.
status
===
JOB_STATUS
.
QUEUED
||
job
.
status
===
JOB_STATUS
.
PROCESSING_EXPORT
)
{
throw
new
AppError
(
"Crawl job is still in progress"
,
400
,
ERROR_CODE
.
CRAWL_JOB_NOT_COMPLETED
,
);
}
throw
new
AppError
(
throw
new
AppError
(
"
Crawl job is not completed ye
t"
,
"
No successfully crawled pages available to expor
t"
,
400
,
400
,
ERROR_CODE
.
CRAWL_JOB_NOT_COMPLETED
,
ERROR_CODE
.
VALIDATION_ERROR
,
);
);
}
}
...
...
src/modules/crawl-jobs/__tests__/crawl-job.events.test.ts
View file @
24ac3314
...
@@ -14,7 +14,7 @@ describe("CrawlJobController - SSE Events", () => {
...
@@ -14,7 +14,7 @@ describe("CrawlJobController - SSE Events", () => {
findById
:
jest
.
fn
(),
findById
:
jest
.
fn
(),
}
as
any
;
}
as
any
;
(
CrawlJobService
as
jest
.
Mock
).
mockReturnValue
(
mockService
);
(
CrawlJobService
as
unknown
as
jest
.
Mock
).
mockReturnValue
(
mockService
);
controller
=
new
CrawlJobController
();
controller
=
new
CrawlJobController
();
});
});
...
...
src/modules/crawl-jobs/crawl-job.repository.ts
View file @
24ac3314
...
@@ -389,4 +389,19 @@ export class CrawlJobRepository {
...
@@ -389,4 +389,19 @@ export class CrawlJobRepository {
]);
]);
return
{
items
,
total
,
page
:
safePage
,
limit
:
safeLimit
};
return
{
items
,
total
,
page
:
safePage
,
limit
:
safeLimit
};
}
}
findRecentActiveJob
(
userId
:
string
,
startUrl
:
string
,
windowMs
=
5000
)
{
const
since
=
new
Date
(
Date
.
now
()
-
windowMs
);
return
prisma
.
crawlJob
.
findFirst
({
where
:
{
userId
,
startUrl
,
status
:
{
in
:
[
JOB_STATUS
.
PENDING
,
JOB_STATUS
.
QUEUED
,
JOB_STATUS
.
RUNNING
],
},
createdAt
:
{
gte
:
since
},
},
orderBy
:
{
createdAt
:
"desc"
},
});
}
}
}
src/modules/crawl-jobs/crawl-job.service.ts
View file @
24ac3314
...
@@ -165,7 +165,7 @@ export class CrawlJobService {
...
@@ -165,7 +165,7 @@ export class CrawlJobService {
);
);
}
}
await
crawlQueue
.
add
(
"crawl-job"
,
{
jobId
:
job
.
id
});
await
crawlQueue
.
add
(
"crawl-job"
,
{
jobId
:
job
.
id
}
,
{
jobId
:
job
.
id
}
);
return
job
;
return
job
;
}
}
...
@@ -216,6 +216,25 @@ export class CrawlJobService {
...
@@ -216,6 +216,25 @@ export class CrawlJobService {
JOB_STATUS
.
CANCELED
,
JOB_STATUS
.
CANCELED
,
);
);
// Remove from BullMQ queue if still waiting/delayed
if
(
crawlQueue
)
{
try
{
const
bullJob
=
await
crawlQueue
.
getJob
(
jobId
);
if
(
bullJob
)
{
await
bullJob
.
remove
();
}
else
{
const
waitingJobs
=
await
crawlQueue
.
getJobs
([
"waiting"
,
"delayed"
,
"prioritized"
]);
for
(
const
wj
of
waitingJobs
)
{
if
(
wj
.
data
?.
jobId
===
jobId
)
{
await
wj
.
remove
();
}
}
}
}
catch
{
// Ignored
}
}
// For CRAWL mode: also cancel at the Firecrawl provider level to stop
// For CRAWL mode: also cancel at the Firecrawl provider level to stop
// quota consumption. firecrawlJobId is saved by the worker as soon as
// quota consumption. firecrawlJobId is saved by the worker as soon as
// asyncCrawlUrl() returns, so it may be null if the job was canceled
// asyncCrawlUrl() returns, so it may be null if the job was canceled
...
@@ -288,20 +307,68 @@ export class CrawlJobService {
...
@@ -288,20 +307,68 @@ export class CrawlJobService {
await
storage
.
deleteFile
(
job
.
diffReportPath
).
catch
(()
=>
{});
await
storage
.
deleteFile
(
job
.
diffReportPath
).
catch
(()
=>
{});
}
}
if
(
crawlQueue
)
{
try
{
const
bullJob
=
await
crawlQueue
.
getJob
(
jobId
);
if
(
bullJob
)
{
await
bullJob
.
remove
();
}
else
{
const
waitingJobs
=
await
crawlQueue
.
getJobs
([
"waiting"
,
"delayed"
,
"prioritized"
]);
for
(
const
wj
of
waitingJobs
)
{
if
(
wj
.
data
?.
jobId
===
jobId
)
{
await
wj
.
remove
();
}
}
}
}
catch
{
// Ignored
}
}
await
this
.
repository
.
delete
(
jobId
);
await
this
.
repository
.
delete
(
jobId
);
return
{
success
:
true
,
message
:
"Crawl job deleted successfully"
};
return
{
success
:
true
,
message
:
"Crawl job deleted successfully"
};
}
}
private
static
readonly
rerunLocks
=
new
Set
<
string
>
();
async
rerun
(
userId
:
string
,
role
:
string
,
jobId
:
string
)
{
async
rerun
(
userId
:
string
,
role
:
string
,
jobId
:
string
)
{
const
existing
=
await
this
.
findById
(
userId
,
role
,
jobId
);
const
existing
=
await
this
.
findById
(
userId
,
role
,
jobId
);
return
this
.
create
(
userId
,
{
const
lockKey
=
`
${
userId
}
:
${
jobId
}
`
;
startUrl
:
existing
.
startUrl
,
if
(
CrawlJobService
.
rerunLocks
.
has
(
lockKey
))
{
mode
:
existing
.
mode
,
if
(
this
.
repository
.
findRecentActiveJob
)
{
maxPages
:
existing
.
maxPages
,
const
recent
=
await
this
.
repository
.
findRecentActiveJob
(
maxDepth
:
existing
.
maxDepth
,
userId
,
urls
:
existing
.
urls
,
existing
.
startUrl
,
});
10000
,
);
if
(
recent
)
return
recent
;
}
}
if
(
this
.
repository
.
findRecentActiveJob
)
{
const
recent
=
await
this
.
repository
.
findRecentActiveJob
(
userId
,
existing
.
startUrl
,
5000
,
);
if
(
recent
)
{
return
recent
;
}
}
CrawlJobService
.
rerunLocks
.
add
(
lockKey
);
try
{
return
await
this
.
create
(
userId
,
{
startUrl
:
existing
.
startUrl
,
mode
:
existing
.
mode
,
maxPages
:
existing
.
maxPages
,
maxDepth
:
existing
.
maxDepth
,
urls
:
existing
.
urls
,
});
}
finally
{
setTimeout
(()
=>
CrawlJobService
.
rerunLocks
.
delete
(
lockKey
),
3000
);
}
}
}
async
getLogs
(
async
getLogs
(
...
...
src/modules/firecrawl/firecrawl.service.ts
View file @
24ac3314
...
@@ -111,7 +111,11 @@ export class FirecrawlService {
...
@@ -111,7 +111,11 @@ export class FirecrawlService {
url
:
string
,
url
:
string
,
maxPages
:
number
,
maxPages
:
number
,
maxDepth
:
number
,
maxDepth
:
number
,
onProgress
?:
(
completed
:
number
,
total
:
number
)
=>
void
|
Promise
<
void
>
,
onProgress
?:
(
completed
:
number
,
total
:
number
,
currentPages
?:
FirecrawlPageResult
[],
)
=>
void
|
Promise
<
void
>
,
shouldCancel
?:
()
=>
Promise
<
boolean
>
,
shouldCancel
?:
()
=>
Promise
<
boolean
>
,
):
Promise
<
CrawlStatusResult
>
{
):
Promise
<
CrawlStatusResult
>
{
const
client
=
getFirecrawlClient
();
const
client
=
getFirecrawlClient
();
...
@@ -171,7 +175,16 @@ export class FirecrawlService {
...
@@ -171,7 +175,16 @@ export class FirecrawlService {
};
};
}
}
await
onProgress
?.(
status
.
completed
,
status
.
total
);
let
currentPages
:
FirecrawlPageResult
[]
|
undefined
;
if
(
status
.
data
&&
status
.
data
.
length
>
0
)
{
currentPages
=
status
.
data
.
map
((
doc
)
=>
normalizePage
(
doc
,
url
));
}
if
(
currentPages
)
{
await
onProgress
?.(
status
.
completed
,
status
.
total
,
currentPages
);
}
else
{
await
onProgress
?.(
status
.
completed
,
status
.
total
);
}
if
(
if
(
status
.
status
===
"completed"
||
status
.
status
===
"completed"
||
...
@@ -292,7 +305,11 @@ export class FirecrawlService {
...
@@ -292,7 +305,11 @@ export class FirecrawlService {
async
batchScrapePages
(
async
batchScrapePages
(
urls
:
string
[],
urls
:
string
[],
maxPages
:
number
,
maxPages
:
number
,
onProgress
?:
(
completed
:
number
,
total
:
number
)
=>
void
|
Promise
<
void
>
,
onProgress
?:
(
completed
:
number
,
total
:
number
,
currentPages
?:
FirecrawlPageResult
[],
)
=>
void
|
Promise
<
void
>
,
shouldCancel
?:
()
=>
Promise
<
boolean
>
,
shouldCancel
?:
()
=>
Promise
<
boolean
>
,
):
Promise
<
CrawlStatusResult
>
{
):
Promise
<
CrawlStatusResult
>
{
const
client
=
getFirecrawlClient
();
const
client
=
getFirecrawlClient
();
...
@@ -361,7 +378,18 @@ export class FirecrawlService {
...
@@ -361,7 +378,18 @@ export class FirecrawlService {
};
};
}
}
await
onProgress
?.(
status
.
completed
,
status
.
total
);
let
currentPages
:
FirecrawlPageResult
[]
|
undefined
;
if
(
status
.
data
&&
status
.
data
.
length
>
0
)
{
currentPages
=
status
.
data
.
map
((
doc
)
=>
normalizePage
(
doc
as
FirecrawlDocument
,
urls
[
0
]),
);
}
if
(
currentPages
)
{
await
onProgress
?.(
status
.
completed
,
status
.
total
,
currentPages
);
}
else
{
await
onProgress
?.(
status
.
completed
,
status
.
total
);
}
if
(
if
(
status
.
status
===
"completed"
||
status
.
status
===
"completed"
||
...
...
src/queues/__tests__/crawl.worker.test.ts
View file @
24ac3314
...
@@ -488,3 +488,95 @@ describe("processCrawlJob — URL_LIST mode", () => {
...
@@ -488,3 +488,95 @@ describe("processCrawlJob — URL_LIST mode", () => {
expect
(
mockPageRepo
.
upsert
).
toHaveBeenCalledTimes
(
1
);
expect
(
mockPageRepo
.
upsert
).
toHaveBeenCalledTimes
(
1
);
});
});
});
});
// ── Real-time persistence & Cancel preservation ────────────────────────────
describe
(
"processCrawlJob — Real-time persistence & Cancel preservation"
,
()
=>
{
it
(
"persists pages incrementally during live crawl onProgress calls"
,
async
()
=>
{
mockJobRepo
.
findById
=
jest
.
fn
().
mockResolvedValue
(
makeJob
({
mode
:
"CRAWL"
}));
mockFirecrawl
.
crawlSite
=
jest
.
fn
()
.
mockImplementation
(
async
(
_url
,
_maxPages
,
_maxDepth
,
onProgress
)
=>
{
// First progress event with 1 page
await
onProgress
?.(
1
,
2
,
[
{
url
:
"https://example.com/p1"
,
success
:
true
,
markdown
:
"# P1"
,
metadata
:
{
statusCode
:
200
},
},
]);
return
{
success
:
true
,
status
:
"completed"
,
pages
:
[
{
url
:
"https://example.com/p1"
,
success
:
true
,
markdown
:
"# P1"
,
metadata
:
{
statusCode
:
200
},
},
{
url
:
"https://example.com/p2"
,
success
:
true
,
markdown
:
"# P2"
,
metadata
:
{
statusCode
:
200
},
},
],
failedUrls
:
[],
robotsBlockedUrls
:
[],
total
:
2
,
};
});
await
processCrawlJob
(
makeBullJob
(
"job-1"
));
// Upsert called twice for 2 pages
expect
(
mockPageRepo
.
upsert
).
toHaveBeenCalledTimes
(
2
);
expect
(
mockJobRepo
.
updateStatus
).
toHaveBeenCalledWith
(
"job-1"
,
"COMPLETED"
,
expect
.
objectContaining
({
successPages
:
2
,
totalPages
:
2
,
}),
);
});
it
(
"preserves scraped pages and maintains CANCELED status when cancelled during crawl"
,
async
()
=>
{
mockJobRepo
.
findById
=
jest
.
fn
().
mockResolvedValue
(
makeJob
({
mode
:
"CRAWL"
}));
mockFirecrawl
.
crawlSite
=
jest
.
fn
()
.
mockImplementation
(
async
(
_url
,
_maxPages
,
_maxDepth
,
onProgress
)
=>
{
await
onProgress
?.(
1
,
10
,
[
{
url
:
"https://example.com/p1"
,
success
:
true
,
markdown
:
"# P1"
,
metadata
:
{
statusCode
:
200
},
},
]);
return
{
success
:
false
,
status
:
"cancelled"
,
error
:
"Cancelled locally"
,
pages
:
[],
total
:
10
,
};
});
await
processCrawlJob
(
makeBullJob
(
"job-1"
));
// Page p1 was persisted during onProgress
expect
(
mockPageRepo
.
upsert
).
toHaveBeenCalledTimes
(
1
);
// Preserved CANCELED status instead of overwriting to FAILED
expect
(
mockJobRepo
.
updateStatus
).
toHaveBeenCalledWith
(
"job-1"
,
"CANCELED"
,
expect
.
objectContaining
({
successPages
:
1
,
}),
);
});
});
src/queues/crawl.worker.processor.ts
View file @
24ac3314
...
@@ -127,10 +127,69 @@ export async function scanAndFlagPage(
...
@@ -127,10 +127,69 @@ export async function scanAndFlagPage(
*
*
* Updates crawl job progress counters every 10 pages saved.
* Updates crawl job progress counters every 10 pages saved.
*/
*/
/**
* Persists a single scraped page (upsert, assets, sensitive scan, extraction template).
*/
export
async
function
persistSinglePage
(
jobId
:
string
,
item
:
FirecrawlPageResult
,
userId
?:
string
,
):
Promise
<
{
success
:
boolean
;
saved
:
boolean
}
>
{
try
{
const
normalized
=
getPageProcessor
().
normalize
(
item
,
jobId
);
const
page
=
await
getPageRepository
().
upsert
(
normalized
);
await
savePageAssets
(
jobId
,
page
.
id
,
item
);
await
scanAndFlagPage
(
page
.
id
,
normalized
.
markdownContent
,
normalized
.
title
,
normalized
.
description
,
);
await
runExtractionIfTemplate
(
jobId
,
page
.
id
,
item
.
url
,
item
,
userId
);
return
{
success
:
item
.
success
,
saved
:
true
};
}
catch
(
err
:
unknown
)
{
console
.
error
(
`[Worker] Failed to save page
${
item
.
url
}
:
${
getErrorMessage
(
err
)}
`
,
);
return
{
success
:
false
,
saved
:
false
};
}
}
/**
* Persists incremental scraped pages, skipping already persisted URLs.
*/
export
async
function
persistIncrementalPages
(
jobId
:
string
,
pages
:
FirecrawlPageResult
[],
persistedUrls
:
Set
<
string
>
,
userId
?:
string
,
):
Promise
<
{
newSuccess
:
number
;
newFailed
:
number
;
newErrors
:
number
}
>
{
let
newSuccess
=
0
;
let
newFailed
=
0
;
let
newErrors
=
0
;
for
(
const
item
of
pages
)
{
if
(
persistedUrls
.
has
(
item
.
url
))
continue
;
persistedUrls
.
add
(
item
.
url
);
const
res
=
await
persistSinglePage
(
jobId
,
item
,
userId
);
if
(
!
res
.
saved
)
{
newErrors
++
;
}
else
if
(
res
.
success
)
{
newSuccess
++
;
}
else
{
newFailed
++
;
}
}
return
{
newSuccess
,
newFailed
,
newErrors
};
}
export
async
function
persistBatchResults
(
export
async
function
persistBatchResults
(
jobId
:
string
,
jobId
:
string
,
result
:
CrawlStatusResult
,
result
:
CrawlStatusResult
,
userId
?:
string
,
userId
?:
string
,
persistedUrls
=
new
Set
<
string
>
(),
):
Promise
<
{
):
Promise
<
{
successCount
:
number
;
successCount
:
number
;
failedCount
:
number
;
failedCount
:
number
;
...
@@ -140,30 +199,21 @@ export async function persistBatchResults(
...
@@ -140,30 +199,21 @@ export async function persistBatchResults(
let
successCount
=
0
;
let
successCount
=
0
;
let
failedCount
=
0
;
let
failedCount
=
0
;
let
saveErrors
=
0
;
let
saveErrors
=
0
;
const
seenUrls
=
new
Set
<
string
>
();
for
(
const
item
of
result
.
pages
)
{
for
(
const
item
of
result
.
pages
)
{
if
(
seenUrls
.
has
(
item
.
url
))
continue
;
if
(
persistedUrls
.
has
(
item
.
url
))
{
seenUrls
.
add
(
item
.
url
);
try
{
const
normalized
=
getPageProcessor
().
normalize
(
item
,
jobId
);
// upsert on (jobId, url) — idempotent on BullMQ retries
const
page
=
await
getPageRepository
().
upsert
(
normalized
);
await
savePageAssets
(
jobId
,
page
.
id
,
item
);
await
scanAndFlagPage
(
page
.
id
,
normalized
.
markdownContent
,
normalized
.
title
,
normalized
.
description
,
);
await
runExtractionIfTemplate
(
jobId
,
page
.
id
,
item
.
url
,
item
,
userId
);
if
(
item
.
success
)
successCount
++
;
if
(
item
.
success
)
successCount
++
;
else
failedCount
++
;
else
failedCount
++
;
}
catch
(
err
:
unknown
)
{
continue
;
console
.
error
(
}
`[Worker] Failed to save page
${
item
.
url
}
:
${
getErrorMessage
(
err
)}
`
,
persistedUrls
.
add
(
item
.
url
);
);
const
res
=
await
persistSinglePage
(
jobId
,
item
,
userId
);
if
(
!
res
.
saved
)
{
saveErrors
++
;
saveErrors
++
;
}
else
if
(
res
.
success
)
{
successCount
++
;
}
else
{
failedCount
++
;
}
}
// Update DB progress counters every 10 pages — avoids N DB writes for large batches
// Update DB progress counters every 10 pages — avoids N DB writes for large batches
...
@@ -172,14 +222,14 @@ export async function persistBatchResults(
...
@@ -172,14 +222,14 @@ export async function persistBatchResults(
void
getJobRepository
().
updateProgress
(
jobId
,
{
void
getJobRepository
().
updateProgress
(
jobId
,
{
successPages
:
successCount
,
successPages
:
successCount
,
failedPages
:
failedCount
+
saveErrors
,
failedPages
:
failedCount
+
saveErrors
,
totalPages
:
seen
Urls
.
size
,
totalPages
:
persisted
Urls
.
size
,
});
});
}
}
}
}
for
(
const
failed
of
result
.
failedUrls
??
[])
{
for
(
const
failed
of
result
.
failedUrls
??
[])
{
if
(
seen
Urls
.
has
(
failed
.
url
))
continue
;
if
(
persisted
Urls
.
has
(
failed
.
url
))
continue
;
seen
Urls
.
add
(
failed
.
url
);
persisted
Urls
.
add
(
failed
.
url
);
try
{
try
{
const
normalized
=
getPageProcessor
().
normalizeFailedPage
(
failed
,
jobId
);
const
normalized
=
getPageProcessor
().
normalizeFailedPage
(
failed
,
jobId
);
await
getPageRepository
().
upsert
(
normalized
);
await
getPageRepository
().
upsert
(
normalized
);
...
@@ -193,8 +243,8 @@ export async function persistBatchResults(
...
@@ -193,8 +243,8 @@ export async function persistBatchResults(
}
}
for
(
const
blockedUrl
of
result
.
robotsBlockedUrls
??
[])
{
for
(
const
blockedUrl
of
result
.
robotsBlockedUrls
??
[])
{
if
(
seen
Urls
.
has
(
blockedUrl
))
continue
;
if
(
persisted
Urls
.
has
(
blockedUrl
))
continue
;
seen
Urls
.
add
(
blockedUrl
);
persisted
Urls
.
add
(
blockedUrl
);
try
{
try
{
const
normalized
=
getPageProcessor
().
normalizeFailedPage
(
const
normalized
=
getPageProcessor
().
normalizeFailedPage
(
{
url
:
blockedUrl
,
error
:
"Blocked by robots.txt"
},
{
url
:
blockedUrl
,
error
:
"Blocked by robots.txt"
},
...
@@ -211,7 +261,7 @@ export async function persistBatchResults(
...
@@ -211,7 +261,7 @@ export async function persistBatchResults(
}
}
}
}
return
{
successCount
,
failedCount
,
saveErrors
,
totalPages
:
seen
Urls
.
size
};
return
{
successCount
,
failedCount
,
saveErrors
,
totalPages
:
persisted
Urls
.
size
};
}
}
async
function
logStep
(
async
function
logStep
(
...
@@ -369,17 +419,41 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -369,17 +419,41 @@ export async function processCrawlJob(job: Job): Promise<void> {
`[Worker] Job
${
jobId
}
sitemap parsed:
${
sitemapUrls
.
length
}
URLs`
,
`[Worker] Job
${
jobId
}
sitemap parsed:
${
sitemapUrls
.
length
}
URLs`
,
);
);
const
persistedUrls
=
new
Set
<
string
>
();
let
currentSuccessCount
=
0
;
let
currentFailedCount
=
0
;
let
pollCount
=
0
;
let
pollCount
=
0
;
const
result
=
await
getFirecrawlService
().
batchScrapePages
(
const
result
=
await
getFirecrawlService
().
batchScrapePages
(
sitemapUrls
,
sitemapUrls
,
crawlJob
.
maxPages
,
crawlJob
.
maxPages
,
async
(
completed
,
total
)
=>
{
async
(
completed
,
total
,
currentPages
)
=>
{
if
(
currentPages
&&
currentPages
.
length
>
0
)
{
const
{
newSuccess
,
newFailed
}
=
await
persistIncrementalPages
(
jobId
,
currentPages
,
persistedUrls
,
crawlJob
.
userId
,
);
currentSuccessCount
+=
newSuccess
;
currentFailedCount
+=
newFailed
;
}
const
effectiveSuccess
=
Math
.
max
(
completed
,
currentSuccessCount
);
const
progressData
:
{
successPages
:
number
;
totalPages
:
number
;
failedPages
?:
number
;
}
=
{
successPages
:
effectiveSuccess
,
totalPages
:
total
,
};
if
(
currentFailedCount
>
0
)
{
progressData
.
failedPages
=
currentFailedCount
;
}
await
Promise
.
all
([
await
Promise
.
all
([
job
.
updateProgress
({
completed
,
total
}),
job
.
updateProgress
({
completed
:
effectiveSuccess
,
total
}),
getJobRepository
().
updateProgress
(
jobId
,
{
getJobRepository
().
updateProgress
(
jobId
,
progressData
),
successPages
:
completed
,
totalPages
:
total
,
}),
]);
]);
},
},
async
()
=>
{
async
()
=>
{
...
@@ -391,12 +465,24 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -391,12 +465,24 @@ export async function processCrawlJob(job: Job): Promise<void> {
);
);
if
(
!
result
.
success
)
{
if
(
!
result
.
success
)
{
if
(
result
.
status
===
"cancelled"
)
{
console
.
log
(
`[Worker] Job
${
jobId
}
sitemap scrape was cancelled. Preserved
${
persistedUrls
.
size
}
crawled pages.`
,
);
await
getJobRepository
().
updateStatus
(
jobId
,
JOB_STATUS
.
CANCELED
,
{
finishedAt
:
new
Date
(),
successPages
:
currentSuccessCount
,
totalPages
:
Math
.
max
(
result
.
total
,
persistedUrls
.
size
),
});
return
;
}
await
getJobRepository
().
updateStatus
(
jobId
,
JOB_STATUS
.
FAILED
,
{
await
getJobRepository
().
updateStatus
(
jobId
,
JOB_STATUS
.
FAILED
,
{
finishedAt
:
new
Date
(),
finishedAt
:
new
Date
(),
errorMessage
:
mapCrawlError
(
result
.
error
??
"Batch scrape failed"
),
errorMessage
:
mapCrawlError
(
result
.
error
??
"Batch scrape failed"
),
totalPages
:
result
.
total
,
totalPages
:
Math
.
max
(
result
.
total
,
persistedUrls
.
size
)
,
successPages
:
0
,
successPages
:
currentSuccessCount
,
failedPages
:
result
.
total
||
1
,
failedPages
:
Math
.
max
(
1
,
result
.
total
-
currentSuccessCount
)
,
});
});
console
.
log
(
console
.
log
(
`[Worker] Job
${
jobId
}
batch scrape failed:
${
result
.
error
??
"Batch scrape failed"
}
`,
`[Worker] Job
${
jobId
}
batch scrape failed:
${
result
.
error
??
"Batch scrape failed"
}
`,
...
@@ -405,7 +491,7 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -405,7 +491,7 @@ export async function processCrawlJob(job: Job): Promise<void> {
}
}
const { successCount, failedCount, saveErrors, totalPages } =
const { successCount, failedCount, saveErrors, totalPages } =
await persistBatchResults(jobId, result, crawlJob.userId);
await persistBatchResults(jobId, result, crawlJob.userId
, persistedUrls
);
console.log(
console.log(
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
);
);
...
@@ -431,17 +517,41 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -431,17 +517,41 @@ export async function processCrawlJob(job: Job): Promise<void> {
return;
return;
}
}
const persistedUrls = new Set<string>();
let currentSuccessCount = 0;
let currentFailedCount = 0;
let pollCount = 0;
let pollCount = 0;
const result = await getFirecrawlService().batchScrapePages(
const result = await getFirecrawlService().batchScrapePages(
crawlJob.urls,
crawlJob.urls,
crawlJob.maxPages,
crawlJob.maxPages,
async (completed, total) => {
async (completed, total, currentPages) => {
if (currentPages && currentPages.length > 0) {
const { newSuccess, newFailed } = await persistIncrementalPages(
jobId,
currentPages,
persistedUrls,
crawlJob.userId,
);
currentSuccessCount += newSuccess;
currentFailedCount += newFailed;
}
const effectiveSuccess = Math.max(completed, currentSuccessCount);
const progressData: {
successPages: number;
totalPages: number;
failedPages?: number;
} = {
successPages: effectiveSuccess,
totalPages: total,
};
if (currentFailedCount > 0) {
progressData.failedPages = currentFailedCount;
}
await Promise.all([
await Promise.all([
job.updateProgress({ completed, total }),
job.updateProgress({ completed: effectiveSuccess, total }),
getJobRepository().updateProgress(jobId, {
getJobRepository().updateProgress(jobId, progressData),
successPages: completed,
totalPages: total,
}),
]);
]);
},
},
async () => {
async () => {
...
@@ -453,18 +563,30 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -453,18 +563,30 @@ export async function processCrawlJob(job: Job): Promise<void> {
);
);
if (!result.success) {
if (!result.success) {
if (result.status === "cancelled") {
console.log(
`
[
Worker
]
Job
$
{
jobId
}
URL_LIST
scrape
was
cancelled
.
Preserved
$
{
persistedUrls
.
size
}
crawled
pages
.
`,
);
await getJobRepository().updateStatus(jobId, JOB_STATUS.CANCELED, {
finishedAt: new Date(),
successPages: currentSuccessCount,
totalPages: Math.max(result.total, persistedUrls.size),
});
return;
}
await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, {
await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, {
finishedAt: new Date(),
finishedAt: new Date(),
errorMessage: mapCrawlError(result.error ?? "Batch scrape failed"),
errorMessage: mapCrawlError(result.error ?? "Batch scrape failed"),
totalPages:
result.total
,
totalPages:
Math.max(result.total, persistedUrls.size)
,
successPages:
0
,
successPages:
currentSuccessCount
,
failedPages:
result.total || 1
,
failedPages:
Math.max(1, result.total - currentSuccessCount)
,
});
});
return;
return;
}
}
const { successCount, failedCount, saveErrors, totalPages } =
const { successCount, failedCount, saveErrors, totalPages } =
await persistBatchResults(jobId, result, crawlJob.userId);
await persistBatchResults(jobId, result, crawlJob.userId
, persistedUrls
);
console.log(
console.log(
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
);
);
...
@@ -480,6 +602,9 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -480,6 +602,9 @@ export async function processCrawlJob(job: Job): Promise<void> {
`
[
Worker
]
Job
$
{
jobId
}
started
,
mode
=
$
{
crawlJob
.
mode
},
url
=
$
{
crawlJob
.
startUrl
}
`,
`
[
Worker
]
Job
$
{
jobId
}
started
,
mode
=
$
{
crawlJob
.
mode
},
url
=
$
{
crawlJob
.
startUrl
}
`,
);
);
const persistedUrls = new Set<string>();
let currentSuccessCount = 0;
let currentFailedCount = 0;
let pollCount = 0;
let pollCount = 0;
let firecrawlJobIdSaved = false;
let firecrawlJobIdSaved = false;
...
@@ -487,13 +612,33 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -487,13 +612,33 @@ export async function processCrawlJob(job: Job): Promise<void> {
crawlJob.startUrl,
crawlJob.startUrl,
crawlJob.maxPages,
crawlJob.maxPages,
crawlJob.maxDepth,
crawlJob.maxDepth,
async (completed, total) => {
async (completed, total, currentPages) => {
if (currentPages && currentPages.length > 0) {
const { newSuccess, newFailed } = await persistIncrementalPages(
jobId,
currentPages,
persistedUrls,
crawlJob.userId,
);
currentSuccessCount += newSuccess;
currentFailedCount += newFailed;
}
const effectiveSuccess = Math.max(completed, currentSuccessCount);
const progressData: {
successPages: number;
totalPages: number;
failedPages?: number;
} = {
successPages: effectiveSuccess,
totalPages: total,
};
if (currentFailedCount > 0) {
progressData.failedPages = currentFailedCount;
}
await Promise.all([
await Promise.all([
job.updateProgress({ completed, total }),
job.updateProgress({ completed: effectiveSuccess, total }),
getJobRepository().updateProgress(jobId, {
getJobRepository().updateProgress(jobId, progressData),
successPages: completed,
totalPages: total,
}),
]);
]);
},
},
async () => {
async () => {
...
@@ -521,12 +666,24 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -521,12 +666,24 @@ export async function processCrawlJob(job: Job): Promise<void> {
}
}
if (!result.success) {
if (!result.success) {
if (result.status === "cancelled") {
console.log(
`
[
Worker
]
Job
$
{
jobId
}
crawl
was
cancelled
.
Preserved
$
{
persistedUrls
.
size
}
crawled
pages
.
`,
);
await getJobRepository().updateStatus(jobId, JOB_STATUS.CANCELED, {
finishedAt: new Date(),
successPages: currentSuccessCount,
totalPages: Math.max(result.total, persistedUrls.size),
});
return;
}
await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, {
await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, {
finishedAt: new Date(),
finishedAt: new Date(),
errorMessage: mapCrawlError(result.error ?? "Crawl failed"),
errorMessage: mapCrawlError(result.error ?? "Crawl failed"),
totalPages:
result.total
,
totalPages:
Math.max(result.total, persistedUrls.size)
,
successPages:
0
,
successPages:
currentSuccessCount
,
failedPages:
result.total || 1
,
failedPages:
Math.max(1, result.total - currentSuccessCount)
,
});
});
console.log(
console.log(
`
[
Worker
]
Job
$
{
jobId
}
crawl
failed
:
$
{
result
.
error
??
"Crawl failed"
}
`,
`
[
Worker
]
Job
$
{
jobId
}
crawl
failed
:
$
{
result
.
error
??
"Crawl failed"
}
`,
...
@@ -535,7 +692,7 @@ export async function processCrawlJob(job: Job): Promise<void> {
...
@@ -535,7 +692,7 @@ export async function processCrawlJob(job: Job): Promise<void> {
}
}
const { successCount, failedCount, saveErrors, totalPages } =
const { successCount, failedCount, saveErrors, totalPages } =
await persistBatchResults(jobId, result, crawlJob.userId);
await persistBatchResults(jobId, result, crawlJob.userId
, persistedUrls
);
console.log(
console.log(
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
`
[
Worker
]
Job
$
{
jobId
}
completed
:
$
{
successCount
}
success
,
$
{
failedCount
}
failed
,
$
{
saveErrors
}
save
errors
,
$
{
totalPages
}
total
`,
);
);
...
...
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