@govtechsg/oobee 0.10.97 → 0.10.98

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 CHANGED
@@ -299,6 +299,14 @@ When making changes, validate these areas which have well-established edge cases
299
299
  - Without the circuit breaker, a rate-limited crawl with thousands of enqueued URLs would run indefinitely, never hit the success threshold, and never generate a report.
300
300
  - When enqueuing all sitemap URLs (which we do for accurate `totalLinksFetchedFromSitemaps` reporting), always ensure either a scan duration (`-d`) or the circuit breaker is in place as a safety net.
301
301
 
302
+ ### Scan Consistency Between crawlDomain and crawlSitemap
303
+ - Both crawlers must produce equivalent axe scan results for the same page. Any difference in how the page is observed/stabilized before `runAxeScript()` will cause inconsistent accessibility findings between scan types.
304
+ - **postNavigationHook DOM observer must be identical**: Both crawlers use a MutationObserver in `postNavigationHooks` to wait for DOM stabilization before proceeding to the request handler. The observer must call `observer.observe(root, { childList: true, subtree: true })` — without this call, the hook degrades to a fixed 5-second timeout (the `OBSERVER_TIMEOUT` fallback) and the DOM may be in a different state when scanning begins.
305
+ - **`runAxeScript()` has its own secondary DOM observer** (in `commonCrawlerFunc.ts`) that additionally watches `attributes: true`. This is a second stabilization gate shared by all crawlers — it ensures attribute animations settle before axe runs.
306
+ - **`waitForPageLoaded(page, 10000)` in the requestHandler** is the third stabilization check (waits for `load` event or 10s timeout). All crawlers call this at the top of their request handler.
307
+ - **Parameters passed to `runAxeScript()` must match**: both must pass `ruleset` (controls `DISABLE_OOBEE` / `ENABLE_WCAG_AAA`). Any new parameter added to `runAxeScript()` must be propagated to all crawlers via `combine.ts`.
308
+ - **Error handling in postNavigationHook**: wrap `page.evaluate()` in try/catch to handle pages destroyed during the DOM observer (navigation, timeout, crash). Without this, the error propagates and may affect Crawlee's retry logic differently between crawlers.
309
+
302
310
  ### Axe & Custom Checks
303
311
  - When axe reports color-contrast violations but cannot determine the actual colors, skip augmenting the message with contrast context (avoids crashes on null/undefined color values).
304
312
  - Violation messages are enriched with live DOM context (element text, computed styles, dimensions) via `page.evaluate()` during scan. Handle cases where elements are no longer in DOM at evaluation time.
package/dist/combine.js CHANGED
@@ -117,6 +117,7 @@ const combineRun = async (details, deviceToScan) => {
117
117
  strategy,
118
118
  userUrl: url,
119
119
  scanDuration,
120
+ ruleset,
120
121
  });
121
122
  urlsCrawledObj = sitemapResult.urlsCrawled;
122
123
  durationExceeded = sitemapResult.durationExceeded;
@@ -149,7 +150,7 @@ const combineRun = async (details, deviceToScan) => {
149
150
  }
150
151
  break;
151
152
  case ScannerTypes.INTELLIGENT:
152
- const intelligentResult = await crawlIntelligentSitemap(url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration);
153
+ const intelligentResult = await crawlIntelligentSitemap(url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration, ruleset);
153
154
  urlsCrawledObj = intelligentResult.urlsCrawled;
154
155
  durationExceeded = intelligentResult.durationExceeded;
155
156
  break;
@@ -974,33 +974,45 @@ export const getPreLaunchHook = (userDataDirectory) => {
974
974
  ? userDataDirectory
975
975
  : `${userDataDirectory}_pool${launchCount}`;
976
976
  await fsp.mkdir(effectiveDir, { recursive: true });
977
- // For pool re-launches, best-effort clone profile data from base directory
978
- // so authenticated sessions are preserved across browser pool retirements.
977
+ // For pool re-launches, copy only auth-relevant files from the base
978
+ // directory so authenticated sessions are preserved without cloning the
979
+ // entire profile (which grows with caches, IndexedDB, etc. during the crawl).
979
980
  if (launchCount > 1) {
980
- const skipDirs = new Set([
981
- 'Singleton', 'lockfile', 'LOCK',
982
- 'Cache', 'Code Cache', 'GPUCache', 'DawnGraphiteCache', 'DawnWebGPUCache',
983
- 'Service Worker', 'ScriptCache', 'ShaderCache', 'GrShaderCache',
984
- 'component_crx_cache', 'optimization_guide_model_store',
985
- 'BrowserMetrics', 'Crashpad', 'FileTypePolicies',
986
- ]);
987
981
  try {
988
- const copyRecursive = async (src, dest) => {
989
- const stat = await fsp.stat(src).catch(() => null);
990
- if (!stat)
991
- return;
992
- if (stat.isDirectory()) {
993
- await fsp.mkdir(dest, { recursive: true }).catch(() => { });
994
- const entries = await fsp.readdir(src).catch(() => []);
995
- await Promise.all(entries
996
- .filter(entry => !entry.startsWith('Singleton') && !skipDirs.has(entry))
997
- .map(entry => copyRecursive(path.join(src, entry), path.join(dest, entry)).catch(() => { })));
982
+ // Copy top-level Local State (cookie encryption keys on Windows)
983
+ const localStateSrc = path.join(userDataDirectory, 'Local State');
984
+ if (await fsp.stat(localStateSrc).catch(() => null)) {
985
+ await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => { });
986
+ }
987
+ // Find profile directories (Default, Profile 1, Profile 2, etc.)
988
+ const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
989
+ const profileDirs = entries.filter((e) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name));
990
+ for (const profile of profileDirs) {
991
+ const srcProfile = path.join(userDataDirectory, profile.name);
992
+ const destProfile = path.join(effectiveDir, profile.name);
993
+ await fsp.mkdir(destProfile, { recursive: true }).catch(() => { });
994
+ // Cookies (macOS layout: <Profile>/Cookies)
995
+ const cookiesSrc = path.join(srcProfile, 'Cookies');
996
+ if (await fsp.stat(cookiesSrc).catch(() => null)) {
997
+ await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => { });
998
998
  }
999
- else {
1000
- await fsp.copyFile(src, dest).catch(() => { });
999
+ // Cookies (Windows layout: <Profile>/Network/Cookies)
1000
+ const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
1001
+ if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
1002
+ const destNetwork = path.join(destProfile, 'Network');
1003
+ await fsp.mkdir(destNetwork, { recursive: true }).catch(() => { });
1004
+ await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => { });
1001
1005
  }
1002
- };
1003
- await copyRecursive(userDataDirectory, effectiveDir).catch(() => { });
1006
+ // Local Storage (auth tokens, session data)
1007
+ const localStorageSrc = path.join(srcProfile, 'Local Storage');
1008
+ const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
1009
+ if (localStorageStat && localStorageStat.isDirectory()) {
1010
+ const destLocalStorage = path.join(destProfile, 'Local Storage');
1011
+ await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => { });
1012
+ const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
1013
+ await Promise.all(lsFiles.map(f => fsp.copyFile(path.join(localStorageSrc, f), path.join(destLocalStorage, f)).catch(() => { })));
1014
+ }
1015
+ }
1004
1016
  }
1005
1017
  catch {
1006
1018
  // Silent fallback: use empty profile if clone fails
@@ -274,7 +274,10 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
274
274
  }
275
275
  };
276
276
  let isAbortingScanNow = false;
277
- const rateController = new CrawlRateController(maxRequestsPerCrawl, specifiedMaxConcurrency || constants.maxConcurrency);
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) => {
@@ -309,40 +314,48 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
309
314
  postNavigationHooks: [
310
315
  async (crawlingContext) => {
311
316
  const { page, request } = crawlingContext;
312
- await page.evaluate(() => {
313
- return new Promise(resolve => {
314
- let timeout;
315
- let mutationCount = 0;
316
- const MAX_MUTATIONS = 500; // stop if things never quiet down
317
- const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
318
- const observer = new MutationObserver(() => {
319
- clearTimeout(timeout);
320
- mutationCount += 1;
321
- if (mutationCount > MAX_MUTATIONS) {
322
- observer.disconnect();
323
- resolve('Too many mutations, exiting.');
324
- return;
325
- }
326
- // restart quiet‑period timer
317
+ try {
318
+ await page.evaluate(() => {
319
+ return new Promise(resolve => {
320
+ let timeout;
321
+ let mutationCount = 0;
322
+ const MAX_MUTATIONS = 500; // stop if things never quiet down
323
+ const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
324
+ const observer = new MutationObserver(() => {
325
+ clearTimeout(timeout);
326
+ mutationCount += 1;
327
+ if (mutationCount > MAX_MUTATIONS) {
328
+ observer.disconnect();
329
+ resolve('Too many mutations, exiting.');
330
+ return;
331
+ }
332
+ // restart quiet‑period timer
333
+ timeout = setTimeout(() => {
334
+ observer.disconnect();
335
+ resolve('DOM stabilized.');
336
+ }, 1000);
337
+ });
338
+ // overall timeout in case the page never settles
327
339
  timeout = setTimeout(() => {
328
340
  observer.disconnect();
329
- resolve('DOM stabilized.');
330
- }, 1000);
341
+ resolve('Observer timeout reached.');
342
+ }, OBSERVER_TIMEOUT);
343
+ const root = document.documentElement || document.body || document;
344
+ if (!root || typeof observer.observe !== 'function') {
345
+ resolve('No root node to observe.');
346
+ }
347
+ else {
348
+ observer.observe(root, { childList: true, subtree: true });
349
+ }
331
350
  });
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
351
  });
345
- });
352
+ }
353
+ catch (err) {
354
+ if (err.message?.includes('was destroyed')) {
355
+ return;
356
+ }
357
+ throw err;
358
+ }
346
359
  let finalUrl = page.url();
347
360
  const requestLabelUrl = request.label;
348
361
  // to handle scenario where the redirected link is not within the scanning website
@@ -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
  }
@@ -6,13 +6,16 @@ import { getLinksFromSitemap, getPlaywrightLaunchOptions, isSkippedUrl, waitForP
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 rateController = new CrawlRateController(maxRequestsPerCrawl, specifiedMaxConcurrency || constants.maxConcurrency);
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;
@@ -46,6 +49,8 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
46
49
  retryOnBlocked: true,
47
50
  browserPoolOptions: {
48
51
  useFingerprints: false,
52
+ retireBrowserAfterPageCount: 500,
53
+ closeInactiveBrowserAfterSecs: 30,
49
54
  preLaunchHooks: [
50
55
  getPreLaunchHook(userDataDirectory),
51
56
  async (_pageId, launchContext) => {
@@ -97,6 +102,9 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
97
102
  if (!root || typeof observer.observe !== 'function') {
98
103
  resolve('No root node to observe.');
99
104
  }
105
+ else {
106
+ observer.observe(root, { childList: true, subtree: true });
107
+ }
100
108
  });
101
109
  });
102
110
  }
@@ -209,7 +217,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
209
217
  });
210
218
  return;
211
219
  }
212
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
220
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
213
221
  // Detect JS redirects that fire during/after axe scan.
214
222
  // Listen for navigation, then give a brief window for pending redirects to complete.
215
223
  try {
@@ -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
- // Check if the element has a valid width or height
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
  }
@@ -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.10.97
6
+ * App version : 0.10.98
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
- // Check if the element has a valid width or height
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.10.97";
34890
+ var _oobeeAppVersion = "0.10.98";
34887
34891
  var _oobeeSentryVersion = "10.58.0";
34888
34892
  var _oobeeSentryInitialized = false;
34889
34893
  var _oobeeSentryLoadPromise = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.97",
4
+ "version": "0.10.98",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
package/src/combine.ts CHANGED
@@ -186,6 +186,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
186
186
  strategy,
187
187
  userUrl: url,
188
188
  scanDuration,
189
+ ruleset,
189
190
  });
190
191
  urlsCrawledObj = sitemapResult.urlsCrawled;
191
192
  durationExceeded = sitemapResult.durationExceeded;
@@ -236,6 +237,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
236
237
  extraHTTPHeaders,
237
238
  safeMode,
238
239
  scanDuration,
240
+ ruleset,
239
241
  );
240
242
  urlsCrawledObj = intelligentResult.urlsCrawled;
241
243
  durationExceeded = intelligentResult.durationExceeded;
@@ -1230,35 +1230,59 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
1230
1230
 
1231
1231
  await fsp.mkdir(effectiveDir, { recursive: true });
1232
1232
 
1233
- // For pool re-launches, best-effort clone profile data from base directory
1234
- // so authenticated sessions are preserved across browser pool retirements.
1233
+ // For pool re-launches, copy only auth-relevant files from the base
1234
+ // directory so authenticated sessions are preserved without cloning the
1235
+ // entire profile (which grows with caches, IndexedDB, etc. during the crawl).
1235
1236
  if (launchCount > 1) {
1236
- const skipDirs = new Set([
1237
- 'Singleton', 'lockfile', 'LOCK',
1238
- 'Cache', 'Code Cache', 'GPUCache', 'DawnGraphiteCache', 'DawnWebGPUCache',
1239
- 'Service Worker', 'ScriptCache', 'ShaderCache', 'GrShaderCache',
1240
- 'component_crx_cache', 'optimization_guide_model_store',
1241
- 'BrowserMetrics', 'Crashpad', 'FileTypePolicies',
1242
- ]);
1243
1237
  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(() => []);
1238
+ // Copy top-level Local State (cookie encryption keys on Windows)
1239
+ const localStateSrc = path.join(userDataDirectory, 'Local State');
1240
+ if (await fsp.stat(localStateSrc).catch(() => null)) {
1241
+ await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
1242
+ }
1243
+
1244
+ // Find profile directories (Default, Profile 1, Profile 2, etc.)
1245
+ const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
1246
+ const profileDirs = (entries as any[]).filter(
1247
+ (e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
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
+ }
1260
+
1261
+ // Cookies (Windows layout: <Profile>/Network/Cookies)
1262
+ const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
1263
+ if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
1264
+ const destNetwork = path.join(destProfile, 'Network');
1265
+ await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
1266
+ await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
1267
+ }
1268
+
1269
+ // Local Storage (auth tokens, session data)
1270
+ const localStorageSrc = path.join(srcProfile, 'Local Storage');
1271
+ const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
1272
+ if (localStorageStat && localStorageStat.isDirectory()) {
1273
+ const destLocalStorage = path.join(destProfile, 'Local Storage');
1274
+ await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
1275
+ const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
1250
1276
  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
- ),
1277
+ lsFiles.map(f =>
1278
+ fsp.copyFile(
1279
+ path.join(localStorageSrc, f),
1280
+ path.join(destLocalStorage, f),
1281
+ ).catch(() => {}),
1282
+ ),
1256
1283
  );
1257
- } else {
1258
- await fsp.copyFile(src, dest).catch(() => {});
1259
1284
  }
1260
- };
1261
- await copyRecursive(userDataDirectory, effectiveDir).catch(() => {});
1285
+ }
1262
1286
  } catch {
1263
1287
  // Silent fallback: use empty profile if clone fails
1264
1288
  }
@@ -382,8 +382,11 @@ const crawlDomain = async ({
382
382
  };
383
383
 
384
384
  let isAbortingScanNow = false;
385
+ const remainingBudget = fromCrawlIntelligentSitemap
386
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
387
+ : maxRequestsPerCrawl;
385
388
  const rateController = new CrawlRateController(
386
- maxRequestsPerCrawl,
389
+ remainingBudget,
387
390
  specifiedMaxConcurrency || constants.maxConcurrency,
388
391
  );
389
392
 
@@ -398,6 +401,8 @@ const crawlDomain = async ({
398
401
  retryOnBlocked: true,
399
402
  browserPoolOptions: {
400
403
  useFingerprints: false,
404
+ retireBrowserAfterPageCount: 500,
405
+ closeInactiveBrowserAfterSecs: 30,
401
406
  preLaunchHooks: [
402
407
  getPreLaunchHook(userDataDirectory),
403
408
  async (_pageId, launchContext) => {
@@ -424,44 +429,51 @@ const crawlDomain = async ({
424
429
  async crawlingContext => {
425
430
  const { page, request } = crawlingContext;
426
431
 
427
- await page.evaluate(() => {
428
- return new Promise(resolve => {
429
- let timeout;
430
- let mutationCount = 0;
431
- const MAX_MUTATIONS = 500; // stop if things never quiet down
432
- const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
433
-
434
- const observer = new MutationObserver(() => {
435
- clearTimeout(timeout);
436
-
437
- mutationCount += 1;
438
- if (mutationCount > MAX_MUTATIONS) {
439
- observer.disconnect();
440
- resolve('Too many mutations, exiting.');
441
- return;
442
- }
432
+ try {
433
+ await page.evaluate(() => {
434
+ return new Promise(resolve => {
435
+ let timeout;
436
+ let mutationCount = 0;
437
+ const MAX_MUTATIONS = 500; // stop if things never quiet down
438
+ const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
439
+
440
+ const observer = new MutationObserver(() => {
441
+ clearTimeout(timeout);
442
+
443
+ mutationCount += 1;
444
+ if (mutationCount > MAX_MUTATIONS) {
445
+ observer.disconnect();
446
+ resolve('Too many mutations, exiting.');
447
+ return;
448
+ }
449
+
450
+ // restart quiet‑period timer
451
+ timeout = setTimeout(() => {
452
+ observer.disconnect();
453
+ resolve('DOM stabilized.');
454
+ }, 1000);
455
+ });
443
456
 
444
- // restart quiet‑period timer
457
+ // overall timeout in case the page never settles
445
458
  timeout = setTimeout(() => {
446
459
  observer.disconnect();
447
- resolve('DOM stabilized.');
448
- }, 1000);
460
+ resolve('Observer timeout reached.');
461
+ }, OBSERVER_TIMEOUT);
462
+
463
+ const root = document.documentElement || document.body || document;
464
+ if (!root || typeof observer.observe !== 'function') {
465
+ resolve('No root node to observe.');
466
+ } else {
467
+ observer.observe(root, { childList: true, subtree: true });
468
+ }
449
469
  });
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
470
  });
464
- });
471
+ } catch (err) {
472
+ if (err.message?.includes('was destroyed')) {
473
+ return;
474
+ }
475
+ throw err;
476
+ }
465
477
 
466
478
  let finalUrl = page.url();
467
479
  const requestLabelUrl = request.label;
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import { chromium, Page } from 'playwright';
3
3
  import { EnqueueStrategy } from 'crawlee';
4
4
  import { createCrawleeSubFolders, splitAuthHeaders, addAuthRouteHandler } from './commonCrawlerFunc.js';
5
- import constants, { FileTypes, guiInfoStatusTypes, sitemapPaths } from '../constants/constants.js';
5
+ import constants, { FileTypes, guiInfoStatusTypes, RuleFlags, sitemapPaths } from '../constants/constants.js';
6
6
  import { consoleLogger, guiInfoLog } from '../logs.js';
7
7
  import crawlDomain from './crawlDomain.js';
8
8
  import crawlSitemap from './crawlSitemap.js';
@@ -27,6 +27,7 @@ const crawlIntelligentSitemap = async (
27
27
  extraHTTPHeaders: Record<string, string>,
28
28
  safeMode: boolean,
29
29
  scanDuration: number,
30
+ ruleset: RuleFlags[] = [],
30
31
  ) => {
31
32
  const startTime = Date.now(); // Track start time
32
33
 
@@ -191,6 +192,7 @@ const crawlIntelligentSitemap = async (
191
192
  urlsCrawledFromIntelligent: urlsCrawled,
192
193
  crawledFromLocalFile: false,
193
194
  scanDuration: scanDuration > 0 ? remainingDuration : 0,
195
+ ruleset,
194
196
  });
195
197
  }
196
198
 
@@ -222,6 +224,7 @@ const crawlIntelligentSitemap = async (
222
224
  datasetFromIntelligent: dataset,
223
225
  urlsCrawledFromIntelligent: urlsCrawled,
224
226
  scanDuration: remainingScanDuration,
227
+ ruleset,
225
228
  });
226
229
  } else if (!hasDurationRemaining) {
227
230
  consoleLogger.info(
@@ -127,6 +127,7 @@ export const crawlLocalFile = async ({
127
127
  datasetFromIntelligent,
128
128
  urlsCrawledFromIntelligent,
129
129
  crawledFromLocalFile: true,
130
+ ruleset,
130
131
  });
131
132
 
132
133
  urlsCrawled = { ...urlsCrawled, ...updatedUrlsCrawled };
@@ -36,14 +36,10 @@ export class CrawlRateController {
36
36
  }
37
37
 
38
38
  onFailure(httpStatus: number | undefined, pool?: { maxConcurrency: number }): boolean {
39
- if (typeof httpStatus !== 'number' || httpStatus < 400) {
40
- return false;
41
- }
42
-
43
39
  this.consecutiveSuccesses = 0;
44
40
  this.consecutiveFailures++;
45
41
 
46
- if (pool && pool.maxConcurrency > 1) {
42
+ if (typeof httpStatus === 'number' && httpStatus >= 400 && pool && pool.maxConcurrency > 1) {
47
43
  pool.maxConcurrency = Math.max(1, Math.floor(pool.maxConcurrency / 2));
48
44
  consoleLogger.info(
49
45
  `Rate limited (HTTP ${httpStatus}) — reducing concurrency to ${pool.maxConcurrency}`,
@@ -16,6 +16,7 @@ import constants, {
16
16
  UrlsCrawled,
17
17
  disallowedListOfPatterns,
18
18
  FileTypes,
19
+ RuleFlags,
19
20
  } from '../constants/constants.js';
20
21
  import {
21
22
  getLinksFromSitemap,
@@ -55,6 +56,7 @@ const crawlSitemap = async ({
55
56
  datasetFromIntelligent = null,
56
57
  urlsCrawledFromIntelligent = null,
57
58
  crawledFromLocalFile = false,
59
+ ruleset = [],
58
60
  }: {
59
61
  sitemapUrl: string;
60
62
  randomToken: string;
@@ -76,14 +78,18 @@ const crawlSitemap = async ({
76
78
  datasetFromIntelligent?: Dataset;
77
79
  urlsCrawledFromIntelligent?: UrlsCrawled;
78
80
  crawledFromLocalFile?: boolean;
81
+ ruleset?: RuleFlags[];
79
82
  }) => {
80
83
  const crawlStartTime = Date.now();
81
84
  let dataset: crawlee.Dataset;
82
85
  let urlsCrawled: UrlsCrawled;
83
86
  let durationExceeded = false;
84
87
  let isAbortingScan = false;
88
+ const remainingBudget = fromCrawlIntelligentSitemap
89
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
90
+ : maxRequestsPerCrawl;
85
91
  const rateController = new CrawlRateController(
86
- maxRequestsPerCrawl,
92
+ remainingBudget,
87
93
  specifiedMaxConcurrency || constants.maxConcurrency,
88
94
  );
89
95
  const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
@@ -136,6 +142,8 @@ const crawlSitemap = async ({
136
142
  retryOnBlocked: true,
137
143
  browserPoolOptions: {
138
144
  useFingerprints: false,
145
+ retireBrowserAfterPageCount: 500,
146
+ closeInactiveBrowserAfterSecs: 30,
139
147
  preLaunchHooks: [
140
148
  getPreLaunchHook(userDataDirectory),
141
149
  async (_pageId, launchContext) => {
@@ -191,6 +199,8 @@ const crawlSitemap = async ({
191
199
  const root = document.documentElement || document.body || document;
192
200
  if (!root || typeof observer.observe !== 'function') {
193
201
  resolve('No root node to observe.');
202
+ } else {
203
+ observer.observe(root, { childList: true, subtree: true });
194
204
  }
195
205
  });
196
206
  });
@@ -334,7 +344,7 @@ const crawlSitemap = async ({
334
344
  return;
335
345
  }
336
346
 
337
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
347
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
338
348
 
339
349
  // Detect JS redirects that fire during/after axe scan.
340
350
  // Listen for navigation, then give a brief window for pending redirects to complete.
@@ -457,8 +457,13 @@ function hasPointerCursor(node: Node): boolean {
457
457
  function isElementTooSmall(element: Element) {
458
458
  // Get the bounding rectangle of the element
459
459
  const rect = element.getBoundingClientRect();
460
-
461
- // Check if the element has a valid width or height
460
+
461
+ // If either dimension is 0, the element is non-perceivable
462
+ if (rect.width === 0 || rect.height === 0) {
463
+ return true;
464
+ }
465
+
466
+ // Check if the element has a valid width and height
462
467
  if (rect.width > 0 || rect.height > 0) {
463
468
  return false; // Element is not too small
464
469
  }