@govtechsg/oobee 0.10.98 → 0.11.1

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.98
6
+ * App version : 0.11.1
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
  *
@@ -34887,7 +34887,7 @@
34887
34887
  // ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
34888
34888
 
34889
34889
  var _oobeeSentryDsn = "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168";
34890
- var _oobeeAppVersion = "0.10.98";
34890
+ var _oobeeAppVersion = "0.11.1";
34891
34891
  var _oobeeSentryVersion = "10.58.0";
34892
34892
  var _oobeeSentryInitialized = false;
34893
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.98",
4
+ "version": "0.11.1",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
package/src/combine.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  getS3UploadPrefix,
19
19
  uploadFolderToS3,
20
20
  } from './services/s3Uploader.js';
21
+ import { writeManifest, resetCaptureEntries, isPageCaptureEnabled } from './crawlers/pageCapture.js';
21
22
 
22
23
  // Class exports
23
24
  export class ViewportSettingsClass {
@@ -75,6 +76,10 @@ const combineRun = async (details: Data, deviceToScan: string) => {
75
76
  process.env.CRAWLEE_STORAGE_DIR = randomToken;
76
77
  constants.sitemapFetchedLinks = null;
77
78
 
79
+ if (isPageCaptureEnabled() && !process.env.OOBEE_SCAN_PRODUCT) {
80
+ process.env.OOBEE_SCAN_PRODUCT = 'U&A';
81
+ }
82
+
78
83
  if (process.env.CRAWLEE_SYSTEM_INFO_V2 === undefined) {
79
84
  // Set the environment variable to enable system info v2
80
85
  // Resolves issue with when wmic is not installed on Windows
@@ -96,6 +101,13 @@ const combineRun = async (details: Data, deviceToScan: string) => {
96
101
  consoleLogger.info(`Suppressed Playwright post-close connection error: ${err.message}`);
97
102
  return;
98
103
  }
104
+ // Suppress EPERM errors from Crawlee's async lock-file operations that fire
105
+ // after a crawl phase finishes. On Windows, the sitemap phase's async lock
106
+ // cleanup can race with the domain phase starting in the same directory.
107
+ if (err.message?.includes('EPERM') && err.message?.includes('.json.lock')) {
108
+ consoleLogger.info(`Suppressed Crawlee lock-file EPERM (stale async cleanup): ${err.message}`);
109
+ return;
110
+ }
99
111
  throw err;
100
112
  };
101
113
  process.on('uncaughtException', psTreeHandler);
@@ -278,6 +290,8 @@ const combineRun = async (details: Data, deviceToScan: string) => {
278
290
  if (scanDetails.urlsCrawled) {
279
291
  if (scanDetails.urlsCrawled.scanned.length > 0) {
280
292
  await createAndUpdateResultsFolders(randomToken);
293
+ await writeManifest(randomToken);
294
+ resetCaptureEntries();
281
295
  const pagesNotScanned = [
282
296
  ...urlsCrawledObj.error,
283
297
  ...urlsCrawledObj.invalid,
@@ -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,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,
@@ -40,6 +41,7 @@ import {
40
41
  } from './pdfScanFunc.js';
41
42
  import { consoleLogger, guiInfoLog } from '../logs.js';
42
43
  import { ViewportSettingsClass } from '../combine.js';
44
+ import { capturePageData } from './pageCapture.js';
43
45
 
44
46
  const isBlacklisted = (url: string, blacklistedPatterns: string[]) => {
45
47
  if (!blacklistedPatterns) {
@@ -398,7 +400,7 @@ const crawlDomain = async ({
398
400
  launcher: constants.launcher,
399
401
  launchOptions: getPlaywrightLaunchOptions(browser),
400
402
  },
401
- retryOnBlocked: true,
403
+ retryOnBlocked: false,
402
404
  browserPoolOptions: {
403
405
  useFingerprints: false,
404
406
  retireBrowserAfterPageCount: 500,
@@ -418,12 +420,22 @@ const crawlDomain = async ({
418
420
  };
419
421
  },
420
422
  ],
423
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
421
424
  },
422
425
  requestQueue,
423
426
  maxRequestRetries: 3,
424
- 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
 
@@ -639,6 +644,21 @@ const crawlDomain = async ({
639
644
  }
640
645
 
641
646
  const responseStatus = response?.status();
647
+ if (responseStatus === 403) {
648
+ rateController.onFailure(responseStatus, activeCrawler.autoscaledPool);
649
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
650
+ numScanned: urlsCrawled.scanned.length,
651
+ urlScanned: request.url,
652
+ });
653
+ urlsCrawled.userExcluded.push({
654
+ url: request.url,
655
+ pageTitle: request.url,
656
+ actualUrl,
657
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
658
+ httpStatusCode: 403,
659
+ });
660
+ return;
661
+ }
642
662
  if (responseStatus && responseStatus >= 300) {
643
663
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
644
664
  numScanned: urlsCrawled.scanned.length,
@@ -656,6 +676,8 @@ const crawlDomain = async ({
656
676
 
657
677
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
658
678
 
679
+ await capturePageData(page, actualUrl, randomToken);
680
+
659
681
  // Detect JS redirects that fire during/after axe scan.
660
682
  // Listen for navigation, then give a brief window for pending redirects to complete.
661
683
  try {
@@ -800,7 +822,56 @@ const crawlDomain = async ({
800
822
  return;
801
823
  }
802
824
 
825
+ // Handle download-triggered navigation errors: Playwright throws
826
+ // "Download is starting" when page.goto() hits a file download URL.
827
+ // Crawlee retries 3 times (all fail) then lands here.
828
+ const isDownloadError = request.errorMessages?.some(
829
+ (msg: string) => msg.includes('Download is starting'),
830
+ );
831
+ if (isDownloadError) {
832
+ if (isScanPdfs) {
833
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
834
+ try {
835
+ await requestQueue.addRequest({
836
+ url: request.url,
837
+ skipNavigation: true,
838
+ label: request.url,
839
+ uniqueKey: `download_${request.url}`,
840
+ });
841
+ } catch {}
842
+ } else {
843
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
844
+ numScanned: urlsCrawled.scanned.length,
845
+ urlScanned: request.url,
846
+ });
847
+ urlsCrawled.userExcluded.push({
848
+ url: request.url,
849
+ pageTitle: request.url,
850
+ actualUrl: request.url,
851
+ metadata: STATUS_CODE_METADATA[1],
852
+ httpStatusCode: 1,
853
+ });
854
+ }
855
+ return;
856
+ }
857
+
803
858
  const status = response?.status();
859
+
860
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
861
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
862
+ if (status === 403 && !request.userData?.rateLimitRetried) {
863
+ rateController.onFailure(status, crawler.autoscaledPool);
864
+ try {
865
+ await requestQueue.addRequest({
866
+ url: request.url,
867
+ label: request.url,
868
+ uniqueKey: `ratelimit_${request.url}`,
869
+ userData: { rateLimitRetried: true },
870
+ });
871
+ } catch {}
872
+ return;
873
+ }
874
+
804
875
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
805
876
  consoleLogger.info(
806
877
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -844,7 +915,9 @@ const crawlDomain = async ({
844
915
 
845
916
  // Additional passes: keep re-visiting scanned seed-hostname pages for
846
917
  // click-discovery until no new pages are found or limits are reached.
847
- if (!safeMode && !isAbortingScanNow && !durationExceeded) {
918
+ // Skip when called from intelligent sitemap — the domain phase is only meant
919
+ // to discover new pages via <a> links, not re-click 3000+ already-scanned pages.
920
+ if (!safeMode && !isAbortingScanNow && !durationExceeded && !fromCrawlIntelligentSitemap) {
848
921
  const seedHostname = new URL(url).hostname;
849
922
  const clickPassVisited = new Set<string>();
850
923
  let prevScannedCount: number;
@@ -21,6 +21,7 @@ import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.j
21
21
  import { guiInfoLog } from '../logs.js';
22
22
  import crawlSitemap from './crawlSitemap.js';
23
23
  import { getPdfStoragePath, getStoragePath, register } from '../utils.js';
24
+ import { capturePageData } from './pageCapture.js';
24
25
 
25
26
  export const crawlLocalFile = async ({
26
27
  url,
@@ -186,6 +187,8 @@ export const crawlLocalFile = async ({
186
187
 
187
188
  const actualUrl = page.url() || request.loadedUrl || url;
188
189
 
190
+ await capturePageData(page, actualUrl, randomToken);
191
+
189
192
  guiInfoLog(guiInfoStatusTypes.SCANNED, {
190
193
  numScanned: urlsCrawled.scanned.length,
191
194
  urlScanned: url,