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