@govtechsg/oobee 0.10.93 → 0.10.95

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.
Files changed (71) hide show
  1. package/AGENTS.md +20 -0
  2. package/dist/cli.js +3 -2
  3. package/dist/combine.js +10 -3
  4. package/dist/constants/common.js +131 -52
  5. package/dist/crawlers/commonCrawlerFunc.js +11 -2
  6. package/dist/crawlers/crawlDomain.js +4 -6
  7. package/dist/crawlers/crawlSitemap.js +14 -2
  8. package/dist/crawlers/custom/utils.js +22 -9
  9. package/dist/crawlers/guards/urlGuard.js +19 -1
  10. package/dist/generateHtmlReport.js +12 -1
  11. package/dist/logs.js +6 -0
  12. package/dist/mergeAxeResults/jsonArtifacts.js +6 -1
  13. package/dist/mergeAxeResults.js +41 -15
  14. package/dist/static/ejs/partials/components/allIssues/CategoryBadges.ejs +3 -0
  15. package/dist/static/ejs/partials/components/allIssues/IssuesTable.ejs +3 -3
  16. package/dist/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +1 -1
  17. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +3 -3
  18. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +34 -27
  19. package/dist/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +1 -0
  20. package/dist/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +7 -0
  21. package/dist/static/ejs/partials/components/wcagCoverageDetails.ejs +5 -5
  22. package/dist/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +3 -3
  23. package/dist/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +21 -19
  24. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +39 -8
  25. package/dist/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +11 -5
  26. package/dist/static/ejs/partials/scripts/screenshotLightbox.ejs +49 -31
  27. package/dist/static/ejs/partials/styles/header/SiteInfo.ejs +1 -1
  28. package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +36 -16
  29. package/dist/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +22 -1
  30. package/dist/static/ejs/partials/styles/styles.ejs +1 -1
  31. package/dist/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +6 -0
  32. package/dist/static/ejs/partials/styles/wcagCompliance.ejs +5 -4
  33. package/dist/static/ejs/partials/styles/wcagCoverageDetails.ejs +6 -1
  34. package/dist/utils.js +7 -0
  35. package/oobee-client-scanner.js +2 -2
  36. package/package.json +1 -1
  37. package/src/cli.ts +3 -2
  38. package/src/combine.ts +10 -2
  39. package/src/constants/common.ts +125 -36
  40. package/src/crawlers/commonCrawlerFunc.ts +11 -2
  41. package/src/crawlers/crawlDomain.ts +4 -5
  42. package/src/crawlers/crawlSitemap.ts +19 -2
  43. package/src/crawlers/custom/utils.ts +26 -13
  44. package/src/crawlers/guards/urlGuard.ts +18 -1
  45. package/src/generateHtmlReport.ts +13 -1
  46. package/src/logs.ts +6 -0
  47. package/src/mergeAxeResults/jsonArtifacts.ts +6 -1
  48. package/src/mergeAxeResults.ts +41 -15
  49. package/src/static/ejs/partials/components/allIssues/CategoryBadges.ejs +3 -0
  50. package/src/static/ejs/partials/components/allIssues/IssuesTable.ejs +3 -3
  51. package/src/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +1 -1
  52. package/src/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +3 -3
  53. package/src/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +34 -27
  54. package/src/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +1 -0
  55. package/src/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +7 -0
  56. package/src/static/ejs/partials/components/wcagCoverageDetails.ejs +5 -5
  57. package/src/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +3 -3
  58. package/src/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +21 -19
  59. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +39 -8
  60. package/src/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +11 -5
  61. package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +49 -31
  62. package/src/static/ejs/partials/styles/header/SiteInfo.ejs +1 -1
  63. package/src/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +36 -16
  64. package/src/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +22 -1
  65. package/src/static/ejs/partials/styles/styles.ejs +1 -1
  66. package/src/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +6 -0
  67. package/src/static/ejs/partials/styles/wcagCompliance.ejs +5 -4
  68. package/src/static/ejs/partials/styles/wcagCoverageDetails.ejs +6 -1
  69. package/src/utils.ts +6 -0
  70. package/testStaticJSScanner.html +1 -1
  71. /package/{7339fae5-e8ed-4b50-af13-317847620dbf.txt → fb5db217-5ab7-4120-b33b-71fa69b454d0.txt} +0 -0
@@ -359,8 +359,11 @@ const checkUrlConnectivityWithBrowser = async (
359
359
  }
360
360
  }
361
361
 
362
- // Ensure Accept header for non-html content fallback
363
- extraHTTPHeaders.Accept ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
362
+ // Ensure Accept header for non-html content fallback — use a local copy to avoid
363
+ // mutating the caller's extraHTTPHeaders object (which is later checked by crawlers
364
+ // to decide whether to enable preNavigationHooks header rewriting).
365
+ const localHeaders = { ...extraHTTPHeaders };
366
+ localHeaders.Accept ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
364
367
 
365
368
  await initModifiedUserAgent(browserToRun, playwrightDeviceDetailsObject, clonedDataDir);
366
369
 
@@ -378,7 +381,7 @@ const checkUrlConnectivityWithBrowser = async (
378
381
 
379
382
  const launchOptions = getPlaywrightLaunchOptions(browserToRun);
380
383
 
381
- const { Authorization, ...nonAuthHeaders } = extraHTTPHeaders || {};
384
+ const { Authorization, ...nonAuthHeaders } = localHeaders || {};
382
385
  let httpCredentials = undefined;
383
386
  if (Authorization?.startsWith('Basic ')) {
384
387
  const decoded = Buffer.from(Authorization.slice(6), 'base64').toString();
@@ -436,19 +439,21 @@ const checkUrlConnectivityWithBrowser = async (
436
439
  // Only enable generic Authorization header routing interception broadly if
437
440
  // a non-Basic Bearer auth string is heavily relied upon, thereby bypassing
438
441
  // performance warnings inside the check checkUrl phase for typical public scans
439
- if (Authorization && !httpCredentials) {
440
- const entryOrigin = new URL(url).origin;
441
- await browserContext.route('**/*', async (route: any, request: any) => {
442
- try {
443
- if (new URL(request.url()).origin === entryOrigin) {
444
- await route.continue({ headers: { ...request.headers(), Authorization } });
445
- } else {
442
+ if (Object.keys(localHeaders).length > 0) {
443
+ if (Authorization && !httpCredentials) {
444
+ const entryOrigin = new URL(url).origin;
445
+ await browserContext.route('**/*', async (route: any, request: any) => {
446
+ try {
447
+ if (new URL(request.url()).origin === entryOrigin) {
448
+ await route.continue({ headers: { ...request.headers(), Authorization } });
449
+ } else {
450
+ await route.continue();
451
+ }
452
+ } catch {
446
453
  await route.continue();
447
454
  }
448
- } catch {
449
- await route.continue();
450
- }
451
- });
455
+ });
456
+ }
452
457
  }
453
458
 
454
459
  const page = await browserContext.newPage();
@@ -569,7 +574,7 @@ export const isSitemapContent = (content: string) => {
569
574
  }
570
575
 
571
576
  const regexForHtml = new RegExp('<(?:!doctype html|html|head|body)+?>', 'gmi');
572
- const regexForXmlSitemap = new RegExp('<(?:urlset|feed|rss)+?.*>', 'gmi');
577
+ const regexForXmlSitemap = new RegExp('<(?:urlset|sitemapindex|feed|rss)+?.*>', 'gmi');
573
578
  if (content.match(regexForHtml) && content.match(regexForXmlSitemap)) {
574
579
  // is an XML sitemap wrapped in a HTML document
575
580
  return true;
@@ -592,8 +597,22 @@ export const checkUrl = async (
592
597
  extraHTTPHeaders: Record<string, string>,
593
598
  fileTypes: FileTypes,
594
599
  ) => {
600
+ let urlToCheck = url;
601
+
602
+ if (scanner === ScannerTypes.LOCALFILE) {
603
+ if (!isFilePath(url)) {
604
+ const res = new RES();
605
+ res.status = constants.urlCheckStatuses.notALocalFile.code;
606
+ return res;
607
+ }
608
+
609
+ if (!url.toLowerCase().startsWith('file://')) {
610
+ urlToCheck = pathToFileURL(path.resolve(url)).toString();
611
+ }
612
+ }
613
+
595
614
  const res = await checkUrlConnectivityWithBrowser(
596
- url,
615
+ urlToCheck,
597
616
  browser,
598
617
  clonedDataDir,
599
618
  playwrightDeviceDetailsObject,
@@ -681,6 +700,7 @@ export const prepareData = async (argv: Answers): Promise<Data> => {
681
700
  ruleset,
682
701
  generateJsonFiles,
683
702
  scanDuration,
703
+ finalUrl,
684
704
  } = argv;
685
705
 
686
706
  const extraHTTPHeaders = parseHeaders(header);
@@ -714,6 +734,10 @@ export const prepareData = async (argv: Answers): Promise<Data> => {
714
734
  url = temp.toString();
715
735
  }
716
736
 
737
+ // Keep browser-resolved URL (if provided by pre-check flow) as canonical entry URL.
738
+ // For local file paths, keep using the normalized `url` value below.
739
+ const resolvedEntryUrl = finalUrl && !isFilePath(finalUrl) ? finalUrl : url;
740
+
717
741
  // construct filename for scan results
718
742
  const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
719
743
  const domain = isLocalFileScan ? path.basename(url) : new URL(url).hostname;
@@ -758,7 +782,7 @@ export const prepareData = async (argv: Answers): Promise<Data> => {
758
782
  return {
759
783
  type: scanner,
760
784
  url,
761
- entryUrl: url,
785
+ entryUrl: resolvedEntryUrl,
762
786
  isHeadless: headless,
763
787
  deviceChosen,
764
788
  customDevice,
@@ -1009,6 +1033,8 @@ export const getLinksFromSitemap = async (
1009
1033
  const scannedSitemaps = new Set<string>();
1010
1034
  const sitemapLinkCounts: Record<string, number> = {};
1011
1035
  const allUrls = new Set<string>(); // all discovered URLs (lightweight strings)
1036
+ const isImageSitemapUrl = (candidateUrl: string) =>
1037
+ /(^|\/)image-sitemap(?:-index)?(?:-\d+)?\.xml(?:$|[?#])/i.test(candidateUrl);
1012
1038
 
1013
1039
  const addToUrlList = (url: string) => {
1014
1040
  if (!url) return;
@@ -1092,6 +1118,11 @@ export const getLinksFromSitemap = async (
1092
1118
  let data;
1093
1119
  let sitemapType;
1094
1120
 
1121
+ if (isImageSitemapUrl(url)) {
1122
+ consoleLogger.info(`Skipping image sitemap: ${url}`);
1123
+ return;
1124
+ }
1125
+
1095
1126
  if (scannedSitemaps.has(url)) {
1096
1127
  // Skip processing if the sitemap has already been scanned
1097
1128
  return;
@@ -1147,11 +1178,28 @@ export const getLinksFromSitemap = async (
1147
1178
 
1148
1179
  const page = await browserContext.newPage();
1149
1180
 
1150
- await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
1181
+ // Use 'domcontentloaded' instead of 'networkidle' sitemap XMLs with
1182
+ // XSL stylesheet references (e.g. <?xml-stylesheet ...?>) cause the browser
1183
+ // to fetch and apply the stylesheet, which may load additional resources
1184
+ // (fonts, CSS, images) that prevent 'networkidle' from ever being reached.
1185
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
1186
+
1187
+ // Prefer the raw response body — this gives us the original XML before
1188
+ // the browser applies any XSL transformation (which would turn the XML
1189
+ // into rendered HTML, losing the sitemap structure).
1190
+ if (response) {
1191
+ try {
1192
+ data = await response.text();
1193
+ } catch {
1194
+ // response.text() can fail if the body was already consumed or
1195
+ // if a redirect occurred; fall through to DOM extraction below.
1196
+ }
1197
+ }
1151
1198
 
1152
- if ((await page.locator('body').count()) > 0) {
1153
- data = await page.locator('body').innerText();
1154
- } else {
1199
+ if (!data) {
1200
+ if ((await page.locator('body').count()) > 0) {
1201
+ data = await page.locator('body').innerText();
1202
+ } else {
1155
1203
  const urlSet = page.locator('urlset');
1156
1204
  const sitemapIndex = page.locator('sitemapindex');
1157
1205
  const rss = page.locator('rss');
@@ -1166,6 +1214,7 @@ export const getLinksFromSitemap = async (
1166
1214
  data = await rss.evaluate(elem => elem.outerHTML);
1167
1215
  } else if (await isRoot(feed)) {
1168
1216
  data = await feed.evaluate(elem => elem.outerHTML);
1217
+ }
1169
1218
  }
1170
1219
  }
1171
1220
  } finally {
@@ -1189,39 +1238,65 @@ export const getLinksFromSitemap = async (
1189
1238
  }
1190
1239
 
1191
1240
  const $ = cheerio.load(data, { xml: true });
1241
+ const countBefore = allUrls.size;
1192
1242
 
1193
1243
  // This case is when the document is not an XML format document
1194
1244
  if ($(':root').length === 0) {
1195
1245
  processNonStandardSitemap(data);
1246
+
1247
+ const linksFromThisSitemap = allUrls.size - countBefore;
1248
+ if (linksFromThisSitemap > 0) {
1249
+ sitemapLinkCounts[url] = (sitemapLinkCounts[url] || 0) + linksFromThisSitemap;
1250
+ }
1196
1251
  return;
1197
1252
  }
1198
1253
 
1199
1254
  // Root element
1200
1255
  const root = $(':root')[0];
1256
+ const hasImageNamespace = Object.values(root?.attribs ?? {}).some(
1257
+ attribVal => typeof attribVal === 'string' && attribVal.toLowerCase().includes('sitemap-image'),
1258
+ );
1259
+
1260
+ if (hasImageNamespace) {
1261
+ consoleLogger.info(`Skipping image sitemap: ${url}`);
1262
+ return;
1263
+ }
1201
1264
 
1202
- const { xmlns } = root.attribs;
1265
+ const rootName = root?.name?.toLowerCase().split(':').pop() ?? '';
1266
+ const hasXmlSitemapIndexTag = /<\s*(?:[a-z0-9_-]+:)?sitemapindex\b/i.test(data);
1267
+ const hasXmlUrlsetTag = /<\s*(?:[a-z0-9_-]+:)?urlset\b/i.test(data);
1203
1268
 
1204
- const xmlFormatNamespace = '/schemas/sitemap';
1205
- if (root.name === 'urlset' && xmlns.includes(xmlFormatNamespace)) {
1269
+ if (rootName === 'urlset') {
1206
1270
  sitemapType = constants.xmlSitemapTypes.xml;
1207
- } else if (root.name === 'sitemapindex' && xmlns.includes(xmlFormatNamespace)) {
1271
+ } else if (rootName === 'sitemapindex') {
1208
1272
  sitemapType = constants.xmlSitemapTypes.xmlIndex;
1209
- } else if (root.name === 'rss') {
1273
+ } else if (rootName === 'rss') {
1210
1274
  sitemapType = constants.xmlSitemapTypes.rss;
1211
- } else if (root.name === 'feed') {
1275
+ } else if (rootName === 'feed') {
1212
1276
  sitemapType = constants.xmlSitemapTypes.atom;
1277
+ } else if (hasXmlSitemapIndexTag) {
1278
+ sitemapType = constants.xmlSitemapTypes.xmlIndex;
1279
+ } else if (hasXmlUrlsetTag) {
1280
+ sitemapType = constants.xmlSitemapTypes.xml;
1213
1281
  } else {
1214
1282
  sitemapType = constants.xmlSitemapTypes.unknown;
1215
1283
  }
1216
1284
 
1217
- const countBefore = allUrls.size;
1218
-
1219
1285
  switch (sitemapType) {
1220
1286
  case constants.xmlSitemapTypes.xmlIndex:
1221
- consoleLogger.info(`This is a XML format sitemap index.`);
1287
+ consoleLogger.info(`This is a XML format sitemap index: ${url}`);
1222
1288
  for (const childSitemapUrl of $('loc')) {
1223
- const childSitemapUrlText = $(childSitemapUrl).text();
1224
- if (childSitemapUrlText.endsWith('.xml') || childSitemapUrlText.endsWith('.txt')) {
1289
+ const childSitemapUrlText = $(childSitemapUrl).text().trim();
1290
+ if (!childSitemapUrlText) {
1291
+ continue;
1292
+ }
1293
+
1294
+ const childSitemapPath = childSitemapUrlText.split(/[?#]/)[0].toLowerCase();
1295
+ if (childSitemapPath.endsWith('.xml') || childSitemapPath.endsWith('.txt')) {
1296
+ if (isImageSitemapUrl(childSitemapUrlText)) {
1297
+ consoleLogger.info(`Skipping image sitemap: ${childSitemapUrlText}`);
1298
+ continue;
1299
+ }
1225
1300
  await fetchUrls(childSitemapUrlText, extraHTTPHeaders); // Recursive call for nested sitemaps
1226
1301
  } else {
1227
1302
  addToUrlList(childSitemapUrlText); // Add regular URLs to the list
@@ -1229,19 +1304,19 @@ export const getLinksFromSitemap = async (
1229
1304
  }
1230
1305
  break;
1231
1306
  case constants.xmlSitemapTypes.xml:
1232
- consoleLogger.info(`This is a XML format sitemap.`);
1307
+ consoleLogger.info(`This is a XML format sitemap: ${url}`);
1233
1308
  await processXmlSitemap($, sitemapType, 'loc', 'lastmod', 'url');
1234
1309
  break;
1235
1310
  case constants.xmlSitemapTypes.rss:
1236
- consoleLogger.info(`This is a RSS format sitemap.`);
1311
+ consoleLogger.info(`This is a RSS format sitemap: ${url}`);
1237
1312
  await processXmlSitemap($, sitemapType, 'link', 'pubDate', 'item');
1238
1313
  break;
1239
1314
  case constants.xmlSitemapTypes.atom:
1240
- consoleLogger.info(`This is a Atom format sitemap.`);
1315
+ consoleLogger.info(`This is a Atom format sitemap: ${url}`);
1241
1316
  await processXmlSitemap($, sitemapType, 'link', 'published', 'entry');
1242
1317
  break;
1243
1318
  default:
1244
- consoleLogger.info(`This is an unrecognised XML sitemap format.`);
1319
+ consoleLogger.info(`This is an unrecognised XML sitemap format: ${url}`);
1245
1320
  processNonStandardSitemap(data);
1246
1321
  }
1247
1322
 
@@ -1422,6 +1497,19 @@ export const getBrowserToRun = (
1422
1497
  * after checkingUrl and unable to utilise same cookie for scan
1423
1498
  * */
1424
1499
  export const getClonedProfilesWithRandomToken = (browser: string, randomToken: string): string => {
1500
+ // In Docker, redirect browser temp files away from /tmp to avoid ENOSPC.
1501
+ // Keep the path short — Chrome creates Unix sockets inside TMPDIR-based paths,
1502
+ // and socket paths are limited to 107 bytes on Linux.
1503
+ // Use process.pid to isolate concurrent scan instances.
1504
+ if (fs.existsSync('/.dockerenv')) {
1505
+ const baseDir = getDefaultChromiumDataDir();
1506
+ if (baseDir) {
1507
+ const scanTmpDir = path.join(baseDir, 'tmp', String(process.pid));
1508
+ fs.mkdirSync(scanTmpDir, { recursive: true });
1509
+ process.env.TMPDIR = scanTmpDir;
1510
+ }
1511
+ }
1512
+
1425
1513
  if (browser === BrowserTypes.CHROME) {
1426
1514
  return cloneChromeProfiles(randomToken);
1427
1515
  }
@@ -2191,6 +2279,7 @@ export const isFilePath = (url: string): boolean => {
2191
2279
  const driveLetterPattern = /^[A-Z]:/i;
2192
2280
  const backslashPattern = /\\/;
2193
2281
  return (
2282
+ url.toLowerCase().startsWith('file://') ||
2194
2283
  url.startsWith('/') ||
2195
2284
  driveLetterPattern.test(url) ||
2196
2285
  backslashPattern.test(url) ||
@@ -1145,10 +1145,19 @@ export const createCrawleeSubFolders = async (
1145
1145
  export const preNavigationHooks = (extraHTTPHeaders: Record<string, string>) => {
1146
1146
  return [
1147
1147
  async (crawlingContext: CrawlingContext, gotoOptions: PlaywrightGotoOptions) => {
1148
- if (extraHTTPHeaders) {
1148
+ if (extraHTTPHeaders && Object.keys(extraHTTPHeaders).length > 0) {
1149
1149
  crawlingContext.request.headers = extraHTTPHeaders;
1150
1150
  }
1151
- gotoOptions = { waitUntil: 'networkidle', timeout: 30000 };
1151
+ // Use domcontentloaded fires as soon as the DOM is parsed, before
1152
+ // images/stylesheets/network requests settle. This avoids indefinite
1153
+ // hangs on sites with WebSockets, analytics polling, or infinite-scroll
1154
+ // beacons that never reach networkidle. Further page stability is
1155
+ // handled by waitForPageLoaded() in each crawler's requestHandler and
1156
+ // by the DOM mutation observer in postNavigationHooks.
1157
+ if (gotoOptions) {
1158
+ gotoOptions.waitUntil = 'domcontentloaded';
1159
+ gotoOptions.timeout = 30000;
1160
+ }
1152
1161
  },
1153
1162
  ];
1154
1163
  };
@@ -5,6 +5,7 @@ import type { PlaywrightCrawlingContext, RequestOptions } from 'crawlee';
5
5
  import {
6
6
  createCrawleeSubFolders,
7
7
  getPreLaunchHook,
8
+ preNavigationHooks,
8
9
  runAxeScript,
9
10
  isUrlPdf,
10
11
  shouldSkipClickDueToDisallowedHref,
@@ -414,12 +415,10 @@ const crawlDomain = async ({
414
415
  ],
415
416
  },
416
417
  requestQueue,
418
+ maxRequestRetries: 3,
419
+ maxSessionRotations: 1,
417
420
  preNavigationHooks: [
418
- async (crawlingContext) => {
419
- if (extraHTTPHeaders) {
420
- crawlingContext.request.headers = extraHTTPHeaders;
421
- }
422
- },
421
+ ...preNavigationHooks(extraHTTPHeaders),
423
422
  ],
424
423
  postNavigationHooks: [
425
424
  async crawlingContext => {
@@ -7,6 +7,7 @@ import {
7
7
  preNavigationHooks,
8
8
  runAxeScript,
9
9
  isUrlPdf,
10
+ splitAuthHeaders,
10
11
  } from './commonCrawlerFunc.js';
11
12
 
12
13
  import constants, {
@@ -85,6 +86,7 @@ const crawlSitemap = async ({
85
86
  maxRequestsPerCrawl,
86
87
  specifiedMaxConcurrency || constants.maxConcurrency,
87
88
  );
89
+ const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
88
90
 
89
91
  if (fromCrawlIntelligentSitemap) {
90
92
  dataset = datasetFromIntelligent;
@@ -119,6 +121,7 @@ const crawlSitemap = async ({
119
121
  const isScanPdfs = [FileTypes.All, FileTypes.PdfOnly].includes(fileTypes as FileTypes);
120
122
  const { playwrightDeviceDetailsObject } = viewportSettings;
121
123
  const { maxConcurrency } = constants;
124
+ const { nonAuthHeaders, httpCredentials } = splitAuthHeaders(extraHTTPHeaders);
122
125
 
123
126
  const requestList = await RequestList.open({
124
127
  sources: linksFromSitemap,
@@ -142,11 +145,15 @@ const crawlSitemap = async ({
142
145
  ...playwrightDeviceDetailsObject,
143
146
  ...(process.env.OOBEE_USER_AGENT && { userAgent: process.env.OOBEE_USER_AGENT }),
144
147
  ...(process.env.OOBEE_DISABLE_BROWSER_DOWNLOAD && { acceptDownloads: false }),
148
+ ...(nonAuthHeaders && { extraHTTPHeaders: nonAuthHeaders }),
149
+ ...(httpCredentials && { httpCredentials }),
145
150
  };
146
151
  },
147
152
  ],
148
153
  },
149
154
  requestList,
155
+ maxRequestRetries: 3,
156
+ maxSessionRotations: 1,
150
157
  postNavigationHooks: [
151
158
  async ({ page }) => {
152
159
  try {
@@ -197,6 +204,7 @@ const crawlSitemap = async ({
197
204
  },
198
205
  ],
199
206
  preNavigationHooks: [
207
+ ...preNavigationHooks(extraHTTPHeaders),
200
208
  async ({ request, page }, gotoOptions) => {
201
209
  const url = request.url.toLowerCase();
202
210
 
@@ -213,8 +221,6 @@ const crawlSitemap = async ({
213
221
 
214
222
  return;
215
223
  }
216
-
217
- preNavigationHooks(extraHTTPHeaders);
218
224
  },
219
225
  ],
220
226
  requestHandlerTimeoutSecs: 90,
@@ -449,6 +455,17 @@ const crawlSitemap = async ({
449
455
  httpStatusCode: typeof status === 'number' ? status : 0,
450
456
  });
451
457
  crawlee.log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
458
+
459
+ if (
460
+ urlsCrawled.scanned.length === 0 &&
461
+ urlsCrawled.error.length >= initialNoSuccessFailureAbortThreshold
462
+ ) {
463
+ consoleLogger.info(
464
+ `Aborting sitemap crawl: ${urlsCrawled.error.length} failed pages with 0 successful scans.`,
465
+ );
466
+ isAbortingScan = true;
467
+ crawler.autoscaledPool?.abort();
468
+ }
452
469
  },
453
470
  maxRequestsPerCrawl: Infinity,
454
471
  maxConcurrency: specifiedMaxConcurrency || maxConcurrency,
@@ -1228,19 +1228,32 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1228
1228
  const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1229
1229
 
1230
1230
  if (!allowed) {
1231
- await Promise.race([
1232
- removeOverlayMenu(page),
1233
- new Promise((_, reject) => {
1234
- setTimeout(() => {
1235
- reject(
1236
- new Error(
1237
- `removeOverlayMenu timed out after ${OVERLAY_OPERATION_TIMEOUT_MS}ms`,
1238
- ),
1239
- );
1240
- }, OVERLAY_OPERATION_TIMEOUT_MS);
1241
- }),
1242
- ]);
1243
- return;
1231
+ // On macOS and Windows the custom flow always runs headful.
1232
+ // The URL guard (urlGuard.ts) intercepts non-http/https navigations
1233
+ // and calls page.goto(safeUrl). Do NOT remove the overlay here —
1234
+ // removing it causes it to stay permanently disabled if the redirect
1235
+ // races ahead of the next reconcile cycle.
1236
+ // Instead, fall through to the hasOverlay / addOverlayMenu block so
1237
+ // the overlay is (re-)injected even on transient non-http/https URLs
1238
+ // (e.g. file://, about:blank) and again after the guard's redirect.
1239
+ const isDesktopHost = process.platform === 'darwin' || process.platform === 'win32';
1240
+ if (!isDesktopHost) {
1241
+ // On Linux / Docker: remove overlay for non-http/https URLs and stop.
1242
+ await Promise.race([
1243
+ removeOverlayMenu(page),
1244
+ new Promise((_, reject) => {
1245
+ setTimeout(() => {
1246
+ reject(
1247
+ new Error(
1248
+ `removeOverlayMenu timed out after ${OVERLAY_OPERATION_TIMEOUT_MS}ms`,
1249
+ ),
1250
+ );
1251
+ }, OVERLAY_OPERATION_TIMEOUT_MS);
1252
+ }),
1253
+ ]);
1254
+ return;
1255
+ }
1256
+ // Desktop hosts: skip removal and fall through to re-add overlay.
1244
1257
  }
1245
1258
 
1246
1259
  const hasOverlay = await page.evaluate(() =>
@@ -35,8 +35,18 @@ export function addUrlGuardScript(context, opts = {}) {
35
35
  });
36
36
 
37
37
  const restoreToSafeUrl = async (page, attemptedUrl) => {
38
+ const safeUrl = lastAllowedUrlByPage.get(page) || fallbackUrl || 'about:blank';
39
+ // Only redirect if the safe URL is itself an allowed (http/https) URL.
40
+ // If the entry URL is file:// (e.g. scanning a local HTML file), the
41
+ // fallback is also file://, and redirecting would create an infinite loop:
42
+ // file:// → restoreToSafeUrl → file:// → framenavigated → restoreToSafeUrl → …
43
+ try {
44
+ const safeObj = new URL(safeUrl);
45
+ if (!ALLOWED_PROTOCOLS.has(safeObj.protocol)) return;
46
+ } catch {
47
+ return;
48
+ }
38
49
  try {
39
- const safeUrl = lastAllowedUrlByPage.get(page) || fallbackUrl || 'about:blank';
40
50
  await page.goto(safeUrl, { waitUntil: 'domcontentloaded' });
41
51
  } catch {
42
52
  // page might be closing; ignore
@@ -58,6 +68,13 @@ export function addUrlGuardScript(context, opts = {}) {
58
68
  lastAllowedUrlByPage.set(page, urlObj.toString());
59
69
  return;
60
70
  }
71
+
72
+ // Skip browser-internal transitional states (about:blank, about:srcdoc, etc.).
73
+ // page.goto() navigates through about:blank before loading the target URL.
74
+ // Redirecting from about: creates an infinite loop:
75
+ // restoreToSafeUrl → page.goto(safeUrl) → about:blank → restoreToSafeUrl → …
76
+ if (urlObj.protocol === 'about:') return;
77
+
61
78
  await restoreToSafeUrl(page, urlStr);
62
79
  });
63
80
  };
@@ -126,6 +126,18 @@ export const generateHtmlReport = async (
126
126
  const scanData = JSON.parse(await fs.readFile(scanDataJsonPath, 'utf8'));
127
127
  const scanItemsAll = JSON.parse(await fs.readFile(scanItemsJsonPath, 'utf8'));
128
128
 
129
+ // Disk space: passed may be absent from scanItems.json (excluded to reduce disk usage).
130
+ // Provide an empty-category fallback so convertItemsToReferences does not crash.
131
+ // To revert (when passed is restored in scanItems.json), remove this block.
132
+ if (!scanItemsAll.passed) {
133
+ scanItemsAll.passed = {
134
+ description: itemTypeDescription.passed,
135
+ totalItems: 0,
136
+ totalRuleIssues: 0,
137
+ rules: [],
138
+ };
139
+ }
140
+
129
141
  // Build the lighter scanItems payload used by the HTML report.
130
142
  const lightScanItemsPayload = convertItemsToReferences({
131
143
  items: scanItemsAll,
@@ -141,7 +153,7 @@ export const generateHtmlReport = async (
141
153
  mustFix: ensureCategory(mustFix, 'mustFix'),
142
154
  goodToFix: ensureCategory(goodToFix, 'goodToFix'),
143
155
  needsReview: ensureCategory(needsReview, 'needsReview'),
144
- passed: ensureCategory(scanItemsAll.passed || {}, 'passed'),
156
+ passed: ensureCategory(scanItemsAll.passed, 'passed'),
145
157
  };
146
158
 
147
159
  const pagesScanned = Array.isArray(scanData.pagesScanned) ? scanData.pagesScanned : [];
package/src/logs.ts CHANGED
@@ -39,6 +39,10 @@ export const errorsTxtPath = path.join(basePath, `${uuid}.txt`);
39
39
 
40
40
  const consoleLogger = createLogger({
41
41
  silent: !(process.env.RUNNING_FROM_PH_GUI || process.env.OOBEE_VERBOSE),
42
+ // exitOnError: false — Winston logs uncaught exceptions but does not call process.exit().
43
+ // Process lifecycle is controlled by psTreeHandler in combine.ts, which suppresses known
44
+ // benign errors (e.g. Playwright post-close connection errors) and rethrows real ones.
45
+ exitOnError: false,
42
46
  format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat),
43
47
  transports: [
44
48
  new transports.Console({ level: 'info' }),
@@ -54,6 +58,8 @@ const consoleLogger = createLogger({
54
58
  // Also used in common functions to not link internal information
55
59
  // if running from mass scanner, log out errors in console
56
60
  const silentLogger = createLogger({
61
+ // exitOnError: false — see consoleLogger comment above.
62
+ exitOnError: false,
57
63
  format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat),
58
64
  transports: [
59
65
  new transports.File({
@@ -292,9 +292,14 @@ const writeJsonAndBase64Files = async (
292
292
  const { items, ...rest } = allIssues;
293
293
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
294
294
  await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
295
+ // Disk space: passed items excluded from scanItems.json to reduce disk usage.
296
+ // Passed counts are still in scanData.json and the embedded report payload (scanItems-light).
297
+ // To revert, remove the destructure line and restore the original argument:
298
+ // { oobeeAppVersion: allIssues.oobeeAppVersion, ...items }
299
+ const { passed: _passedItems, ...itemsWithoutPassed } = items;
295
300
  const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
296
301
  await writeJsonFileAndCompressedJsonFile(
297
- { oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
302
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...itemsWithoutPassed },
298
303
  storagePath,
299
304
  'scanItems',
300
305
  itemsStore,
@@ -359,6 +359,16 @@ const writeSummaryPdf = async (
359
359
  browser: string,
360
360
  _userDataDirectory: string,
361
361
  ) => {
362
+ // Flush stale browser temp files before launching PDF browser (Docker only,
363
+ // where TMPDIR is a per-scan subdirectory we control).
364
+ if (process.env.TMPDIR && fs.existsSync('/.dockerenv')) {
365
+ try {
366
+ fs.emptyDirSync(process.env.TMPDIR);
367
+ } catch {
368
+ // Best-effort; locked files from prior browser sessions are non-fatal.
369
+ }
370
+ }
371
+
362
372
  const renderPdfWithBrowser = async (browserToUse: string) => {
363
373
  let browserInstance;
364
374
  let context;
@@ -525,26 +535,40 @@ const pushResults = async (pageResults, allIssues, isCustomFlow, itemsStore: Ite
525
535
  metadata,
526
536
  itemsCount: items.length,
527
537
  };
528
- await itemsStore.appendPageItems(category, rule, {
529
- url,
530
- pageTitle,
531
- items,
532
- pageIndex,
533
- pageImagePath,
534
- metadata,
535
- });
538
+ // Disk space: skip storing passed items in itemsStore.
539
+ // To revert, remove the `if` guard and use the original call directly:
540
+ // await itemsStore.appendPageItems(category, rule, {
541
+ // url, pageTitle, items, pageIndex, pageImagePath, metadata,
542
+ // });
543
+ if (category !== 'passed') {
544
+ await itemsStore.appendPageItems(category, rule, {
545
+ url,
546
+ pageTitle,
547
+ items,
548
+ pageIndex,
549
+ pageImagePath,
550
+ metadata,
551
+ });
552
+ }
536
553
  } else if (!(url in currRuleFromAllIssues.pagesAffected)) {
537
554
  currRuleFromAllIssues.pagesAffected[url] = {
538
555
  pageTitle,
539
556
  itemsCount: items.length,
540
557
  ...(filePath && { filePath }),
541
558
  };
542
- await itemsStore.appendPageItems(category, rule, {
543
- url,
544
- pageTitle,
545
- items,
546
- ...(filePath && { filePath }),
547
- });
559
+ // Disk space: skip storing passed items in itemsStore.
560
+ // To revert, remove the `if` guard and use the original call directly:
561
+ // await itemsStore.appendPageItems(category, rule, {
562
+ // url, pageTitle, items, ...(filePath && { filePath }),
563
+ // });
564
+ if (category !== 'passed') {
565
+ await itemsStore.appendPageItems(category, rule, {
566
+ url,
567
+ pageTitle,
568
+ items,
569
+ ...(filePath && { filePath }),
570
+ });
571
+ }
548
572
  }
549
573
  }
550
574
  }
@@ -924,7 +948,9 @@ const generateArtifacts = async (
924
948
  populateScanPagesDetail(allIssues);
925
949
 
926
950
  // Build htmlGroups in a second pass from disk-backed items
927
- for (const category of ['mustFix', 'goodToFix', 'needsReview', 'passed'] as const) {
951
+ // Disk space: 'passed' removed from this loop (no items stored in itemsStore for passed).
952
+ // To revert, restore: ['mustFix', 'goodToFix', 'needsReview', 'passed']
953
+ for (const category of ['mustFix', 'goodToFix', 'needsReview'] as const) {
928
954
  for (const rule of allIssues.items[category].rules) {
929
955
  for await (const entry of itemsStore.readRuleItems(category, rule.rule)) {
930
956
  buildHtmlGroups(rule, entry.items, entry.url);