@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.
@@ -1,6 +1,6 @@
1
1
  import crawlee from 'crawlee';
2
2
  import { CrawlRateController } from './crawlRateController.js';
3
- import { createCrawleeSubFolders, getPreLaunchHook, preNavigationHooks, runAxeScript, isUrlPdf, shouldSkipClickDueToDisallowedHref, shouldSkipDueToUnsupportedContent, splitAuthHeaders, } from './commonCrawlerFunc.js';
3
+ import { createCrawleeSubFolders, getPreLaunchHook, getPostPageCloseHook, preNavigationHooks, runAxeScript, isUrlPdf, shouldSkipClickDueToDisallowedHref, shouldSkipDueToUnsupportedContent, splitAuthHeaders, } from './commonCrawlerFunc.js';
4
4
  import constants, { blackListedFileExtensions, guiInfoStatusTypes, cssQuerySelectors, STATUS_CODE_METADATA, disallowedListOfPatterns, disallowedSelectorPatterns, FileTypes, } from '../constants/constants.js';
5
5
  import { getPlaywrightLaunchOptions, isBlacklistedFileExtensions, isSkippedUrl, isDisallowedInRobotsTxt, getUrlsFromRobotsTxt, waitForPageLoaded, } from '../constants/common.js';
6
6
  import { areLinksEqual, isFollowStrategy, isSameHostname, normUrl, register } from '../utils.js';
@@ -274,7 +274,10 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
274
274
  }
275
275
  };
276
276
  let isAbortingScanNow = false;
277
- const rateController = new CrawlRateController(maxRequestsPerCrawl, specifiedMaxConcurrency || constants.maxConcurrency);
277
+ const remainingBudget = fromCrawlIntelligentSitemap
278
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
279
+ : maxRequestsPerCrawl;
280
+ const rateController = new CrawlRateController(remainingBudget, specifiedMaxConcurrency || constants.maxConcurrency);
278
281
  const { nonAuthHeaders, httpCredentials } = splitAuthHeaders(extraHTTPHeaders);
279
282
  const crawler = register(new crawlee.PlaywrightCrawler({
280
283
  launchContext: {
@@ -284,6 +287,8 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
284
287
  retryOnBlocked: true,
285
288
  browserPoolOptions: {
286
289
  useFingerprints: false,
290
+ retireBrowserAfterPageCount: 500,
291
+ closeInactiveBrowserAfterSecs: 30,
287
292
  preLaunchHooks: [
288
293
  getPreLaunchHook(userDataDirectory),
289
294
  async (_pageId, launchContext) => {
@@ -299,50 +304,70 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
299
304
  };
300
305
  },
301
306
  ],
307
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
302
308
  },
303
309
  requestQueue,
304
310
  maxRequestRetries: 3,
305
311
  maxSessionRotations: 1,
306
312
  preNavigationHooks: [
307
313
  ...preNavigationHooks(extraHTTPHeaders),
314
+ async ({ request }) => {
315
+ const url = request.url.toLowerCase();
316
+ try {
317
+ const pathname = new URL(url).pathname;
318
+ const ext = pathname.split('.').pop();
319
+ if (ext && blackListedFileExtensions.includes(ext)) {
320
+ request.skipNavigation = true;
321
+ }
322
+ }
323
+ catch { }
324
+ },
308
325
  ],
309
326
  postNavigationHooks: [
310
327
  async (crawlingContext) => {
311
328
  const { page, request } = crawlingContext;
312
- await page.evaluate(() => {
313
- return new Promise(resolve => {
314
- let timeout;
315
- let mutationCount = 0;
316
- const MAX_MUTATIONS = 500; // stop if things never quiet down
317
- const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
318
- const observer = new MutationObserver(() => {
319
- clearTimeout(timeout);
320
- mutationCount += 1;
321
- if (mutationCount > MAX_MUTATIONS) {
322
- observer.disconnect();
323
- resolve('Too many mutations, exiting.');
324
- return;
325
- }
326
- // restart quiet‑period timer
329
+ try {
330
+ await page.evaluate(() => {
331
+ return new Promise(resolve => {
332
+ let timeout;
333
+ let mutationCount = 0;
334
+ const MAX_MUTATIONS = 500; // stop if things never quiet down
335
+ const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
336
+ const observer = new MutationObserver(() => {
337
+ clearTimeout(timeout);
338
+ mutationCount += 1;
339
+ if (mutationCount > MAX_MUTATIONS) {
340
+ observer.disconnect();
341
+ resolve('Too many mutations, exiting.');
342
+ return;
343
+ }
344
+ // restart quiet‑period timer
345
+ timeout = setTimeout(() => {
346
+ observer.disconnect();
347
+ resolve('DOM stabilized.');
348
+ }, 1000);
349
+ });
350
+ // overall timeout in case the page never settles
327
351
  timeout = setTimeout(() => {
328
352
  observer.disconnect();
329
- resolve('DOM stabilized.');
330
- }, 1000);
353
+ resolve('Observer timeout reached.');
354
+ }, OBSERVER_TIMEOUT);
355
+ const root = document.documentElement || document.body || document;
356
+ if (!root || typeof observer.observe !== 'function') {
357
+ resolve('No root node to observe.');
358
+ }
359
+ else {
360
+ observer.observe(root, { childList: true, subtree: true });
361
+ }
331
362
  });
332
- // overall timeout in case the page never settles
333
- timeout = setTimeout(() => {
334
- observer.disconnect();
335
- resolve('Observer timeout reached.');
336
- }, OBSERVER_TIMEOUT);
337
- const root = document.documentElement || document.body || document;
338
- if (!root || typeof observer.observe !== 'function') {
339
- resolve('No root node to observe.');
340
- }
341
- else {
342
- observer.observe(root, { childList: true, subtree: true });
343
- }
344
363
  });
345
- });
364
+ }
365
+ catch (err) {
366
+ if (err.message?.includes('was destroyed')) {
367
+ return;
368
+ }
369
+ throw err;
370
+ }
346
371
  let finalUrl = page.url();
347
372
  const requestLabelUrl = request.label;
348
373
  // to handle scenario where the redirected link is not within the scanning website
@@ -402,20 +427,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
402
427
  if (shouldSkipDueToUnsupportedContent(response, request.url) ||
403
428
  (request.skipNavigation && actualUrl === 'about:blank')) {
404
429
  if (!isScanPdfs) {
405
- // Don't inform the user it is skipped since web crawler is best-effort.
406
- /*
407
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
408
- numScanned: urlsCrawled.scanned.length,
409
- urlScanned: request.url,
410
- });
411
- urlsCrawled.userExcluded.push({
412
- url: request.url,
413
- pageTitle: request.url,
414
- actualUrl: request.url, // because about:blank is not useful
415
- metadata: STATUS_CODE_METADATA[1],
416
- httpStatusCode: 0,
417
- });
418
- */
430
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
431
+ numScanned: urlsCrawled.scanned.length,
432
+ urlScanned: request.url,
433
+ });
434
+ urlsCrawled.userExcluded.push({
435
+ url: request.url,
436
+ pageTitle: request.url,
437
+ actualUrl: request.url,
438
+ metadata: STATUS_CODE_METADATA[1],
439
+ httpStatusCode: 1,
440
+ });
419
441
  return;
420
442
  }
421
443
  const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(randomToken, pdfDownloads, request, sendRequest, urlsCrawled);
@@ -423,20 +445,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
423
445
  return;
424
446
  }
425
447
  if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
426
- // Don't inform the user it is skipped since web crawler is best-effort.
427
- /*
428
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
429
- numScanned: urlsCrawled.scanned.length,
430
- urlScanned: request.url,
431
- });
432
- urlsCrawled.userExcluded.push({
433
- url: request.url,
434
- pageTitle: request.url,
435
- actualUrl, // because about:blank is not useful
436
- metadata: STATUS_CODE_METADATA[1],
437
- httpStatusCode: 0,
438
- });
439
- */
448
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
449
+ numScanned: urlsCrawled.scanned.length,
450
+ urlScanned: request.url,
451
+ });
452
+ urlsCrawled.userExcluded.push({
453
+ url: request.url,
454
+ pageTitle: request.url,
455
+ actualUrl,
456
+ metadata: STATUS_CODE_METADATA[1],
457
+ httpStatusCode: 1,
458
+ });
440
459
  return;
441
460
  }
442
461
  if (!isFollowStrategy(url, actualUrl, strategy) &&
@@ -621,7 +640,57 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
621
640
  if (isAbortingScanNow) {
622
641
  return;
623
642
  }
643
+ // Handle download-triggered navigation errors: Playwright throws
644
+ // "Download is starting" when page.goto() hits a file download URL.
645
+ // Crawlee retries 3 times (all fail) then lands here.
646
+ const isDownloadError = request.errorMessages?.some((msg) => msg.includes('Download is starting'));
647
+ if (isDownloadError) {
648
+ if (isScanPdfs) {
649
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
650
+ try {
651
+ await requestQueue.addRequest({
652
+ url: request.url,
653
+ skipNavigation: true,
654
+ label: request.url,
655
+ uniqueKey: `download_${request.url}`,
656
+ });
657
+ }
658
+ catch { }
659
+ }
660
+ else {
661
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
662
+ numScanned: urlsCrawled.scanned.length,
663
+ urlScanned: request.url,
664
+ });
665
+ urlsCrawled.userExcluded.push({
666
+ url: request.url,
667
+ pageTitle: request.url,
668
+ actualUrl: request.url,
669
+ metadata: STATUS_CODE_METADATA[1],
670
+ httpStatusCode: 1,
671
+ });
672
+ }
673
+ return;
674
+ }
624
675
  const status = response?.status();
676
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
677
+ // Without this, URLs that fail during a rate-limit burst are permanently lost
678
+ // even though the site is accessible at lower concurrency.
679
+ // Don't call onFailure here — the re-enqueued request will get a fresh attempt.
680
+ // If it fails again (rateLimitRetried=true), it falls through to the normal
681
+ // onFailure + circuit breaker path below.
682
+ if (status === 403 && !request.userData?.rateLimitRetried) {
683
+ try {
684
+ await requestQueue.addRequest({
685
+ url: request.url,
686
+ label: request.url,
687
+ uniqueKey: `ratelimit_${request.url}`,
688
+ userData: { rateLimitRetried: true },
689
+ });
690
+ }
691
+ catch { }
692
+ return;
693
+ }
625
694
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
626
695
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
627
696
  isAbortingScanNow = true;
@@ -656,7 +725,9 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
656
725
  await crawler.run();
657
726
  // Additional passes: keep re-visiting scanned seed-hostname pages for
658
727
  // click-discovery until no new pages are found or limits are reached.
659
- if (!safeMode && !isAbortingScanNow && !durationExceeded) {
728
+ // Skip when called from intelligent sitemap — the domain phase is only meant
729
+ // to discover new pages via <a> links, not re-click 3000+ already-scanned pages.
730
+ if (!safeMode && !isAbortingScanNow && !durationExceeded && !fromCrawlIntelligentSitemap) {
660
731
  const seedHostname = new URL(url).hostname;
661
732
  const clickPassVisited = new Set();
662
733
  let prevScannedCount;
@@ -5,7 +5,7 @@ import crawlDomain from './crawlDomain.js';
5
5
  import crawlSitemap from './crawlSitemap.js';
6
6
  import { getPlaywrightLaunchOptions, getSitemapsFromRobotsTxt, initModifiedUserAgent } from '../constants/common.js';
7
7
  import { register } from '../utils.js';
8
- const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration) => {
8
+ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration, ruleset = []) => {
9
9
  const startTime = Date.now(); // Track start time
10
10
  let urlsCrawledFinal;
11
11
  const urlsCrawled = { ...constants.urlsCrawledObj };
@@ -153,6 +153,7 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
153
153
  urlsCrawledFromIntelligent: urlsCrawled,
154
154
  crawledFromLocalFile: false,
155
155
  scanDuration: scanDuration > 0 ? remainingDuration : 0,
156
+ ruleset,
156
157
  });
157
158
  }
158
159
  const elapsed = Date.now() - startTime;
@@ -180,6 +181,7 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
180
181
  datasetFromIntelligent: dataset,
181
182
  urlsCrawledFromIntelligent: urlsCrawled,
182
183
  scanDuration: remainingScanDuration,
184
+ ruleset,
183
185
  });
184
186
  }
185
187
  else if (!hasDurationRemaining) {
@@ -69,6 +69,7 @@ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings,
69
69
  datasetFromIntelligent,
70
70
  urlsCrawledFromIntelligent,
71
71
  crawledFromLocalFile: true,
72
+ ruleset,
72
73
  });
73
74
  urlsCrawled = { ...urlsCrawled, ...updatedUrlsCrawled };
74
75
  return urlsCrawled;
@@ -26,12 +26,9 @@ export class CrawlRateController {
26
26
  }
27
27
  }
28
28
  onFailure(httpStatus, pool) {
29
- if (typeof httpStatus !== 'number' || httpStatus < 400) {
30
- return false;
31
- }
32
29
  this.consecutiveSuccesses = 0;
33
30
  this.consecutiveFailures++;
34
- if (pool && pool.maxConcurrency > 1) {
31
+ if (typeof httpStatus === 'number' && httpStatus >= 400 && pool && pool.maxConcurrency > 1) {
35
32
  pool.maxConcurrency = Math.max(1, Math.floor(pool.maxConcurrency / 2));
36
33
  consoleLogger.info(`Rate limited (HTTP ${httpStatus}) — reducing concurrency to ${pool.maxConcurrency}`);
37
34
  }
@@ -1,18 +1,21 @@
1
1
  import crawlee, { EnqueueStrategy, RequestList } from 'crawlee';
2
2
  import { CrawlRateController } from './crawlRateController.js';
3
- import { createCrawleeSubFolders, getPreLaunchHook, preNavigationHooks, runAxeScript, splitAuthHeaders, } from './commonCrawlerFunc.js';
4
- import constants, { STATUS_CODE_METADATA, guiInfoStatusTypes, disallowedListOfPatterns, FileTypes, } from '../constants/constants.js';
5
- import { getLinksFromSitemap, getPlaywrightLaunchOptions, isSkippedUrl, waitForPageLoaded, isFilePath, } from '../constants/common.js';
3
+ import { createCrawleeSubFolders, getPreLaunchHook, getPostPageCloseHook, preNavigationHooks, runAxeScript, isUrlPdf, splitAuthHeaders, } from './commonCrawlerFunc.js';
4
+ import constants, { STATUS_CODE_METADATA, guiInfoStatusTypes, blackListedFileExtensions, disallowedListOfPatterns, disallowedSelectorPatterns, FileTypes, } from '../constants/constants.js';
5
+ import { getLinksFromSitemap, getPlaywrightLaunchOptions, isDisallowedInRobotsTxt, isSkippedUrl, waitForPageLoaded, isFilePath, } from '../constants/common.js';
6
6
  import { areLinksEqual, isFollowStrategy, isWhitelistedContentType, normUrl, register } from '../utils.js';
7
7
  import { handlePdfDownload, runPdfScan, mapPdfScanResults, doPdfScreenshots, } from './pdfScanFunc.js';
8
8
  import { consoleLogger, guiInfoLog } from '../logs.js';
9
- const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, strategy = EnqueueStrategy.All, userUrl = '', scanDuration = 0, fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, crawledFromLocalFile = false, }) => {
9
+ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, strategy = EnqueueStrategy.All, userUrl = '', scanDuration = 0, fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, crawledFromLocalFile = false, ruleset = [], }) => {
10
10
  const crawlStartTime = Date.now();
11
11
  let dataset;
12
12
  let urlsCrawled;
13
13
  let durationExceeded = false;
14
14
  let isAbortingScan = false;
15
- const rateController = new CrawlRateController(maxRequestsPerCrawl, specifiedMaxConcurrency || constants.maxConcurrency);
15
+ const remainingBudget = fromCrawlIntelligentSitemap
16
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
17
+ : maxRequestsPerCrawl;
18
+ const rateController = new CrawlRateController(remainingBudget, specifiedMaxConcurrency || constants.maxConcurrency);
16
19
  const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
17
20
  if (fromCrawlIntelligentSitemap) {
18
21
  dataset = datasetFromIntelligent;
@@ -38,6 +41,11 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
38
41
  const requestList = await RequestList.open({
39
42
  sources: linksFromSitemap,
40
43
  });
44
+ // Always create a request queue alongside the request list. An empty queue
45
+ // has zero impact on crawl behavior (Crawlee processes RequestList first).
46
+ // Having it available enables: download re-enqueue for PDF scanning,
47
+ // 403 rate-limit retry, and enqueueLinks for intelligent sitemap discovery.
48
+ const { requestQueue } = await createCrawleeSubFolders(randomToken);
41
49
  const crawler = register(new crawlee.PlaywrightCrawler({
42
50
  launchContext: {
43
51
  launcher: constants.launcher,
@@ -46,6 +54,8 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
46
54
  retryOnBlocked: true,
47
55
  browserPoolOptions: {
48
56
  useFingerprints: false,
57
+ retireBrowserAfterPageCount: 500,
58
+ closeInactiveBrowserAfterSecs: 30,
49
59
  preLaunchHooks: [
50
60
  getPreLaunchHook(userDataDirectory),
51
61
  async (_pageId, launchContext) => {
@@ -60,8 +70,10 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
60
70
  };
61
71
  },
62
72
  ],
73
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
63
74
  },
64
75
  requestList,
76
+ requestQueue,
65
77
  maxRequestRetries: 3,
66
78
  maxSessionRotations: 1,
67
79
  postNavigationHooks: [
@@ -97,6 +109,9 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
97
109
  if (!root || typeof observer.observe !== 'function') {
98
110
  resolve('No root node to observe.');
99
111
  }
112
+ else {
113
+ observer.observe(root, { childList: true, subtree: true });
114
+ }
100
115
  });
101
116
  });
102
117
  }
@@ -117,14 +132,22 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
117
132
  if (isNotSupportedDocument) {
118
133
  request.skipNavigation = true;
119
134
  request.userData.isNotSupportedDocument = true;
120
- // Log for verification (optional, but not required for correctness)
121
- // console.log(`[SKIP] Not supported: ${request.url}`);
122
135
  return;
123
136
  }
137
+ try {
138
+ const pathname = new URL(url).pathname;
139
+ const ext = pathname.split('.').pop();
140
+ if (ext && blackListedFileExtensions.includes(ext)) {
141
+ request.skipNavigation = true;
142
+ request.userData.isNotSupportedDocument = true;
143
+ return;
144
+ }
145
+ }
146
+ catch { }
124
147
  },
125
148
  ],
126
149
  requestHandlerTimeoutSecs: 90,
127
- requestHandler: async ({ page, request, response, sendRequest }) => {
150
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
128
151
  // Log documents that are not supported
129
152
  if (request.userData?.isNotSupportedDocument) {
130
153
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -209,7 +232,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
209
232
  });
210
233
  return;
211
234
  }
212
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
235
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
213
236
  // Detect JS redirects that fire during/after axe scan.
214
237
  // Listen for navigation, then give a brief window for pending redirects to complete.
215
238
  try {
@@ -260,6 +283,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
260
283
  results.url = request.url;
261
284
  results.actualUrl = actualUrl;
262
285
  await dataset.pushData(results);
286
+ // Discover <a> links from this page for the intelligent sitemap flow.
287
+ // This eliminates the need for a separate crawlDomain supplement phase
288
+ // that would re-visit all these pages just to extract links.
289
+ if (fromCrawlIntelligentSitemap) {
290
+ try {
291
+ await enqueueLinks({
292
+ selector: `a:not(${disallowedSelectorPatterns})`,
293
+ strategy,
294
+ requestQueue,
295
+ transformRequestFunction: (req) => {
296
+ try {
297
+ req.url = req.url.replace(/(?<=&|\?)utm_.*?(&|$)/gim, '');
298
+ }
299
+ catch { }
300
+ if (isDisallowedInRobotsTxt(req.url))
301
+ return null;
302
+ if (isUrlPdf(req.url)) {
303
+ req.skipNavigation = true;
304
+ }
305
+ req.label = req.url;
306
+ return req;
307
+ },
308
+ });
309
+ }
310
+ catch {
311
+ // Best-effort link discovery; don't fail the scan
312
+ }
313
+ }
263
314
  }
264
315
  }
265
316
  else {
@@ -268,18 +319,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
268
319
  urlScanned: request.url,
269
320
  });
270
321
  if (isScanHtml) {
271
- // carry through the HTTP status metadata
272
- const status = response?.status();
273
- const metadata = typeof status === 'number'
274
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
275
- : STATUS_CODE_METADATA[2];
276
- urlsCrawled.invalid.push({
277
- actualUrl,
278
- url: request.url,
279
- pageTitle: request.url,
280
- metadata,
281
- httpStatusCode: typeof status === 'number' ? status : 0,
282
- });
322
+ // Non-HTML content types (images, PDFs, binary files) and URLs
323
+ // that redirect to non-HTML resources should be classified as
324
+ // unsupported documents, not generic page errors.
325
+ const isNonHtmlContent = contentType &&
326
+ !contentType.startsWith('text/html') &&
327
+ !contentType.includes('html');
328
+ if (isNonHtmlContent && status !== 0) {
329
+ urlsCrawled.userExcluded.push({
330
+ actualUrl,
331
+ url: request.url,
332
+ pageTitle: request.url,
333
+ metadata: STATUS_CODE_METADATA[1],
334
+ httpStatusCode: 1,
335
+ });
336
+ }
337
+ else {
338
+ const httpStatus = response?.status();
339
+ const metadata = typeof httpStatus === 'number'
340
+ ? STATUS_CODE_METADATA[httpStatus] || STATUS_CODE_METADATA[599]
341
+ : STATUS_CODE_METADATA[2];
342
+ urlsCrawled.invalid.push({
343
+ actualUrl,
344
+ url: request.url,
345
+ pageTitle: request.url,
346
+ metadata,
347
+ httpStatusCode: typeof httpStatus === 'number' ? httpStatus : 0,
348
+ });
349
+ }
283
350
  }
284
351
  }
285
352
  }
@@ -294,7 +361,53 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
294
361
  if (isAbortingScan) {
295
362
  return;
296
363
  }
364
+ // Handle download-triggered navigation errors: Playwright throws
365
+ // "Download is starting" when page.goto() hits a file download URL.
366
+ const isDownloadError = request.errorMessages?.some((msg) => msg.includes('Download is starting'));
367
+ if (isDownloadError) {
368
+ if (isScanPdfs) {
369
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
370
+ try {
371
+ await requestQueue.addRequest({
372
+ url: request.url,
373
+ skipNavigation: true,
374
+ label: request.url,
375
+ uniqueKey: `download_${request.url}`,
376
+ });
377
+ }
378
+ catch { }
379
+ }
380
+ else {
381
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
382
+ numScanned: urlsCrawled.scanned.length,
383
+ urlScanned: request.url,
384
+ });
385
+ urlsCrawled.userExcluded.push({
386
+ url: request.url,
387
+ pageTitle: request.url,
388
+ actualUrl: request.url,
389
+ metadata: STATUS_CODE_METADATA[1],
390
+ httpStatusCode: 1,
391
+ });
392
+ }
393
+ return;
394
+ }
297
395
  const status = response?.status();
396
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
397
+ // Don't call onFailure here — the re-enqueued request gets a fresh attempt.
398
+ // If it fails again (rateLimitRetried=true), it falls through to the normal path.
399
+ if (status === 403 && !request.userData?.rateLimitRetried) {
400
+ try {
401
+ await requestQueue.addRequest({
402
+ url: request.url,
403
+ label: request.url,
404
+ uniqueKey: `ratelimit_${request.url}`,
405
+ userData: { rateLimitRetried: true },
406
+ });
407
+ }
408
+ catch { }
409
+ return;
410
+ }
298
411
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
299
412
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
300
413
  isAbortingScan = true;
@@ -382,7 +382,11 @@ export async function flagUnlabelledClickableElements() {
382
382
  function isElementTooSmall(element) {
383
383
  // Get the bounding rectangle of the element
384
384
  const rect = element.getBoundingClientRect();
385
- // Check if the element has a valid width or height
385
+ // If either dimension is 0, the element is non-perceivable
386
+ if (rect.width === 0 || rect.height === 0) {
387
+ return true;
388
+ }
389
+ // Check if the element has a valid width and height
386
390
  if (rect.width > 0 || rect.height > 0) {
387
391
  return false; // Element is not too small
388
392
  }
@@ -831,7 +831,7 @@ generateJsonFiles = false, preferredBrowser) => {
831
831
  consoleLogger.info(`Dataset drop: ${error.message}`);
832
832
  }
833
833
  try {
834
- const requestQueue = await RequestQueue.open(crawleeDir);
834
+ const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
835
835
  await requestQueue.drop();
836
836
  }
837
837
  catch (error) {
@@ -841,6 +841,7 @@ generateJsonFiles = false, preferredBrowser) => {
841
841
  const crawleePath = path.join(storagePath, 'crawlee');
842
842
  try {
843
843
  await fs.promises.rm(crawleePath, { recursive: true, force: true });
844
+ await fs.promises.rm(`${crawleePath}_rq`, { recursive: true, force: true });
844
845
  }
845
846
  catch {
846
847
  // Best-effort; storage was already dropped via API