@govtechsg/oobee 0.10.97 → 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.
package/dist/utils.js CHANGED
@@ -346,15 +346,34 @@ export const cleanUp = async (randomToken, isError = false) => {
346
346
  catch (error) {
347
347
  consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
348
348
  }
349
- // Also remove _pool* sibling directories created by browser pool re-launches
350
- try {
351
- const poolDirs = globSync(`${constants.userDataDirectory}_pool*`);
349
+ // Also remove _pool* sibling directories created by browser pool re-launches.
350
+ // On Windows, Chrome writes files (optimization_guide_model_store,
351
+ // segmentation_platform, first_party_sets.db, etc.) during its shutdown
352
+ // sequence and may still hold file locks. Retry up to 10 times at 5s intervals.
353
+ const removePoolDirs = () => {
354
+ // On Windows, glob interprets backslashes as escape characters, not path
355
+ // separators. Normalize to forward slashes so the pattern actually matches.
356
+ const globPattern = `${constants.userDataDirectory}_pool*`.replace(/\\/g, '/');
357
+ const poolDirs = globSync(globPattern);
352
358
  for (const dir of poolDirs) {
353
359
  fs.rmSync(dir, { recursive: true, force: true });
354
360
  }
355
- }
356
- catch (error) {
357
- consoleLogger.warn(`Unable to remove pool directories: ${error.message}`);
361
+ return poolDirs.length;
362
+ };
363
+ for (let attempt = 0; attempt < 10; attempt++) {
364
+ try {
365
+ if (removePoolDirs() === 0)
366
+ break; // nothing left to clean
367
+ break; // rmSync succeeded for all dirs
368
+ }
369
+ catch {
370
+ if (attempt < 9) {
371
+ await new Promise(r => setTimeout(r, 5000));
372
+ }
373
+ else {
374
+ consoleLogger.warn('Unable to remove pool directories after 10 attempts');
375
+ }
376
+ }
358
377
  }
359
378
  }
360
379
  if (process.env.TMPDIR && fs.existsSync('/.dockerenv'))
@@ -374,7 +393,7 @@ export const cleanUp = async (randomToken, isError = false) => {
374
393
  const crawleeDir = path.join(storagePath, 'crawlee');
375
394
  const dataset = await Dataset.open(crawleeDir);
376
395
  await dataset.drop();
377
- const requestQueue = await RequestQueue.open(crawleeDir);
396
+ const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
378
397
  await requestQueue.drop();
379
398
  }
380
399
  catch (error) {
@@ -386,6 +405,12 @@ export const cleanUp = async (randomToken, isError = false) => {
386
405
  catch (error) {
387
406
  consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
388
407
  }
408
+ try {
409
+ fs.rmSync(path.join(storagePath, 'crawlee_rq'), { recursive: true, force: true });
410
+ }
411
+ catch (error) {
412
+ consoleLogger.warn(`Unable to force remove crawlee_rq folder: ${error.message}`);
413
+ }
389
414
  try {
390
415
  fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
391
416
  }
@@ -808,9 +833,11 @@ export const zipResults = async (zipName, resultsPath) => {
808
833
  if (!stats.isDirectory()) {
809
834
  throw new Error(`resultsPath is not a directory: ${resultsPath}`);
810
835
  }
811
- async function addFolderToZip(folderPath, zipFolder) {
836
+ async function addFolderToZip(folderPath, zipFolder, isRoot = false) {
812
837
  const items = await fs.readdir(folderPath);
813
838
  for (const item of items) {
839
+ if (isRoot && item.startsWith('crawlee'))
840
+ continue;
814
841
  const fullPath = path.join(folderPath, item);
815
842
  const stats = await fs.stat(fullPath);
816
843
  if (stats.isDirectory()) {
@@ -824,7 +851,7 @@ export const zipResults = async (zipName, resultsPath) => {
824
851
  }
825
852
  }
826
853
  const zip = new JSZip();
827
- await addFolderToZip(resultsPath, zip);
854
+ await addFolderToZip(resultsPath, zip, true);
828
855
  const zipStream = zip.generateNodeStream({
829
856
  type: 'nodebuffer',
830
857
  streamFiles: true,
@@ -3,7 +3,7 @@
3
3
  * DO NOT EDIT MANUALLY. Re-generate with: node dist/generateOobeeClientScanner.js
4
4
  *
5
5
  * Embedded at generation time:
6
- * App version : 0.10.97
6
+ * App version : 0.11.0
7
7
  * Sentry DSN : (from OOBEE_SENTRY_DSN env var or constants.ts default)
8
8
  * Sentry SDK : @sentry/browser 10.58.0 (loaded from CDN at runtime)
9
9
  *
@@ -33593,7 +33593,11 @@
33593
33593
  function isElementTooSmall(element) {
33594
33594
  // Get the bounding rectangle of the element
33595
33595
  const rect = element.getBoundingClientRect();
33596
- // Check if the element has a valid width or height
33596
+ // If either dimension is 0, the element is non-perceivable
33597
+ if (rect.width === 0 || rect.height === 0) {
33598
+ return true;
33599
+ }
33600
+ // Check if the element has a valid width and height
33597
33601
  if (rect.width > 0 || rect.height > 0) {
33598
33602
  return false; // Element is not too small
33599
33603
  }
@@ -34883,7 +34887,7 @@
34883
34887
  // ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
34884
34888
 
34885
34889
  var _oobeeSentryDsn = "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168";
34886
- var _oobeeAppVersion = "0.10.97";
34890
+ var _oobeeAppVersion = "0.11.0";
34887
34891
  var _oobeeSentryVersion = "10.58.0";
34888
34892
  var _oobeeSentryInitialized = false;
34889
34893
  var _oobeeSentryLoadPromise = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.97",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
package/src/combine.ts CHANGED
@@ -96,6 +96,13 @@ const combineRun = async (details: Data, deviceToScan: string) => {
96
96
  consoleLogger.info(`Suppressed Playwright post-close connection error: ${err.message}`);
97
97
  return;
98
98
  }
99
+ // Suppress EPERM errors from Crawlee's async lock-file operations that fire
100
+ // after a crawl phase finishes. On Windows, the sitemap phase's async lock
101
+ // cleanup can race with the domain phase starting in the same directory.
102
+ if (err.message?.includes('EPERM') && err.message?.includes('.json.lock')) {
103
+ consoleLogger.info(`Suppressed Crawlee lock-file EPERM (stale async cleanup): ${err.message}`);
104
+ return;
105
+ }
99
106
  throw err;
100
107
  };
101
108
  process.on('uncaughtException', psTreeHandler);
@@ -186,6 +193,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
186
193
  strategy,
187
194
  userUrl: url,
188
195
  scanDuration,
196
+ ruleset,
189
197
  });
190
198
  urlsCrawledObj = sitemapResult.urlsCrawled;
191
199
  durationExceeded = sitemapResult.durationExceeded;
@@ -236,6 +244,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
236
244
  extraHTTPHeaders,
237
245
  safeMode,
238
246
  scanDuration,
247
+ ruleset,
239
248
  );
240
249
  urlsCrawledObj = intelligentResult.urlsCrawled;
241
250
  durationExceeded = intelligentResult.durationExceeded;
@@ -2157,6 +2157,15 @@ export const getPlaywrightLaunchOptions = (browser?: string): LaunchOptions => {
2157
2157
  !(browser === BrowserTypes.CHROME && arg === '--edge-skip-compat-layer-relaunch'),
2158
2158
  );
2159
2159
 
2160
+ // Cap browser disk cache to 10MB per instance to prevent storage bloat
2161
+ // during long crawls with multiple pool rotations
2162
+ finalArgs.push('--disk-cache-size=10485760');
2163
+
2164
+ // Prevent Windows from throttling background Chromium processes
2165
+ if (os.platform() === 'win32') {
2166
+ finalArgs.push('--disable-features=UseEcoQoSForBackgroundProcess');
2167
+ }
2168
+
2160
2169
  // Headless flags (unchanged)
2161
2170
  if (process.env.CRAWLEE_HEADLESS === '1') {
2162
2171
  if (!finalArgs.includes('--mute-audio')) finalArgs.push('--mute-audio');
@@ -30,10 +30,27 @@ export const blackListedFileExtensions = [
30
30
  'svg',
31
31
  'gif',
32
32
  'woff',
33
+ 'woff2',
33
34
  'zip',
34
35
  'webp',
35
36
  'json',
36
37
  'xml',
38
+ 'ico',
39
+ 'bmp',
40
+ 'tiff',
41
+ 'tif',
42
+ 'avi',
43
+ 'mov',
44
+ 'wmv',
45
+ 'flv',
46
+ 'ogg',
47
+ 'wav',
48
+ 'doc',
49
+ 'docx',
50
+ 'xls',
51
+ 'xlsx',
52
+ 'ppt',
53
+ 'pptx',
37
54
  ];
38
55
 
39
56
  export const getIntermediateScreenshotsPath = (datasetsPath: string): string =>
@@ -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,53 +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, best-effort clone profile data from base directory
1234
- // so authenticated sessions are preserved across browser pool retirements.
1235
- if (launchCount > 1) {
1236
- const skipDirs = new Set([
1237
- 'Singleton', 'lockfile', 'LOCK',
1238
- 'Cache', 'Code Cache', 'GPUCache', 'DawnGraphiteCache', 'DawnWebGPUCache',
1239
- 'Service Worker', 'ScriptCache', 'ShaderCache', 'GrShaderCache',
1240
- 'component_crx_cache', 'optimization_guide_model_store',
1241
- 'BrowserMetrics', 'Crashpad', 'FileTypePolicies',
1242
- ]);
1243
- try {
1244
- const copyRecursive = async (src: string, dest: string) => {
1245
- const stat = await fsp.stat(src).catch(() => null);
1246
- if (!stat) return;
1247
- if (stat.isDirectory()) {
1248
- await fsp.mkdir(dest, { recursive: true }).catch(() => {});
1249
- const entries = await fsp.readdir(src).catch(() => []);
1250
- await Promise.all(
1251
- entries
1252
- .filter(entry => !entry.startsWith('Singleton') && !skipDirs.has(entry))
1253
- .map(entry =>
1254
- copyRecursive(path.join(src, entry), path.join(dest, entry)).catch(() => {}),
1255
- ),
1256
- );
1257
- } else {
1258
- await fsp.copyFile(src, dest).catch(() => {});
1259
- }
1260
- };
1261
- await copyRecursive(userDataDirectory, effectiveDir).catch(() => {});
1262
- } catch {
1263
- // Silent fallback: use empty profile if clone fails
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(() => {});
1264
1256
  }
1257
+
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
+ );
1262
+
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(() => {});
1267
+
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
+ );
1297
+ }
1298
+ }
1299
+ } catch {
1300
+ // Silent fallback: use empty profile if clone fails
1265
1301
  }
1266
1302
 
1267
1303
  // Clean any stale lock files that may block browser launches on Windows
@@ -1275,11 +1311,38 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
1275
1311
  ];
1276
1312
  await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => {})));
1277
1313
 
1314
+ previousPoolDir = effectiveDir;
1278
1315
  // eslint-disable-next-line no-param-reassign
1279
1316
  launchContext.userDataDir = effectiveDir;
1280
1317
  };
1281
1318
  };
1282
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
+
1283
1346
  export const failedRequestHandler = async ({ request }: { request: Request }) => {
1284
1347
  guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
1285
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,
@@ -382,8 +383,11 @@ const crawlDomain = async ({
382
383
  };
383
384
 
384
385
  let isAbortingScanNow = false;
386
+ const remainingBudget = fromCrawlIntelligentSitemap
387
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
388
+ : maxRequestsPerCrawl;
385
389
  const rateController = new CrawlRateController(
386
- maxRequestsPerCrawl,
390
+ remainingBudget,
387
391
  specifiedMaxConcurrency || constants.maxConcurrency,
388
392
  );
389
393
 
@@ -398,6 +402,8 @@ const crawlDomain = async ({
398
402
  retryOnBlocked: true,
399
403
  browserPoolOptions: {
400
404
  useFingerprints: false,
405
+ retireBrowserAfterPageCount: 500,
406
+ closeInactiveBrowserAfterSecs: 30,
401
407
  preLaunchHooks: [
402
408
  getPreLaunchHook(userDataDirectory),
403
409
  async (_pageId, launchContext) => {
@@ -413,55 +419,73 @@ const crawlDomain = async ({
413
419
  };
414
420
  },
415
421
  ],
422
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
416
423
  },
417
424
  requestQueue,
418
425
  maxRequestRetries: 3,
419
426
  maxSessionRotations: 1,
420
427
  preNavigationHooks: [
421
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
+ },
422
439
  ],
423
440
  postNavigationHooks: [
424
441
  async crawlingContext => {
425
442
  const { page, request } = crawlingContext;
426
443
 
427
- await page.evaluate(() => {
428
- return new Promise(resolve => {
429
- let timeout;
430
- let mutationCount = 0;
431
- const MAX_MUTATIONS = 500; // stop if things never quiet down
432
- const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
433
-
434
- const observer = new MutationObserver(() => {
435
- clearTimeout(timeout);
436
-
437
- mutationCount += 1;
438
- if (mutationCount > MAX_MUTATIONS) {
439
- observer.disconnect();
440
- resolve('Too many mutations, exiting.');
441
- return;
442
- }
444
+ try {
445
+ await page.evaluate(() => {
446
+ return new Promise(resolve => {
447
+ let timeout;
448
+ let mutationCount = 0;
449
+ const MAX_MUTATIONS = 500; // stop if things never quiet down
450
+ const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
451
+
452
+ const observer = new MutationObserver(() => {
453
+ clearTimeout(timeout);
454
+
455
+ mutationCount += 1;
456
+ if (mutationCount > MAX_MUTATIONS) {
457
+ observer.disconnect();
458
+ resolve('Too many mutations, exiting.');
459
+ return;
460
+ }
461
+
462
+ // restart quiet‑period timer
463
+ timeout = setTimeout(() => {
464
+ observer.disconnect();
465
+ resolve('DOM stabilized.');
466
+ }, 1000);
467
+ });
443
468
 
444
- // restart quiet‑period timer
469
+ // overall timeout in case the page never settles
445
470
  timeout = setTimeout(() => {
446
471
  observer.disconnect();
447
- resolve('DOM stabilized.');
448
- }, 1000);
472
+ resolve('Observer timeout reached.');
473
+ }, OBSERVER_TIMEOUT);
474
+
475
+ const root = document.documentElement || document.body || document;
476
+ if (!root || typeof observer.observe !== 'function') {
477
+ resolve('No root node to observe.');
478
+ } else {
479
+ observer.observe(root, { childList: true, subtree: true });
480
+ }
449
481
  });
450
-
451
- // overall timeout in case the page never settles
452
- timeout = setTimeout(() => {
453
- observer.disconnect();
454
- resolve('Observer timeout reached.');
455
- }, OBSERVER_TIMEOUT);
456
-
457
- const root = document.documentElement || document.body || document;
458
- if (!root || typeof observer.observe !== 'function') {
459
- resolve('No root node to observe.');
460
- } else {
461
- observer.observe(root, { childList: true, subtree: true });
462
- }
463
482
  });
464
- });
483
+ } catch (err) {
484
+ if (err.message?.includes('was destroyed')) {
485
+ return;
486
+ }
487
+ throw err;
488
+ }
465
489
 
466
490
  let finalUrl = page.url();
467
491
  const requestLabelUrl = request.label;
@@ -543,21 +567,17 @@ const crawlDomain = async ({
543
567
  (request.skipNavigation && actualUrl === 'about:blank')
544
568
  ) {
545
569
  if (!isScanPdfs) {
546
- // Don't inform the user it is skipped since web crawler is best-effort.
547
- /*
548
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
549
- numScanned: urlsCrawled.scanned.length,
550
- urlScanned: request.url,
551
- });
552
- urlsCrawled.userExcluded.push({
553
- url: request.url,
554
- pageTitle: request.url,
555
- actualUrl: request.url, // because about:blank is not useful
556
- metadata: STATUS_CODE_METADATA[1],
557
- httpStatusCode: 0,
558
- });
559
- */
560
-
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
+ });
561
581
  return;
562
582
  }
563
583
  const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(
@@ -573,20 +593,17 @@ const crawlDomain = async ({
573
593
  }
574
594
 
575
595
  if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
576
- // Don't inform the user it is skipped since web crawler is best-effort.
577
- /*
578
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
579
- numScanned: urlsCrawled.scanned.length,
580
- urlScanned: request.url,
581
- });
582
- urlsCrawled.userExcluded.push({
583
- url: request.url,
584
- pageTitle: request.url,
585
- actualUrl, // because about:blank is not useful
586
- metadata: STATUS_CODE_METADATA[1],
587
- httpStatusCode: 0,
588
- });
589
- */
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
+ });
590
607
  return;
591
608
  }
592
609
 
@@ -788,7 +805,59 @@ const crawlDomain = async ({
788
805
  return;
789
806
  }
790
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
+
791
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
+
792
861
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
793
862
  consoleLogger.info(
794
863
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -832,7 +901,9 @@ const crawlDomain = async ({
832
901
 
833
902
  // Additional passes: keep re-visiting scanned seed-hostname pages for
834
903
  // click-discovery until no new pages are found or limits are reached.
835
- 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) {
836
907
  const seedHostname = new URL(url).hostname;
837
908
  const clickPassVisited = new Set<string>();
838
909
  let prevScannedCount: number;