@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.
@@ -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}`,
@@ -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,12 +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,
22
+ RuleFlags,
19
23
  } from '../constants/constants.js';
20
24
  import {
21
25
  getLinksFromSitemap,
22
26
  getPlaywrightLaunchOptions,
27
+ isDisallowedInRobotsTxt,
23
28
  isSkippedUrl,
24
29
  waitForPageLoaded,
25
30
  isFilePath,
@@ -55,6 +60,7 @@ const crawlSitemap = async ({
55
60
  datasetFromIntelligent = null,
56
61
  urlsCrawledFromIntelligent = null,
57
62
  crawledFromLocalFile = false,
63
+ ruleset = [],
58
64
  }: {
59
65
  sitemapUrl: string;
60
66
  randomToken: string;
@@ -76,14 +82,18 @@ const crawlSitemap = async ({
76
82
  datasetFromIntelligent?: Dataset;
77
83
  urlsCrawledFromIntelligent?: UrlsCrawled;
78
84
  crawledFromLocalFile?: boolean;
85
+ ruleset?: RuleFlags[];
79
86
  }) => {
80
87
  const crawlStartTime = Date.now();
81
88
  let dataset: crawlee.Dataset;
82
89
  let urlsCrawled: UrlsCrawled;
83
90
  let durationExceeded = false;
84
91
  let isAbortingScan = false;
92
+ const remainingBudget = fromCrawlIntelligentSitemap
93
+ ? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
94
+ : maxRequestsPerCrawl;
85
95
  const rateController = new CrawlRateController(
86
- maxRequestsPerCrawl,
96
+ remainingBudget,
87
97
  specifiedMaxConcurrency || constants.maxConcurrency,
88
98
  );
89
99
  const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
@@ -127,6 +137,12 @@ const crawlSitemap = async ({
127
137
  sources: linksFromSitemap,
128
138
  });
129
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
+
130
146
  const crawler = register(
131
147
  new crawlee.PlaywrightCrawler({
132
148
  launchContext: {
@@ -136,6 +152,8 @@ const crawlSitemap = async ({
136
152
  retryOnBlocked: true,
137
153
  browserPoolOptions: {
138
154
  useFingerprints: false,
155
+ retireBrowserAfterPageCount: 500,
156
+ closeInactiveBrowserAfterSecs: 30,
139
157
  preLaunchHooks: [
140
158
  getPreLaunchHook(userDataDirectory),
141
159
  async (_pageId, launchContext) => {
@@ -150,8 +168,10 @@ const crawlSitemap = async ({
150
168
  };
151
169
  },
152
170
  ],
171
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
153
172
  },
154
173
  requestList,
174
+ requestQueue,
155
175
  maxRequestRetries: 3,
156
176
  maxSessionRotations: 1,
157
177
  postNavigationHooks: [
@@ -191,6 +211,8 @@ const crawlSitemap = async ({
191
211
  const root = document.documentElement || document.body || document;
192
212
  if (!root || typeof observer.observe !== 'function') {
193
213
  resolve('No root node to observe.');
214
+ } else {
215
+ observer.observe(root, { childList: true, subtree: true });
194
216
  }
195
217
  });
196
218
  });
@@ -215,16 +237,22 @@ const crawlSitemap = async ({
215
237
  if (isNotSupportedDocument) {
216
238
  request.skipNavigation = true;
217
239
  request.userData.isNotSupportedDocument = true;
218
-
219
- // Log for verification (optional, but not required for correctness)
220
- // console.log(`[SKIP] Not supported: ${request.url}`);
221
-
222
240
  return;
223
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 {}
224
252
  },
225
253
  ],
226
254
  requestHandlerTimeoutSecs: 90,
227
- requestHandler: async ({ page, request, response, sendRequest }) => {
255
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
228
256
  // Log documents that are not supported
229
257
  if (request.userData?.isNotSupportedDocument) {
230
258
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -334,7 +362,7 @@ const crawlSitemap = async ({
334
362
  return;
335
363
  }
336
364
 
337
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
365
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
338
366
 
339
367
  // Detect JS redirects that fire during/after axe scan.
340
368
  // Listen for navigation, then give a brief window for pending redirects to complete.
@@ -391,6 +419,32 @@ const crawlSitemap = async ({
391
419
  results.actualUrl = actualUrl;
392
420
 
393
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
+ }
394
448
  }
395
449
  } else {
396
450
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -399,20 +453,37 @@ const crawlSitemap = async ({
399
453
  });
400
454
 
401
455
  if (isScanHtml) {
402
- // carry through the HTTP status metadata
403
- const status = response?.status();
404
- const metadata =
405
- typeof status === 'number'
406
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
407
- : STATUS_CODE_METADATA[2];
408
-
409
- urlsCrawled.invalid.push({
410
- actualUrl,
411
- url: request.url,
412
- pageTitle: request.url,
413
- metadata,
414
- httpStatusCode: typeof status === 'number' ? status : 0,
415
- });
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
+ }
416
487
  }
417
488
  }
418
489
  } catch (e) {
@@ -427,7 +498,55 @@ const crawlSitemap = async ({
427
498
  return;
428
499
  }
429
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
+
430
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
+
431
550
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
432
551
  consoleLogger.info(
433
552
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -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
  }
@@ -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
  }
package/src/utils.ts CHANGED
@@ -390,14 +390,31 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P
390
390
  consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
391
391
  }
392
392
 
393
- // Also remove _pool* sibling directories created by browser pool re-launches
394
- try {
395
- const poolDirs = globSync(`${constants.userDataDirectory}_pool*`);
393
+ // Also remove _pool* sibling directories created by browser pool re-launches.
394
+ // On Windows, Chrome writes files (optimization_guide_model_store,
395
+ // segmentation_platform, first_party_sets.db, etc.) during its shutdown
396
+ // sequence and may still hold file locks. Retry up to 10 times at 5s intervals.
397
+ const removePoolDirs = (): number => {
398
+ // On Windows, glob interprets backslashes as escape characters, not path
399
+ // separators. Normalize to forward slashes so the pattern actually matches.
400
+ const globPattern = `${constants.userDataDirectory}_pool*`.replace(/\\/g, '/');
401
+ const poolDirs = globSync(globPattern);
396
402
  for (const dir of poolDirs) {
397
403
  fs.rmSync(dir, { recursive: true, force: true });
398
404
  }
399
- } catch (error) {
400
- consoleLogger.warn(`Unable to remove pool directories: ${error.message}`);
405
+ return poolDirs.length;
406
+ };
407
+ for (let attempt = 0; attempt < 10; attempt++) {
408
+ try {
409
+ if (removePoolDirs() === 0) break; // nothing left to clean
410
+ break; // rmSync succeeded for all dirs
411
+ } catch {
412
+ if (attempt < 9) {
413
+ await new Promise(r => setTimeout(r, 5000));
414
+ } else {
415
+ consoleLogger.warn('Unable to remove pool directories after 10 attempts');
416
+ }
417
+ }
401
418
  }
402
419
  }
403
420
 
@@ -418,7 +435,7 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P
418
435
  const crawleeDir = path.join(storagePath, 'crawlee');
419
436
  const dataset = await Dataset.open(crawleeDir);
420
437
  await dataset.drop();
421
- const requestQueue = await RequestQueue.open(crawleeDir);
438
+ const requestQueue = await RequestQueue.open(`${crawleeDir}_rq`);
422
439
  await requestQueue.drop();
423
440
  } catch (error) {
424
441
  consoleLogger.info(`Crawlee storage drop in cleanUp: ${error.message}`);
@@ -428,6 +445,11 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P
428
445
  } catch (error) {
429
446
  consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
430
447
  }
448
+ try {
449
+ fs.rmSync(path.join(storagePath, 'crawlee_rq'), { recursive: true, force: true });
450
+ } catch (error) {
451
+ consoleLogger.warn(`Unable to force remove crawlee_rq folder: ${error.message}`);
452
+ }
431
453
 
432
454
  try {
433
455
  fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
@@ -1022,9 +1044,10 @@ export const zipResults = async (zipName: string, resultsPath: string): Promise<
1022
1044
  if (!stats.isDirectory()) {
1023
1045
  throw new Error(`resultsPath is not a directory: ${resultsPath}`);
1024
1046
  }
1025
- async function addFolderToZip(folderPath: string, zipFolder: JSZip): Promise<void> {
1047
+ async function addFolderToZip(folderPath: string, zipFolder: JSZip, isRoot = false): Promise<void> {
1026
1048
  const items = await fs.readdir(folderPath);
1027
1049
  for (const item of items) {
1050
+ if (isRoot && item.startsWith('crawlee')) continue;
1028
1051
  const fullPath = path.join(folderPath, item);
1029
1052
  const stats = await fs.stat(fullPath);
1030
1053
  if (stats.isDirectory()) {
@@ -1038,7 +1061,7 @@ export const zipResults = async (zipName: string, resultsPath: string): Promise<
1038
1061
  }
1039
1062
 
1040
1063
  const zip = new JSZip();
1041
- await addFolderToZip(resultsPath, zip);
1064
+ await addFolderToZip(resultsPath, zip, true);
1042
1065
 
1043
1066
  const zipStream = zip.generateNodeStream({
1044
1067
  type: 'nodebuffer',