@govtechsg/oobee 0.10.98 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +40 -1
- package/CRAWL.md +260 -0
- package/README.md +6 -0
- package/dist/combine.js +13 -0
- package/dist/constants/common.js +7 -0
- package/dist/constants/constants.js +17 -0
- package/dist/crawlers/commonCrawlerFunc.js +89 -48
- package/dist/crawlers/crawlDomain.js +103 -32
- package/dist/crawlers/crawlLocalFile.js +2 -0
- package/dist/crawlers/crawlSitemap.js +141 -20
- package/dist/crawlers/custom/utils.js +2 -0
- package/dist/crawlers/pageCapture.js +134 -0
- package/dist/mergeAxeResults.js +2 -1
- package/dist/utils.js +36 -9
- package/oobee-client-scanner.js +2 -2
- package/package.json +1 -1
- package/src/combine.ts +14 -0
- package/src/constants/common.ts +9 -0
- package/src/constants/constants.ts +17 -0
- package/src/crawlers/commonCrawlerFunc.ts +101 -62
- package/src/crawlers/crawlDomain.ts +105 -32
- package/src/crawlers/crawlLocalFile.ts +3 -0
- package/src/crawlers/crawlSitemap.ts +148 -21
- package/src/crawlers/custom/utils.ts +3 -0
- package/src/crawlers/pageCapture.ts +172 -0
- package/src/mergeAxeResults.ts +2 -1
- package/src/utils.ts +31 -8
- /package/{0a93900f-3345-4f25-8be7-c2d3748034be.txt → 45de6711-5a2f-492e-99b1-290059c74264.txt} +0 -0
|
@@ -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:
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
}
|
package/dist/mergeAxeResults.js
CHANGED
|
@@ -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
|