@govtechsg/oobee 0.10.98 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1135,10 +1135,9 @@ export const createCrawleeSubFolders = async (
1135
1135
  randomToken: string,
1136
1136
  ): Promise<{ dataset: Dataset; requestQueue: RequestQueue }> => {
1137
1137
 
1138
- const crawleeDir = path.join(getStoragePath(randomToken),"crawlee");
1139
-
1140
- const dataset = await Dataset.open(crawleeDir);
1141
- const requestQueue = await RequestQueue.open(crawleeDir);
1138
+ const baseDir = path.join(getStoragePath(randomToken), "crawlee");
1139
+ const dataset = await Dataset.open(baseDir);
1140
+ const requestQueue = await RequestQueue.open(`${baseDir}_rq`);
1142
1141
  return { dataset, requestQueue };
1143
1142
  };
1144
1143
 
@@ -1215,77 +1214,90 @@ export const postNavigationHooks = [
1215
1214
 
1216
1215
  export const getPreLaunchHook = (userDataDirectory: string) => {
1217
1216
  let launchCount = 0;
1217
+ let previousPoolDir: string | null = null;
1218
1218
 
1219
1219
  return async (_pageId: string, launchContext: any) => {
1220
1220
  const fsp = await import('fs/promises').then(m => m.default);
1221
1221
  launchCount += 1;
1222
1222
 
1223
- // First launch uses the base directory; subsequent launches get a unique
1224
- // directory so that lingering file handles from a retired browser don't
1225
- // cause Chrome exit code 21 on Windows.
1226
- const effectiveDir =
1227
- launchCount === 1
1228
- ? userDataDirectory
1229
- : `${userDataDirectory}_pool${launchCount}`;
1223
+ // Clean up the previous pool directory. When preLaunchHooks fires, the
1224
+ // previous browser has been retired and is finishing its last pages.
1225
+ // Schedule cleanup with enough delay for Chrome to fully exit
1226
+ // closeInactiveBrowserAfterSecs (30s) + Windows file-lock grace.
1227
+ if (previousPoolDir) {
1228
+ const dirToClean = previousPoolDir;
1229
+ const isWin = process.platform === 'win32';
1230
+ const delay = isWin ? 40000 : 35000;
1231
+ setTimeout(async () => {
1232
+ for (let attempt = 0; attempt < 3; attempt++) {
1233
+ try {
1234
+ await fsp.rm(dirToClean, { recursive: true, force: true });
1235
+ return;
1236
+ } catch {
1237
+ await new Promise(r => setTimeout(r, 5000));
1238
+ }
1239
+ }
1240
+ }, delay);
1241
+ }
1242
+
1243
+ // Every browser gets its own directory. The base userDataDirectory is
1244
+ // treated as a read-only cookie source (the pristine clone of the user's
1245
+ // profile) and is never used directly by a running browser.
1246
+ const effectiveDir = `${userDataDirectory}_pool${launchCount}`;
1230
1247
 
1231
1248
  await fsp.mkdir(effectiveDir, { recursive: true });
1232
1249
 
1233
- // For pool re-launches, copy only auth-relevant files from the base
1234
- // directory so authenticated sessions are preserved without cloning the
1235
- // entire profile (which grows with caches, IndexedDB, etc. during the crawl).
1236
- if (launchCount > 1) {
1237
- try {
1238
- // Copy top-level Local State (cookie encryption keys on Windows)
1239
- const localStateSrc = path.join(userDataDirectory, 'Local State');
1240
- if (await fsp.stat(localStateSrc).catch(() => null)) {
1241
- await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
1242
- }
1250
+ // Copy auth-relevant files from the pristine base directory so
1251
+ // authenticated sessions are preserved across pool rotations.
1252
+ try {
1253
+ const localStateSrc = path.join(userDataDirectory, 'Local State');
1254
+ if (await fsp.stat(localStateSrc).catch(() => null)) {
1255
+ await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
1256
+ }
1243
1257
 
1244
- // Find profile directories (Default, Profile 1, Profile 2, etc.)
1245
- const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
1246
- const profileDirs = (entries as any[]).filter(
1247
- (e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
1248
- );
1249
-
1250
- for (const profile of profileDirs) {
1251
- const srcProfile = path.join(userDataDirectory, profile.name);
1252
- const destProfile = path.join(effectiveDir, profile.name);
1253
- await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});
1254
-
1255
- // Cookies (macOS layout: <Profile>/Cookies)
1256
- const cookiesSrc = path.join(srcProfile, 'Cookies');
1257
- if (await fsp.stat(cookiesSrc).catch(() => null)) {
1258
- await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
1259
- }
1258
+ const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
1259
+ const profileDirs = (entries as any[]).filter(
1260
+ (e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
1261
+ );
1260
1262
 
1261
- // Cookies (Windows layout: <Profile>/Network/Cookies)
1262
- const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
1263
- if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
1264
- const destNetwork = path.join(destProfile, 'Network');
1265
- await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
1266
- await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
1267
- }
1263
+ for (const profile of profileDirs) {
1264
+ const srcProfile = path.join(userDataDirectory, profile.name);
1265
+ const destProfile = path.join(effectiveDir, profile.name);
1266
+ await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});
1268
1267
 
1269
- // Local Storage (auth tokens, session data)
1270
- const localStorageSrc = path.join(srcProfile, 'Local Storage');
1271
- const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
1272
- if (localStorageStat && localStorageStat.isDirectory()) {
1273
- const destLocalStorage = path.join(destProfile, 'Local Storage');
1274
- await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
1275
- const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
1276
- await Promise.all(
1277
- lsFiles.map(f =>
1278
- fsp.copyFile(
1279
- path.join(localStorageSrc, f),
1280
- path.join(destLocalStorage, f),
1281
- ).catch(() => {}),
1282
- ),
1283
- );
1284
- }
1268
+ // Cookies (macOS layout: <Profile>/Cookies)
1269
+ const cookiesSrc = path.join(srcProfile, 'Cookies');
1270
+ if (await fsp.stat(cookiesSrc).catch(() => null)) {
1271
+ await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
1272
+ }
1273
+
1274
+ // Cookies (Windows layout: <Profile>/Network/Cookies)
1275
+ const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
1276
+ if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
1277
+ const destNetwork = path.join(destProfile, 'Network');
1278
+ await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
1279
+ await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
1280
+ }
1281
+
1282
+ // Local Storage (auth tokens, session data)
1283
+ const localStorageSrc = path.join(srcProfile, 'Local Storage');
1284
+ const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
1285
+ if (localStorageStat && localStorageStat.isDirectory()) {
1286
+ const destLocalStorage = path.join(destProfile, 'Local Storage');
1287
+ await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
1288
+ const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
1289
+ await Promise.all(
1290
+ lsFiles.map(f =>
1291
+ fsp.copyFile(
1292
+ path.join(localStorageSrc, f),
1293
+ path.join(destLocalStorage, f),
1294
+ ).catch(() => {}),
1295
+ ),
1296
+ );
1285
1297
  }
1286
- } catch {
1287
- // Silent fallback: use empty profile if clone fails
1288
1298
  }
1299
+ } catch {
1300
+ // Silent fallback: use empty profile if clone fails
1289
1301
  }
1290
1302
 
1291
1303
  // Clean any stale lock files that may block browser launches on Windows
@@ -1299,11 +1311,38 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
1299
1311
  ];
1300
1312
  await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => {})));
1301
1313
 
1314
+ previousPoolDir = effectiveDir;
1302
1315
  // eslint-disable-next-line no-param-reassign
1303
1316
  launchContext.userDataDir = effectiveDir;
1304
1317
  };
1305
1318
  };
1306
1319
 
1320
+ export const getPostPageCloseHook = (userDataDirectory: string) => {
1321
+ return async (_pageId: string, browserController: any) => {
1322
+ if (browserController.activePages === 0) {
1323
+ const dir = browserController.launchContext?.userDataDir;
1324
+ if (dir && dir !== userDataDirectory && dir.includes('_pool')) {
1325
+ const fsp = await import('fs/promises').then(m => m.default);
1326
+ // Chrome on Windows writes optimization/segmentation data during shutdown;
1327
+ // wait longer (5s) for the process to fully release file handles.
1328
+ const isWin = process.platform === 'win32';
1329
+ const initialDelay = isWin ? 5000 : 2000;
1330
+ setTimeout(async () => {
1331
+ for (let attempt = 0; attempt < 3; attempt++) {
1332
+ try {
1333
+ await fsp.rm(dir, { recursive: true, force: true });
1334
+ return;
1335
+ } catch {
1336
+ // Retry after a short back-off (Windows file locks)
1337
+ await new Promise(r => setTimeout(r, 2000));
1338
+ }
1339
+ }
1340
+ }, initialDelay);
1341
+ }
1342
+ }
1343
+ };
1344
+ };
1345
+
1307
1346
  export const failedRequestHandler = async ({ request }: { request: Request }) => {
1308
1347
  guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
1309
1348
  log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
@@ -5,6 +5,7 @@ import type { PlaywrightCrawlingContext, RequestOptions } from 'crawlee';
5
5
  import {
6
6
  createCrawleeSubFolders,
7
7
  getPreLaunchHook,
8
+ getPostPageCloseHook,
8
9
  preNavigationHooks,
9
10
  runAxeScript,
10
11
  isUrlPdf,
@@ -418,12 +419,23 @@ const crawlDomain = async ({
418
419
  };
419
420
  },
420
421
  ],
422
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
421
423
  },
422
424
  requestQueue,
423
425
  maxRequestRetries: 3,
424
426
  maxSessionRotations: 1,
425
427
  preNavigationHooks: [
426
428
  ...preNavigationHooks(extraHTTPHeaders),
429
+ async ({ request }) => {
430
+ const url = request.url.toLowerCase();
431
+ try {
432
+ const pathname = new URL(url).pathname;
433
+ const ext = pathname.split('.').pop();
434
+ if (ext && blackListedFileExtensions.includes(ext)) {
435
+ request.skipNavigation = true;
436
+ }
437
+ } catch {}
438
+ },
427
439
  ],
428
440
  postNavigationHooks: [
429
441
  async crawlingContext => {
@@ -555,21 +567,17 @@ const crawlDomain = async ({
555
567
  (request.skipNavigation && actualUrl === 'about:blank')
556
568
  ) {
557
569
  if (!isScanPdfs) {
558
- // Don't inform the user it is skipped since web crawler is best-effort.
559
- /*
560
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
561
- numScanned: urlsCrawled.scanned.length,
562
- urlScanned: request.url,
563
- });
564
- urlsCrawled.userExcluded.push({
565
- url: request.url,
566
- pageTitle: request.url,
567
- actualUrl: request.url, // because about:blank is not useful
568
- metadata: STATUS_CODE_METADATA[1],
569
- httpStatusCode: 0,
570
- });
571
- */
572
-
570
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
571
+ numScanned: urlsCrawled.scanned.length,
572
+ urlScanned: request.url,
573
+ });
574
+ urlsCrawled.userExcluded.push({
575
+ url: request.url,
576
+ pageTitle: request.url,
577
+ actualUrl: request.url,
578
+ metadata: STATUS_CODE_METADATA[1],
579
+ httpStatusCode: 1,
580
+ });
573
581
  return;
574
582
  }
575
583
  const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(
@@ -585,20 +593,17 @@ const crawlDomain = async ({
585
593
  }
586
594
 
587
595
  if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
588
- // Don't inform the user it is skipped since web crawler is best-effort.
589
- /*
590
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
591
- numScanned: urlsCrawled.scanned.length,
592
- urlScanned: request.url,
593
- });
594
- urlsCrawled.userExcluded.push({
595
- url: request.url,
596
- pageTitle: request.url,
597
- actualUrl, // because about:blank is not useful
598
- metadata: STATUS_CODE_METADATA[1],
599
- httpStatusCode: 0,
600
- });
601
- */
596
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
597
+ numScanned: urlsCrawled.scanned.length,
598
+ urlScanned: request.url,
599
+ });
600
+ urlsCrawled.userExcluded.push({
601
+ url: request.url,
602
+ pageTitle: request.url,
603
+ actualUrl,
604
+ metadata: STATUS_CODE_METADATA[1],
605
+ httpStatusCode: 1,
606
+ });
602
607
  return;
603
608
  }
604
609
 
@@ -800,7 +805,59 @@ const crawlDomain = async ({
800
805
  return;
801
806
  }
802
807
 
808
+ // Handle download-triggered navigation errors: Playwright throws
809
+ // "Download is starting" when page.goto() hits a file download URL.
810
+ // Crawlee retries 3 times (all fail) then lands here.
811
+ const isDownloadError = request.errorMessages?.some(
812
+ (msg: string) => msg.includes('Download is starting'),
813
+ );
814
+ if (isDownloadError) {
815
+ if (isScanPdfs) {
816
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
817
+ try {
818
+ await requestQueue.addRequest({
819
+ url: request.url,
820
+ skipNavigation: true,
821
+ label: request.url,
822
+ uniqueKey: `download_${request.url}`,
823
+ });
824
+ } catch {}
825
+ } else {
826
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
827
+ numScanned: urlsCrawled.scanned.length,
828
+ urlScanned: request.url,
829
+ });
830
+ urlsCrawled.userExcluded.push({
831
+ url: request.url,
832
+ pageTitle: request.url,
833
+ actualUrl: request.url,
834
+ metadata: STATUS_CODE_METADATA[1],
835
+ httpStatusCode: 1,
836
+ });
837
+ }
838
+ return;
839
+ }
840
+
803
841
  const status = response?.status();
842
+
843
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
844
+ // Without this, URLs that fail during a rate-limit burst are permanently lost
845
+ // even though the site is accessible at lower concurrency.
846
+ // Don't call onFailure here — the re-enqueued request will get a fresh attempt.
847
+ // If it fails again (rateLimitRetried=true), it falls through to the normal
848
+ // onFailure + circuit breaker path below.
849
+ if (status === 403 && !request.userData?.rateLimitRetried) {
850
+ try {
851
+ await requestQueue.addRequest({
852
+ url: request.url,
853
+ label: request.url,
854
+ uniqueKey: `ratelimit_${request.url}`,
855
+ userData: { rateLimitRetried: true },
856
+ });
857
+ } catch {}
858
+ return;
859
+ }
860
+
804
861
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
805
862
  consoleLogger.info(
806
863
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -844,7 +901,9 @@ const crawlDomain = async ({
844
901
 
845
902
  // Additional passes: keep re-visiting scanned seed-hostname pages for
846
903
  // click-discovery until no new pages are found or limits are reached.
847
- if (!safeMode && !isAbortingScanNow && !durationExceeded) {
904
+ // Skip when called from intelligent sitemap — the domain phase is only meant
905
+ // to discover new pages via <a> links, not re-click 3000+ already-scanned pages.
906
+ if (!safeMode && !isAbortingScanNow && !durationExceeded && !fromCrawlIntelligentSitemap) {
848
907
  const seedHostname = new URL(url).hostname;
849
908
  const clickPassVisited = new Set<string>();
850
909
  let prevScannedCount: number;
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import {
5
5
  createCrawleeSubFolders,
6
6
  getPreLaunchHook,
7
+ getPostPageCloseHook,
7
8
  preNavigationHooks,
8
9
  runAxeScript,
9
10
  isUrlPdf,
@@ -14,13 +15,16 @@ import constants, {
14
15
  STATUS_CODE_METADATA,
15
16
  guiInfoStatusTypes,
16
17
  UrlsCrawled,
18
+ blackListedFileExtensions,
17
19
  disallowedListOfPatterns,
20
+ disallowedSelectorPatterns,
18
21
  FileTypes,
19
22
  RuleFlags,
20
23
  } from '../constants/constants.js';
21
24
  import {
22
25
  getLinksFromSitemap,
23
26
  getPlaywrightLaunchOptions,
27
+ isDisallowedInRobotsTxt,
24
28
  isSkippedUrl,
25
29
  waitForPageLoaded,
26
30
  isFilePath,
@@ -133,6 +137,12 @@ const crawlSitemap = async ({
133
137
  sources: linksFromSitemap,
134
138
  });
135
139
 
140
+ // Always create a request queue alongside the request list. An empty queue
141
+ // has zero impact on crawl behavior (Crawlee processes RequestList first).
142
+ // Having it available enables: download re-enqueue for PDF scanning,
143
+ // 403 rate-limit retry, and enqueueLinks for intelligent sitemap discovery.
144
+ const { requestQueue } = await createCrawleeSubFolders(randomToken);
145
+
136
146
  const crawler = register(
137
147
  new crawlee.PlaywrightCrawler({
138
148
  launchContext: {
@@ -158,8 +168,10 @@ const crawlSitemap = async ({
158
168
  };
159
169
  },
160
170
  ],
171
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
161
172
  },
162
173
  requestList,
174
+ requestQueue,
163
175
  maxRequestRetries: 3,
164
176
  maxSessionRotations: 1,
165
177
  postNavigationHooks: [
@@ -225,16 +237,22 @@ const crawlSitemap = async ({
225
237
  if (isNotSupportedDocument) {
226
238
  request.skipNavigation = true;
227
239
  request.userData.isNotSupportedDocument = true;
228
-
229
- // Log for verification (optional, but not required for correctness)
230
- // console.log(`[SKIP] Not supported: ${request.url}`);
231
-
232
240
  return;
233
241
  }
242
+
243
+ try {
244
+ const pathname = new URL(url).pathname;
245
+ const ext = pathname.split('.').pop();
246
+ if (ext && blackListedFileExtensions.includes(ext)) {
247
+ request.skipNavigation = true;
248
+ request.userData.isNotSupportedDocument = true;
249
+ return;
250
+ }
251
+ } catch {}
234
252
  },
235
253
  ],
236
254
  requestHandlerTimeoutSecs: 90,
237
- requestHandler: async ({ page, request, response, sendRequest }) => {
255
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
238
256
  // Log documents that are not supported
239
257
  if (request.userData?.isNotSupportedDocument) {
240
258
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -401,6 +419,32 @@ const crawlSitemap = async ({
401
419
  results.actualUrl = actualUrl;
402
420
 
403
421
  await dataset.pushData(results);
422
+
423
+ // Discover <a> links from this page for the intelligent sitemap flow.
424
+ // This eliminates the need for a separate crawlDomain supplement phase
425
+ // that would re-visit all these pages just to extract links.
426
+ if (fromCrawlIntelligentSitemap) {
427
+ try {
428
+ await enqueueLinks({
429
+ selector: `a:not(${disallowedSelectorPatterns})`,
430
+ strategy,
431
+ requestQueue,
432
+ transformRequestFunction: (req) => {
433
+ try {
434
+ req.url = req.url.replace(/(?<=&|\?)utm_.*?(&|$)/gim, '');
435
+ } catch {}
436
+ if (isDisallowedInRobotsTxt(req.url)) return null;
437
+ if (isUrlPdf(req.url)) {
438
+ req.skipNavigation = true;
439
+ }
440
+ req.label = req.url;
441
+ return req;
442
+ },
443
+ });
444
+ } catch {
445
+ // Best-effort link discovery; don't fail the scan
446
+ }
447
+ }
404
448
  }
405
449
  } else {
406
450
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -409,20 +453,37 @@ const crawlSitemap = async ({
409
453
  });
410
454
 
411
455
  if (isScanHtml) {
412
- // carry through the HTTP status metadata
413
- const status = response?.status();
414
- const metadata =
415
- typeof status === 'number'
416
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
417
- : STATUS_CODE_METADATA[2];
418
-
419
- urlsCrawled.invalid.push({
420
- actualUrl,
421
- url: request.url,
422
- pageTitle: request.url,
423
- metadata,
424
- httpStatusCode: typeof status === 'number' ? status : 0,
425
- });
456
+ // Non-HTML content types (images, PDFs, binary files) and URLs
457
+ // that redirect to non-HTML resources should be classified as
458
+ // unsupported documents, not generic page errors.
459
+ const isNonHtmlContent =
460
+ contentType &&
461
+ !contentType.startsWith('text/html') &&
462
+ !contentType.includes('html');
463
+
464
+ if (isNonHtmlContent && status !== 0) {
465
+ urlsCrawled.userExcluded.push({
466
+ actualUrl,
467
+ url: request.url,
468
+ pageTitle: request.url,
469
+ metadata: STATUS_CODE_METADATA[1],
470
+ httpStatusCode: 1,
471
+ });
472
+ } else {
473
+ const httpStatus = response?.status();
474
+ const metadata =
475
+ typeof httpStatus === 'number'
476
+ ? STATUS_CODE_METADATA[httpStatus] || STATUS_CODE_METADATA[599]
477
+ : STATUS_CODE_METADATA[2];
478
+
479
+ urlsCrawled.invalid.push({
480
+ actualUrl,
481
+ url: request.url,
482
+ pageTitle: request.url,
483
+ metadata,
484
+ httpStatusCode: typeof httpStatus === 'number' ? httpStatus : 0,
485
+ });
486
+ }
426
487
  }
427
488
  }
428
489
  } catch (e) {
@@ -437,7 +498,55 @@ const crawlSitemap = async ({
437
498
  return;
438
499
  }
439
500
 
501
+ // Handle download-triggered navigation errors: Playwright throws
502
+ // "Download is starting" when page.goto() hits a file download URL.
503
+ const isDownloadError = request.errorMessages?.some(
504
+ (msg: string) => msg.includes('Download is starting'),
505
+ );
506
+ if (isDownloadError) {
507
+ if (isScanPdfs) {
508
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
509
+ try {
510
+ await requestQueue.addRequest({
511
+ url: request.url,
512
+ skipNavigation: true,
513
+ label: request.url,
514
+ uniqueKey: `download_${request.url}`,
515
+ });
516
+ } catch {}
517
+ } else {
518
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
519
+ numScanned: urlsCrawled.scanned.length,
520
+ urlScanned: request.url,
521
+ });
522
+ urlsCrawled.userExcluded.push({
523
+ url: request.url,
524
+ pageTitle: request.url,
525
+ actualUrl: request.url,
526
+ metadata: STATUS_CODE_METADATA[1],
527
+ httpStatusCode: 1,
528
+ });
529
+ }
530
+ return;
531
+ }
532
+
440
533
  const status = response?.status();
534
+
535
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
536
+ // Don't call onFailure here — the re-enqueued request gets a fresh attempt.
537
+ // If it fails again (rateLimitRetried=true), it falls through to the normal path.
538
+ if (status === 403 && !request.userData?.rateLimitRetried) {
539
+ try {
540
+ await requestQueue.addRequest({
541
+ url: request.url,
542
+ label: request.url,
543
+ uniqueKey: `ratelimit_${request.url}`,
544
+ userData: { rateLimitRetried: true },
545
+ });
546
+ } catch {}
547
+ return;
548
+ }
549
+
441
550
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
442
551
  consoleLogger.info(
443
552
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -1110,7 +1110,7 @@ const generateArtifacts = async (
1110
1110
  }
1111
1111
 
1112
1112
  try {
1113
- const requestQueue = await RequestQueue.open(crawleeDir);
1113
+ const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
1114
1114
  await requestQueue.drop();
1115
1115
  } catch (error) {
1116
1116
  consoleLogger.info(`RequestQueue drop: ${error.message}`);
@@ -1120,6 +1120,7 @@ const generateArtifacts = async (
1120
1120
  const crawleePath = path.join(storagePath, 'crawlee');
1121
1121
  try {
1122
1122
  await fs.promises.rm(crawleePath, { recursive: true, force: true });
1123
+ await fs.promises.rm(`${crawleePath}_rq`, { recursive: true, force: true });
1123
1124
  } catch {
1124
1125
  // Best-effort; storage was already dropped via API
1125
1126
  }