@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 +15 -1
- package/CRAWL.md +2 -2
- package/README.md +2 -0
- package/dist/combine.js +6 -0
- package/dist/crawlers/crawlDomain.js +20 -7
- package/dist/crawlers/crawlLocalFile.js +2 -0
- package/dist/crawlers/crawlSitemap.js +20 -4
- package/dist/crawlers/custom/utils.js +2 -0
- package/dist/crawlers/pageCapture.js +134 -0
- package/oobee-client-scanner.js +2 -2
- package/package.json +1 -1
- package/src/combine.ts +7 -0
- package/src/crawlers/crawlDomain.ts +21 -7
- package/src/crawlers/crawlLocalFile.ts +3 -0
- package/src/crawlers/crawlSitemap.ts +22 -4
- package/src/crawlers/custom/utils.ts +3 -0
- package/src/crawlers/pageCapture.ts +172 -0
- /package/{f0ce5c33-4dd0-4e14-8e67-1394f57ff517.txt → 45de6711-5a2f-492e-99b1-290059c74264.txt} +0 -0
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
|
-
|
|
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/
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
+
}
|
package/oobee-client-scanner.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
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:
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
+
}
|
/package/{f0ce5c33-4dd0-4e14-8e67-1394f57ff517.txt → 45de6711-5a2f-492e-99b1-290059c74264.txt}
RENAMED
|
File without changes
|