@govtechsg/oobee 0.10.98 → 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 +25 -0
- package/CRAWL.md +260 -0
- package/README.md +4 -0
- package/dist/combine.js +7 -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 +88 -30
- package/dist/crawlers/crawlSitemap.js +123 -18
- 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 +7 -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 +89 -30
- package/src/crawlers/crawlSitemap.ts +128 -19
- package/src/mergeAxeResults.ts +2 -1
- package/src/utils.ts +31 -8
- /package/{0a93900f-3345-4f25-8be7-c2d3748034be.txt → f0ce5c33-4dd0-4e14-8e67-1394f57ff517.txt} +0 -0
|
@@ -1135,10 +1135,9 @@ export const createCrawleeSubFolders = async (
|
|
|
1135
1135
|
randomToken: string,
|
|
1136
1136
|
): Promise<{ dataset: Dataset; requestQueue: RequestQueue }> => {
|
|
1137
1137
|
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
const
|
|
1141
|
-
const requestQueue = await RequestQueue.open(crawleeDir);
|
|
1138
|
+
const baseDir = path.join(getStoragePath(randomToken), "crawlee");
|
|
1139
|
+
const dataset = await Dataset.open(baseDir);
|
|
1140
|
+
const requestQueue = await RequestQueue.open(`${baseDir}_rq`);
|
|
1142
1141
|
return { dataset, requestQueue };
|
|
1143
1142
|
};
|
|
1144
1143
|
|
|
@@ -1215,77 +1214,90 @@ export const postNavigationHooks = [
|
|
|
1215
1214
|
|
|
1216
1215
|
export const getPreLaunchHook = (userDataDirectory: string) => {
|
|
1217
1216
|
let launchCount = 0;
|
|
1217
|
+
let previousPoolDir: string | null = null;
|
|
1218
1218
|
|
|
1219
1219
|
return async (_pageId: string, launchContext: any) => {
|
|
1220
1220
|
const fsp = await import('fs/promises').then(m => m.default);
|
|
1221
1221
|
launchCount += 1;
|
|
1222
1222
|
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1223
|
+
// Clean up the previous pool directory. When preLaunchHooks fires, the
|
|
1224
|
+
// previous browser has been retired and is finishing its last pages.
|
|
1225
|
+
// Schedule cleanup with enough delay for Chrome to fully exit —
|
|
1226
|
+
// closeInactiveBrowserAfterSecs (30s) + Windows file-lock grace.
|
|
1227
|
+
if (previousPoolDir) {
|
|
1228
|
+
const dirToClean = previousPoolDir;
|
|
1229
|
+
const isWin = process.platform === 'win32';
|
|
1230
|
+
const delay = isWin ? 40000 : 35000;
|
|
1231
|
+
setTimeout(async () => {
|
|
1232
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1233
|
+
try {
|
|
1234
|
+
await fsp.rm(dirToClean, { recursive: true, force: true });
|
|
1235
|
+
return;
|
|
1236
|
+
} catch {
|
|
1237
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}, delay);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Every browser gets its own directory. The base userDataDirectory is
|
|
1244
|
+
// treated as a read-only cookie source (the pristine clone of the user's
|
|
1245
|
+
// profile) and is never used directly by a running browser.
|
|
1246
|
+
const effectiveDir = `${userDataDirectory}_pool${launchCount}`;
|
|
1230
1247
|
|
|
1231
1248
|
await fsp.mkdir(effectiveDir, { recursive: true });
|
|
1232
1249
|
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
if (await fsp.stat(localStateSrc).catch(() => null)) {
|
|
1241
|
-
await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
|
|
1242
|
-
}
|
|
1250
|
+
// Copy auth-relevant files from the pristine base directory so
|
|
1251
|
+
// authenticated sessions are preserved across pool rotations.
|
|
1252
|
+
try {
|
|
1253
|
+
const localStateSrc = path.join(userDataDirectory, 'Local State');
|
|
1254
|
+
if (await fsp.stat(localStateSrc).catch(() => null)) {
|
|
1255
|
+
await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
|
|
1256
|
+
}
|
|
1243
1257
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
);
|
|
1249
|
-
|
|
1250
|
-
for (const profile of profileDirs) {
|
|
1251
|
-
const srcProfile = path.join(userDataDirectory, profile.name);
|
|
1252
|
-
const destProfile = path.join(effectiveDir, profile.name);
|
|
1253
|
-
await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});
|
|
1254
|
-
|
|
1255
|
-
// Cookies (macOS layout: <Profile>/Cookies)
|
|
1256
|
-
const cookiesSrc = path.join(srcProfile, 'Cookies');
|
|
1257
|
-
if (await fsp.stat(cookiesSrc).catch(() => null)) {
|
|
1258
|
-
await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
|
|
1259
|
-
}
|
|
1258
|
+
const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
|
|
1259
|
+
const profileDirs = (entries as any[]).filter(
|
|
1260
|
+
(e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
|
|
1261
|
+
);
|
|
1260
1262
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
|
|
1266
|
-
await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
|
|
1267
|
-
}
|
|
1263
|
+
for (const profile of profileDirs) {
|
|
1264
|
+
const srcProfile = path.join(userDataDirectory, profile.name);
|
|
1265
|
+
const destProfile = path.join(effectiveDir, profile.name);
|
|
1266
|
+
await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});
|
|
1268
1267
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1268
|
+
// Cookies (macOS layout: <Profile>/Cookies)
|
|
1269
|
+
const cookiesSrc = path.join(srcProfile, 'Cookies');
|
|
1270
|
+
if (await fsp.stat(cookiesSrc).catch(() => null)) {
|
|
1271
|
+
await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Cookies (Windows layout: <Profile>/Network/Cookies)
|
|
1275
|
+
const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
|
|
1276
|
+
if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
|
|
1277
|
+
const destNetwork = path.join(destProfile, 'Network');
|
|
1278
|
+
await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
|
|
1279
|
+
await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Local Storage (auth tokens, session data)
|
|
1283
|
+
const localStorageSrc = path.join(srcProfile, 'Local Storage');
|
|
1284
|
+
const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
|
|
1285
|
+
if (localStorageStat && localStorageStat.isDirectory()) {
|
|
1286
|
+
const destLocalStorage = path.join(destProfile, 'Local Storage');
|
|
1287
|
+
await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
|
|
1288
|
+
const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
|
|
1289
|
+
await Promise.all(
|
|
1290
|
+
lsFiles.map(f =>
|
|
1291
|
+
fsp.copyFile(
|
|
1292
|
+
path.join(localStorageSrc, f),
|
|
1293
|
+
path.join(destLocalStorage, f),
|
|
1294
|
+
).catch(() => {}),
|
|
1295
|
+
),
|
|
1296
|
+
);
|
|
1285
1297
|
}
|
|
1286
|
-
} catch {
|
|
1287
|
-
// Silent fallback: use empty profile if clone fails
|
|
1288
1298
|
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Silent fallback: use empty profile if clone fails
|
|
1289
1301
|
}
|
|
1290
1302
|
|
|
1291
1303
|
// Clean any stale lock files that may block browser launches on Windows
|
|
@@ -1299,11 +1311,38 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
|
|
|
1299
1311
|
];
|
|
1300
1312
|
await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => {})));
|
|
1301
1313
|
|
|
1314
|
+
previousPoolDir = effectiveDir;
|
|
1302
1315
|
// eslint-disable-next-line no-param-reassign
|
|
1303
1316
|
launchContext.userDataDir = effectiveDir;
|
|
1304
1317
|
};
|
|
1305
1318
|
};
|
|
1306
1319
|
|
|
1320
|
+
export const getPostPageCloseHook = (userDataDirectory: string) => {
|
|
1321
|
+
return async (_pageId: string, browserController: any) => {
|
|
1322
|
+
if (browserController.activePages === 0) {
|
|
1323
|
+
const dir = browserController.launchContext?.userDataDir;
|
|
1324
|
+
if (dir && dir !== userDataDirectory && dir.includes('_pool')) {
|
|
1325
|
+
const fsp = await import('fs/promises').then(m => m.default);
|
|
1326
|
+
// Chrome on Windows writes optimization/segmentation data during shutdown;
|
|
1327
|
+
// wait longer (5s) for the process to fully release file handles.
|
|
1328
|
+
const isWin = process.platform === 'win32';
|
|
1329
|
+
const initialDelay = isWin ? 5000 : 2000;
|
|
1330
|
+
setTimeout(async () => {
|
|
1331
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1332
|
+
try {
|
|
1333
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
1334
|
+
return;
|
|
1335
|
+
} catch {
|
|
1336
|
+
// Retry after a short back-off (Windows file locks)
|
|
1337
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}, initialDelay);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1307
1346
|
export const failedRequestHandler = async ({ request }: { request: Request }) => {
|
|
1308
1347
|
guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
|
|
1309
1348
|
log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
|
|
@@ -5,6 +5,7 @@ import type { PlaywrightCrawlingContext, RequestOptions } from 'crawlee';
|
|
|
5
5
|
import {
|
|
6
6
|
createCrawleeSubFolders,
|
|
7
7
|
getPreLaunchHook,
|
|
8
|
+
getPostPageCloseHook,
|
|
8
9
|
preNavigationHooks,
|
|
9
10
|
runAxeScript,
|
|
10
11
|
isUrlPdf,
|
|
@@ -418,12 +419,23 @@ const crawlDomain = async ({
|
|
|
418
419
|
};
|
|
419
420
|
},
|
|
420
421
|
],
|
|
422
|
+
postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
|
|
421
423
|
},
|
|
422
424
|
requestQueue,
|
|
423
425
|
maxRequestRetries: 3,
|
|
424
426
|
maxSessionRotations: 1,
|
|
425
427
|
preNavigationHooks: [
|
|
426
428
|
...preNavigationHooks(extraHTTPHeaders),
|
|
429
|
+
async ({ request }) => {
|
|
430
|
+
const url = request.url.toLowerCase();
|
|
431
|
+
try {
|
|
432
|
+
const pathname = new URL(url).pathname;
|
|
433
|
+
const ext = pathname.split('.').pop();
|
|
434
|
+
if (ext && blackListedFileExtensions.includes(ext)) {
|
|
435
|
+
request.skipNavigation = true;
|
|
436
|
+
}
|
|
437
|
+
} catch {}
|
|
438
|
+
},
|
|
427
439
|
],
|
|
428
440
|
postNavigationHooks: [
|
|
429
441
|
async crawlingContext => {
|
|
@@ -555,21 +567,17 @@ const crawlDomain = async ({
|
|
|
555
567
|
(request.skipNavigation && actualUrl === 'about:blank')
|
|
556
568
|
) {
|
|
557
569
|
if (!isScanPdfs) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
httpStatusCode: 0,
|
|
570
|
-
});
|
|
571
|
-
*/
|
|
572
|
-
|
|
570
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
571
|
+
numScanned: urlsCrawled.scanned.length,
|
|
572
|
+
urlScanned: request.url,
|
|
573
|
+
});
|
|
574
|
+
urlsCrawled.userExcluded.push({
|
|
575
|
+
url: request.url,
|
|
576
|
+
pageTitle: request.url,
|
|
577
|
+
actualUrl: request.url,
|
|
578
|
+
metadata: STATUS_CODE_METADATA[1],
|
|
579
|
+
httpStatusCode: 1,
|
|
580
|
+
});
|
|
573
581
|
return;
|
|
574
582
|
}
|
|
575
583
|
const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(
|
|
@@ -585,20 +593,17 @@ const crawlDomain = async ({
|
|
|
585
593
|
}
|
|
586
594
|
|
|
587
595
|
if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
httpStatusCode: 0,
|
|
600
|
-
});
|
|
601
|
-
*/
|
|
596
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
597
|
+
numScanned: urlsCrawled.scanned.length,
|
|
598
|
+
urlScanned: request.url,
|
|
599
|
+
});
|
|
600
|
+
urlsCrawled.userExcluded.push({
|
|
601
|
+
url: request.url,
|
|
602
|
+
pageTitle: request.url,
|
|
603
|
+
actualUrl,
|
|
604
|
+
metadata: STATUS_CODE_METADATA[1],
|
|
605
|
+
httpStatusCode: 1,
|
|
606
|
+
});
|
|
602
607
|
return;
|
|
603
608
|
}
|
|
604
609
|
|
|
@@ -800,7 +805,59 @@ const crawlDomain = async ({
|
|
|
800
805
|
return;
|
|
801
806
|
}
|
|
802
807
|
|
|
808
|
+
// Handle download-triggered navigation errors: Playwright throws
|
|
809
|
+
// "Download is starting" when page.goto() hits a file download URL.
|
|
810
|
+
// Crawlee retries 3 times (all fail) then lands here.
|
|
811
|
+
const isDownloadError = request.errorMessages?.some(
|
|
812
|
+
(msg: string) => msg.includes('Download is starting'),
|
|
813
|
+
);
|
|
814
|
+
if (isDownloadError) {
|
|
815
|
+
if (isScanPdfs) {
|
|
816
|
+
// Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
|
|
817
|
+
try {
|
|
818
|
+
await requestQueue.addRequest({
|
|
819
|
+
url: request.url,
|
|
820
|
+
skipNavigation: true,
|
|
821
|
+
label: request.url,
|
|
822
|
+
uniqueKey: `download_${request.url}`,
|
|
823
|
+
});
|
|
824
|
+
} catch {}
|
|
825
|
+
} else {
|
|
826
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
827
|
+
numScanned: urlsCrawled.scanned.length,
|
|
828
|
+
urlScanned: request.url,
|
|
829
|
+
});
|
|
830
|
+
urlsCrawled.userExcluded.push({
|
|
831
|
+
url: request.url,
|
|
832
|
+
pageTitle: request.url,
|
|
833
|
+
actualUrl: request.url,
|
|
834
|
+
metadata: STATUS_CODE_METADATA[1],
|
|
835
|
+
httpStatusCode: 1,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
803
841
|
const status = response?.status();
|
|
842
|
+
|
|
843
|
+
// Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
|
|
844
|
+
// Without this, URLs that fail during a rate-limit burst are permanently lost
|
|
845
|
+
// even though the site is accessible at lower concurrency.
|
|
846
|
+
// Don't call onFailure here — the re-enqueued request will get a fresh attempt.
|
|
847
|
+
// If it fails again (rateLimitRetried=true), it falls through to the normal
|
|
848
|
+
// onFailure + circuit breaker path below.
|
|
849
|
+
if (status === 403 && !request.userData?.rateLimitRetried) {
|
|
850
|
+
try {
|
|
851
|
+
await requestQueue.addRequest({
|
|
852
|
+
url: request.url,
|
|
853
|
+
label: request.url,
|
|
854
|
+
uniqueKey: `ratelimit_${request.url}`,
|
|
855
|
+
userData: { rateLimitRetried: true },
|
|
856
|
+
});
|
|
857
|
+
} catch {}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
804
861
|
if (rateController.onFailure(status, crawler.autoscaledPool)) {
|
|
805
862
|
consoleLogger.info(
|
|
806
863
|
`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
|
|
@@ -844,7 +901,9 @@ const crawlDomain = async ({
|
|
|
844
901
|
|
|
845
902
|
// Additional passes: keep re-visiting scanned seed-hostname pages for
|
|
846
903
|
// click-discovery until no new pages are found or limits are reached.
|
|
847
|
-
|
|
904
|
+
// Skip when called from intelligent sitemap — the domain phase is only meant
|
|
905
|
+
// to discover new pages via <a> links, not re-click 3000+ already-scanned pages.
|
|
906
|
+
if (!safeMode && !isAbortingScanNow && !durationExceeded && !fromCrawlIntelligentSitemap) {
|
|
848
907
|
const seedHostname = new URL(url).hostname;
|
|
849
908
|
const clickPassVisited = new Set<string>();
|
|
850
909
|
let prevScannedCount: number;
|
|
@@ -4,6 +4,7 @@ import fs from 'fs';
|
|
|
4
4
|
import {
|
|
5
5
|
createCrawleeSubFolders,
|
|
6
6
|
getPreLaunchHook,
|
|
7
|
+
getPostPageCloseHook,
|
|
7
8
|
preNavigationHooks,
|
|
8
9
|
runAxeScript,
|
|
9
10
|
isUrlPdf,
|
|
@@ -14,13 +15,16 @@ import constants, {
|
|
|
14
15
|
STATUS_CODE_METADATA,
|
|
15
16
|
guiInfoStatusTypes,
|
|
16
17
|
UrlsCrawled,
|
|
18
|
+
blackListedFileExtensions,
|
|
17
19
|
disallowedListOfPatterns,
|
|
20
|
+
disallowedSelectorPatterns,
|
|
18
21
|
FileTypes,
|
|
19
22
|
RuleFlags,
|
|
20
23
|
} from '../constants/constants.js';
|
|
21
24
|
import {
|
|
22
25
|
getLinksFromSitemap,
|
|
23
26
|
getPlaywrightLaunchOptions,
|
|
27
|
+
isDisallowedInRobotsTxt,
|
|
24
28
|
isSkippedUrl,
|
|
25
29
|
waitForPageLoaded,
|
|
26
30
|
isFilePath,
|
|
@@ -133,6 +137,12 @@ const crawlSitemap = async ({
|
|
|
133
137
|
sources: linksFromSitemap,
|
|
134
138
|
});
|
|
135
139
|
|
|
140
|
+
// Always create a request queue alongside the request list. An empty queue
|
|
141
|
+
// has zero impact on crawl behavior (Crawlee processes RequestList first).
|
|
142
|
+
// Having it available enables: download re-enqueue for PDF scanning,
|
|
143
|
+
// 403 rate-limit retry, and enqueueLinks for intelligent sitemap discovery.
|
|
144
|
+
const { requestQueue } = await createCrawleeSubFolders(randomToken);
|
|
145
|
+
|
|
136
146
|
const crawler = register(
|
|
137
147
|
new crawlee.PlaywrightCrawler({
|
|
138
148
|
launchContext: {
|
|
@@ -158,8 +168,10 @@ const crawlSitemap = async ({
|
|
|
158
168
|
};
|
|
159
169
|
},
|
|
160
170
|
],
|
|
171
|
+
postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
|
|
161
172
|
},
|
|
162
173
|
requestList,
|
|
174
|
+
requestQueue,
|
|
163
175
|
maxRequestRetries: 3,
|
|
164
176
|
maxSessionRotations: 1,
|
|
165
177
|
postNavigationHooks: [
|
|
@@ -225,16 +237,22 @@ const crawlSitemap = async ({
|
|
|
225
237
|
if (isNotSupportedDocument) {
|
|
226
238
|
request.skipNavigation = true;
|
|
227
239
|
request.userData.isNotSupportedDocument = true;
|
|
228
|
-
|
|
229
|
-
// Log for verification (optional, but not required for correctness)
|
|
230
|
-
// console.log(`[SKIP] Not supported: ${request.url}`);
|
|
231
|
-
|
|
232
240
|
return;
|
|
233
241
|
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const pathname = new URL(url).pathname;
|
|
245
|
+
const ext = pathname.split('.').pop();
|
|
246
|
+
if (ext && blackListedFileExtensions.includes(ext)) {
|
|
247
|
+
request.skipNavigation = true;
|
|
248
|
+
request.userData.isNotSupportedDocument = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
} catch {}
|
|
234
252
|
},
|
|
235
253
|
],
|
|
236
254
|
requestHandlerTimeoutSecs: 90,
|
|
237
|
-
requestHandler: async ({ page, request, response, sendRequest }) => {
|
|
255
|
+
requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
|
|
238
256
|
// Log documents that are not supported
|
|
239
257
|
if (request.userData?.isNotSupportedDocument) {
|
|
240
258
|
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
@@ -401,6 +419,32 @@ const crawlSitemap = async ({
|
|
|
401
419
|
results.actualUrl = actualUrl;
|
|
402
420
|
|
|
403
421
|
await dataset.pushData(results);
|
|
422
|
+
|
|
423
|
+
// Discover <a> links from this page for the intelligent sitemap flow.
|
|
424
|
+
// This eliminates the need for a separate crawlDomain supplement phase
|
|
425
|
+
// that would re-visit all these pages just to extract links.
|
|
426
|
+
if (fromCrawlIntelligentSitemap) {
|
|
427
|
+
try {
|
|
428
|
+
await enqueueLinks({
|
|
429
|
+
selector: `a:not(${disallowedSelectorPatterns})`,
|
|
430
|
+
strategy,
|
|
431
|
+
requestQueue,
|
|
432
|
+
transformRequestFunction: (req) => {
|
|
433
|
+
try {
|
|
434
|
+
req.url = req.url.replace(/(?<=&|\?)utm_.*?(&|$)/gim, '');
|
|
435
|
+
} catch {}
|
|
436
|
+
if (isDisallowedInRobotsTxt(req.url)) return null;
|
|
437
|
+
if (isUrlPdf(req.url)) {
|
|
438
|
+
req.skipNavigation = true;
|
|
439
|
+
}
|
|
440
|
+
req.label = req.url;
|
|
441
|
+
return req;
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
// Best-effort link discovery; don't fail the scan
|
|
446
|
+
}
|
|
447
|
+
}
|
|
404
448
|
}
|
|
405
449
|
} else {
|
|
406
450
|
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
@@ -409,20 +453,37 @@ const crawlSitemap = async ({
|
|
|
409
453
|
});
|
|
410
454
|
|
|
411
455
|
if (isScanHtml) {
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
456
|
+
// Non-HTML content types (images, PDFs, binary files) and URLs
|
|
457
|
+
// that redirect to non-HTML resources should be classified as
|
|
458
|
+
// unsupported documents, not generic page errors.
|
|
459
|
+
const isNonHtmlContent =
|
|
460
|
+
contentType &&
|
|
461
|
+
!contentType.startsWith('text/html') &&
|
|
462
|
+
!contentType.includes('html');
|
|
463
|
+
|
|
464
|
+
if (isNonHtmlContent && status !== 0) {
|
|
465
|
+
urlsCrawled.userExcluded.push({
|
|
466
|
+
actualUrl,
|
|
467
|
+
url: request.url,
|
|
468
|
+
pageTitle: request.url,
|
|
469
|
+
metadata: STATUS_CODE_METADATA[1],
|
|
470
|
+
httpStatusCode: 1,
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
const httpStatus = response?.status();
|
|
474
|
+
const metadata =
|
|
475
|
+
typeof httpStatus === 'number'
|
|
476
|
+
? STATUS_CODE_METADATA[httpStatus] || STATUS_CODE_METADATA[599]
|
|
477
|
+
: STATUS_CODE_METADATA[2];
|
|
478
|
+
|
|
479
|
+
urlsCrawled.invalid.push({
|
|
480
|
+
actualUrl,
|
|
481
|
+
url: request.url,
|
|
482
|
+
pageTitle: request.url,
|
|
483
|
+
metadata,
|
|
484
|
+
httpStatusCode: typeof httpStatus === 'number' ? httpStatus : 0,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
426
487
|
}
|
|
427
488
|
}
|
|
428
489
|
} catch (e) {
|
|
@@ -437,7 +498,55 @@ const crawlSitemap = async ({
|
|
|
437
498
|
return;
|
|
438
499
|
}
|
|
439
500
|
|
|
501
|
+
// Handle download-triggered navigation errors: Playwright throws
|
|
502
|
+
// "Download is starting" when page.goto() hits a file download URL.
|
|
503
|
+
const isDownloadError = request.errorMessages?.some(
|
|
504
|
+
(msg: string) => msg.includes('Download is starting'),
|
|
505
|
+
);
|
|
506
|
+
if (isDownloadError) {
|
|
507
|
+
if (isScanPdfs) {
|
|
508
|
+
// Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
|
|
509
|
+
try {
|
|
510
|
+
await requestQueue.addRequest({
|
|
511
|
+
url: request.url,
|
|
512
|
+
skipNavigation: true,
|
|
513
|
+
label: request.url,
|
|
514
|
+
uniqueKey: `download_${request.url}`,
|
|
515
|
+
});
|
|
516
|
+
} catch {}
|
|
517
|
+
} else {
|
|
518
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
519
|
+
numScanned: urlsCrawled.scanned.length,
|
|
520
|
+
urlScanned: request.url,
|
|
521
|
+
});
|
|
522
|
+
urlsCrawled.userExcluded.push({
|
|
523
|
+
url: request.url,
|
|
524
|
+
pageTitle: request.url,
|
|
525
|
+
actualUrl: request.url,
|
|
526
|
+
metadata: STATUS_CODE_METADATA[1],
|
|
527
|
+
httpStatusCode: 1,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
440
533
|
const status = response?.status();
|
|
534
|
+
|
|
535
|
+
// Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
|
|
536
|
+
// Don't call onFailure here — the re-enqueued request gets a fresh attempt.
|
|
537
|
+
// If it fails again (rateLimitRetried=true), it falls through to the normal path.
|
|
538
|
+
if (status === 403 && !request.userData?.rateLimitRetried) {
|
|
539
|
+
try {
|
|
540
|
+
await requestQueue.addRequest({
|
|
541
|
+
url: request.url,
|
|
542
|
+
label: request.url,
|
|
543
|
+
uniqueKey: `ratelimit_${request.url}`,
|
|
544
|
+
userData: { rateLimitRetried: true },
|
|
545
|
+
});
|
|
546
|
+
} catch {}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
441
550
|
if (rateController.onFailure(status, crawler.autoscaledPool)) {
|
|
442
551
|
consoleLogger.info(
|
|
443
552
|
`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
|
package/src/mergeAxeResults.ts
CHANGED
|
@@ -1110,7 +1110,7 @@ const generateArtifacts = async (
|
|
|
1110
1110
|
}
|
|
1111
1111
|
|
|
1112
1112
|
try {
|
|
1113
|
-
const requestQueue = await RequestQueue.open(crawleeDir);
|
|
1113
|
+
const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
|
|
1114
1114
|
await requestQueue.drop();
|
|
1115
1115
|
} catch (error) {
|
|
1116
1116
|
consoleLogger.info(`RequestQueue drop: ${error.message}`);
|
|
@@ -1120,6 +1120,7 @@ const generateArtifacts = async (
|
|
|
1120
1120
|
const crawleePath = path.join(storagePath, 'crawlee');
|
|
1121
1121
|
try {
|
|
1122
1122
|
await fs.promises.rm(crawleePath, { recursive: true, force: true });
|
|
1123
|
+
await fs.promises.rm(`${crawleePath}_rq`, { recursive: true, force: true });
|
|
1123
1124
|
} catch {
|
|
1124
1125
|
// Best-effort; storage was already dropped via API
|
|
1125
1126
|
}
|