@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.
@@ -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';
@@ -304,12 +304,24 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
304
304
  };
305
305
  },
306
306
  ],
307
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
307
308
  },
308
309
  requestQueue,
309
310
  maxRequestRetries: 3,
310
311
  maxSessionRotations: 1,
311
312
  preNavigationHooks: [
312
313
  ...preNavigationHooks(extraHTTPHeaders),
314
+ async ({ request }) => {
315
+ const url = request.url.toLowerCase();
316
+ try {
317
+ const pathname = new URL(url).pathname;
318
+ const ext = pathname.split('.').pop();
319
+ if (ext && blackListedFileExtensions.includes(ext)) {
320
+ request.skipNavigation = true;
321
+ }
322
+ }
323
+ catch { }
324
+ },
313
325
  ],
314
326
  postNavigationHooks: [
315
327
  async (crawlingContext) => {
@@ -415,20 +427,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
415
427
  if (shouldSkipDueToUnsupportedContent(response, request.url) ||
416
428
  (request.skipNavigation && actualUrl === 'about:blank')) {
417
429
  if (!isScanPdfs) {
418
- // Don't inform the user it is skipped since web crawler is best-effort.
419
- /*
420
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
421
- numScanned: urlsCrawled.scanned.length,
422
- urlScanned: request.url,
423
- });
424
- urlsCrawled.userExcluded.push({
425
- url: request.url,
426
- pageTitle: request.url,
427
- actualUrl: request.url, // because about:blank is not useful
428
- metadata: STATUS_CODE_METADATA[1],
429
- httpStatusCode: 0,
430
- });
431
- */
430
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
431
+ numScanned: urlsCrawled.scanned.length,
432
+ urlScanned: request.url,
433
+ });
434
+ urlsCrawled.userExcluded.push({
435
+ url: request.url,
436
+ pageTitle: request.url,
437
+ actualUrl: request.url,
438
+ metadata: STATUS_CODE_METADATA[1],
439
+ httpStatusCode: 1,
440
+ });
432
441
  return;
433
442
  }
434
443
  const { pdfFileName, url: downloadedPdfUrl } = handlePdfDownload(randomToken, pdfDownloads, request, sendRequest, urlsCrawled);
@@ -436,20 +445,17 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
436
445
  return;
437
446
  }
438
447
  if (isBlacklistedFileExtensions(actualUrl, blackListedFileExtensions)) {
439
- // Don't inform the user it is skipped since web crawler is best-effort.
440
- /*
441
- guiInfoLog(guiInfoStatusTypes.SKIPPED, {
442
- numScanned: urlsCrawled.scanned.length,
443
- urlScanned: request.url,
444
- });
445
- urlsCrawled.userExcluded.push({
446
- url: request.url,
447
- pageTitle: request.url,
448
- actualUrl, // because about:blank is not useful
449
- metadata: STATUS_CODE_METADATA[1],
450
- httpStatusCode: 0,
451
- });
452
- */
448
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
449
+ numScanned: urlsCrawled.scanned.length,
450
+ urlScanned: request.url,
451
+ });
452
+ urlsCrawled.userExcluded.push({
453
+ url: request.url,
454
+ pageTitle: request.url,
455
+ actualUrl,
456
+ metadata: STATUS_CODE_METADATA[1],
457
+ httpStatusCode: 1,
458
+ });
453
459
  return;
454
460
  }
455
461
  if (!isFollowStrategy(url, actualUrl, strategy) &&
@@ -634,7 +640,57 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
634
640
  if (isAbortingScanNow) {
635
641
  return;
636
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
+ }
637
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
+ }
638
694
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
639
695
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
640
696
  isAbortingScanNow = true;
@@ -669,7 +725,9 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
669
725
  await crawler.run();
670
726
  // Additional passes: keep re-visiting scanned seed-hostname pages for
671
727
  // click-discovery until no new pages are found or limits are reached.
672
- if (!safeMode && !isAbortingScanNow && !durationExceeded) {
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) {
673
731
  const seedHostname = new URL(url).hostname;
674
732
  const clickPassVisited = new Set();
675
733
  let prevScannedCount;
@@ -1,8 +1,8 @@
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';
@@ -41,6 +41,11 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
41
41
  const requestList = await RequestList.open({
42
42
  sources: linksFromSitemap,
43
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);
44
49
  const crawler = register(new crawlee.PlaywrightCrawler({
45
50
  launchContext: {
46
51
  launcher: constants.launcher,
@@ -65,8 +70,10 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
65
70
  };
66
71
  },
67
72
  ],
73
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
68
74
  },
69
75
  requestList,
76
+ requestQueue,
70
77
  maxRequestRetries: 3,
71
78
  maxSessionRotations: 1,
72
79
  postNavigationHooks: [
@@ -125,14 +132,22 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
125
132
  if (isNotSupportedDocument) {
126
133
  request.skipNavigation = true;
127
134
  request.userData.isNotSupportedDocument = true;
128
- // Log for verification (optional, but not required for correctness)
129
- // console.log(`[SKIP] Not supported: ${request.url}`);
130
135
  return;
131
136
  }
137
+ try {
138
+ const pathname = new URL(url).pathname;
139
+ const ext = pathname.split('.').pop();
140
+ if (ext && blackListedFileExtensions.includes(ext)) {
141
+ request.skipNavigation = true;
142
+ request.userData.isNotSupportedDocument = true;
143
+ return;
144
+ }
145
+ }
146
+ catch { }
132
147
  },
133
148
  ],
134
149
  requestHandlerTimeoutSecs: 90,
135
- requestHandler: async ({ page, request, response, sendRequest }) => {
150
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
136
151
  // Log documents that are not supported
137
152
  if (request.userData?.isNotSupportedDocument) {
138
153
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -268,6 +283,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
268
283
  results.url = request.url;
269
284
  results.actualUrl = actualUrl;
270
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
+ }
271
314
  }
272
315
  }
273
316
  else {
@@ -276,18 +319,34 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
276
319
  urlScanned: request.url,
277
320
  });
278
321
  if (isScanHtml) {
279
- // carry through the HTTP status metadata
280
- const status = response?.status();
281
- const metadata = typeof status === 'number'
282
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
283
- : STATUS_CODE_METADATA[2];
284
- urlsCrawled.invalid.push({
285
- actualUrl,
286
- url: request.url,
287
- pageTitle: request.url,
288
- metadata,
289
- httpStatusCode: typeof status === 'number' ? status : 0,
290
- });
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
+ }
291
350
  }
292
351
  }
293
352
  }
@@ -302,7 +361,53 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
302
361
  if (isAbortingScan) {
303
362
  return;
304
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
+ }
305
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
+ }
306
411
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
307
412
  consoleLogger.info(`Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`);
308
413
  isAbortingScan = true;
@@ -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
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
- try {
351
- const poolDirs = globSync(`${constants.userDataDirectory}_pool*`);
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
- catch (error) {
357
- consoleLogger.warn(`Unable to remove pool directories: ${error.message}`);
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,
@@ -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.98
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
  *
@@ -34887,7 +34887,7 @@
34887
34887
  // ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
34888
34888
 
34889
34889
  var _oobeeSentryDsn = "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168";
34890
- var _oobeeAppVersion = "0.10.98";
34890
+ var _oobeeAppVersion = "0.11.0";
34891
34891
  var _oobeeSentryVersion = "10.58.0";
34892
34892
  var _oobeeSentryInitialized = false;
34893
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.98",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
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);
@@ -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 =>