@govtechsg/oobee 0.10.98 → 0.11.1

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.
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import {
5
5
  createCrawleeSubFolders,
6
6
  getPreLaunchHook,
7
+ getPostPageCloseHook,
7
8
  preNavigationHooks,
8
9
  runAxeScript,
9
10
  isUrlPdf,
@@ -14,13 +15,16 @@ import constants, {
14
15
  STATUS_CODE_METADATA,
15
16
  guiInfoStatusTypes,
16
17
  UrlsCrawled,
18
+ blackListedFileExtensions,
17
19
  disallowedListOfPatterns,
20
+ disallowedSelectorPatterns,
18
21
  FileTypes,
19
22
  RuleFlags,
20
23
  } from '../constants/constants.js';
21
24
  import {
22
25
  getLinksFromSitemap,
23
26
  getPlaywrightLaunchOptions,
27
+ isDisallowedInRobotsTxt,
24
28
  isSkippedUrl,
25
29
  waitForPageLoaded,
26
30
  isFilePath,
@@ -34,6 +38,7 @@ import {
34
38
  } from './pdfScanFunc.js';
35
39
  import { consoleLogger, guiInfoLog } from '../logs.js';
36
40
  import { ViewportSettingsClass } from '../combine.js';
41
+ import { capturePageData } from './pageCapture.js';
37
42
 
38
43
  const crawlSitemap = async ({
39
44
  sitemapUrl,
@@ -133,13 +138,19 @@ const crawlSitemap = async ({
133
138
  sources: linksFromSitemap,
134
139
  });
135
140
 
141
+ // Always create a request queue alongside the request list. An empty queue
142
+ // has zero impact on crawl behavior (Crawlee processes RequestList first).
143
+ // Having it available enables: download re-enqueue for PDF scanning,
144
+ // 403 rate-limit retry, and enqueueLinks for intelligent sitemap discovery.
145
+ const { requestQueue } = await createCrawleeSubFolders(randomToken);
146
+
136
147
  const crawler = register(
137
148
  new crawlee.PlaywrightCrawler({
138
149
  launchContext: {
139
150
  launcher: constants.launcher,
140
151
  launchOptions: getPlaywrightLaunchOptions(browser),
141
152
  },
142
- retryOnBlocked: true,
153
+ retryOnBlocked: false,
143
154
  browserPoolOptions: {
144
155
  useFingerprints: false,
145
156
  retireBrowserAfterPageCount: 500,
@@ -158,10 +169,11 @@ const crawlSitemap = async ({
158
169
  };
159
170
  },
160
171
  ],
172
+ postPageCloseHooks: [getPostPageCloseHook(userDataDirectory)],
161
173
  },
162
174
  requestList,
175
+ requestQueue,
163
176
  maxRequestRetries: 3,
164
- maxSessionRotations: 1,
165
177
  postNavigationHooks: [
166
178
  async ({ page }) => {
167
179
  try {
@@ -225,16 +237,22 @@ const crawlSitemap = async ({
225
237
  if (isNotSupportedDocument) {
226
238
  request.skipNavigation = true;
227
239
  request.userData.isNotSupportedDocument = true;
228
-
229
- // Log for verification (optional, but not required for correctness)
230
- // console.log(`[SKIP] Not supported: ${request.url}`);
231
-
232
240
  return;
233
241
  }
242
+
243
+ try {
244
+ const pathname = new URL(url).pathname;
245
+ const ext = pathname.split('.').pop();
246
+ if (ext && blackListedFileExtensions.includes(ext)) {
247
+ request.skipNavigation = true;
248
+ request.userData.isNotSupportedDocument = true;
249
+ return;
250
+ }
251
+ } catch {}
234
252
  },
235
253
  ],
236
254
  requestHandlerTimeoutSecs: 90,
237
- requestHandler: async ({ page, request, response, sendRequest }) => {
255
+ requestHandler: async ({ page, request, response, sendRequest, enqueueLinks }) => {
238
256
  // Log documents that are not supported
239
257
  if (request.userData?.isNotSupportedDocument) {
240
258
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -301,6 +319,22 @@ const crawlSitemap = async ({
301
319
  const contentType = response?.headers?.()['content-type'] || '';
302
320
  const status = response ? response.status() : 0;
303
321
 
322
+ if (status === 403) {
323
+ rateController.onFailure(status, crawler.autoscaledPool);
324
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
325
+ numScanned: urlsCrawled.scanned.length,
326
+ urlScanned: request.url,
327
+ });
328
+ urlsCrawled.userExcluded.push({
329
+ url: request.url,
330
+ pageTitle: request.url,
331
+ actualUrl,
332
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
333
+ httpStatusCode: 403,
334
+ });
335
+ return;
336
+ }
337
+
304
338
  if (isScanHtml && status < 300 && isWhitelistedContentType(contentType)) {
305
339
  const isRedirected = !areLinksEqual(page.url(), request.url);
306
340
  const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some(
@@ -346,6 +380,8 @@ const crawlSitemap = async ({
346
380
 
347
381
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
348
382
 
383
+ await capturePageData(page, actualUrl, randomToken);
384
+
349
385
  // Detect JS redirects that fire during/after axe scan.
350
386
  // Listen for navigation, then give a brief window for pending redirects to complete.
351
387
  try {
@@ -401,6 +437,32 @@ const crawlSitemap = async ({
401
437
  results.actualUrl = actualUrl;
402
438
 
403
439
  await dataset.pushData(results);
440
+
441
+ // Discover <a> links from this page for the intelligent sitemap flow.
442
+ // This eliminates the need for a separate crawlDomain supplement phase
443
+ // that would re-visit all these pages just to extract links.
444
+ if (fromCrawlIntelligentSitemap) {
445
+ try {
446
+ await enqueueLinks({
447
+ selector: `a:not(${disallowedSelectorPatterns})`,
448
+ strategy,
449
+ requestQueue,
450
+ transformRequestFunction: (req) => {
451
+ try {
452
+ req.url = req.url.replace(/(?<=&|\?)utm_.*?(&|$)/gim, '');
453
+ } catch {}
454
+ if (isDisallowedInRobotsTxt(req.url)) return null;
455
+ if (isUrlPdf(req.url)) {
456
+ req.skipNavigation = true;
457
+ }
458
+ req.label = req.url;
459
+ return req;
460
+ },
461
+ });
462
+ } catch {
463
+ // Best-effort link discovery; don't fail the scan
464
+ }
465
+ }
404
466
  }
405
467
  } else {
406
468
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
@@ -409,20 +471,37 @@ const crawlSitemap = async ({
409
471
  });
410
472
 
411
473
  if (isScanHtml) {
412
- // carry through the HTTP status metadata
413
- const status = response?.status();
414
- const metadata =
415
- typeof status === 'number'
416
- ? STATUS_CODE_METADATA[status] || STATUS_CODE_METADATA[599]
417
- : STATUS_CODE_METADATA[2];
418
-
419
- urlsCrawled.invalid.push({
420
- actualUrl,
421
- url: request.url,
422
- pageTitle: request.url,
423
- metadata,
424
- httpStatusCode: typeof status === 'number' ? status : 0,
425
- });
474
+ // Non-HTML content types (images, PDFs, binary files) and URLs
475
+ // that redirect to non-HTML resources should be classified as
476
+ // unsupported documents, not generic page errors.
477
+ const isNonHtmlContent =
478
+ contentType &&
479
+ !contentType.startsWith('text/html') &&
480
+ !contentType.includes('html');
481
+
482
+ if (isNonHtmlContent && status !== 0) {
483
+ urlsCrawled.userExcluded.push({
484
+ actualUrl,
485
+ url: request.url,
486
+ pageTitle: request.url,
487
+ metadata: STATUS_CODE_METADATA[1],
488
+ httpStatusCode: 1,
489
+ });
490
+ } else {
491
+ const httpStatus = response?.status();
492
+ const metadata =
493
+ typeof httpStatus === 'number'
494
+ ? STATUS_CODE_METADATA[httpStatus] || STATUS_CODE_METADATA[599]
495
+ : STATUS_CODE_METADATA[2];
496
+
497
+ urlsCrawled.invalid.push({
498
+ actualUrl,
499
+ url: request.url,
500
+ pageTitle: request.url,
501
+ metadata,
502
+ httpStatusCode: typeof httpStatus === 'number' ? httpStatus : 0,
503
+ });
504
+ }
426
505
  }
427
506
  }
428
507
  } catch (e) {
@@ -437,7 +516,55 @@ const crawlSitemap = async ({
437
516
  return;
438
517
  }
439
518
 
519
+ // Handle download-triggered navigation errors: Playwright throws
520
+ // "Download is starting" when page.goto() hits a file download URL.
521
+ const isDownloadError = request.errorMessages?.some(
522
+ (msg: string) => msg.includes('Download is starting'),
523
+ );
524
+ if (isDownloadError) {
525
+ if (isScanPdfs) {
526
+ // Re-enqueue with skipNavigation so the requestHandler's PDF download path handles it
527
+ try {
528
+ await requestQueue.addRequest({
529
+ url: request.url,
530
+ skipNavigation: true,
531
+ label: request.url,
532
+ uniqueKey: `download_${request.url}`,
533
+ });
534
+ } catch {}
535
+ } else {
536
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
537
+ numScanned: urlsCrawled.scanned.length,
538
+ urlScanned: request.url,
539
+ });
540
+ urlsCrawled.userExcluded.push({
541
+ url: request.url,
542
+ pageTitle: request.url,
543
+ actualUrl: request.url,
544
+ metadata: STATUS_CODE_METADATA[1],
545
+ httpStatusCode: 1,
546
+ });
547
+ }
548
+ return;
549
+ }
550
+
440
551
  const status = response?.status();
552
+
553
+ // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
554
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
555
+ if (status === 403 && !request.userData?.rateLimitRetried) {
556
+ rateController.onFailure(status, crawler.autoscaledPool);
557
+ try {
558
+ await requestQueue.addRequest({
559
+ url: request.url,
560
+ label: request.url,
561
+ uniqueKey: `ratelimit_${request.url}`,
562
+ userData: { rateLimitRetried: true },
563
+ });
564
+ } catch {}
565
+ return;
566
+ }
567
+
441
568
  if (rateController.onFailure(status, crawler.autoscaledPool)) {
442
569
  consoleLogger.info(
443
570
  `Aborting crawl: consecutive HTTP failures threshold reached (site may be rate-limiting). Successfully scanned ${urlsCrawled.scanned.length} pages.`,
@@ -4,6 +4,7 @@
4
4
  import path from 'path';
5
5
  import { getDomain } from 'tldts';
6
6
  import { runAxeScript } from '../commonCrawlerFunc.js';
7
+ import { capturePageData } from '../pageCapture.js';
7
8
  import { consoleLogger, guiInfoLog, silentLogger } from '../../logs.js';
8
9
  import { guiInfoStatusTypes } from '../../constants/constants.js';
9
10
  import { isSkippedUrl, validateCustomFlowLabel } from '../../constants/common.js';
@@ -159,6 +160,8 @@ export const runAxeScan = async (
159
160
  ) => {
160
161
  const result = await runAxeScript({ includeScreenshots, page, randomToken, customFlowDetails });
161
162
 
163
+ await capturePageData(page, page.url(), randomToken);
164
+
162
165
  await dataset.pushData(result);
163
166
 
164
167
  const rawTitle = result.pageTitle ?? '';
@@ -0,0 +1,172 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { Page, devices } from 'playwright';
5
+ import { getStoragePath } from '../utils.js';
6
+
7
+ const MOBILE_VIEWPORT_WIDTH = devices['iPhone 11'].viewport.width;
8
+ const MOBILE_VIEWPORT_HEIGHT = devices['iPhone 11'].viewport.height;
9
+
10
+ export interface PageCaptureEntry {
11
+ url: string;
12
+ hash: string;
13
+ domFile?: string;
14
+ desktopScreenshot?: string;
15
+ mobileScreenshot?: string;
16
+ errors: string[];
17
+ }
18
+
19
+ const captureEntries: Map<string, PageCaptureEntry> = new Map();
20
+
21
+ export function getUrlHash(url: string): string {
22
+ return crypto.createHash('sha256').update(url).digest('hex').slice(0, 7);
23
+ }
24
+
25
+ function getTruncatedPath(url: string): string {
26
+ try {
27
+ const parsed = new URL(url);
28
+ let pathStr = parsed.pathname + (parsed.search || '');
29
+ pathStr = pathStr.replace(/^\//, '').replace(/\//g, '_').replace(/[^a-zA-Z0-9\-_.]/g, '_');
30
+ if (pathStr.length > 80) {
31
+ pathStr = pathStr.slice(0, 80);
32
+ }
33
+ return pathStr || 'index';
34
+ } catch {
35
+ return 'unknown';
36
+ }
37
+ }
38
+
39
+ function getPageDomsDir(randomToken: string): string {
40
+ const storagePath = getStoragePath(randomToken);
41
+ return path.join(storagePath, 'pageDOMs');
42
+ }
43
+
44
+ async function getUniqueFilePath(dir: string, baseName: string, ext: string): Promise<string> {
45
+ let candidate = path.join(dir, `${baseName}${ext}`);
46
+ if (!await fs.pathExists(candidate)) return candidate;
47
+
48
+ let counter = 2;
49
+ while (await fs.pathExists(candidate)) {
50
+ candidate = path.join(dir, `${baseName}-${counter}${ext}`);
51
+ counter++;
52
+ }
53
+ return candidate;
54
+ }
55
+
56
+ function getRelativeName(filePath: string, baseDir: string): string {
57
+ return path.relative(baseDir, filePath).replace(/\\/g, '/');
58
+ }
59
+
60
+ export function isSaveDomEnabled(): boolean {
61
+ return process.env.OOBEE_SAVE_DOM === '1' || process.env.OOBEE_SAVE_DOM === 'true';
62
+ }
63
+
64
+ export function isSavePageScreenshotEnabled(): boolean {
65
+ return (
66
+ process.env.OOBEE_SAVE_PAGE_SCREENSHOT === '1' ||
67
+ process.env.OOBEE_SAVE_PAGE_SCREENSHOT === 'true'
68
+ );
69
+ }
70
+
71
+ export function isPageCaptureEnabled(): boolean {
72
+ return isSaveDomEnabled() || isSavePageScreenshotEnabled();
73
+ }
74
+
75
+ export async function capturePageData(
76
+ page: Page,
77
+ url: string,
78
+ randomToken: string,
79
+ ): Promise<void> {
80
+ if (!isPageCaptureEnabled()) return;
81
+
82
+ const hash = getUrlHash(url);
83
+ const truncatedPath = getTruncatedPath(url);
84
+ const fileName = `${hash}-${truncatedPath}`;
85
+ const pageDomsDir = getPageDomsDir(randomToken);
86
+
87
+ const entry: PageCaptureEntry = {
88
+ url,
89
+ hash,
90
+ errors: [],
91
+ };
92
+
93
+ if (isSaveDomEnabled()) {
94
+ try {
95
+ await fs.ensureDir(pageDomsDir);
96
+ const domContent = await page.content();
97
+ const domFilePath = await getUniqueFilePath(pageDomsDir, fileName, '.html');
98
+ await fs.writeFile(domFilePath, domContent, 'utf-8');
99
+ entry.domFile = `pageDOMs/${getRelativeName(domFilePath, pageDomsDir)}`;
100
+ } catch (err) {
101
+ entry.errors.push(`DOM save failed: ${err instanceof Error ? err.message : String(err)}`);
102
+ }
103
+ }
104
+
105
+ if (isSavePageScreenshotEnabled()) {
106
+ const desktopDir = path.join(pageDomsDir, 'desktopPageScreenshots');
107
+ const mobileDir = path.join(pageDomsDir, 'mobilePageScreenshots');
108
+
109
+ try {
110
+ await fs.ensureDir(desktopDir);
111
+ const desktopPath = await getUniqueFilePath(desktopDir, fileName, '.png');
112
+ await page.screenshot({ path: desktopPath, fullPage: true });
113
+ entry.desktopScreenshot = `pageDOMs/desktopPageScreenshots/${getRelativeName(desktopPath, desktopDir)}`;
114
+ } catch (err) {
115
+ entry.errors.push(
116
+ `Desktop screenshot failed: ${err instanceof Error ? err.message : String(err)}`,
117
+ );
118
+ }
119
+
120
+ try {
121
+ await fs.ensureDir(mobileDir);
122
+ const currentViewport = page.viewportSize();
123
+
124
+ await page.setViewportSize({
125
+ width: MOBILE_VIEWPORT_WIDTH,
126
+ height: MOBILE_VIEWPORT_HEIGHT,
127
+ });
128
+ await page.waitForTimeout(500);
129
+
130
+ const mobilePath = await getUniqueFilePath(mobileDir, fileName, '.png');
131
+ await page.screenshot({ path: mobilePath, fullPage: true });
132
+ entry.mobileScreenshot = `pageDOMs/mobilePageScreenshots/${getRelativeName(mobilePath, mobileDir)}`;
133
+
134
+ if (currentViewport) {
135
+ await page.setViewportSize(currentViewport);
136
+ }
137
+ } catch (err) {
138
+ entry.errors.push(
139
+ `Mobile screenshot failed: ${err instanceof Error ? err.message : String(err)}`,
140
+ );
141
+ }
142
+ }
143
+
144
+ captureEntries.set(url, entry);
145
+ }
146
+
147
+ export async function writeManifest(randomToken: string): Promise<void> {
148
+ if (!isPageCaptureEnabled()) return;
149
+ if (captureEntries.size === 0) return;
150
+
151
+ const pageDomsDir = getPageDomsDir(randomToken);
152
+ await fs.ensureDir(pageDomsDir);
153
+
154
+ const manifest = {
155
+ generatedAt: new Date().toISOString(),
156
+ pages: Array.from(captureEntries.values()).map(entry => ({
157
+ url: entry.url,
158
+ hash: entry.hash,
159
+ ...(entry.domFile && { domFile: entry.domFile }),
160
+ ...(entry.desktopScreenshot && { desktopScreenshot: entry.desktopScreenshot }),
161
+ ...(entry.mobileScreenshot && { mobileScreenshot: entry.mobileScreenshot }),
162
+ errors: entry.errors,
163
+ })),
164
+ };
165
+
166
+ const manifestPath = path.join(pageDomsDir, 'domManifest.json');
167
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
168
+ }
169
+
170
+ export function resetCaptureEntries(): void {
171
+ captureEntries.clear();
172
+ }
@@ -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',