@govtechsg/oobee 0.11.0 → 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.
package/AGENTS.md CHANGED
@@ -144,6 +144,8 @@ The `constants` default export object holds runtime state:
144
144
  | `OOBEE_SCAN_PRODUCT` | Adds `scanProduct` tag to Sentry events |
145
145
  | `OOBEE_CONSECUTIVE_MAX_RETRIES` | Max consecutive HTTP failures before circuit breaker aborts crawl (default 100) |
146
146
  | `OOBEE_VALIDATE_URL` | If set, exit after URL validation without scanning |
147
+ | `OOBEE_SAVE_DOM` | `1` or `true` = save full-page DOM HTML to `pageDOMs/` in results directory. Supported scan types: Website, Sitemap, Intelligent, LocalFile, Custom |
148
+ | `OOBEE_SAVE_PAGE_SCREENSHOT` | `1` or `true` = save full-page desktop + mobile viewport screenshots to `pageDOMs/desktopPageScreenshots/` and `pageDOMs/mobilePageScreenshots/`. Mobile viewport uses iPhone 11 width programmatically. Supported scan types: Website, Sitemap, Intelligent, LocalFile, Custom |
147
149
  | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | Proxy configuration |
148
150
  | `NO_PROXY` / `INCLUDE_PROXY` | Proxy bypass/include lists |
149
151
 
@@ -352,5 +354,17 @@ When making changes, validate these areas which have well-established edge cases
352
354
  ├── scanPagesDetail.json # Per-page breakdown
353
355
  ├── scanPagesSummary.json # Page-level summary
354
356
  ├── sitemap.xml # Discovered URLs
355
- └── screenshots/ # Violation screenshots (if enabled)
357
+ ├── screenshots/ # Violation screenshots (if enabled)
358
+ └── pageDOMs/ # Page capture (if OOBEE_SAVE_DOM or OOBEE_SAVE_PAGE_SCREENSHOT)
359
+ ├── {hash}-{truncated_path}.html # Full DOM (OOBEE_SAVE_DOM)
360
+ ├── domManifest.json # Maps URLs → hash, file paths, errors
361
+ ├── desktopPageScreenshots/ # Desktop viewport PNGs (OOBEE_SAVE_PAGE_SCREENSHOT)
362
+ │ └── {hash}-{truncated_path}.png
363
+ └── mobilePageScreenshots/ # Mobile viewport PNGs (OOBEE_SAVE_PAGE_SCREENSHOT)
364
+ └── {hash}-{truncated_path}.png
356
365
  ```
366
+
367
+ - `{hash}` is a 7-character SHA-256 of the page URL (consistent per URL, like git short hashes)
368
+ - `{truncated_path}` is the URL path with `/` replaced by `_`, max 80 characters
369
+ - If filename collisions occur, a `-2`, `-3` etc. suffix is added before the extension
370
+ - `domManifest.json` is written when either env var is enabled, containing URL-to-file mappings
package/CRAWL.md CHANGED
@@ -114,12 +114,12 @@ A representative scan means enough pages have been checked that the issues found
114
114
 
115
115
  ### Recommended Hardware
116
116
 
117
- Scanning is CPU and memory intensive — each page runs in a real browser with full JavaScript execution.
117
+ Scanning is CPU and memory intensive — each page runs in a real browser with full JavaScript execution. The time estimates illustrated below for scans that do not include screenshots.
118
118
 
119
119
  | Scan Size | Hardware | Max Concurrency | Expected Duration |
120
120
  |-----------|----------|-----------------|-------------------|
121
121
  | 1,000 pages | ECS Fargate, 2 vCPU / 4 GB RAM | 10 (`-t 10`) | 2-3 hours |
122
- | 5,000 pages | Laptop/desktop, 8 cores / 12 threads, 24 GB RAM | 25 (`-t 25`) | 2-3 hours |
122
+ | 5,000 pages | Recent Core Ultra or Snapdragon X Series Laptop/Desktop, 8 cores / 12 threads, 24 GB RAM | 25 (`-t 25`) | 2-3 hours |
123
123
 
124
124
  **Oobee Desktop** runs with `OOBEE_FAST_CRAWLER=true`, which means concurrency scales up aggressively to the maximum (25) as fast as possible. This is suitable for desktop machines with adequate CPU and RAM, but may cause stability issues on low-powered devices.
125
125
 
package/README.md CHANGED
@@ -100,6 +100,8 @@ verapdf --version
100
100
  | OOBEE_SCAN_METADATA | Overrides the `entryUrl` tag sent to telemetry. | |
101
101
  | OOBEE_SCAN_PRODUCT | Adds a `scanProduct` tag to telemetry events. | |
102
102
  | OOBEE_CONSECUTIVE_MAX_RETRIES | Max consecutive HTTP failures before the circuit breaker aborts the crawl. | `100` |
103
+ | OOBEE_SAVE_DOM | Set to `1` or `true` to save full-page DOM HTML for each scanned page to the results directory. Works with all scan types (Website, Sitemap, Intelligent, LocalFile, Custom). | |
104
+ | OOBEE_SAVE_PAGE_SCREENSHOT | Set to `1` or `true` to save full-page desktop and mobile viewport screenshots for each scanned page. Mobile viewport width is determined from iPhone 11 device profile. Works with all scan types (Website, Sitemap, Intelligent, LocalFile, Custom). | |
103
105
  | HTTP_PROXY | URL of the proxy server to be used for HTTP requests (e.g. `http://proxy.example.com:8080`). | |
104
106
  | HTTPS_PROXY | URL of the proxy server to be used for HTTPS requests (e.g. `https://proxy.example.com:8080`). | |
105
107
  | ALL_PROXY | URL of the proxy server to be used for all requests, typically used for SOCKS5 proxies (e.g. `socks5://proxy.example.com:1080`. Note: IPv6 direct connections may still continue even though socks5 proxy is specified due to a known issue with Chrome/Chromium. (Recommended workaround is to turn off IPv6 at host-level). | |
package/dist/combine.js CHANGED
@@ -12,6 +12,7 @@ import { consoleLogger } from './logs.js';
12
12
  import runCustom from './crawlers/runCustom.js';
13
13
  import { alertMessageOptions } from './constants/cliFunctions.js';
14
14
  import { isS3UploadEnabled, getS3MetadataFromEnv, getS3UploadPrefix, uploadFolderToS3, } from './services/s3Uploader.js';
15
+ import { writeManifest, resetCaptureEntries, isPageCaptureEnabled } from './crawlers/pageCapture.js';
15
16
  // Class exports
16
17
  export class ViewportSettingsClass {
17
18
  constructor(deviceChosen, customDevice, viewportWidth, playwrightDeviceDetailsObject) {
@@ -32,6 +33,9 @@ const combineRun = async (details, deviceToScan) => {
32
33
  process.env.CRAWLEE_LOG_LEVEL = 'ERROR';
33
34
  process.env.CRAWLEE_STORAGE_DIR = randomToken;
34
35
  constants.sitemapFetchedLinks = null;
36
+ if (isPageCaptureEnabled() && !process.env.OOBEE_SCAN_PRODUCT) {
37
+ process.env.OOBEE_SCAN_PRODUCT = 'U&A';
38
+ }
35
39
  if (process.env.CRAWLEE_SYSTEM_INFO_V2 === undefined) {
36
40
  // Set the environment variable to enable system info v2
37
41
  // Resolves issue with when wmic is not installed on Windows
@@ -193,6 +197,8 @@ const combineRun = async (details, deviceToScan) => {
193
197
  if (scanDetails.urlsCrawled) {
194
198
  if (scanDetails.urlsCrawled.scanned.length > 0) {
195
199
  await createAndUpdateResultsFolders(randomToken);
200
+ await writeManifest(randomToken);
201
+ resetCaptureEntries();
196
202
  const pagesNotScanned = [
197
203
  ...urlsCrawledObj.error,
198
204
  ...urlsCrawledObj.invalid,
@@ -6,6 +6,7 @@ import { getPlaywrightLaunchOptions, isBlacklistedFileExtensions, isSkippedUrl,
6
6
  import { areLinksEqual, isFollowStrategy, isSameHostname, normUrl, register } from '../utils.js';
7
7
  import { handlePdfDownload, runPdfScan, mapPdfScanResults, doPdfScreenshots, } from './pdfScanFunc.js';
8
8
  import { consoleLogger, guiInfoLog } from '../logs.js';
9
+ import { capturePageData } from './pageCapture.js';
9
10
  const isBlacklisted = (url, blacklistedPatterns) => {
10
11
  if (!blacklistedPatterns) {
11
12
  return false;
@@ -284,7 +285,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
284
285
  launcher: constants.launcher,
285
286
  launchOptions: getPlaywrightLaunchOptions(browser),
286
287
  },
287
- retryOnBlocked: true,
288
+ retryOnBlocked: false,
288
289
  browserPoolOptions: {
289
290
  useFingerprints: false,
290
291
  retireBrowserAfterPageCount: 500,
@@ -308,7 +309,6 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
308
309
  },
309
310
  requestQueue,
310
311
  maxRequestRetries: 3,
311
- maxSessionRotations: 1,
312
312
  preNavigationHooks: [
313
313
  ...preNavigationHooks(extraHTTPHeaders),
314
314
  async ({ request }) => {
@@ -488,6 +488,21 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
488
488
  return;
489
489
  }
490
490
  const responseStatus = response?.status();
491
+ if (responseStatus === 403) {
492
+ rateController.onFailure(responseStatus, activeCrawler.autoscaledPool);
493
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
494
+ numScanned: urlsCrawled.scanned.length,
495
+ urlScanned: request.url,
496
+ });
497
+ urlsCrawled.userExcluded.push({
498
+ url: request.url,
499
+ pageTitle: request.url,
500
+ actualUrl,
501
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
502
+ httpStatusCode: 403,
503
+ });
504
+ return;
505
+ }
491
506
  if (responseStatus && responseStatus >= 300) {
492
507
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
493
508
  numScanned: urlsCrawled.scanned.length,
@@ -503,6 +518,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
503
518
  return;
504
519
  }
505
520
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
521
+ await capturePageData(page, actualUrl, randomToken);
506
522
  // Detect JS redirects that fire during/after axe scan.
507
523
  // Listen for navigation, then give a brief window for pending redirects to complete.
508
524
  try {
@@ -674,12 +690,9 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
674
690
  }
675
691
  const status = response?.status();
676
692
  // 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.
693
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
682
694
  if (status === 403 && !request.userData?.rateLimitRetried) {
695
+ rateController.onFailure(status, crawler.autoscaledPool);
683
696
  try {
684
697
  await requestQueue.addRequest({
685
698
  url: request.url,
@@ -8,6 +8,7 @@ import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.j
8
8
  import { guiInfoLog } from '../logs.js';
9
9
  import crawlSitemap from './crawlSitemap.js';
10
10
  import { getPdfStoragePath, register } from '../utils.js';
11
+ import { capturePageData } from './pageCapture.js';
11
12
  export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, scanDuration = 0, ruleset = [], fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, }) => {
12
13
  let dataset;
13
14
  let urlsCrawled;
@@ -108,6 +109,7 @@ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings,
108
109
  }
109
110
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
110
111
  const actualUrl = page.url() || request.loadedUrl || url;
112
+ await capturePageData(page, actualUrl, randomToken);
111
113
  guiInfoLog(guiInfoStatusTypes.SCANNED, {
112
114
  numScanned: urlsCrawled.scanned.length,
113
115
  urlScanned: url,
@@ -6,6 +6,7 @@ import { getLinksFromSitemap, getPlaywrightLaunchOptions, isDisallowedInRobotsTx
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
+ import { capturePageData } from './pageCapture.js';
9
10
  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
11
  const crawlStartTime = Date.now();
11
12
  let dataset;
@@ -51,7 +52,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
51
52
  launcher: constants.launcher,
52
53
  launchOptions: getPlaywrightLaunchOptions(browser),
53
54
  },
54
- retryOnBlocked: true,
55
+ retryOnBlocked: false,
55
56
  browserPoolOptions: {
56
57
  useFingerprints: false,
57
58
  retireBrowserAfterPageCount: 500,
@@ -75,7 +76,6 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
75
76
  requestList,
76
77
  requestQueue,
77
78
  maxRequestRetries: 3,
78
- maxSessionRotations: 1,
79
79
  postNavigationHooks: [
80
80
  async ({ page }) => {
81
81
  try {
@@ -196,6 +196,21 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
196
196
  }
197
197
  const contentType = response?.headers?.()['content-type'] || '';
198
198
  const status = response ? response.status() : 0;
199
+ if (status === 403) {
200
+ rateController.onFailure(status, crawler.autoscaledPool);
201
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
202
+ numScanned: urlsCrawled.scanned.length,
203
+ urlScanned: request.url,
204
+ });
205
+ urlsCrawled.userExcluded.push({
206
+ url: request.url,
207
+ pageTitle: request.url,
208
+ actualUrl,
209
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
210
+ httpStatusCode: 403,
211
+ });
212
+ return;
213
+ }
199
214
  if (isScanHtml && status < 300 && isWhitelistedContentType(contentType)) {
200
215
  const isRedirected = !areLinksEqual(page.url(), request.url);
201
216
  const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some(item => normUrl(item.actualUrl || item.url) === normUrl(page.url()));
@@ -233,6 +248,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
233
248
  return;
234
249
  }
235
250
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
251
+ await capturePageData(page, actualUrl, randomToken);
236
252
  // Detect JS redirects that fire during/after axe scan.
237
253
  // Listen for navigation, then give a brief window for pending redirects to complete.
238
254
  try {
@@ -394,9 +410,9 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
394
410
  }
395
411
  const status = response?.status();
396
412
  // 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.
413
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
399
414
  if (status === 403 && !request.userData?.rateLimitRetried) {
415
+ rateController.onFailure(status, crawler.autoscaledPool);
400
416
  try {
401
417
  await requestQueue.addRequest({
402
418
  url: request.url,
@@ -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 } from '../../logs.js';
8
9
  import { guiInfoStatusTypes } from '../../constants/constants.js';
9
10
  import { isSkippedUrl, validateCustomFlowLabel } from '../../constants/common.js';
@@ -109,6 +110,7 @@ export const screenshotFullPage = async (page, screenshotsDir, screenshotIdx) =>
109
110
  };
110
111
  export const runAxeScan = async (page, includeScreenshots, randomToken, customFlowDetails, dataset, urlsCrawled) => {
111
112
  const result = await runAxeScript({ includeScreenshots, page, randomToken, customFlowDetails });
113
+ await capturePageData(page, page.url(), randomToken);
112
114
  await dataset.pushData(result);
113
115
  const rawTitle = result.pageTitle ?? '';
114
116
  let pageTitleTextOnly = rawTitle; // Note: The original pageTitle contains the index and is being used in top 10 issues
@@ -0,0 +1,134 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { devices } from 'playwright';
5
+ import { getStoragePath } from '../utils.js';
6
+ const MOBILE_VIEWPORT_WIDTH = devices['iPhone 11'].viewport.width;
7
+ const MOBILE_VIEWPORT_HEIGHT = devices['iPhone 11'].viewport.height;
8
+ const captureEntries = new Map();
9
+ export function getUrlHash(url) {
10
+ return crypto.createHash('sha256').update(url).digest('hex').slice(0, 7);
11
+ }
12
+ function getTruncatedPath(url) {
13
+ try {
14
+ const parsed = new URL(url);
15
+ let pathStr = parsed.pathname + (parsed.search || '');
16
+ pathStr = pathStr.replace(/^\//, '').replace(/\//g, '_').replace(/[^a-zA-Z0-9\-_.]/g, '_');
17
+ if (pathStr.length > 80) {
18
+ pathStr = pathStr.slice(0, 80);
19
+ }
20
+ return pathStr || 'index';
21
+ }
22
+ catch {
23
+ return 'unknown';
24
+ }
25
+ }
26
+ function getPageDomsDir(randomToken) {
27
+ const storagePath = getStoragePath(randomToken);
28
+ return path.join(storagePath, 'pageDOMs');
29
+ }
30
+ async function getUniqueFilePath(dir, baseName, ext) {
31
+ let candidate = path.join(dir, `${baseName}${ext}`);
32
+ if (!await fs.pathExists(candidate))
33
+ return candidate;
34
+ let counter = 2;
35
+ while (await fs.pathExists(candidate)) {
36
+ candidate = path.join(dir, `${baseName}-${counter}${ext}`);
37
+ counter++;
38
+ }
39
+ return candidate;
40
+ }
41
+ function getRelativeName(filePath, baseDir) {
42
+ return path.relative(baseDir, filePath).replace(/\\/g, '/');
43
+ }
44
+ export function isSaveDomEnabled() {
45
+ return process.env.OOBEE_SAVE_DOM === '1' || process.env.OOBEE_SAVE_DOM === 'true';
46
+ }
47
+ export function isSavePageScreenshotEnabled() {
48
+ return (process.env.OOBEE_SAVE_PAGE_SCREENSHOT === '1' ||
49
+ process.env.OOBEE_SAVE_PAGE_SCREENSHOT === 'true');
50
+ }
51
+ export function isPageCaptureEnabled() {
52
+ return isSaveDomEnabled() || isSavePageScreenshotEnabled();
53
+ }
54
+ export async function capturePageData(page, url, randomToken) {
55
+ if (!isPageCaptureEnabled())
56
+ return;
57
+ const hash = getUrlHash(url);
58
+ const truncatedPath = getTruncatedPath(url);
59
+ const fileName = `${hash}-${truncatedPath}`;
60
+ const pageDomsDir = getPageDomsDir(randomToken);
61
+ const entry = {
62
+ url,
63
+ hash,
64
+ errors: [],
65
+ };
66
+ if (isSaveDomEnabled()) {
67
+ try {
68
+ await fs.ensureDir(pageDomsDir);
69
+ const domContent = await page.content();
70
+ const domFilePath = await getUniqueFilePath(pageDomsDir, fileName, '.html');
71
+ await fs.writeFile(domFilePath, domContent, 'utf-8');
72
+ entry.domFile = `pageDOMs/${getRelativeName(domFilePath, pageDomsDir)}`;
73
+ }
74
+ catch (err) {
75
+ entry.errors.push(`DOM save failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ }
77
+ }
78
+ if (isSavePageScreenshotEnabled()) {
79
+ const desktopDir = path.join(pageDomsDir, 'desktopPageScreenshots');
80
+ const mobileDir = path.join(pageDomsDir, 'mobilePageScreenshots');
81
+ try {
82
+ await fs.ensureDir(desktopDir);
83
+ const desktopPath = await getUniqueFilePath(desktopDir, fileName, '.png');
84
+ await page.screenshot({ path: desktopPath, fullPage: true });
85
+ entry.desktopScreenshot = `pageDOMs/desktopPageScreenshots/${getRelativeName(desktopPath, desktopDir)}`;
86
+ }
87
+ catch (err) {
88
+ entry.errors.push(`Desktop screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ try {
91
+ await fs.ensureDir(mobileDir);
92
+ const currentViewport = page.viewportSize();
93
+ await page.setViewportSize({
94
+ width: MOBILE_VIEWPORT_WIDTH,
95
+ height: MOBILE_VIEWPORT_HEIGHT,
96
+ });
97
+ await page.waitForTimeout(500);
98
+ const mobilePath = await getUniqueFilePath(mobileDir, fileName, '.png');
99
+ await page.screenshot({ path: mobilePath, fullPage: true });
100
+ entry.mobileScreenshot = `pageDOMs/mobilePageScreenshots/${getRelativeName(mobilePath, mobileDir)}`;
101
+ if (currentViewport) {
102
+ await page.setViewportSize(currentViewport);
103
+ }
104
+ }
105
+ catch (err) {
106
+ entry.errors.push(`Mobile screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
109
+ captureEntries.set(url, entry);
110
+ }
111
+ export async function writeManifest(randomToken) {
112
+ if (!isPageCaptureEnabled())
113
+ return;
114
+ if (captureEntries.size === 0)
115
+ return;
116
+ const pageDomsDir = getPageDomsDir(randomToken);
117
+ await fs.ensureDir(pageDomsDir);
118
+ const manifest = {
119
+ generatedAt: new Date().toISOString(),
120
+ pages: Array.from(captureEntries.values()).map(entry => ({
121
+ url: entry.url,
122
+ hash: entry.hash,
123
+ ...(entry.domFile && { domFile: entry.domFile }),
124
+ ...(entry.desktopScreenshot && { desktopScreenshot: entry.desktopScreenshot }),
125
+ ...(entry.mobileScreenshot && { mobileScreenshot: entry.mobileScreenshot }),
126
+ errors: entry.errors,
127
+ })),
128
+ };
129
+ const manifestPath = path.join(pageDomsDir, 'domManifest.json');
130
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
131
+ }
132
+ export function resetCaptureEntries() {
133
+ captureEntries.clear();
134
+ }
@@ -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.11.0
6
+ * App version : 0.11.1
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.11.0";
34890
+ var _oobeeAppVersion = "0.11.1";
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.11.0",
4
+ "version": "0.11.1",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
package/src/combine.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  getS3UploadPrefix,
19
19
  uploadFolderToS3,
20
20
  } from './services/s3Uploader.js';
21
+ import { writeManifest, resetCaptureEntries, isPageCaptureEnabled } from './crawlers/pageCapture.js';
21
22
 
22
23
  // Class exports
23
24
  export class ViewportSettingsClass {
@@ -75,6 +76,10 @@ const combineRun = async (details: Data, deviceToScan: string) => {
75
76
  process.env.CRAWLEE_STORAGE_DIR = randomToken;
76
77
  constants.sitemapFetchedLinks = null;
77
78
 
79
+ if (isPageCaptureEnabled() && !process.env.OOBEE_SCAN_PRODUCT) {
80
+ process.env.OOBEE_SCAN_PRODUCT = 'U&A';
81
+ }
82
+
78
83
  if (process.env.CRAWLEE_SYSTEM_INFO_V2 === undefined) {
79
84
  // Set the environment variable to enable system info v2
80
85
  // Resolves issue with when wmic is not installed on Windows
@@ -285,6 +290,8 @@ const combineRun = async (details: Data, deviceToScan: string) => {
285
290
  if (scanDetails.urlsCrawled) {
286
291
  if (scanDetails.urlsCrawled.scanned.length > 0) {
287
292
  await createAndUpdateResultsFolders(randomToken);
293
+ await writeManifest(randomToken);
294
+ resetCaptureEntries();
288
295
  const pagesNotScanned = [
289
296
  ...urlsCrawledObj.error,
290
297
  ...urlsCrawledObj.invalid,
@@ -41,6 +41,7 @@ import {
41
41
  } from './pdfScanFunc.js';
42
42
  import { consoleLogger, guiInfoLog } from '../logs.js';
43
43
  import { ViewportSettingsClass } from '../combine.js';
44
+ import { capturePageData } from './pageCapture.js';
44
45
 
45
46
  const isBlacklisted = (url: string, blacklistedPatterns: string[]) => {
46
47
  if (!blacklistedPatterns) {
@@ -399,7 +400,7 @@ const crawlDomain = async ({
399
400
  launcher: constants.launcher,
400
401
  launchOptions: getPlaywrightLaunchOptions(browser),
401
402
  },
402
- retryOnBlocked: true,
403
+ retryOnBlocked: false,
403
404
  browserPoolOptions: {
404
405
  useFingerprints: false,
405
406
  retireBrowserAfterPageCount: 500,
@@ -423,7 +424,6 @@ const crawlDomain = async ({
423
424
  },
424
425
  requestQueue,
425
426
  maxRequestRetries: 3,
426
- maxSessionRotations: 1,
427
427
  preNavigationHooks: [
428
428
  ...preNavigationHooks(extraHTTPHeaders),
429
429
  async ({ request }) => {
@@ -644,6 +644,21 @@ const crawlDomain = async ({
644
644
  }
645
645
 
646
646
  const responseStatus = response?.status();
647
+ if (responseStatus === 403) {
648
+ rateController.onFailure(responseStatus, activeCrawler.autoscaledPool);
649
+ guiInfoLog(guiInfoStatusTypes.SKIPPED, {
650
+ numScanned: urlsCrawled.scanned.length,
651
+ urlScanned: request.url,
652
+ });
653
+ urlsCrawled.userExcluded.push({
654
+ url: request.url,
655
+ pageTitle: request.url,
656
+ actualUrl,
657
+ metadata: STATUS_CODE_METADATA[403] || STATUS_CODE_METADATA[599],
658
+ httpStatusCode: 403,
659
+ });
660
+ return;
661
+ }
647
662
  if (responseStatus && responseStatus >= 300) {
648
663
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
649
664
  numScanned: urlsCrawled.scanned.length,
@@ -661,6 +676,8 @@ const crawlDomain = async ({
661
676
 
662
677
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
663
678
 
679
+ await capturePageData(page, actualUrl, randomToken);
680
+
664
681
  // Detect JS redirects that fire during/after axe scan.
665
682
  // Listen for navigation, then give a brief window for pending redirects to complete.
666
683
  try {
@@ -841,12 +858,9 @@ const crawlDomain = async ({
841
858
  const status = response?.status();
842
859
 
843
860
  // Re-enqueue rate-limited (403) URLs once for a retry after concurrency recovers.
844
- // Without this, URLs that fail during a rate-limit burst are permanently lost
845
- // even though the site is accessible at lower concurrency.
846
- // Don't call onFailure here — the re-enqueued request will get a fresh attempt.
847
- // If it fails again (rateLimitRetried=true), it falls through to the normal
848
- // onFailure + circuit breaker path below.
861
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
849
862
  if (status === 403 && !request.userData?.rateLimitRetried) {
863
+ rateController.onFailure(status, crawler.autoscaledPool);
850
864
  try {
851
865
  await requestQueue.addRequest({
852
866
  url: request.url,
@@ -21,6 +21,7 @@ import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.j
21
21
  import { guiInfoLog } from '../logs.js';
22
22
  import crawlSitemap from './crawlSitemap.js';
23
23
  import { getPdfStoragePath, getStoragePath, register } from '../utils.js';
24
+ import { capturePageData } from './pageCapture.js';
24
25
 
25
26
  export const crawlLocalFile = async ({
26
27
  url,
@@ -186,6 +187,8 @@ export const crawlLocalFile = async ({
186
187
 
187
188
  const actualUrl = page.url() || request.loadedUrl || url;
188
189
 
190
+ await capturePageData(page, actualUrl, randomToken);
191
+
189
192
  guiInfoLog(guiInfoStatusTypes.SCANNED, {
190
193
  numScanned: urlsCrawled.scanned.length,
191
194
  urlScanned: url,
@@ -38,6 +38,7 @@ import {
38
38
  } from './pdfScanFunc.js';
39
39
  import { consoleLogger, guiInfoLog } from '../logs.js';
40
40
  import { ViewportSettingsClass } from '../combine.js';
41
+ import { capturePageData } from './pageCapture.js';
41
42
 
42
43
  const crawlSitemap = async ({
43
44
  sitemapUrl,
@@ -149,7 +150,7 @@ const crawlSitemap = async ({
149
150
  launcher: constants.launcher,
150
151
  launchOptions: getPlaywrightLaunchOptions(browser),
151
152
  },
152
- retryOnBlocked: true,
153
+ retryOnBlocked: false,
153
154
  browserPoolOptions: {
154
155
  useFingerprints: false,
155
156
  retireBrowserAfterPageCount: 500,
@@ -173,7 +174,6 @@ const crawlSitemap = async ({
173
174
  requestList,
174
175
  requestQueue,
175
176
  maxRequestRetries: 3,
176
- maxSessionRotations: 1,
177
177
  postNavigationHooks: [
178
178
  async ({ page }) => {
179
179
  try {
@@ -319,6 +319,22 @@ const crawlSitemap = async ({
319
319
  const contentType = response?.headers?.()['content-type'] || '';
320
320
  const status = response ? response.status() : 0;
321
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
+
322
338
  if (isScanHtml && status < 300 && isWhitelistedContentType(contentType)) {
323
339
  const isRedirected = !areLinksEqual(page.url(), request.url);
324
340
  const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some(
@@ -364,6 +380,8 @@ const crawlSitemap = async ({
364
380
 
365
381
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
366
382
 
383
+ await capturePageData(page, actualUrl, randomToken);
384
+
367
385
  // Detect JS redirects that fire during/after axe scan.
368
386
  // Listen for navigation, then give a brief window for pending redirects to complete.
369
387
  try {
@@ -533,9 +551,9 @@ const crawlSitemap = async ({
533
551
  const status = response?.status();
534
552
 
535
553
  // 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.
554
+ // Call onFailure to reduce concurrency immediately on rate-limit detection.
538
555
  if (status === 403 && !request.userData?.rateLimitRetried) {
556
+ rateController.onFailure(status, crawler.autoscaledPool);
539
557
  try {
540
558
  await requestQueue.addRequest({
541
559
  url: request.url,
@@ -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
+ }