@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
package/dist/utils.js
CHANGED
|
@@ -346,15 +346,34 @@ export const cleanUp = async (randomToken, isError = false) => {
|
|
|
346
346
|
catch (error) {
|
|
347
347
|
consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
|
|
348
348
|
}
|
|
349
|
-
// Also remove _pool* sibling directories created by browser pool re-launches
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
// Also remove _pool* sibling directories created by browser pool re-launches.
|
|
350
|
+
// On Windows, Chrome writes files (optimization_guide_model_store,
|
|
351
|
+
// segmentation_platform, first_party_sets.db, etc.) during its shutdown
|
|
352
|
+
// sequence and may still hold file locks. Retry up to 10 times at 5s intervals.
|
|
353
|
+
const removePoolDirs = () => {
|
|
354
|
+
// On Windows, glob interprets backslashes as escape characters, not path
|
|
355
|
+
// separators. Normalize to forward slashes so the pattern actually matches.
|
|
356
|
+
const globPattern = `${constants.userDataDirectory}_pool*`.replace(/\\/g, '/');
|
|
357
|
+
const poolDirs = globSync(globPattern);
|
|
352
358
|
for (const dir of poolDirs) {
|
|
353
359
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
354
360
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
return poolDirs.length;
|
|
362
|
+
};
|
|
363
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
364
|
+
try {
|
|
365
|
+
if (removePoolDirs() === 0)
|
|
366
|
+
break; // nothing left to clean
|
|
367
|
+
break; // rmSync succeeded for all dirs
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
if (attempt < 9) {
|
|
371
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
consoleLogger.warn('Unable to remove pool directories after 10 attempts');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
358
377
|
}
|
|
359
378
|
}
|
|
360
379
|
if (process.env.TMPDIR && fs.existsSync('/.dockerenv'))
|
|
@@ -374,7 +393,7 @@ export const cleanUp = async (randomToken, isError = false) => {
|
|
|
374
393
|
const crawleeDir = path.join(storagePath, 'crawlee');
|
|
375
394
|
const dataset = await Dataset.open(crawleeDir);
|
|
376
395
|
await dataset.drop();
|
|
377
|
-
const requestQueue = await RequestQueue.open(crawleeDir);
|
|
396
|
+
const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
|
|
378
397
|
await requestQueue.drop();
|
|
379
398
|
}
|
|
380
399
|
catch (error) {
|
|
@@ -386,6 +405,12 @@ export const cleanUp = async (randomToken, isError = false) => {
|
|
|
386
405
|
catch (error) {
|
|
387
406
|
consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
|
|
388
407
|
}
|
|
408
|
+
try {
|
|
409
|
+
fs.rmSync(path.join(storagePath, 'crawlee_rq'), { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
consoleLogger.warn(`Unable to force remove crawlee_rq folder: ${error.message}`);
|
|
413
|
+
}
|
|
389
414
|
try {
|
|
390
415
|
fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
|
|
391
416
|
}
|
|
@@ -808,9 +833,11 @@ export const zipResults = async (zipName, resultsPath) => {
|
|
|
808
833
|
if (!stats.isDirectory()) {
|
|
809
834
|
throw new Error(`resultsPath is not a directory: ${resultsPath}`);
|
|
810
835
|
}
|
|
811
|
-
async function addFolderToZip(folderPath, zipFolder) {
|
|
836
|
+
async function addFolderToZip(folderPath, zipFolder, isRoot = false) {
|
|
812
837
|
const items = await fs.readdir(folderPath);
|
|
813
838
|
for (const item of items) {
|
|
839
|
+
if (isRoot && item.startsWith('crawlee'))
|
|
840
|
+
continue;
|
|
814
841
|
const fullPath = path.join(folderPath, item);
|
|
815
842
|
const stats = await fs.stat(fullPath);
|
|
816
843
|
if (stats.isDirectory()) {
|
|
@@ -824,7 +851,7 @@ export const zipResults = async (zipName, resultsPath) => {
|
|
|
824
851
|
}
|
|
825
852
|
}
|
|
826
853
|
const zip = new JSZip();
|
|
827
|
-
await addFolderToZip(resultsPath, zip);
|
|
854
|
+
await addFolderToZip(resultsPath, zip, true);
|
|
828
855
|
const zipStream = zip.generateNodeStream({
|
|
829
856
|
type: 'nodebuffer',
|
|
830
857
|
streamFiles: true,
|
package/oobee-client-scanner.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* DO NOT EDIT MANUALLY. Re-generate with: node dist/generateOobeeClientScanner.js
|
|
4
4
|
*
|
|
5
5
|
* Embedded at generation time:
|
|
6
|
-
* App version : 0.
|
|
6
|
+
* App version : 0.11.0
|
|
7
7
|
* Sentry DSN : (from OOBEE_SENTRY_DSN env var or constants.ts default)
|
|
8
8
|
* Sentry SDK : @sentry/browser 10.58.0 (loaded from CDN at runtime)
|
|
9
9
|
*
|
|
@@ -33593,7 +33593,11 @@
|
|
|
33593
33593
|
function isElementTooSmall(element) {
|
|
33594
33594
|
// Get the bounding rectangle of the element
|
|
33595
33595
|
const rect = element.getBoundingClientRect();
|
|
33596
|
-
//
|
|
33596
|
+
// If either dimension is 0, the element is non-perceivable
|
|
33597
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
33598
|
+
return true;
|
|
33599
|
+
}
|
|
33600
|
+
// Check if the element has a valid width and height
|
|
33597
33601
|
if (rect.width > 0 || rect.height > 0) {
|
|
33598
33602
|
return false; // Element is not too small
|
|
33599
33603
|
}
|
|
@@ -34883,7 +34887,7 @@
|
|
|
34883
34887
|
// ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
|
|
34884
34888
|
|
|
34885
34889
|
var _oobeeSentryDsn = "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168";
|
|
34886
|
-
var _oobeeAppVersion = "0.
|
|
34890
|
+
var _oobeeAppVersion = "0.11.0";
|
|
34887
34891
|
var _oobeeSentryVersion = "10.58.0";
|
|
34888
34892
|
var _oobeeSentryInitialized = false;
|
|
34889
34893
|
var _oobeeSentryLoadPromise = null;
|
package/package.json
CHANGED
package/src/combine.ts
CHANGED
|
@@ -96,6 +96,13 @@ const combineRun = async (details: Data, deviceToScan: string) => {
|
|
|
96
96
|
consoleLogger.info(`Suppressed Playwright post-close connection error: ${err.message}`);
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
+
// Suppress EPERM errors from Crawlee's async lock-file operations that fire
|
|
100
|
+
// after a crawl phase finishes. On Windows, the sitemap phase's async lock
|
|
101
|
+
// cleanup can race with the domain phase starting in the same directory.
|
|
102
|
+
if (err.message?.includes('EPERM') && err.message?.includes('.json.lock')) {
|
|
103
|
+
consoleLogger.info(`Suppressed Crawlee lock-file EPERM (stale async cleanup): ${err.message}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
99
106
|
throw err;
|
|
100
107
|
};
|
|
101
108
|
process.on('uncaughtException', psTreeHandler);
|
|
@@ -186,6 +193,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
|
|
|
186
193
|
strategy,
|
|
187
194
|
userUrl: url,
|
|
188
195
|
scanDuration,
|
|
196
|
+
ruleset,
|
|
189
197
|
});
|
|
190
198
|
urlsCrawledObj = sitemapResult.urlsCrawled;
|
|
191
199
|
durationExceeded = sitemapResult.durationExceeded;
|
|
@@ -236,6 +244,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
|
|
|
236
244
|
extraHTTPHeaders,
|
|
237
245
|
safeMode,
|
|
238
246
|
scanDuration,
|
|
247
|
+
ruleset,
|
|
239
248
|
);
|
|
240
249
|
urlsCrawledObj = intelligentResult.urlsCrawled;
|
|
241
250
|
durationExceeded = intelligentResult.durationExceeded;
|
package/src/constants/common.ts
CHANGED
|
@@ -2157,6 +2157,15 @@ export const getPlaywrightLaunchOptions = (browser?: string): LaunchOptions => {
|
|
|
2157
2157
|
!(browser === BrowserTypes.CHROME && arg === '--edge-skip-compat-layer-relaunch'),
|
|
2158
2158
|
);
|
|
2159
2159
|
|
|
2160
|
+
// Cap browser disk cache to 10MB per instance to prevent storage bloat
|
|
2161
|
+
// during long crawls with multiple pool rotations
|
|
2162
|
+
finalArgs.push('--disk-cache-size=10485760');
|
|
2163
|
+
|
|
2164
|
+
// Prevent Windows from throttling background Chromium processes
|
|
2165
|
+
if (os.platform() === 'win32') {
|
|
2166
|
+
finalArgs.push('--disable-features=UseEcoQoSForBackgroundProcess');
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2160
2169
|
// Headless flags (unchanged)
|
|
2161
2170
|
if (process.env.CRAWLEE_HEADLESS === '1') {
|
|
2162
2171
|
if (!finalArgs.includes('--mute-audio')) finalArgs.push('--mute-audio');
|
|
@@ -30,10 +30,27 @@ export const blackListedFileExtensions = [
|
|
|
30
30
|
'svg',
|
|
31
31
|
'gif',
|
|
32
32
|
'woff',
|
|
33
|
+
'woff2',
|
|
33
34
|
'zip',
|
|
34
35
|
'webp',
|
|
35
36
|
'json',
|
|
36
37
|
'xml',
|
|
38
|
+
'ico',
|
|
39
|
+
'bmp',
|
|
40
|
+
'tiff',
|
|
41
|
+
'tif',
|
|
42
|
+
'avi',
|
|
43
|
+
'mov',
|
|
44
|
+
'wmv',
|
|
45
|
+
'flv',
|
|
46
|
+
'ogg',
|
|
47
|
+
'wav',
|
|
48
|
+
'doc',
|
|
49
|
+
'docx',
|
|
50
|
+
'xls',
|
|
51
|
+
'xlsx',
|
|
52
|
+
'ppt',
|
|
53
|
+
'pptx',
|
|
37
54
|
];
|
|
38
55
|
|
|
39
56
|
export const getIntermediateScreenshotsPath = (datasetsPath: string): string =>
|
|
@@ -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,53 +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
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
'Service Worker', 'ScriptCache', 'ShaderCache', 'GrShaderCache',
|
|
1240
|
-
'component_crx_cache', 'optimization_guide_model_store',
|
|
1241
|
-
'BrowserMetrics', 'Crashpad', 'FileTypePolicies',
|
|
1242
|
-
]);
|
|
1243
|
-
try {
|
|
1244
|
-
const copyRecursive = async (src: string, dest: string) => {
|
|
1245
|
-
const stat = await fsp.stat(src).catch(() => null);
|
|
1246
|
-
if (!stat) return;
|
|
1247
|
-
if (stat.isDirectory()) {
|
|
1248
|
-
await fsp.mkdir(dest, { recursive: true }).catch(() => {});
|
|
1249
|
-
const entries = await fsp.readdir(src).catch(() => []);
|
|
1250
|
-
await Promise.all(
|
|
1251
|
-
entries
|
|
1252
|
-
.filter(entry => !entry.startsWith('Singleton') && !skipDirs.has(entry))
|
|
1253
|
-
.map(entry =>
|
|
1254
|
-
copyRecursive(path.join(src, entry), path.join(dest, entry)).catch(() => {}),
|
|
1255
|
-
),
|
|
1256
|
-
);
|
|
1257
|
-
} else {
|
|
1258
|
-
await fsp.copyFile(src, dest).catch(() => {});
|
|
1259
|
-
}
|
|
1260
|
-
};
|
|
1261
|
-
await copyRecursive(userDataDirectory, effectiveDir).catch(() => {});
|
|
1262
|
-
} catch {
|
|
1263
|
-
// Silent fallback: use empty profile if clone fails
|
|
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(() => {});
|
|
1264
1256
|
}
|
|
1257
|
+
|
|
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
|
+
);
|
|
1262
|
+
|
|
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(() => {});
|
|
1267
|
+
|
|
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
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Silent fallback: use empty profile if clone fails
|
|
1265
1301
|
}
|
|
1266
1302
|
|
|
1267
1303
|
// Clean any stale lock files that may block browser launches on Windows
|
|
@@ -1275,11 +1311,38 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
|
|
|
1275
1311
|
];
|
|
1276
1312
|
await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => {})));
|
|
1277
1313
|
|
|
1314
|
+
previousPoolDir = effectiveDir;
|
|
1278
1315
|
// eslint-disable-next-line no-param-reassign
|
|
1279
1316
|
launchContext.userDataDir = effectiveDir;
|
|
1280
1317
|
};
|
|
1281
1318
|
};
|
|
1282
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
|
+
|
|
1283
1346
|
export const failedRequestHandler = async ({ request }: { request: Request }) => {
|
|
1284
1347
|
guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
|
|
1285
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,
|
|
@@ -382,8 +383,11 @@ const crawlDomain = async ({
|
|
|
382
383
|
};
|
|
383
384
|
|
|
384
385
|
let isAbortingScanNow = false;
|
|
386
|
+
const remainingBudget = fromCrawlIntelligentSitemap
|
|
387
|
+
? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
|
|
388
|
+
: maxRequestsPerCrawl;
|
|
385
389
|
const rateController = new CrawlRateController(
|
|
386
|
-
|
|
390
|
+
remainingBudget,
|
|
387
391
|
specifiedMaxConcurrency || constants.maxConcurrency,
|
|
388
392
|
);
|
|
389
393
|
|
|
@@ -398,6 +402,8 @@ const crawlDomain = async ({
|
|
|
398
402
|
retryOnBlocked: true,
|
|
399
403
|
browserPoolOptions: {
|
|
400
404
|
useFingerprints: false,
|
|
405
|
+
retireBrowserAfterPageCount: 500,
|
|
406
|
+
closeInactiveBrowserAfterSecs: 30,
|
|
401
407
|
preLaunchHooks: [
|
|
402
408
|
getPreLaunchHook(userDataDirectory),
|
|
403
409
|
async (_pageId, launchContext) => {
|
|
@@ -413,55 +419,73 @@ const crawlDomain = async ({
|
|
|
413
419
|
};
|
|
414
420
|
},
|
|
415
421
|
],
|
|
422
|
+
postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
|
|
416
423
|
},
|
|
417
424
|
requestQueue,
|
|
418
425
|
maxRequestRetries: 3,
|
|
419
426
|
maxSessionRotations: 1,
|
|
420
427
|
preNavigationHooks: [
|
|
421
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
|
+
},
|
|
422
439
|
],
|
|
423
440
|
postNavigationHooks: [
|
|
424
441
|
async crawlingContext => {
|
|
425
442
|
const { page, request } = crawlingContext;
|
|
426
443
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
444
|
+
try {
|
|
445
|
+
await page.evaluate(() => {
|
|
446
|
+
return new Promise(resolve => {
|
|
447
|
+
let timeout;
|
|
448
|
+
let mutationCount = 0;
|
|
449
|
+
const MAX_MUTATIONS = 500; // stop if things never quiet down
|
|
450
|
+
const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
|
|
451
|
+
|
|
452
|
+
const observer = new MutationObserver(() => {
|
|
453
|
+
clearTimeout(timeout);
|
|
454
|
+
|
|
455
|
+
mutationCount += 1;
|
|
456
|
+
if (mutationCount > MAX_MUTATIONS) {
|
|
457
|
+
observer.disconnect();
|
|
458
|
+
resolve('Too many mutations, exiting.');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// restart quiet‑period timer
|
|
463
|
+
timeout = setTimeout(() => {
|
|
464
|
+
observer.disconnect();
|
|
465
|
+
resolve('DOM stabilized.');
|
|
466
|
+
}, 1000);
|
|
467
|
+
});
|
|
443
468
|
|
|
444
|
-
//
|
|
469
|
+
// overall timeout in case the page never settles
|
|
445
470
|
timeout = setTimeout(() => {
|
|
446
471
|
observer.disconnect();
|
|
447
|
-
resolve('
|
|
448
|
-
},
|
|
472
|
+
resolve('Observer timeout reached.');
|
|
473
|
+
}, OBSERVER_TIMEOUT);
|
|
474
|
+
|
|
475
|
+
const root = document.documentElement || document.body || document;
|
|
476
|
+
if (!root || typeof observer.observe !== 'function') {
|
|
477
|
+
resolve('No root node to observe.');
|
|
478
|
+
} else {
|
|
479
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
480
|
+
}
|
|
449
481
|
});
|
|
450
|
-
|
|
451
|
-
// overall timeout in case the page never settles
|
|
452
|
-
timeout = setTimeout(() => {
|
|
453
|
-
observer.disconnect();
|
|
454
|
-
resolve('Observer timeout reached.');
|
|
455
|
-
}, OBSERVER_TIMEOUT);
|
|
456
|
-
|
|
457
|
-
const root = document.documentElement || document.body || document;
|
|
458
|
-
if (!root || typeof observer.observe !== 'function') {
|
|
459
|
-
resolve('No root node to observe.');
|
|
460
|
-
} else {
|
|
461
|
-
observer.observe(root, { childList: true, subtree: true });
|
|
462
|
-
}
|
|
463
482
|
});
|
|
464
|
-
})
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (err.message?.includes('was destroyed')) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
465
489
|
|
|
466
490
|
let finalUrl = page.url();
|
|
467
491
|
const requestLabelUrl = request.label;
|
|
@@ -543,21 +567,17 @@ const crawlDomain = async ({
|
|
|
543
567
|
(request.skipNavigation && actualUrl === 'about:blank')
|
|
544
568
|
) {
|
|
545
569
|
if (!isScanPdfs) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
httpStatusCode: 0,
|
|
558
|
-
});
|
|
559
|
-
*/
|
|
560
|
-
|
|
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
|
+
});
|
|
561
581
|
return;
|
|
562
582
|
}
|
|
563
583
|
const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(
|
|
@@ -573,20 +593,17 @@ const crawlDomain = async ({
|
|
|
573
593
|
}
|
|
574
594
|
|
|
575
595
|
if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
httpStatusCode: 0,
|
|
588
|
-
});
|
|
589
|
-
*/
|
|
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
|
+
});
|
|
590
607
|
return;
|
|
591
608
|
}
|
|
592
609
|
|
|
@@ -788,7 +805,59 @@ const crawlDomain = async ({
|
|
|
788
805
|
return;
|
|
789
806
|
}
|
|
790
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
|
+
|
|
791
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
|
+
|
|
792
861
|
if (rateController.onFailure(status, crawler.autoscaledPool)) {
|
|
793
862
|
consoleLogger.info(
|
|
794
863
|
`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
|
|
@@ -832,7 +901,9 @@ const crawlDomain = async ({
|
|
|
832
901
|
|
|
833
902
|
// Additional passes: keep re-visiting scanned seed-hostname pages for
|
|
834
903
|
// click-discovery until no new pages are found or limits are reached.
|
|
835
|
-
|
|
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) {
|
|
836
907
|
const seedHostname = new URL(url).hostname;
|
|
837
908
|
const clickPassVisited = new Set<string>();
|
|
838
909
|
let prevScannedCount: number;
|