@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.
- package/AGENTS.md +40 -1
- package/CRAWL.md +260 -0
- package/README.md +6 -0
- package/dist/combine.js +13 -0
- package/dist/constants/common.js +7 -0
- package/dist/constants/constants.js +17 -0
- package/dist/crawlers/commonCrawlerFunc.js +89 -48
- package/dist/crawlers/crawlDomain.js +103 -32
- package/dist/crawlers/crawlLocalFile.js +2 -0
- package/dist/crawlers/crawlSitemap.js +141 -20
- package/dist/crawlers/custom/utils.js +2 -0
- package/dist/crawlers/pageCapture.js +134 -0
- package/dist/mergeAxeResults.js +2 -1
- package/dist/utils.js +36 -9
- package/oobee-client-scanner.js +2 -2
- package/package.json +1 -1
- package/src/combine.ts +14 -0
- package/src/constants/common.ts +9 -0
- package/src/constants/constants.ts +17 -0
- package/src/crawlers/commonCrawlerFunc.ts +101 -62
- package/src/crawlers/crawlDomain.ts +105 -32
- package/src/crawlers/crawlLocalFile.ts +3 -0
- package/src/crawlers/crawlSitemap.ts +148 -21
- package/src/crawlers/custom/utils.ts +3 -0
- package/src/crawlers/pageCapture.ts +172 -0
- package/src/mergeAxeResults.ts +2 -1
- package/src/utils.ts +31 -8
- /package/{0a93900f-3345-4f25-8be7-c2d3748034be.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
|
|
|
@@ -212,6 +214,7 @@ docker run oobee node dist/cli.js ...
|
|
|
212
214
|
|
|
213
215
|
- If Chrome/Edge profile cloning fails (for example `EBUSY` while copying locked cookie/state files on Windows), Oobee now falls back to an empty cloned profile directory for that scan. This keeps browser launch stable, but authenticated session cookies may not be available.
|
|
214
216
|
- Crawlee's browser pool retires and re-launches browser instances after ~4 minutes. On Windows, reusing the same `--user-data-dir` causes Chrome exit code 21 (stale lock contention). `getPreLaunchHook()` in `commonCrawlerFunc.ts` assigns unique `_pool{N}` directories for each re-launch and performs a best-effort async clone of the base profile. Cleanup must glob `_pool*` directories alongside the base `oobee-{token}` dir.
|
|
217
|
+
- On Windows, Chrome writes files asynchronously during its shutdown sequence (`first_party_sets.db`, `optimization_guide_model_store/`, `segmentation_platform/`, `Local State`, `Profile N/`). Pool directory cleanup uses a 5s initial delay (vs 2s on other platforms) and retries up to 3 times in `getPostPageCloseHook()`. The final sweep in `cleanUp()` also retries after a 3s delay on Windows.
|
|
215
218
|
|
|
216
219
|
4. **`constants.launcher` mutation** — When webkit is the fallback, `constants.launcher` is reassigned globally. This affects all subsequent browser launches in the same process.
|
|
217
220
|
|
|
@@ -252,6 +255,14 @@ When making changes, validate these areas which have well-established edge cases
|
|
|
252
255
|
- Crawlee's async lock-file operations (`.json.lock` mkdir) can fire after the crawl finishes. On Windows, this triggers uncaughtException EPERM during report generation. A scoped exception handler suppresses these. The cleanup delay is 5s on Windows, 3s on others.
|
|
253
256
|
- The crawlee dataset folder and `tmp-items` (intermediate JSONL store) must be deleted BEFORE zipping results. `zipResults` must be the last step in `generateArtifacts()` — any cleanup or processing that removes temp files from `storagePath` must happen earlier. The dataset deletion uses an awaited delay (not fire-and-forget setTimeout) to let lingering Crawlee I/O flush.
|
|
254
257
|
- Errors must only be recorded in `failedRequestHandler` (after all retries exhausted), not in the `requestHandler` catch block. Crawlee retries up to 3 times, so recording in the catch block creates duplicates and false positives for URLs that succeed on retry.
|
|
258
|
+
- **"Download is starting" navigation errors**: URLs that trigger file downloads (e.g. Salesforce `/download/` endpoints without `.pdf` extension) cause `page.goto()` to throw. Crawlee retries 3 times (all fail the same way). In `failedRequestHandler`, detect via `request.errorMessages.includes('Download is starting')`. If `isScanPdfs`: re-enqueue with `skipNavigation: true` and unique key `download_${url}` so the requestHandler's PDF download path handles it via `sendRequest`. If not scanning PDFs: classify as `STATUS_CODE_METADATA[1]` ("Not A Supported Document").
|
|
259
|
+
- **403 rate-limit retry**: In `failedRequestHandler`, 403 URLs are re-enqueued once with `userData.rateLimitRetried = true` and unique key `ratelimit_${url}`. This gives them a fresh attempt cycle after adaptive concurrency recovers from the rate-limit burst. Do NOT call `rateController.onFailure()` on the first pass — this avoids double-counting toward the circuit breaker. If the retry also fails, it falls through to the normal `onFailure` + circuit breaker path.
|
|
260
|
+
- **Non-HTML document classification ("Unsupported Documents")**: URLs serving non-HTML content (images, PDFs, media, binary files) must be classified as `STATUS_CODE_METADATA[1]` ("Not A Supported Document") with `httpStatusCode: 1` in both crawlers. This applies whether the content-type is detected pre-navigation (via file extension in `preNavigationHooks`) or post-navigation (via response `content-type` header). The report UI identifies unsupported documents by `httpStatusCode === 1` — any other status code (even 0) with incorrect metadata may land in "Pages Not Scanned" instead. Both crawlDomain and crawlSitemap now report these consistently — crawlDomain no longer silently drops them.
|
|
261
|
+
- **`blackListedFileExtensions` (shared constant in `constants.ts`)**: Single source of truth for non-scannable file extensions. Used in both crawlers' `preNavigationHooks` to skip navigation, and in crawlDomain's post-navigation `isBlacklistedFileExtensions()` check. When adding new non-scannable types, update this one list. Does NOT include `.pdf` — PDFs are handled by the separate PDF scan path.
|
|
262
|
+
- **Both crawlers' preNavigationHooks**: Check URL pathname extension against `blackListedFileExtensions`. If matched, set `skipNavigation=true` (crawlDomain) or `skipNavigation=true` + `isNotSupportedDocument=true` (crawlSitemap). This avoids wasting browser resources on non-HTML URLs.
|
|
263
|
+
- **crawlSitemap else block**: when navigation succeeds but `content-type` is non-HTML (e.g. `image/png`, `application/pdf` after a 302 redirect), classifies as unsupported instead of using the HTTP status code. The check: `contentType && !contentType.startsWith('text/html') && !contentType.includes('html')`. Empty content-type falls through to existing logic (preserves behavior for pages that don't send content-type headers).
|
|
264
|
+
- **crawlDomain**: `shouldSkipDueToUnsupportedContent()` and `isBlacklistedFileExtensions()` now push to `urlsCrawled.userExcluded` with `httpStatusCode: 1`. Previously these silently returned with no classification. The preNavigationHook catches blacklisted extensions before navigation; for URLs without obvious extensions (e.g. redirects to non-HTML), the post-navigation content-type check is the safety net.
|
|
265
|
+
- **No impact to runAxeScript**: HTML pages never trigger these paths — `shouldSkipDueToUnsupportedContent` returns false for `text/html`, `blackListedFileExtensions` doesn't match `.html`/no-extension URLs, and `isWhitelistedContentType` passes for `text/html`. The full page lifecycle (waitForPageLoaded → DOM mutation observer → runAxeScript) is unchanged for scannable pages.
|
|
255
266
|
|
|
256
267
|
### URL & Redirect Handling
|
|
257
268
|
- `https://example.com` and `https://example.com/` must be treated as the same page. Use `normUrl()` (wrapping `@apify/utilities normalizeUrl`) for all dedup sets.
|
|
@@ -298,6 +309,22 @@ When making changes, validate these areas which have well-established edge cases
|
|
|
298
309
|
- In intelligent crawl, each phase (sitemap then domain) creates its own `CrawlRateController` instance — transitioning from sitemap to domain crawl starts fresh.
|
|
299
310
|
- Without the circuit breaker, a rate-limited crawl with thousands of enqueued URLs would run indefinitely, never hit the success threshold, and never generate a report.
|
|
300
311
|
- When enqueuing all sitemap URLs (which we do for accurate `totalLinksFetchedFromSitemaps` reporting), always ensure either a scan duration (`-d`) or the circuit breaker is in place as a safety net.
|
|
312
|
+
- **WAF behavior differs by source IP**: Datacenter/cloud IPs (Linux servers) are often rate-limited more aggressively by WAFs than residential/ISP IPs (Windows desktops). This means the circuit breaker (100 consecutive failures) fires on Linux but NOT on Windows for the same site — Windows successfully recovers concurrency between rate-limit bursts, keeping the consecutive failure counter below 100. This difference affects whether `isAbortingScanNow` is set, which gates the second-pass click-discovery loop.
|
|
313
|
+
|
|
314
|
+
### Click-Discovery Second Pass in crawlDomain
|
|
315
|
+
- After `crawler.run()` completes, `crawlDomain` has a second-pass loop that re-visits all scanned same-hostname pages for `customEnqueueLinksByClickingElements` — a sequential loop that clicks every interactive element with 1s delay between clicks.
|
|
316
|
+
- **This loop is skipped when `fromCrawlIntelligentSitemap` is true** — the domain phase of intelligent crawl is only meant to discover new pages via `<a>` link extraction, not exhaustively click 3000+ already-scanned pages.
|
|
317
|
+
- **This loop is skipped when `isAbortingScanNow` is true** — if the circuit breaker fired or the rate controller hit its page limit, the second pass is skipped. This is why Linux scans (where the circuit breaker fires due to stricter WAF) don't get stuck, but Windows scans (where recovery succeeds) enter the loop.
|
|
318
|
+
- Without `scanDuration` or the `fromCrawlIntelligentSitemap` guard, the second pass on a 3000+ page site can take 10+ hours (each page × 90s `requestHandlerTimeoutSecs` at concurrency 1).
|
|
319
|
+
- The second pass produces no log output — `__clickpass__` handlers call `enqueueProcess` and return without `guiInfoLog` or rate controller interaction, making it appear as if the scan is hung.
|
|
320
|
+
|
|
321
|
+
### Intelligent Sitemap Link Discovery Optimization
|
|
322
|
+
- In intelligent crawl mode, `crawlSitemap` now performs `enqueueLinks` on each successfully scanned page (gated by `fromCrawlIntelligentSitemap`). This discovers `<a>` links from sitemap pages without any additional page loads — just a DOM query on the already-loaded page.
|
|
323
|
+
- Discovered URLs go into a `RequestQueue`. Crawlee processes `RequestList` (sitemap URLs) first, then `RequestQueue` items after. So discovered links are scanned after all sitemap URLs complete, within the same crawlSitemap phase.
|
|
324
|
+
- This eliminates most of the work the subsequent `crawlDomain` supplement phase would otherwise do. The domain phase still runs (to discover pages reachable only from the entry URL), but finds almost everything already in `scannedUrlSet` and finishes quickly.
|
|
325
|
+
- **No behavior change for standalone scans**: The `enqueueLinks` block is gated by `fromCrawlIntelligentSitemap` — standalone sitemap scans (`-s sitemap`) and standalone website scans (`-s website`) are unaffected.
|
|
326
|
+
- The `transformRequestFunction` in sitemap `enqueueLinks` filters robots.txt-disallowed URLs and marks PDFs for `skipNavigation`, matching `crawlDomain`'s behavior.
|
|
327
|
+
- `crawlSitemap` always creates a `RequestQueue` (even in standalone mode) to enable download re-enqueue and 403 retry handling. An empty queue has zero impact on crawl behavior.
|
|
301
328
|
|
|
302
329
|
### Scan Consistency Between crawlDomain and crawlSitemap
|
|
303
330
|
- Both crawlers must produce equivalent axe scan results for the same page. Any difference in how the page is observed/stabilized before `runAxeScript()` will cause inconsistent accessibility findings between scan types.
|
|
@@ -327,5 +354,17 @@ When making changes, validate these areas which have well-established edge cases
|
|
|
327
354
|
├── scanPagesDetail.json # Per-page breakdown
|
|
328
355
|
├── scanPagesSummary.json # Page-level summary
|
|
329
356
|
├── sitemap.xml # Discovered URLs
|
|
330
|
-
|
|
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
|
|
331
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
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# How Oobee Scanning Works
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
**Part 1: How Scanning Affects Accessibility Results**
|
|
6
|
+
- [What Oobee Does](#what-oobee-does)
|
|
7
|
+
- [What Gets Scanned](#what-gets-scanned)
|
|
8
|
+
- [Scan Strategy](#scan-strategy)
|
|
9
|
+
- [Why Some Pages Are Not Scanned](#why-some-pages-are-not-scanned)
|
|
10
|
+
- [What Affects Result Accuracy](#what-affects-result-accuracy)
|
|
11
|
+
- [Understanding the Report](#understanding-the-report)
|
|
12
|
+
- [Choosing a Page Budget](#choosing-a-page-budget)
|
|
13
|
+
- [Tips for Better Scan Results](#tips-for-better-scan-results)
|
|
14
|
+
- [Recommended Hardware](#recommended-hardware)
|
|
15
|
+
|
|
16
|
+
**Part 2: Technical Details**
|
|
17
|
+
- [Scan Modes](#scan-modes)
|
|
18
|
+
- [Page Discovery Mechanics](#page-discovery-mechanics)
|
|
19
|
+
- [Concurrency and Ordering](#concurrency-and-ordering)
|
|
20
|
+
- [Adaptive Concurrency and Rate Limiting](#adaptive-concurrency-and-rate-limiting)
|
|
21
|
+
- [Error Handling Pipeline](#error-handling-pipeline)
|
|
22
|
+
- [Page Classification](#page-classification)
|
|
23
|
+
- [Page Budget](#page-budget)
|
|
24
|
+
- [Browser Pool and Session State](#browser-pool-and-session-state)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Part 1: How Scanning Affects Accessibility Results
|
|
29
|
+
|
|
30
|
+
### What Oobee Does
|
|
31
|
+
|
|
32
|
+
Oobee visits web pages using a real browser (Chrome/Chromium) and runs automated accessibility checks against each page. It produces a report of issues found, organized by severity (must fix, good to fix, needs review).
|
|
33
|
+
|
|
34
|
+
### What Gets Scanned
|
|
35
|
+
|
|
36
|
+
Oobee supports three main scan modes:
|
|
37
|
+
|
|
38
|
+
- **Intelligent** (recommended): Automatically finds your sitemap via robots.txt, scans all sitemap pages, then follows links to discover any pages the sitemap missed. This gives the most complete and predictable coverage — the sitemap ensures known pages are scanned in a consistent order, while link discovery catches pages the sitemap doesn't list.
|
|
39
|
+
- **Sitemap**: Scans only the pages listed in your website's sitemap XML file. You provide the sitemap URL, and Oobee visits each page listed in it. Good when you know your sitemap is comprehensive.
|
|
40
|
+
- **Website**: Starts at one page and follows links to discover more pages on the same site. Discovers pages organically by extracting links from each page it visits. Coverage depends heavily on site navigation structure.
|
|
41
|
+
|
|
42
|
+
Not all pages on a site will necessarily be scanned. The scanner stops when it reaches the page limit, time limit, or runs out of discoverable pages.
|
|
43
|
+
|
|
44
|
+
### Scan Strategy
|
|
45
|
+
|
|
46
|
+
The **strategy** (`-s`) controls which discovered links are followed. It is supported in Intelligent, Website, and Sitemap modes:
|
|
47
|
+
|
|
48
|
+
- **`same-domain`** (default): Follows links on the same registered domain, including subdomains.
|
|
49
|
+
- **`same-hostname`**: Only follows links on the exact same hostname (after stripping `www.`).
|
|
50
|
+
- **`ignore`** (sitemap mode only): No URL filtering — all URLs in the sitemap are scanned regardless of domain. This is the default for standalone sitemap scans.
|
|
51
|
+
|
|
52
|
+
**Example**: Scanning `https://www.acme.gov.sg/services/home`
|
|
53
|
+
|
|
54
|
+
| Discovered Link | `same-domain` | `same-hostname` |
|
|
55
|
+
|-----------------|:---:|:---:|
|
|
56
|
+
| `https://www.acme.gov.sg/services/faq` | Followed | Followed |
|
|
57
|
+
| `https://acme.gov.sg/about` | Followed | Followed (www. stripped) |
|
|
58
|
+
| `https://employer.acme.gov.sg/dashboard` | Followed (same domain `acme.gov.sg`) | Skipped (different hostname) |
|
|
59
|
+
| `https://blog.acme.gov.sg/news` | Followed (same domain `acme.gov.sg`) | Skipped (different hostname) |
|
|
60
|
+
| `https://www.other.gov.sg/resources` | Skipped (different domain) | Skipped (different hostname) |
|
|
61
|
+
|
|
62
|
+
Choosing the right strategy affects coverage:
|
|
63
|
+
- Use `same-domain` when your site spans multiple subdomains and you want a holistic view.
|
|
64
|
+
- Use `same-hostname` when you only care about one specific subdomain, or when following subdomains would waste budget on unrelated content (e.g., a separate blog platform).
|
|
65
|
+
- The strategy only filters links discovered dynamically during the crawl — it does NOT affect which pages are listed in the sitemap.
|
|
66
|
+
|
|
67
|
+
### Why Some Pages Are Not Scanned
|
|
68
|
+
|
|
69
|
+
Pages may be skipped for several reasons:
|
|
70
|
+
|
|
71
|
+
- **Not an HTML document**: The URL points to an image, PDF, spreadsheet, video, or other file type that cannot be checked for web accessibility.
|
|
72
|
+
- **Blocked by the server**: The website's firewall or rate limiter returned an error (commonly 403 Forbidden). This is especially common on large scans where the server detects rapid automated requests.
|
|
73
|
+
- **Requires authentication the scanner doesn't have**: Download endpoints, APIs, or pages behind a different login system than the one whose cookies were provided.
|
|
74
|
+
- **Took too long to respond**: Pages that don't load within 30 seconds are skipped.
|
|
75
|
+
- **Budget exhausted**: The scanner reached its page limit or time limit before getting to this page.
|
|
76
|
+
- **Redirected out of scope**: The page redirected to a different domain that's outside the scan's strategy filter.
|
|
77
|
+
|
|
78
|
+
### What Affects Result Accuracy
|
|
79
|
+
|
|
80
|
+
- **Dynamic content**: Pages with heavy JavaScript may produce slightly different results on different runs because content loads asynchronously. The scanner waits for the page to stabilize, but some elements may appear after the check completes.
|
|
81
|
+
- **Rate limiting**: When a site blocks the scanner, it reduces its speed and retries, but some pages may be permanently lost if the site continues blocking. Accuracy improves with higher page budgets and longer scan durations.
|
|
82
|
+
- **Authentication**: The scanner sees pages as a logged-out visitor unless you provide authenticated browser cookies. Some page content is only visible when logged in.
|
|
83
|
+
- **Non-deterministic discovery** (website mode): Pages are discovered by following links. Because multiple pages load simultaneously, the order in which links are found varies between runs. A page discovered on one run may not be found on another if the scanner hits its limit first.
|
|
84
|
+
- **Mid-scan redirects**: If a page redirects while being scanned (JavaScript redirect, meta refresh), the accessibility check may run on the redirect target rather than the original page.
|
|
85
|
+
|
|
86
|
+
### Understanding the Report
|
|
87
|
+
|
|
88
|
+
The scan report categorizes URLs into three groups:
|
|
89
|
+
|
|
90
|
+
- **Pages Scanned**: Pages where the accessibility check completed successfully. These have full results.
|
|
91
|
+
- **Pages Not Scanned**: Pages that were found but couldn't be checked. The report shows why (e.g., "403 - Forbidden", "Web Crawler Errored", "access restrictions").
|
|
92
|
+
- **Unsupported Documents**: URLs that point to non-web-page content — images, PDFs (when PDF scanning is disabled), downloads, media files. These are not accessibility-checkable.
|
|
93
|
+
|
|
94
|
+
A higher "Pages Scanned" count relative to total discovered URLs means more comprehensive coverage.
|
|
95
|
+
|
|
96
|
+
### Choosing a Page Budget
|
|
97
|
+
|
|
98
|
+
The page budget (`-p`) determines how many pages are scanned. Choosing the right number is important for getting a representative picture of your site's accessibility:
|
|
99
|
+
|
|
100
|
+
- **Small sites (< 100 pages)**: Set the budget equal to or above your total page count. You want full coverage.
|
|
101
|
+
- **Medium sites (100-1000 pages)**: A budget of 500-1000 pages gives a good representative sample. Most accessibility issues repeat across page templates, so scanning a subset often reveals the same patterns as scanning everything.
|
|
102
|
+
- **Large sites (1000+ pages)**: A budget of 2000-5000 pages typically captures all unique page templates and their issues. Beyond this, you'll mostly find the same issue types on more pages.
|
|
103
|
+
|
|
104
|
+
A representative scan means enough pages have been checked that the issues found reflect the overall accessibility posture of the site. If your site has 20 page templates, scanning 100 pages is likely sufficient. If it has hundreds of unique layouts, you need a larger budget.
|
|
105
|
+
|
|
106
|
+
### Tips for Better Scan Results
|
|
107
|
+
|
|
108
|
+
- **Use Intelligent mode** for the most complete coverage — it combines sitemap accuracy with link-discovery thoroughness. This is the recommended mode for all production scans.
|
|
109
|
+
- **Set an appropriate page budget** (`-p`) — too low and you miss page templates; too high and you waste time scanning duplicate layouts. Start with 2000 for large sites and adjust.
|
|
110
|
+
- **Provide authenticated cookies** for sites behind login (clone your Chrome profile with `-u`).
|
|
111
|
+
- **Increase scan duration** (`-d 3600`) for large sites to give the scanner enough time.
|
|
112
|
+
- **Use `same-hostname` strategy** (`-s same-hostname`) to avoid following links to subdomains or external sites.
|
|
113
|
+
- **Run from a residential IP** if possible — datacenter IPs are more likely to be rate-limited by firewalls.
|
|
114
|
+
|
|
115
|
+
### Recommended Hardware
|
|
116
|
+
|
|
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
|
+
|
|
119
|
+
| Scan Size | Hardware | Max Concurrency | Expected Duration |
|
|
120
|
+
|-----------|----------|-----------------|-------------------|
|
|
121
|
+
| 1,000 pages | ECS Fargate, 2 vCPU / 4 GB RAM | 10 (`-t 10`) | 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
|
+
|
|
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
|
+
|
|
126
|
+
Setting concurrency above 25 (e.g. `-t 50`) is possible but generally provides no speed improvement — either the target website rate-limits the extra requests, or the machine itself becomes the bottleneck (CPU saturation, memory pressure). In practice, 25 is the sweet spot for most hardware and most sites.
|
|
127
|
+
|
|
128
|
+
For smaller environments (Fargate, low-spec VMs), reduce concurrency to 10 to avoid overwhelming the container's limited CPU and memory. Higher concurrency on constrained hardware causes thrashing, not faster scans.
|
|
129
|
+
|
|
130
|
+
**Slow scan mode** (`-c website` with click discovery): The second-pass click-discovery loop visits every scanned page sequentially and clicks each interactive element with delays. On large sites (1000+ pages), this can add many hours to the scan. Use Intelligent mode instead — it discovers links via `<a>` tag extraction during the sitemap phase without the expensive click loop.
|
|
131
|
+
|
|
132
|
+
These estimates assume the target site doesn't aggressively rate-limit. Actual times depend on page complexity, server response speed, and WAF behavior.
|
|
133
|
+
|
|
134
|
+
**Disk space**: Ensure at least 20 GB free for a 5,000-page scan. This accounts for browser pool directories (~30-50 MB active at any time, but accumulates if cleanup is delayed), intermediate per-page JSON results (~2-5 KB each, ~10-25 MB total), uncompressed report artifacts (HTML report with embedded data can reach 500 MB+ before compression), and temporary PDF/screenshot storage if enabled. Smaller scans (1,000 pages) can get by with 5-10 GB free. Running out of disk space mid-scan causes hard failures (ENOSPC).
|
|
135
|
+
|
|
136
|
+
**Slow machines degrade both coverage and accuracy**: Oobee has fixed timeouts — 30 seconds for a page to start loading, 90 seconds total for the page to load and be scanned. On an underpowered machine, Chromium itself runs slowly, which means:
|
|
137
|
+
|
|
138
|
+
- **Dropped pages**: Pages that would load fine on a fast machine may hit the 30s navigation timeout simply because the CPU can't parse JavaScript fast enough. These pages are retried 3 times and then recorded as errors.
|
|
139
|
+
- **Inaccurate DOM state**: Poorly-coded websites that rely on JavaScript to render content (SPAs, lazy-loaded components, deferred widgets) may not finish rendering before the scanner checks them. On a fast machine, the DOM mutation observer (5s quiet window) catches most dynamic content. On a slow machine, JavaScript execution is delayed — the DOM appears "settled" (no mutations detected) even though rendering hasn't started yet. This means the accessibility scan runs against an incomplete page, producing false negatives (missing issues that exist on the fully-rendered page) or false positives (flagging placeholder content that would normally be replaced).
|
|
140
|
+
- **Thermal throttling cascade**: Sustained CPU load triggers thermal throttling on laptops, which makes subsequent pages even slower, leading to more timeouts and increasingly stale DOM snapshots. A scan that starts fine can degrade badly in its second hour.
|
|
141
|
+
|
|
142
|
+
Desktop machines or cloud instances with adequate cooling and dedicated CPU cores are strongly preferred for large scans.
|
|
143
|
+
|
|
144
|
+
**Docker deployments**: Ensure the container has access to the recommended CPU and memory — Chromium under-performs significantly when CPU-throttled or memory-constrained (swapping). Allocate sufficient storage volume and avoid thin-provisioned storage that can exhaust mid-scan.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Part 2: Technical Details
|
|
149
|
+
|
|
150
|
+
### Scan Modes
|
|
151
|
+
|
|
152
|
+
| Mode | Entry Point | Discovery Method | Page Order |
|
|
153
|
+
|------|-------------|------------------|------------|
|
|
154
|
+
| Website | `crawlDomain.ts` | BFS via `enqueueLinks` + click discovery | Non-deterministic |
|
|
155
|
+
| Sitemap | `crawlSitemap.ts` | URLs from sitemap XML | Mostly FIFO from XML |
|
|
156
|
+
| Intelligent | `crawlIntelligentSitemap.ts` | Sitemap → `enqueueLinks` → domain supplement | Sitemap-ordered, then non-deterministic |
|
|
157
|
+
|
|
158
|
+
### Page Discovery Mechanics
|
|
159
|
+
|
|
160
|
+
**Sitemap mode:**
|
|
161
|
+
- URLs parsed from sitemap XML into a Crawlee `RequestList` (ordered array)
|
|
162
|
+
- In intelligent mode: URLs sorted by closeness to user URL + last-modified date
|
|
163
|
+
- `RequestQueue` created alongside for discovered `<a>` links (intelligent mode) and download/403 re-enqueue
|
|
164
|
+
- Crawlee processes `RequestList` first, then `RequestQueue` items
|
|
165
|
+
|
|
166
|
+
**Website (domain) mode:**
|
|
167
|
+
- Seed URL added to `RequestQueue`
|
|
168
|
+
- Each scanned page runs `enqueueLinks` to extract `<a>` tags matching the strategy filter
|
|
169
|
+
- Click-discovery pass: re-visits seed-hostname pages, clicks interactive elements, captures popup/navigation URLs
|
|
170
|
+
- Second-pass click-discovery is skipped in intelligent mode (already covered by sitemap + link extraction)
|
|
171
|
+
|
|
172
|
+
**Strategy filter** restricts which discovered URLs are followed:
|
|
173
|
+
- `same-hostname`: Only URLs on the same hostname (after stripping `www.`)
|
|
174
|
+
- `same-domain`: Same registered domain (includes subdomains)
|
|
175
|
+
- `all`: Follow all URLs regardless of domain
|
|
176
|
+
|
|
177
|
+
**Pre-navigation filtering:**
|
|
178
|
+
- `blackListedFileExtensions`: Skips known non-HTML extensions (images, media, documents) before browser navigates
|
|
179
|
+
- `disallowedListOfPatterns`: Skips non-HTTP protocols (mailto:, tel:, javascript:, etc.)
|
|
180
|
+
- `robots.txt` disallow rules: Checked before enqueue
|
|
181
|
+
|
|
182
|
+
### Concurrency and Ordering
|
|
183
|
+
|
|
184
|
+
- Default max concurrency: 25 simultaneous pages (configurable via `-t`)
|
|
185
|
+
- Pages process in parallel — completion order depends on server response times, not queue order
|
|
186
|
+
- **Sitemap mode**: `RequestList` provides FIFO order to workers, but with 25 workers, pages 1-25 start together and complete in arbitrary order
|
|
187
|
+
- **Domain mode**: Entirely non-deterministic — faster pages discover and enqueue links first, which get processed before slower pages' links
|
|
188
|
+
- **Practical implication**: Two runs of the same sitemap scan produce the same set of pages (deterministic input), but a domain scan may find different pages depending on timing
|
|
189
|
+
|
|
190
|
+
### Adaptive Concurrency and Rate Limiting
|
|
191
|
+
|
|
192
|
+
The `CrawlRateController` manages concurrency dynamically:
|
|
193
|
+
|
|
194
|
+
- **On HTTP 4xx/5xx**: Concurrency halved (floor 1). Consecutive failure counter incremented.
|
|
195
|
+
- **On success**: After 10 consecutive successes, concurrency increases by 2 (up to original max). Counter reset.
|
|
196
|
+
- **Circuit breaker**: After 100 consecutive failures (`OOBEE_CONSECUTIVE_MAX_RETRIES`), the crawl aborts gracefully.
|
|
197
|
+
- **403 retry**: First 403 re-enqueued with `rateLimitRetried` flag (doesn't count toward circuit breaker). Second 403 is permanent failure.
|
|
198
|
+
|
|
199
|
+
Crawlee's `retryOnBlocked: true` detects blocked responses (403, 429) and rotates sessions automatically before the request reaches the handler.
|
|
200
|
+
|
|
201
|
+
### Error Handling Pipeline
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
Page Request
|
|
205
|
+
↓
|
|
206
|
+
[Navigation: 30s timeout]
|
|
207
|
+
↓ success ↓ failure (timeout, network error, blocked)
|
|
208
|
+
requestHandler Crawlee retries (up to 3 times)
|
|
209
|
+
↓ ↓ all retries exhausted
|
|
210
|
+
[waitForPageLoaded: 10s] failedRequestHandler
|
|
211
|
+
[DOM mutation observer: 5s] ↓
|
|
212
|
+
[runAxeScript: axe-core scan] Final classification:
|
|
213
|
+
↓ - "Download is starting" → Unsupported Document
|
|
214
|
+
Results pushed to dataset - 403 (first time) → re-enqueue for retry
|
|
215
|
+
- 403 (second time) → circuit breaker check → error
|
|
216
|
+
- Other → "Web Crawler Errored"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `requestHandler` processes successful navigations. Errors thrown here are caught by Crawlee and the page is retried.
|
|
220
|
+
- `failedRequestHandler` fires only after all retries are exhausted. This is where final error classification happens.
|
|
221
|
+
- `requestHandlerTimeoutSecs: 90` — if the entire handler (navigation + scan) takes longer, Crawlee kills and retries.
|
|
222
|
+
- Errors are never recorded in the `requestHandler` catch block — only in `failedRequestHandler` — to avoid duplicates for pages that succeed on retry.
|
|
223
|
+
|
|
224
|
+
### Page Classification
|
|
225
|
+
|
|
226
|
+
| Array | Report Category | Trigger |
|
|
227
|
+
|-------|----------------|---------|
|
|
228
|
+
| `urlsCrawled.scanned` | Pages Scanned | Successful axe scan |
|
|
229
|
+
| `urlsCrawled.error` | Pages Not Scanned | All retries exhausted |
|
|
230
|
+
| `urlsCrawled.invalid` | Pages Not Scanned | HTTP 3xx+ status or non-whitelisted content-type |
|
|
231
|
+
| `urlsCrawled.forbidden` | Pages Not Scanned | (reserved) |
|
|
232
|
+
| `urlsCrawled.userExcluded` | Unsupported Documents (if `httpStatusCode=1`) or Pages Not Scanned | Blacklisted extension, non-HTML content-type, excluded pattern, download URL |
|
|
233
|
+
|
|
234
|
+
The report splits "Pages Not Scanned" further:
|
|
235
|
+
- Items with `httpStatusCode === 1` → **Unsupported Documents** tab
|
|
236
|
+
- Everything else → **Pages Not Scanned** tab
|
|
237
|
+
|
|
238
|
+
Key `STATUS_CODE_METADATA` values:
|
|
239
|
+
- `[1]` = "Not A Supported Document"
|
|
240
|
+
- `[2]` = "Web Crawler Errored"
|
|
241
|
+
- `[200]` = "Oobee was not able to scan the page due to access restrictions or compatibility issues"
|
|
242
|
+
- `[403]` = "403 - Forbidden"
|
|
243
|
+
- `[599]` = "Uncommon Response Status Code Received"
|
|
244
|
+
|
|
245
|
+
### Page Budget
|
|
246
|
+
|
|
247
|
+
`maxRequestsPerCrawl` counts **successful scans only**, not total requests:
|
|
248
|
+
- A page that errors 3 times and succeeds on the 4th still counts as 1
|
|
249
|
+
- A page that errors permanently doesn't consume budget
|
|
250
|
+
- In intelligent mode, budget is shared across phases: `remaining = max - sitemapPhaseScanned`
|
|
251
|
+
- `scanDuration` is a hard time limit (seconds) that aborts all phases when exceeded
|
|
252
|
+
|
|
253
|
+
### Browser Pool and Session State
|
|
254
|
+
|
|
255
|
+
- Browser instance retired after 500 pages → new browser launched
|
|
256
|
+
- Each browser gets its own `_pool{N}` directory with cookies cloned fresh from the pristine profile
|
|
257
|
+
- The base `userDataDirectory` is never modified by a running browser — always treated as read-only source
|
|
258
|
+
- Pool directories cleaned mid-scan (when new browser launches) and at scan end
|
|
259
|
+
- `--disk-cache-size=10485760` (10MB) caps per-browser cache to prevent storage bloat
|
|
260
|
+
- Browser rotation means session cookies stay fresh (cloned from original) but any server-side session tracking may see the crawler as a "new visitor" after rotation
|
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ For the **user-friendly desktop application**, check out [Oobee](https://go.gov.
|
|
|
18
18
|
6. [Corretto](https://aws.amazon.com/corretto)
|
|
19
19
|
7. [VeraPDF](https://github.com/veraPDF/veraPDF-apps)
|
|
20
20
|
|
|
21
|
+
## How Scanning Works
|
|
22
|
+
|
|
23
|
+
For a detailed explanation of how Oobee scans websites, what affects result accuracy, recommended hardware specs, and tips for getting the best coverage, see [CRAWL.md](./CRAWL.md). Recommended reading before running large scans.
|
|
24
|
+
|
|
21
25
|
## Using Oobee as a NodeJS module
|
|
22
26
|
|
|
23
27
|
If you wish to use Oobee as a NodeJS module that can be integrated with end-to-end testing frameworks, refer to the [integration guide](./INTEGRATION.md) on how you can do so.
|
|
@@ -96,6 +100,8 @@ verapdf --version
|
|
|
96
100
|
| OOBEE_SCAN_METADATA | Overrides the `entryUrl` tag sent to telemetry. | |
|
|
97
101
|
| OOBEE_SCAN_PRODUCT | Adds a `scanProduct` tag to telemetry events. | |
|
|
98
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). | |
|
|
99
105
|
| HTTP_PROXY | URL of the proxy server to be used for HTTP requests (e.g. `http://proxy.example.com:8080`). | |
|
|
100
106
|
| HTTPS_PROXY | URL of the proxy server to be used for HTTPS requests (e.g. `https://proxy.example.com:8080`). | |
|
|
101
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
|
|
@@ -52,6 +56,13 @@ const combineRun = async (details, deviceToScan) => {
|
|
|
52
56
|
consoleLogger.info(`Suppressed Playwright post-close connection error: ${err.message}`);
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
59
|
+
// Suppress EPERM errors from Crawlee's async lock-file operations that fire
|
|
60
|
+
// after a crawl phase finishes. On Windows, the sitemap phase's async lock
|
|
61
|
+
// cleanup can race with the domain phase starting in the same directory.
|
|
62
|
+
if (err.message?.includes('EPERM') && err.message?.includes('.json.lock')) {
|
|
63
|
+
consoleLogger.info(`Suppressed Crawlee lock-file EPERM (stale async cleanup): ${err.message}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
55
66
|
throw err;
|
|
56
67
|
};
|
|
57
68
|
process.on('uncaughtException', psTreeHandler);
|
|
@@ -186,6 +197,8 @@ const combineRun = async (details, deviceToScan) => {
|
|
|
186
197
|
if (scanDetails.urlsCrawled) {
|
|
187
198
|
if (scanDetails.urlsCrawled.scanned.length > 0) {
|
|
188
199
|
await createAndUpdateResultsFolders(randomToken);
|
|
200
|
+
await writeManifest(randomToken);
|
|
201
|
+
resetCaptureEntries();
|
|
189
202
|
const pagesNotScanned = [
|
|
190
203
|
...urlsCrawledObj.error,
|
|
191
204
|
...urlsCrawledObj.invalid,
|
package/dist/constants/common.js
CHANGED
|
@@ -1806,6 +1806,13 @@ export const getPlaywrightLaunchOptions = (browser) => {
|
|
|
1806
1806
|
!arg.startsWith('--user-agent=') &&
|
|
1807
1807
|
arg !== '--mute-audio' &&
|
|
1808
1808
|
!(browser === BrowserTypes.CHROME && arg === '--edge-skip-compat-layer-relaunch'));
|
|
1809
|
+
// Cap browser disk cache to 10MB per instance to prevent storage bloat
|
|
1810
|
+
// during long crawls with multiple pool rotations
|
|
1811
|
+
finalArgs.push('--disk-cache-size=10485760');
|
|
1812
|
+
// Prevent Windows from throttling background Chromium processes
|
|
1813
|
+
if (os.platform() === 'win32') {
|
|
1814
|
+
finalArgs.push('--disable-features=UseEcoQoSForBackgroundProcess');
|
|
1815
|
+
}
|
|
1809
1816
|
// Headless flags (unchanged)
|
|
1810
1817
|
if (process.env.CRAWLEE_HEADLESS === '1') {
|
|
1811
1818
|
if (!finalArgs.includes('--mute-audio'))
|
|
@@ -25,10 +25,27 @@ export const blackListedFileExtensions = [
|
|
|
25
25
|
'svg',
|
|
26
26
|
'gif',
|
|
27
27
|
'woff',
|
|
28
|
+
'woff2',
|
|
28
29
|
'zip',
|
|
29
30
|
'webp',
|
|
30
31
|
'json',
|
|
31
32
|
'xml',
|
|
33
|
+
'ico',
|
|
34
|
+
'bmp',
|
|
35
|
+
'tiff',
|
|
36
|
+
'tif',
|
|
37
|
+
'avi',
|
|
38
|
+
'mov',
|
|
39
|
+
'wmv',
|
|
40
|
+
'flv',
|
|
41
|
+
'ogg',
|
|
42
|
+
'wav',
|
|
43
|
+
'doc',
|
|
44
|
+
'docx',
|
|
45
|
+
'xls',
|
|
46
|
+
'xlsx',
|
|
47
|
+
'ppt',
|
|
48
|
+
'pptx',
|
|
32
49
|
];
|
|
33
50
|
export const getIntermediateScreenshotsPath = (datasetsPath) => `${datasetsPath}/screenshots`;
|
|
34
51
|
export const destinationPath = (storagePath) => `${storagePath}/screenshots`;
|
|
@@ -890,9 +890,9 @@ export const runAxeScript = async ({ includeScreenshots, page, randomToken, cust
|
|
|
890
890
|
return filterAxeResults(results, pageTitle, customFlowDetails);
|
|
891
891
|
};
|
|
892
892
|
export const createCrawleeSubFolders = async (randomToken) => {
|
|
893
|
-
const
|
|
894
|
-
const dataset = await Dataset.open(
|
|
895
|
-
const requestQueue = await RequestQueue.open(
|
|
893
|
+
const baseDir = path.join(getStoragePath(randomToken), "crawlee");
|
|
894
|
+
const dataset = await Dataset.open(baseDir);
|
|
895
|
+
const requestQueue = await RequestQueue.open(`${baseDir}_rq`);
|
|
896
896
|
return { dataset, requestQueue };
|
|
897
897
|
};
|
|
898
898
|
export const preNavigationHooks = (extraHTTPHeaders) => {
|
|
@@ -964,60 +964,74 @@ export const postNavigationHooks = [
|
|
|
964
964
|
];
|
|
965
965
|
export const getPreLaunchHook = (userDataDirectory) => {
|
|
966
966
|
let launchCount = 0;
|
|
967
|
+
let previousPoolDir = null;
|
|
967
968
|
return async (_pageId, launchContext) => {
|
|
968
969
|
const fsp = await import('fs/promises').then(m => m.default);
|
|
969
970
|
launchCount += 1;
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const localStateSrc = path.join(userDataDirectory, 'Local State');
|
|
984
|
-
if (await fsp.stat(localStateSrc).catch(() => null)) {
|
|
985
|
-
await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => { });
|
|
986
|
-
}
|
|
987
|
-
// Find profile directories (Default, Profile 1, Profile 2, etc.)
|
|
988
|
-
const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
|
|
989
|
-
const profileDirs = entries.filter((e) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name));
|
|
990
|
-
for (const profile of profileDirs) {
|
|
991
|
-
const srcProfile = path.join(userDataDirectory, profile.name);
|
|
992
|
-
const destProfile = path.join(effectiveDir, profile.name);
|
|
993
|
-
await fsp.mkdir(destProfile, { recursive: true }).catch(() => { });
|
|
994
|
-
// Cookies (macOS layout: <Profile>/Cookies)
|
|
995
|
-
const cookiesSrc = path.join(srcProfile, 'Cookies');
|
|
996
|
-
if (await fsp.stat(cookiesSrc).catch(() => null)) {
|
|
997
|
-
await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => { });
|
|
998
|
-
}
|
|
999
|
-
// Cookies (Windows layout: <Profile>/Network/Cookies)
|
|
1000
|
-
const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
|
|
1001
|
-
if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
|
|
1002
|
-
const destNetwork = path.join(destProfile, 'Network');
|
|
1003
|
-
await fsp.mkdir(destNetwork, { recursive: true }).catch(() => { });
|
|
1004
|
-
await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => { });
|
|
971
|
+
// Clean up the previous pool directory. When preLaunchHooks fires, the
|
|
972
|
+
// previous browser has been retired and is finishing its last pages.
|
|
973
|
+
// Schedule cleanup with enough delay for Chrome to fully exit —
|
|
974
|
+
// closeInactiveBrowserAfterSecs (30s) + Windows file-lock grace.
|
|
975
|
+
if (previousPoolDir) {
|
|
976
|
+
const dirToClean = previousPoolDir;
|
|
977
|
+
const isWin = process.platform === 'win32';
|
|
978
|
+
const delay = isWin ? 40000 : 35000;
|
|
979
|
+
setTimeout(async () => {
|
|
980
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
981
|
+
try {
|
|
982
|
+
await fsp.rm(dirToClean, { recursive: true, force: true });
|
|
983
|
+
return;
|
|
1005
984
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
|
|
1009
|
-
if (localStorageStat && localStorageStat.isDirectory()) {
|
|
1010
|
-
const destLocalStorage = path.join(destProfile, 'Local Storage');
|
|
1011
|
-
await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => { });
|
|
1012
|
-
const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
|
|
1013
|
-
await Promise.all(lsFiles.map(f => fsp.copyFile(path.join(localStorageSrc, f), path.join(destLocalStorage, f)).catch(() => { })));
|
|
985
|
+
catch {
|
|
986
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1014
987
|
}
|
|
1015
988
|
}
|
|
989
|
+
}, delay);
|
|
990
|
+
}
|
|
991
|
+
// Every browser gets its own directory. The base userDataDirectory is
|
|
992
|
+
// treated as a read-only cookie source (the pristine clone of the user's
|
|
993
|
+
// profile) and is never used directly by a running browser.
|
|
994
|
+
const effectiveDir = `${userDataDirectory}_pool${launchCount}`;
|
|
995
|
+
await fsp.mkdir(effectiveDir, { recursive: true });
|
|
996
|
+
// Copy auth-relevant files from the pristine base directory so
|
|
997
|
+
// authenticated sessions are preserved across pool rotations.
|
|
998
|
+
try {
|
|
999
|
+
const localStateSrc = path.join(userDataDirectory, 'Local State');
|
|
1000
|
+
if (await fsp.stat(localStateSrc).catch(() => null)) {
|
|
1001
|
+
await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => { });
|
|
1016
1002
|
}
|
|
1017
|
-
catch
|
|
1018
|
-
|
|
1003
|
+
const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
|
|
1004
|
+
const profileDirs = entries.filter((e) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name));
|
|
1005
|
+
for (const profile of profileDirs) {
|
|
1006
|
+
const srcProfile = path.join(userDataDirectory, profile.name);
|
|
1007
|
+
const destProfile = path.join(effectiveDir, profile.name);
|
|
1008
|
+
await fsp.mkdir(destProfile, { recursive: true }).catch(() => { });
|
|
1009
|
+
// Cookies (macOS layout: <Profile>/Cookies)
|
|
1010
|
+
const cookiesSrc = path.join(srcProfile, 'Cookies');
|
|
1011
|
+
if (await fsp.stat(cookiesSrc).catch(() => null)) {
|
|
1012
|
+
await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => { });
|
|
1013
|
+
}
|
|
1014
|
+
// Cookies (Windows layout: <Profile>/Network/Cookies)
|
|
1015
|
+
const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
|
|
1016
|
+
if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
|
|
1017
|
+
const destNetwork = path.join(destProfile, 'Network');
|
|
1018
|
+
await fsp.mkdir(destNetwork, { recursive: true }).catch(() => { });
|
|
1019
|
+
await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => { });
|
|
1020
|
+
}
|
|
1021
|
+
// Local Storage (auth tokens, session data)
|
|
1022
|
+
const localStorageSrc = path.join(srcProfile, 'Local Storage');
|
|
1023
|
+
const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
|
|
1024
|
+
if (localStorageStat && localStorageStat.isDirectory()) {
|
|
1025
|
+
const destLocalStorage = path.join(destProfile, 'Local Storage');
|
|
1026
|
+
await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => { });
|
|
1027
|
+
const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
|
|
1028
|
+
await Promise.all(lsFiles.map(f => fsp.copyFile(path.join(localStorageSrc, f), path.join(destLocalStorage, f)).catch(() => { })));
|
|
1029
|
+
}
|
|
1019
1030
|
}
|
|
1020
1031
|
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// Silent fallback: use empty profile if clone fails
|
|
1034
|
+
}
|
|
1021
1035
|
// Clean any stale lock files that may block browser launches on Windows
|
|
1022
1036
|
const lockFiles = [
|
|
1023
1037
|
path.join(effectiveDir, 'SingletonLock'),
|
|
@@ -1028,10 +1042,37 @@ export const getPreLaunchHook = (userDataDirectory) => {
|
|
|
1028
1042
|
path.join(effectiveDir, 'Default', 'Network', 'LOCK'),
|
|
1029
1043
|
];
|
|
1030
1044
|
await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => { })));
|
|
1045
|
+
previousPoolDir = effectiveDir;
|
|
1031
1046
|
// eslint-disable-next-line no-param-reassign
|
|
1032
1047
|
launchContext.userDataDir = effectiveDir;
|
|
1033
1048
|
};
|
|
1034
1049
|
};
|
|
1050
|
+
export const getPostPageCloseHook = (userDataDirectory) => {
|
|
1051
|
+
return async (_pageId, browserController) => {
|
|
1052
|
+
if (browserController.activePages === 0) {
|
|
1053
|
+
const dir = browserController.launchContext?.userDataDir;
|
|
1054
|
+
if (dir && dir !== userDataDirectory && dir.includes('_pool')) {
|
|
1055
|
+
const fsp = await import('fs/promises').then(m => m.default);
|
|
1056
|
+
// Chrome on Windows writes optimization/segmentation data during shutdown;
|
|
1057
|
+
// wait longer (5s) for the process to fully release file handles.
|
|
1058
|
+
const isWin = process.platform === 'win32';
|
|
1059
|
+
const initialDelay = isWin ? 5000 : 2000;
|
|
1060
|
+
setTimeout(async () => {
|
|
1061
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1062
|
+
try {
|
|
1063
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
// Retry after a short back-off (Windows file locks)
|
|
1068
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}, initialDelay);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
};
|
|
1035
1076
|
export const failedRequestHandler = async ({ request }) => {
|
|
1036
1077
|
guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
|
|
1037
1078
|
log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
|