@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.
- package/AGENTS.md +20 -0
- package/dist/cli.js +3 -2
- package/dist/combine.js +10 -3
- package/dist/constants/common.js +131 -52
- package/dist/crawlers/commonCrawlerFunc.js +11 -2
- package/dist/crawlers/crawlDomain.js +4 -6
- package/dist/crawlers/crawlSitemap.js +14 -2
- package/dist/crawlers/custom/utils.js +22 -9
- package/dist/crawlers/guards/urlGuard.js +19 -1
- package/dist/generateHtmlReport.js +12 -1
- package/dist/logs.js +6 -0
- package/dist/mergeAxeResults/jsonArtifacts.js +6 -1
- package/dist/mergeAxeResults.js +41 -15
- package/dist/static/ejs/partials/components/allIssues/CategoryBadges.ejs +3 -0
- package/dist/static/ejs/partials/components/allIssues/IssuesTable.ejs +3 -3
- package/dist/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +1 -1
- package/dist/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +3 -3
- package/dist/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +34 -27
- package/dist/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +1 -0
- package/dist/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +7 -0
- package/dist/static/ejs/partials/components/wcagCoverageDetails.ejs +5 -5
- package/dist/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +3 -3
- package/dist/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +21 -19
- package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +39 -8
- package/dist/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +11 -5
- package/dist/static/ejs/partials/scripts/screenshotLightbox.ejs +49 -31
- package/dist/static/ejs/partials/styles/header/SiteInfo.ejs +1 -1
- package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +36 -16
- package/dist/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +22 -1
- package/dist/static/ejs/partials/styles/styles.ejs +1 -1
- package/dist/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +6 -0
- package/dist/static/ejs/partials/styles/wcagCompliance.ejs +5 -4
- package/dist/static/ejs/partials/styles/wcagCoverageDetails.ejs +6 -1
- package/dist/utils.js +7 -0
- package/oobee-client-scanner.js +2 -2
- package/package.json +1 -1
- package/src/cli.ts +3 -2
- package/src/combine.ts +10 -2
- package/src/constants/common.ts +125 -36
- package/src/crawlers/commonCrawlerFunc.ts +11 -2
- package/src/crawlers/crawlDomain.ts +4 -5
- package/src/crawlers/crawlSitemap.ts +19 -2
- package/src/crawlers/custom/utils.ts +26 -13
- package/src/crawlers/guards/urlGuard.ts +18 -1
- package/src/generateHtmlReport.ts +13 -1
- package/src/logs.ts +6 -0
- package/src/mergeAxeResults/jsonArtifacts.ts +6 -1
- package/src/mergeAxeResults.ts +41 -15
- package/src/static/ejs/partials/components/allIssues/CategoryBadges.ejs +3 -0
- package/src/static/ejs/partials/components/allIssues/IssuesTable.ejs +3 -3
- package/src/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +1 -1
- package/src/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +3 -3
- package/src/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +34 -27
- package/src/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +1 -0
- package/src/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +7 -0
- package/src/static/ejs/partials/components/wcagCoverageDetails.ejs +5 -5
- package/src/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +3 -3
- package/src/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +21 -19
- package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +39 -8
- package/src/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +11 -5
- package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +49 -31
- package/src/static/ejs/partials/styles/header/SiteInfo.ejs +1 -1
- package/src/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +36 -16
- package/src/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +22 -1
- package/src/static/ejs/partials/styles/styles.ejs +1 -1
- package/src/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +6 -0
- package/src/static/ejs/partials/styles/wcagCompliance.ejs +5 -4
- package/src/static/ejs/partials/styles/wcagCoverageDetails.ejs +6 -1
- package/src/utils.ts +6 -0
- package/testStaticJSScanner.html +1 -1
- /package/{7339fae5-e8ed-4b50-af13-317847620dbf.txt → fb5db217-5ab7-4120-b33b-71fa69b454d0.txt} +0 -0
package/src/constants/common.ts
CHANGED
|
@@ -359,8 +359,11 @@ const checkUrlConnectivityWithBrowser = async (
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
// Ensure Accept header for non-html content fallback
|
|
363
|
-
extraHTTPHeaders
|
|
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 } =
|
|
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 (
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
}
|
|
449
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
|
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
|
-
|
|
1205
|
-
if (root.name === 'urlset' && xmlns.includes(xmlFormatNamespace)) {
|
|
1269
|
+
if (rootName === 'urlset') {
|
|
1206
1270
|
sitemapType = constants.xmlSitemapTypes.xml;
|
|
1207
|
-
} else if (
|
|
1271
|
+
} else if (rootName === 'sitemapindex') {
|
|
1208
1272
|
sitemapType = constants.xmlSitemapTypes.xmlIndex;
|
|
1209
|
-
} else if (
|
|
1273
|
+
} else if (rootName === 'rss') {
|
|
1210
1274
|
sitemapType = constants.xmlSitemapTypes.rss;
|
|
1211
|
-
} else if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
|
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, ...
|
|
302
|
+
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...itemsWithoutPassed },
|
|
298
303
|
storagePath,
|
|
299
304
|
'scanItems',
|
|
300
305
|
itemsStore,
|
package/src/mergeAxeResults.ts
CHANGED
|
@@ -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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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);
|