@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.
@@ -1,11 +1,12 @@
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';
7
7
  import { handlePdfDownload, runPdfScan, mapPdfScanResults, doPdfScreenshots, } from './pdfScanFunc.js';
8
8
  import { consoleLogger, guiInfoLog } from '../logs.js';
9
+ import { capturePageData } from './pageCapture.js';
9
10
  const isBlacklisted = (url, blacklistedPatterns) => {
10
11
  if (!blacklistedPatterns) {
11
12
  return false;
@@ -284,7 +285,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
284
285
  launcher: constants.launcher,
285
286
  launchOptions: getPlaywrightLaunchOptions(browser),
286
287
  },
287
- retryOnBlocked: true,
288
+ retryOnBlocked: false,
288
289
  browserPoolOptions: {
289
290
  useFingerprints: false,
290
291
  retireBrowserAfterPageCount: 500,
@@ -304,12 +305,23 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
304
305
  };
305
306
  },
306
307
  ],
308
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
307
309
  },
308
310
  requestQueue,
309
311
  maxRequestRetries: 3,
310
- maxSessionRotations: 1,
311
312
  preNavigationHooks: [
312
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
+ },
313
325
  ],
314
326
  postNavigationHooks: [
315
327
  async (crawlingContext) => {
@@ -415,20 +427,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
415
427
  if (shouldSkipDueToUnsupportedContent(response, request.url) ||
416
428
  (request.skipNavigation && actualUrl === 'about:blank')) {
417
429
  if (!isScanPdfs) {
418
- // Don't inform the user it is skipped since web crawler is best-effort.
419
- /*
420
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
421
- numScanned: urlsCrawled.scanned.length,
422
- urlScanned: request.url,
423
- });
424
- urlsCrawled.userExcluded.push({
425
- url: request.url,
426
- pageTitle: request.url,
427
- actualUrl: request.url, // because about:blank is not useful
428
- metadata: STATUS_CODE_METADATA[1],
429
- httpStatusCode: 0,
430
- });
431
- */
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
+ });
432
441
  return;
433
442
  }
434
443
  const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(randomToken, pdfDownloads, request, sendRequest, urlsCrawled);
@@ -436,20 +445,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
436
445
  return;
437
446
  }
438
447
  if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
439
- // Don't inform the user it is skipped since web crawler is best-effort.
440
- /*
441
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
442
- numScanned: urlsCrawled.scanned.length,
443
- urlScanned: request.url,
444
- });
445
- urlsCrawled.userExcluded.push({
446
- url: request.url,
447
- pageTitle: request.url,
448
- actualUrl, // because about:blank is not useful
449
- metadata: STATUS_CODE_METADATA[1],
450
- httpStatusCode: 0,
451
- });
452
- */
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
+ });
453
459
  return;
454
460
  }
455
461
  if (!isFollowStrategy(url, actualUrl, strategy) &&
@@ -482,6 +488,21 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
482
488
  return;
483
489
  }
484
490
  const responseStatus = response?.status();
491
+ if (responseStatus === 403) {
492
+ rateController.onFailure(responseStatus, activeCrawler.autoscaledPool);
493
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
494
+ numScanned: urlsCrawled.scanned.length,
495
+ urlScanned: request.url,
496
+ });
497
+ urlsCrawled.userExcluded.push({
498
+ url: request.url,
499
+ pageTitle: request.url,
500
+ actualUrl,
501
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
502
+ httpStatusCode: 403,
503
+ });
504
+ return;
505
+ }
485
506
  if (responseStatus && responseStatus >= 300) {
486
507
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
487
508
  numScanned: urlsCrawled.scanned.length,
@@ -497,6 +518,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
497
518
  return;
498
519
  }
499
520
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
521
+ await capturePageData(page, actualUrl, randomToken);
500
522
  // Detect JS redirects that fire during/after axe scan.
501
523
  // Listen for navigation, then give a brief window for pending redirects to complete.
502
524
  try {
@@ -634,7 +656,54 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
634
656
  if (isAbortingScanNow) {
635
657
  return;
636
658
  }
659
+ // Handle download-triggered navigation errors: Playwright throws
660
+ // "Download is starting" when page.goto() hits a file download URL.
661
+ // Crawlee retries 3 times (all fail) then lands here.
662
+ const isDownloadError = request.errorMessages?.some((msg) => msg.includes('Download is starting'));
663
+ if (isDownloadError) {
664
+ if (isScanPdfs) {
665
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
666
+ try {
667
+ await requestQueue.addRequest({
668
+ url: request.url,
669
+ skipNavigation: true,
670
+ label: request.url,
671
+ uniqueKey: `download_${request.url}`,
672
+ });
673
+ }
674
+ catch { }
675
+ }
676
+ else {
677
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
678
+ numScanned: urlsCrawled.scanned.length,
679
+ urlScanned: request.url,
680
+ });
681
+ urlsCrawled.userExcluded.push({
682
+ url: request.url,
683
+ pageTitle: request.url,
684
+ actualUrl: request.url,
685
+ metadata: STATUS_CODE_METADATA[1],
686
+ httpStatusCode: 1,
687
+ });
688
+ }
689
+ return;
690
+ }
637
691
  const status = response?.status();
692
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
693
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
694
+ if (status === 403 && !request.userData?.rateLimitRetried) {
695
+ rateController.onFailure(status, crawler.autoscaledPool);
696
+ try {
697
+ await requestQueue.addRequest({
698
+ url: request.url,
699
+ label: request.url,
700
+ uniqueKey: `ratelimit_${request.url}`,
701
+ userData: { rateLimitRetried: true },
702
+ });
703
+ }
704
+ catch { }
705
+ return;
706
+ }
638
707
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
639
708
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
640
709
  isAbortingScanNow = true;
@@ -669,7 +738,9 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
669
738
  await crawler.run();
670
739
  // Additional passes: keep re-visiting scanned seed-hostname pages for
671
740
  // click-discovery until no new pages are found or limits are reached.
672
- if (!safeMode && !isAbortingScanNow && !durationExceeded) {
741
+ // Skip when called from intelligent sitemap — the domain phase is only meant
742
+ // to discover new pages via <a> links, not re-click 3000+ already-scanned pages.
743
+ if (!safeMode && !isAbortingScanNow && !durationExceeded && !fromCrawlIntelligentSitemap) {
673
744
  const seedHostname = new URL(url).hostname;
674
745
  const clickPassVisited = new Set();
675
746
  let prevScannedCount;
@@ -8,6 +8,7 @@ import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.j
8
8
  import { guiInfoLog } from '../logs.js';
9
9
  import crawlSitemap from './crawlSitemap.js';
10
10
  import { getPdfStoragePath, register } from '../utils.js';
11
+ import { capturePageData } from './pageCapture.js';
11
12
  export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, scanDuration = 0, ruleset = [], fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, }) => {
12
13
  let dataset;
13
14
  let urlsCrawled;
@@ -108,6 +109,7 @@ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings,
108
109
  }
109
110
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
110
111
  const actualUrl = page.url() || request.loadedUrl || url;
112
+ await capturePageData(page, actualUrl, randomToken);
111
113
  guiInfoLog(guiInfoStatusTypes.SCANNED, {
112
114
  numScanned: urlsCrawled.scanned.length,
113
115
  urlScanned: url,
@@ -1,11 +1,12 @@
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
+ import { capturePageData } from './pageCapture.js';
9
10
  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
11
  const crawlStartTime = Date.now();
11
12
  let dataset;
@@ -41,12 +42,17 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
41
42
  const requestList = await RequestList.open({
42
43
  sources: linksFromSitemap,
43
44
  });
45
+ // Always create a request queue alongside the request list. An empty queue
46
+ // has zero impact on crawl behavior (Crawlee processes RequestList first).
47
+ // Having it available enables: download re-enqueue for PDF scanning,
48
+ // 403 rate-limit retry, and enqueueLinks for intelligent sitemap discovery.
49
+ const { requestQueue } = await createCrawleeSubFolders(randomToken);
44
50
  const crawler = register(new crawlee.PlaywrightCrawler({
45
51
  launchContext: {
46
52
  launcher: constants.launcher,
47
53
  launchOptions: getPlaywrightLaunchOptions(browser),
48
54
  },
49
- retryOnBlocked: true,
55
+ retryOnBlocked: false,
50
56
  browserPoolOptions: {
51
57
  useFingerprints: false,
52
58
  retireBrowserAfterPageCount: 500,
@@ -65,10 +71,11 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
65
71
  };
66
72
  },
67
73
  ],
74
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
68
75
  },
69
76
  requestList,
77
+ requestQueue,
70
78
  maxRequestRetries: 3,
71
- maxSessionRotations: 1,
72
79
  postNavigationHooks: [
73
80
  async ({ page }) => {
74
81
  try {
@@ -125,14 +132,22 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
125
132
  if (isNotSupportedDocument) {
126
133
  request.skipNavigation = true;
127
134
  request.userData.isNotSupportedDocument = true;
128
- // Log for verification (optional, but not required for correctness)
129
- // console.log(`[SKIP] Not supported: ${request.url}`);
130
135
  return;
131
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 { }
132
147
  },
133
148
  ],
134
149
  requestHandlerTimeoutSecs: 90,
135
- requestHandler: async ({ page, request, response, sendRequest }) => {
150
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
136
151
  // Log documents that are not supported
137
152
  if (request.userData?.isNotSupportedDocument) {
138
153
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -181,6 +196,21 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
181
196
  }
182
197
  const contentType = response?.headers?.()['content-type'] || '';
183
198
  const status = response ? response.status() : 0;
199
+ if (status === 403) {
200
+ rateController.onFailure(status, crawler.autoscaledPool);
201
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
202
+ numScanned: urlsCrawled.scanned.length,
203
+ urlScanned: request.url,
204
+ });
205
+ urlsCrawled.userExcluded.push({
206
+ url: request.url,
207
+ pageTitle: request.url,
208
+ actualUrl,
209
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
210
+ httpStatusCode: 403,
211
+ });
212
+ return;
213
+ }
184
214
  if (isScanHtml && status < 300 && isWhitelistedContentType(contentType)) {
185
215
  const isRedirected = !areLinksEqual(page.url(), request.url);
186
216
  const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some(item => normUrl(item.actualUrl || item.url) === normUrl(page.url()));
@@ -218,6 +248,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
218
248
  return;
219
249
  }
220
250
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
251
+ await capturePageData(page, actualUrl, randomToken);
221
252
  // Detect JS redirects that fire during/after axe scan.
222
253
  // Listen for navigation, then give a brief window for pending redirects to complete.
223
254
  try {
@@ -268,6 +299,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
268
299
  results.url = request.url;
269
300
  results.actualUrl = actualUrl;
270
301
  await dataset.pushData(results);
302
+ // Discover <a> links from this page for the intelligent sitemap flow.
303
+ // This eliminates the need for a separate crawlDomain supplement phase
304
+ // that would re-visit all these pages just to extract links.
305
+ if (fromCrawlIntelligentSitemap) {
306
+ try {
307
+ await enqueueLinks({
308
+ selector: `a:not(${disallowedSelectorPatterns})`,
309
+ strategy,
310
+ requestQueue,
311
+ transformRequestFunction: (req) => {
312
+ try {
313
+ req.url = req.url.replace(/(?<=&|\?)utm_.*?(&|$)/gim, '');
314
+ }
315
+ catch { }
316
+ if (isDisallowedInRobotsTxt(req.url))
317
+ return null;
318
+ if (isUrlPdf(req.url)) {
319
+ req.skipNavigation = true;
320
+ }
321
+ req.label = req.url;
322
+ return req;
323
+ },
324
+ });
325
+ }
326
+ catch {
327
+ // Best-effort link discovery; don't fail the scan
328
+ }
329
+ }
271
330
  }
272
331
  }
273
332
  else {
@@ -276,18 +335,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
276
335
  urlScanned: request.url,
277
336
  });
278
337
  if (isScanHtml) {
279
- // carry through the HTTP status metadata
280
- const status = response?.status();
281
- const metadata = typeof status === 'number'
282
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
283
- : STATUS_CODE_METADATA[2];
284
- urlsCrawled.invalid.push({
285
- actualUrl,
286
- url: request.url,
287
- pageTitle: request.url,
288
- metadata,
289
- httpStatusCode: typeof status === 'number' ? status : 0,
290
- });
338
+ // Non-HTML content types (images, PDFs, binary files) and URLs
339
+ // that redirect to non-HTML resources should be classified as
340
+ // unsupported documents, not generic page errors.
341
+ const isNonHtmlContent = contentType &&
342
+ !contentType.startsWith('text/html') &&
343
+ !contentType.includes('html');
344
+ if (isNonHtmlContent && status !== 0) {
345
+ urlsCrawled.userExcluded.push({
346
+ actualUrl,
347
+ url: request.url,
348
+ pageTitle: request.url,
349
+ metadata: STATUS_CODE_METADATA[1],
350
+ httpStatusCode: 1,
351
+ });
352
+ }
353
+ else {
354
+ const httpStatus = response?.status();
355
+ const metadata = typeof httpStatus === 'number'
356
+ ? STATUS_CODE_METADATA[httpStatus] || STATUS_CODE_METADATA[599]
357
+ : STATUS_CODE_METADATA[2];
358
+ urlsCrawled.invalid.push({
359
+ actualUrl,
360
+ url: request.url,
361
+ pageTitle: request.url,
362
+ metadata,
363
+ httpStatusCode: typeof httpStatus === 'number' ? httpStatus : 0,
364
+ });
365
+ }
291
366
  }
292
367
  }
293
368
  }
@@ -302,7 +377,53 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
302
377
  if (isAbortingScan) {
303
378
  return;
304
379
  }
380
+ // Handle download-triggered navigation errors: Playwright throws
381
+ // "Download is starting" when page.goto() hits a file download URL.
382
+ const isDownloadError = request.errorMessages?.some((msg) => msg.includes('Download is starting'));
383
+ if (isDownloadError) {
384
+ if (isScanPdfs) {
385
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
386
+ try {
387
+ await requestQueue.addRequest({
388
+ url: request.url,
389
+ skipNavigation: true,
390
+ label: request.url,
391
+ uniqueKey: `download_${request.url}`,
392
+ });
393
+ }
394
+ catch { }
395
+ }
396
+ else {
397
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
398
+ numScanned: urlsCrawled.scanned.length,
399
+ urlScanned: request.url,
400
+ });
401
+ urlsCrawled.userExcluded.push({
402
+ url: request.url,
403
+ pageTitle: request.url,
404
+ actualUrl: request.url,
405
+ metadata: STATUS_CODE_METADATA[1],
406
+ httpStatusCode: 1,
407
+ });
408
+ }
409
+ return;
410
+ }
305
411
  const status = response?.status();
412
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
413
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
414
+ if (status === 403 && !request.userData?.rateLimitRetried) {
415
+ rateController.onFailure(status, crawler.autoscaledPool);
416
+ try {
417
+ await requestQueue.addRequest({
418
+ url: request.url,
419
+ label: request.url,
420
+ uniqueKey: `ratelimit_${request.url}`,
421
+ userData: { rateLimitRetried: true },
422
+ });
423
+ }
424
+ catch { }
425
+ return;
426
+ }
306
427
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
307
428
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
308
429
  isAbortingScan = true;
@@ -4,6 +4,7 @@
4
4
  import path from 'path';
5
5
  import { getDomain } from 'tldts';
6
6
  import { runAxeScript } from '../commonCrawlerFunc.js';
7
+ import { capturePageData } from '../pageCapture.js';
7
8
  import { consoleLogger, guiInfoLog } from '../../logs.js';
8
9
  import { guiInfoStatusTypes } from '../../constants/constants.js';
9
10
  import { isSkippedUrl, validateCustomFlowLabel } from '../../constants/common.js';
@@ -109,6 +110,7 @@ export const screenshotFullPage = async (page, screenshotsDir, screenshotIdx) =>
109
110
  };
110
111
  export const runAxeScan = async (page, includeScreenshots, randomToken, customFlowDetails, dataset, urlsCrawled) => {
111
112
  const result = await runAxeScript({ includeScreenshots, page, randomToken, customFlowDetails });
113
+ await capturePageData(page, page.url(), randomToken);
112
114
  await dataset.pushData(result);
113
115
  const rawTitle = result.pageTitle ?? '';
114
116
  let pageTitleTextOnly = rawTitle; // Note: The original pageTitle contains the index and is being used in top 10 issues
@@ -0,0 +1,134 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { devices } from 'playwright';
5
+ import { getStoragePath } from '../utils.js';
6
+ const MOBILE_VIEWPORT_WIDTH = devices['iPhone 11'].viewport.width;
7
+ const MOBILE_VIEWPORT_HEIGHT = devices['iPhone 11'].viewport.height;
8
+ const captureEntries = new Map();
9
+ export function getUrlHash(url) {
10
+ return crypto.createHash('sha256').update(url).digest('hex').slice(0, 7);
11
+ }
12
+ function getTruncatedPath(url) {
13
+ try {
14
+ const parsed = new URL(url);
15
+ let pathStr = parsed.pathname + (parsed.search || '');
16
+ pathStr = pathStr.replace(/^\//, '').replace(/\//g, '_').replace(/[^a-zA-Z0-9\-_.]/g, '_');
17
+ if (pathStr.length > 80) {
18
+ pathStr = pathStr.slice(0, 80);
19
+ }
20
+ return pathStr || 'index';
21
+ }
22
+ catch {
23
+ return 'unknown';
24
+ }
25
+ }
26
+ function getPageDomsDir(randomToken) {
27
+ const storagePath = getStoragePath(randomToken);
28
+ return path.join(storagePath, 'pageDOMs');
29
+ }
30
+ async function getUniqueFilePath(dir, baseName, ext) {
31
+ let candidate = path.join(dir, `${baseName}${ext}`);
32
+ if (!await fs.pathExists(candidate))
33
+ return candidate;
34
+ let counter = 2;
35
+ while (await fs.pathExists(candidate)) {
36
+ candidate = path.join(dir, `${baseName}-${counter}${ext}`);
37
+ counter++;
38
+ }
39
+ return candidate;
40
+ }
41
+ function getRelativeName(filePath, baseDir) {
42
+ return path.relative(baseDir, filePath).replace(/\\/g, '/');
43
+ }
44
+ export function isSaveDomEnabled() {
45
+ return process.env.OOBEE_SAVE_DOM === '1' || process.env.OOBEE_SAVE_DOM === 'true';
46
+ }
47
+ export function isSavePageScreenshotEnabled() {
48
+ return (process.env.OOBEE_SAVE_PAGE_SCREENSHOT === '1' ||
49
+ process.env.OOBEE_SAVE_PAGE_SCREENSHOT === 'true');
50
+ }
51
+ export function isPageCaptureEnabled() {
52
+ return isSaveDomEnabled() || isSavePageScreenshotEnabled();
53
+ }
54
+ export async function capturePageData(page, url, randomToken) {
55
+ if (!isPageCaptureEnabled())
56
+ return;
57
+ const hash = getUrlHash(url);
58
+ const truncatedPath = getTruncatedPath(url);
59
+ const fileName = `${hash}-${truncatedPath}`;
60
+ const pageDomsDir = getPageDomsDir(randomToken);
61
+ const entry = {
62
+ url,
63
+ hash,
64
+ errors: [],
65
+ };
66
+ if (isSaveDomEnabled()) {
67
+ try {
68
+ await fs.ensureDir(pageDomsDir);
69
+ const domContent = await page.content();
70
+ const domFilePath = await getUniqueFilePath(pageDomsDir, fileName, '.html');
71
+ await fs.writeFile(domFilePath, domContent, 'utf-8');
72
+ entry.domFile = `pageDOMs/${getRelativeName(domFilePath, pageDomsDir)}`;
73
+ }
74
+ catch (err) {
75
+ entry.errors.push(`DOM save failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ }
77
+ }
78
+ if (isSavePageScreenshotEnabled()) {
79
+ const desktopDir = path.join(pageDomsDir, 'desktopPageScreenshots');
80
+ const mobileDir = path.join(pageDomsDir, 'mobilePageScreenshots');
81
+ try {
82
+ await fs.ensureDir(desktopDir);
83
+ const desktopPath = await getUniqueFilePath(desktopDir, fileName, '.png');
84
+ await page.screenshot({ path: desktopPath, fullPage: true });
85
+ entry.desktopScreenshot = `pageDOMs/desktopPageScreenshots/${getRelativeName(desktopPath, desktopDir)}`;
86
+ }
87
+ catch (err) {
88
+ entry.errors.push(`Desktop screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ try {
91
+ await fs.ensureDir(mobileDir);
92
+ const currentViewport = page.viewportSize();
93
+ await page.setViewportSize({
94
+ width: MOBILE_VIEWPORT_WIDTH,
95
+ height: MOBILE_VIEWPORT_HEIGHT,
96
+ });
97
+ await page.waitForTimeout(500);
98
+ const mobilePath = await getUniqueFilePath(mobileDir, fileName, '.png');
99
+ await page.screenshot({ path: mobilePath, fullPage: true });
100
+ entry.mobileScreenshot = `pageDOMs/mobilePageScreenshots/${getRelativeName(mobilePath, mobileDir)}`;
101
+ if (currentViewport) {
102
+ await page.setViewportSize(currentViewport);
103
+ }
104
+ }
105
+ catch (err) {
106
+ entry.errors.push(`Mobile screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
109
+ captureEntries.set(url, entry);
110
+ }
111
+ export async function writeManifest(randomToken) {
112
+ if (!isPageCaptureEnabled())
113
+ return;
114
+ if (captureEntries.size === 0)
115
+ return;
116
+ const pageDomsDir = getPageDomsDir(randomToken);
117
+ await fs.ensureDir(pageDomsDir);
118
+ const manifest = {
119
+ generatedAt: new Date().toISOString(),
120
+ pages: Array.from(captureEntries.values()).map(entry => ({
121
+ url: entry.url,
122
+ hash: entry.hash,
123
+ ...(entry.domFile && { domFile: entry.domFile }),
124
+ ...(entry.desktopScreenshot && { desktopScreenshot: entry.desktopScreenshot }),
125
+ ...(entry.mobileScreenshot && { mobileScreenshot: entry.mobileScreenshot }),
126
+ errors: entry.errors,
127
+ })),
128
+ };
129
+ const manifestPath = path.join(pageDomsDir, 'domManifest.json');
130
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
131
+ }
132
+ export function resetCaptureEntries() {
133
+ captureEntries.clear();
134
+ }
@@ -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