@govtechsg/oobee 0.10.96 → 0.10.98
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 +8 -0
- package/dist/combine.js +2 -1
- package/dist/crawlers/commonCrawlerFunc.js +35 -16
- package/dist/crawlers/crawlDomain.js +44 -31
- package/dist/crawlers/crawlIntelligentSitemap.js +3 -1
- package/dist/crawlers/crawlLocalFile.js +1 -0
- package/dist/crawlers/crawlRateController.js +1 -4
- package/dist/crawlers/crawlSitemap.js +11 -3
- package/dist/crawlers/custom/flagUnlabelledClickableElements.js +5 -1
- package/dist/utils.js +13 -1
- package/oobee-client-scanner.js +7 -3
- package/package.json +1 -1
- package/src/combine.ts +2 -0
- package/src/crawlers/commonCrawlerFunc.ts +48 -17
- package/src/crawlers/crawlDomain.ts +46 -34
- package/src/crawlers/crawlIntelligentSitemap.ts +4 -1
- package/src/crawlers/crawlLocalFile.ts +1 -0
- package/src/crawlers/crawlRateController.ts +1 -5
- package/src/crawlers/crawlSitemap.ts +12 -2
- package/src/crawlers/custom/flagUnlabelledClickableElements.ts +7 -2
- package/src/utils.ts +17 -4
- /package/{5708fbbe-2d91-4b1b-90e5-eacbed0a74a8.txt → 0a93900f-3345-4f25-8be7-c2d3748034be.txt} +0 -0
package/AGENTS.md
CHANGED
|
@@ -299,6 +299,14 @@ When making changes, validate these areas which have well-established edge cases
|
|
|
299
299
|
- 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
300
|
- 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.
|
|
301
301
|
|
|
302
|
+
### Scan Consistency Between crawlDomain and crawlSitemap
|
|
303
|
+
- 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.
|
|
304
|
+
- **postNavigationHook DOM observer must be identical**: Both crawlers use a MutationObserver in `postNavigationHooks` to wait for DOM stabilization before proceeding to the request handler. The observer must call `observer.observe(root, { childList: true, subtree: true })` — without this call, the hook degrades to a fixed 5-second timeout (the `OBSERVER_TIMEOUT` fallback) and the DOM may be in a different state when scanning begins.
|
|
305
|
+
- **`runAxeScript()` has its own secondary DOM observer** (in `commonCrawlerFunc.ts`) that additionally watches `attributes: true`. This is a second stabilization gate shared by all crawlers — it ensures attribute animations settle before axe runs.
|
|
306
|
+
- **`waitForPageLoaded(page, 10000)` in the requestHandler** is the third stabilization check (waits for `load` event or 10s timeout). All crawlers call this at the top of their request handler.
|
|
307
|
+
- **Parameters passed to `runAxeScript()` must match**: both must pass `ruleset` (controls `DISABLE_OOBEE` / `ENABLE_WCAG_AAA`). Any new parameter added to `runAxeScript()` must be propagated to all crawlers via `combine.ts`.
|
|
308
|
+
- **Error handling in postNavigationHook**: wrap `page.evaluate()` in try/catch to handle pages destroyed during the DOM observer (navigation, timeout, crash). Without this, the error propagates and may affect Crawlee's retry logic differently between crawlers.
|
|
309
|
+
|
|
302
310
|
### Axe & Custom Checks
|
|
303
311
|
- When axe reports color-contrast violations but cannot determine the actual colors, skip augmenting the message with contrast context (avoids crashes on null/undefined color values).
|
|
304
312
|
- Violation messages are enriched with live DOM context (element text, computed styles, dimensions) via `page.evaluate()` during scan. Handle cases where elements are no longer in DOM at evaluation time.
|
package/dist/combine.js
CHANGED
|
@@ -117,6 +117,7 @@ const combineRun = async (details, deviceToScan) => {
|
|
|
117
117
|
strategy,
|
|
118
118
|
userUrl: url,
|
|
119
119
|
scanDuration,
|
|
120
|
+
ruleset,
|
|
120
121
|
});
|
|
121
122
|
urlsCrawledObj = sitemapResult.urlsCrawled;
|
|
122
123
|
durationExceeded = sitemapResult.durationExceeded;
|
|
@@ -149,7 +150,7 @@ const combineRun = async (details, deviceToScan) => {
|
|
|
149
150
|
}
|
|
150
151
|
break;
|
|
151
152
|
case ScannerTypes.INTELLIGENT:
|
|
152
|
-
const intelligentResult = await crawlIntelligentSitemap(url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration);
|
|
153
|
+
const intelligentResult = await crawlIntelligentSitemap(url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration, ruleset);
|
|
153
154
|
urlsCrawledObj = intelligentResult.urlsCrawled;
|
|
154
155
|
durationExceeded = intelligentResult.durationExceeded;
|
|
155
156
|
break;
|
|
@@ -974,26 +974,45 @@ export const getPreLaunchHook = (userDataDirectory) => {
|
|
|
974
974
|
? userDataDirectory
|
|
975
975
|
: `${userDataDirectory}_pool${launchCount}`;
|
|
976
976
|
await fsp.mkdir(effectiveDir, { recursive: true });
|
|
977
|
-
// For pool re-launches,
|
|
978
|
-
// so authenticated sessions are preserved
|
|
977
|
+
// For pool re-launches, copy only auth-relevant files from the base
|
|
978
|
+
// directory so authenticated sessions are preserved without cloning the
|
|
979
|
+
// entire profile (which grows with caches, IndexedDB, etc. during the crawl).
|
|
979
980
|
if (launchCount > 1) {
|
|
980
981
|
try {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
982
|
+
// Copy top-level Local State (cookie encryption keys on Windows)
|
|
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(() => { });
|
|
991
998
|
}
|
|
992
|
-
|
|
993
|
-
|
|
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(() => { });
|
|
994
1005
|
}
|
|
995
|
-
|
|
996
|
-
|
|
1006
|
+
// Local Storage (auth tokens, session data)
|
|
1007
|
+
const localStorageSrc = path.join(srcProfile, 'Local Storage');
|
|
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(() => { })));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
997
1016
|
}
|
|
998
1017
|
catch {
|
|
999
1018
|
// Silent fallback: use empty profile if clone fails
|
|
@@ -274,7 +274,10 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
|
|
|
274
274
|
}
|
|
275
275
|
};
|
|
276
276
|
let isAbortingScanNow = false;
|
|
277
|
-
const
|
|
277
|
+
const remainingBudget = fromCrawlIntelligentSitemap
|
|
278
|
+
? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
|
|
279
|
+
: maxRequestsPerCrawl;
|
|
280
|
+
const rateController = new CrawlRateController(remainingBudget, specifiedMaxConcurrency || constants.maxConcurrency);
|
|
278
281
|
const { nonAuthHeaders, httpCredentials } = splitAuthHeaders(extraHTTPHeaders);
|
|
279
282
|
const crawler = register(new crawlee.PlaywrightCrawler({
|
|
280
283
|
launchContext: {
|
|
@@ -284,6 +287,8 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
|
|
|
284
287
|
retryOnBlocked: true,
|
|
285
288
|
browserPoolOptions: {
|
|
286
289
|
useFingerprints: false,
|
|
290
|
+
retireBrowserAfterPageCount: 500,
|
|
291
|
+
closeInactiveBrowserAfterSecs: 30,
|
|
287
292
|
preLaunchHooks: [
|
|
288
293
|
getPreLaunchHook(userDataDirectory),
|
|
289
294
|
async (_pageId, launchContext) => {
|
|
@@ -309,40 +314,48 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
|
|
|
309
314
|
postNavigationHooks: [
|
|
310
315
|
async (crawlingContext) => {
|
|
311
316
|
const { page, request } = crawlingContext;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
317
|
+
try {
|
|
318
|
+
await page.evaluate(() => {
|
|
319
|
+
return new Promise(resolve => {
|
|
320
|
+
let timeout;
|
|
321
|
+
let mutationCount = 0;
|
|
322
|
+
const MAX_MUTATIONS = 500; // stop if things never quiet down
|
|
323
|
+
const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
|
|
324
|
+
const observer = new MutationObserver(() => {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
mutationCount += 1;
|
|
327
|
+
if (mutationCount > MAX_MUTATIONS) {
|
|
328
|
+
observer.disconnect();
|
|
329
|
+
resolve('Too many mutations, exiting.');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// restart quiet‑period timer
|
|
333
|
+
timeout = setTimeout(() => {
|
|
334
|
+
observer.disconnect();
|
|
335
|
+
resolve('DOM stabilized.');
|
|
336
|
+
}, 1000);
|
|
337
|
+
});
|
|
338
|
+
// overall timeout in case the page never settles
|
|
327
339
|
timeout = setTimeout(() => {
|
|
328
340
|
observer.disconnect();
|
|
329
|
-
resolve('
|
|
330
|
-
},
|
|
341
|
+
resolve('Observer timeout reached.');
|
|
342
|
+
}, OBSERVER_TIMEOUT);
|
|
343
|
+
const root = document.documentElement || document.body || document;
|
|
344
|
+
if (!root || typeof observer.observe !== 'function') {
|
|
345
|
+
resolve('No root node to observe.');
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
349
|
+
}
|
|
331
350
|
});
|
|
332
|
-
// overall timeout in case the page never settles
|
|
333
|
-
timeout = setTimeout(() => {
|
|
334
|
-
observer.disconnect();
|
|
335
|
-
resolve('Observer timeout reached.');
|
|
336
|
-
}, OBSERVER_TIMEOUT);
|
|
337
|
-
const root = document.documentElement || document.body || document;
|
|
338
|
-
if (!root || typeof observer.observe !== 'function') {
|
|
339
|
-
resolve('No root node to observe.');
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
observer.observe(root, { childList: true, subtree: true });
|
|
343
|
-
}
|
|
344
351
|
});
|
|
345
|
-
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err.message?.includes('was destroyed')) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
346
359
|
let finalUrl = page.url();
|
|
347
360
|
const requestLabelUrl = request.label;
|
|
348
361
|
// to handle scenario where the redirected link is not within the scanning website
|
|
@@ -5,7 +5,7 @@ import crawlDomain from './crawlDomain.js';
|
|
|
5
5
|
import crawlSitemap from './crawlSitemap.js';
|
|
6
6
|
import { getPlaywrightLaunchOptions, getSitemapsFromRobotsTxt, initModifiedUserAgent } from '../constants/common.js';
|
|
7
7
|
import { register } from '../utils.js';
|
|
8
|
-
const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration) => {
|
|
8
|
+
const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, strategy, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, followRobots, extraHTTPHeaders, safeMode, scanDuration, ruleset = []) => {
|
|
9
9
|
const startTime = Date.now(); // Track start time
|
|
10
10
|
let urlsCrawledFinal;
|
|
11
11
|
const urlsCrawled = { ...constants.urlsCrawledObj };
|
|
@@ -153,6 +153,7 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
|
|
|
153
153
|
urlsCrawledFromIntelligent: urlsCrawled,
|
|
154
154
|
crawledFromLocalFile: false,
|
|
155
155
|
scanDuration: scanDuration > 0 ? remainingDuration : 0,
|
|
156
|
+
ruleset,
|
|
156
157
|
});
|
|
157
158
|
}
|
|
158
159
|
const elapsed = Date.now() - startTime;
|
|
@@ -180,6 +181,7 @@ const crawlIntelligentSitemap = async (url, randomToken, host, viewportSettings,
|
|
|
180
181
|
datasetFromIntelligent: dataset,
|
|
181
182
|
urlsCrawledFromIntelligent: urlsCrawled,
|
|
182
183
|
scanDuration: remainingScanDuration,
|
|
184
|
+
ruleset,
|
|
183
185
|
});
|
|
184
186
|
}
|
|
185
187
|
else if (!hasDurationRemaining) {
|
|
@@ -69,6 +69,7 @@ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings,
|
|
|
69
69
|
datasetFromIntelligent,
|
|
70
70
|
urlsCrawledFromIntelligent,
|
|
71
71
|
crawledFromLocalFile: true,
|
|
72
|
+
ruleset,
|
|
72
73
|
});
|
|
73
74
|
urlsCrawled = { ...urlsCrawled, ...updatedUrlsCrawled };
|
|
74
75
|
return urlsCrawled;
|
|
@@ -26,12 +26,9 @@ export class CrawlRateController {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
onFailure(httpStatus, pool) {
|
|
29
|
-
if (typeof httpStatus !== 'number' || httpStatus < 400) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
29
|
this.consecutiveSuccesses = 0;
|
|
33
30
|
this.consecutiveFailures++;
|
|
34
|
-
if (pool && pool.maxConcurrency > 1) {
|
|
31
|
+
if (typeof httpStatus === 'number' && httpStatus >= 400 && pool && pool.maxConcurrency > 1) {
|
|
35
32
|
pool.maxConcurrency = Math.max(1, Math.floor(pool.maxConcurrency / 2));
|
|
36
33
|
consoleLogger.info(`Rate limited (HTTP ${httpStatus}) — reducing concurrency to ${pool.maxConcurrency}`);
|
|
37
34
|
}
|
|
@@ -6,13 +6,16 @@ import { getLinksFromSitemap, getPlaywrightLaunchOptions, isSkippedUrl, waitForP
|
|
|
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
|
-
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, }) => {
|
|
9
|
+
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
10
|
const crawlStartTime = Date.now();
|
|
11
11
|
let dataset;
|
|
12
12
|
let urlsCrawled;
|
|
13
13
|
let durationExceeded = false;
|
|
14
14
|
let isAbortingScan = false;
|
|
15
|
-
const
|
|
15
|
+
const remainingBudget = fromCrawlIntelligentSitemap
|
|
16
|
+
? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
|
|
17
|
+
: maxRequestsPerCrawl;
|
|
18
|
+
const rateController = new CrawlRateController(remainingBudget, specifiedMaxConcurrency || constants.maxConcurrency);
|
|
16
19
|
const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
|
|
17
20
|
if (fromCrawlIntelligentSitemap) {
|
|
18
21
|
dataset = datasetFromIntelligent;
|
|
@@ -46,6 +49,8 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
|
|
|
46
49
|
retryOnBlocked: true,
|
|
47
50
|
browserPoolOptions: {
|
|
48
51
|
useFingerprints: false,
|
|
52
|
+
retireBrowserAfterPageCount: 500,
|
|
53
|
+
closeInactiveBrowserAfterSecs: 30,
|
|
49
54
|
preLaunchHooks: [
|
|
50
55
|
getPreLaunchHook(userDataDirectory),
|
|
51
56
|
async (_pageId, launchContext) => {
|
|
@@ -97,6 +102,9 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
|
|
|
97
102
|
if (!root || typeof observer.observe !== 'function') {
|
|
98
103
|
resolve('No root node to observe.');
|
|
99
104
|
}
|
|
105
|
+
else {
|
|
106
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
107
|
+
}
|
|
100
108
|
});
|
|
101
109
|
});
|
|
102
110
|
}
|
|
@@ -209,7 +217,7 @@ const crawlSitemap = async ({ sitemapUrl, randomToken, host, viewportSettings, m
|
|
|
209
217
|
});
|
|
210
218
|
return;
|
|
211
219
|
}
|
|
212
|
-
const results = await runAxeScript({ includeScreenshots, page, randomToken });
|
|
220
|
+
const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
|
|
213
221
|
// Detect JS redirects that fire during/after axe scan.
|
|
214
222
|
// Listen for navigation, then give a brief window for pending redirects to complete.
|
|
215
223
|
try {
|
|
@@ -382,7 +382,11 @@ export async function flagUnlabelledClickableElements() {
|
|
|
382
382
|
function isElementTooSmall(element) {
|
|
383
383
|
// Get the bounding rectangle of the element
|
|
384
384
|
const rect = element.getBoundingClientRect();
|
|
385
|
-
//
|
|
385
|
+
// If either dimension is 0, the element is non-perceivable
|
|
386
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
// Check if the element has a valid width and height
|
|
386
390
|
if (rect.width > 0 || rect.height > 0) {
|
|
387
391
|
return false; // Element is not too small
|
|
388
392
|
}
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
+
import { globSync } from 'glob';
|
|
4
5
|
import axe from 'axe-core';
|
|
5
6
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
7
|
import { getDomain } from 'tldts';
|
|
@@ -338,13 +339,24 @@ export const cleanUp = async (randomToken, isError = false) => {
|
|
|
338
339
|
if (randomToken === undefined && constants.randomToken) {
|
|
339
340
|
randomToken = constants.randomToken;
|
|
340
341
|
}
|
|
341
|
-
if (constants.userDataDirectory)
|
|
342
|
+
if (constants.userDataDirectory) {
|
|
342
343
|
try {
|
|
343
344
|
fs.rmSync(constants.userDataDirectory, { recursive: true, force: true });
|
|
344
345
|
}
|
|
345
346
|
catch (error) {
|
|
346
347
|
consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
|
|
347
348
|
}
|
|
349
|
+
// Also remove _pool* sibling directories created by browser pool re-launches
|
|
350
|
+
try {
|
|
351
|
+
const poolDirs = globSync(`${constants.userDataDirectory}_pool*`);
|
|
352
|
+
for (const dir of poolDirs) {
|
|
353
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
consoleLogger.warn(`Unable to remove pool directories: ${error.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
348
360
|
if (process.env.TMPDIR && fs.existsSync('/.dockerenv'))
|
|
349
361
|
try {
|
|
350
362
|
fs.rmSync(process.env.TMPDIR, { recursive: true, force: true });
|
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.10.
|
|
6
|
+
* App version : 0.10.98
|
|
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
|
*
|
|
@@ -33593,7 +33593,11 @@
|
|
|
33593
33593
|
function isElementTooSmall(element) {
|
|
33594
33594
|
// Get the bounding rectangle of the element
|
|
33595
33595
|
const rect = element.getBoundingClientRect();
|
|
33596
|
-
//
|
|
33596
|
+
// If either dimension is 0, the element is non-perceivable
|
|
33597
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
33598
|
+
return true;
|
|
33599
|
+
}
|
|
33600
|
+
// Check if the element has a valid width and height
|
|
33597
33601
|
if (rect.width > 0 || rect.height > 0) {
|
|
33598
33602
|
return false; // Element is not too small
|
|
33599
33603
|
}
|
|
@@ -34883,7 +34887,7 @@
|
|
|
34883
34887
|
// ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
|
|
34884
34888
|
|
|
34885
34889
|
var _oobeeSentryDsn = "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168";
|
|
34886
|
-
var _oobeeAppVersion = "0.10.
|
|
34890
|
+
var _oobeeAppVersion = "0.10.98";
|
|
34887
34891
|
var _oobeeSentryVersion = "10.58.0";
|
|
34888
34892
|
var _oobeeSentryInitialized = false;
|
|
34889
34893
|
var _oobeeSentryLoadPromise = null;
|
package/package.json
CHANGED
package/src/combine.ts
CHANGED
|
@@ -186,6 +186,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
|
|
|
186
186
|
strategy,
|
|
187
187
|
userUrl: url,
|
|
188
188
|
scanDuration,
|
|
189
|
+
ruleset,
|
|
189
190
|
});
|
|
190
191
|
urlsCrawledObj = sitemapResult.urlsCrawled;
|
|
191
192
|
durationExceeded = sitemapResult.durationExceeded;
|
|
@@ -236,6 +237,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
|
|
|
236
237
|
extraHTTPHeaders,
|
|
237
238
|
safeMode,
|
|
238
239
|
scanDuration,
|
|
240
|
+
ruleset,
|
|
239
241
|
);
|
|
240
242
|
urlsCrawledObj = intelligentResult.urlsCrawled;
|
|
241
243
|
durationExceeded = intelligentResult.durationExceeded;
|
|
@@ -1230,28 +1230,59 @@ export const getPreLaunchHook = (userDataDirectory: string) => {
|
|
|
1230
1230
|
|
|
1231
1231
|
await fsp.mkdir(effectiveDir, { recursive: true });
|
|
1232
1232
|
|
|
1233
|
-
// For pool re-launches,
|
|
1234
|
-
// so authenticated sessions are preserved
|
|
1233
|
+
// For pool re-launches, copy only auth-relevant files from the base
|
|
1234
|
+
// directory so authenticated sessions are preserved without cloning the
|
|
1235
|
+
// entire profile (which grows with caches, IndexedDB, etc. during the crawl).
|
|
1235
1236
|
if (launchCount > 1) {
|
|
1236
1237
|
try {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1238
|
+
// Copy top-level Local State (cookie encryption keys on Windows)
|
|
1239
|
+
const localStateSrc = path.join(userDataDirectory, 'Local State');
|
|
1240
|
+
if (await fsp.stat(localStateSrc).catch(() => null)) {
|
|
1241
|
+
await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Find profile directories (Default, Profile 1, Profile 2, etc.)
|
|
1245
|
+
const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
|
|
1246
|
+
const profileDirs = (entries as any[]).filter(
|
|
1247
|
+
(e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
for (const profile of profileDirs) {
|
|
1251
|
+
const srcProfile = path.join(userDataDirectory, profile.name);
|
|
1252
|
+
const destProfile = path.join(effectiveDir, profile.name);
|
|
1253
|
+
await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});
|
|
1254
|
+
|
|
1255
|
+
// Cookies (macOS layout: <Profile>/Cookies)
|
|
1256
|
+
const cookiesSrc = path.join(srcProfile, 'Cookies');
|
|
1257
|
+
if (await fsp.stat(cookiesSrc).catch(() => null)) {
|
|
1258
|
+
await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Cookies (Windows layout: <Profile>/Network/Cookies)
|
|
1262
|
+
const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
|
|
1263
|
+
if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
|
|
1264
|
+
const destNetwork = path.join(destProfile, 'Network');
|
|
1265
|
+
await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
|
|
1266
|
+
await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Local Storage (auth tokens, session data)
|
|
1270
|
+
const localStorageSrc = path.join(srcProfile, 'Local Storage');
|
|
1271
|
+
const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
|
|
1272
|
+
if (localStorageStat && localStorageStat.isDirectory()) {
|
|
1273
|
+
const destLocalStorage = path.join(destProfile, 'Local Storage');
|
|
1274
|
+
await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
|
|
1275
|
+
const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
|
|
1243
1276
|
await Promise.all(
|
|
1244
|
-
|
|
1245
|
-
.
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
),
|
|
1277
|
+
lsFiles.map(f =>
|
|
1278
|
+
fsp.copyFile(
|
|
1279
|
+
path.join(localStorageSrc, f),
|
|
1280
|
+
path.join(destLocalStorage, f),
|
|
1281
|
+
).catch(() => {}),
|
|
1282
|
+
),
|
|
1249
1283
|
);
|
|
1250
|
-
} else {
|
|
1251
|
-
await fsp.copyFile(src, dest).catch(() => {});
|
|
1252
1284
|
}
|
|
1253
|
-
}
|
|
1254
|
-
await copyRecursive(userDataDirectory, effectiveDir).catch(() => {});
|
|
1285
|
+
}
|
|
1255
1286
|
} catch {
|
|
1256
1287
|
// Silent fallback: use empty profile if clone fails
|
|
1257
1288
|
}
|
|
@@ -382,8 +382,11 @@ const crawlDomain = async ({
|
|
|
382
382
|
};
|
|
383
383
|
|
|
384
384
|
let isAbortingScanNow = false;
|
|
385
|
+
const remainingBudget = fromCrawlIntelligentSitemap
|
|
386
|
+
? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
|
|
387
|
+
: maxRequestsPerCrawl;
|
|
385
388
|
const rateController = new CrawlRateController(
|
|
386
|
-
|
|
389
|
+
remainingBudget,
|
|
387
390
|
specifiedMaxConcurrency || constants.maxConcurrency,
|
|
388
391
|
);
|
|
389
392
|
|
|
@@ -398,6 +401,8 @@ const crawlDomain = async ({
|
|
|
398
401
|
retryOnBlocked: true,
|
|
399
402
|
browserPoolOptions: {
|
|
400
403
|
useFingerprints: false,
|
|
404
|
+
retireBrowserAfterPageCount: 500,
|
|
405
|
+
closeInactiveBrowserAfterSecs: 30,
|
|
401
406
|
preLaunchHooks: [
|
|
402
407
|
getPreLaunchHook(userDataDirectory),
|
|
403
408
|
async (_pageId, launchContext) => {
|
|
@@ -424,44 +429,51 @@ const crawlDomain = async ({
|
|
|
424
429
|
async crawlingContext => {
|
|
425
430
|
const { page, request } = crawlingContext;
|
|
426
431
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
432
|
+
try {
|
|
433
|
+
await page.evaluate(() => {
|
|
434
|
+
return new Promise(resolve => {
|
|
435
|
+
let timeout;
|
|
436
|
+
let mutationCount = 0;
|
|
437
|
+
const MAX_MUTATIONS = 500; // stop if things never quiet down
|
|
438
|
+
const OBSERVER_TIMEOUT = 5000; // hard cap on total wait
|
|
439
|
+
|
|
440
|
+
const observer = new MutationObserver(() => {
|
|
441
|
+
clearTimeout(timeout);
|
|
442
|
+
|
|
443
|
+
mutationCount += 1;
|
|
444
|
+
if (mutationCount > MAX_MUTATIONS) {
|
|
445
|
+
observer.disconnect();
|
|
446
|
+
resolve('Too many mutations, exiting.');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// restart quiet‑period timer
|
|
451
|
+
timeout = setTimeout(() => {
|
|
452
|
+
observer.disconnect();
|
|
453
|
+
resolve('DOM stabilized.');
|
|
454
|
+
}, 1000);
|
|
455
|
+
});
|
|
443
456
|
|
|
444
|
-
//
|
|
457
|
+
// overall timeout in case the page never settles
|
|
445
458
|
timeout = setTimeout(() => {
|
|
446
459
|
observer.disconnect();
|
|
447
|
-
resolve('
|
|
448
|
-
},
|
|
460
|
+
resolve('Observer timeout reached.');
|
|
461
|
+
}, OBSERVER_TIMEOUT);
|
|
462
|
+
|
|
463
|
+
const root = document.documentElement || document.body || document;
|
|
464
|
+
if (!root || typeof observer.observe !== 'function') {
|
|
465
|
+
resolve('No root node to observe.');
|
|
466
|
+
} else {
|
|
467
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
468
|
+
}
|
|
449
469
|
});
|
|
450
|
-
|
|
451
|
-
// overall timeout in case the page never settles
|
|
452
|
-
timeout = setTimeout(() => {
|
|
453
|
-
observer.disconnect();
|
|
454
|
-
resolve('Observer timeout reached.');
|
|
455
|
-
}, OBSERVER_TIMEOUT);
|
|
456
|
-
|
|
457
|
-
const root = document.documentElement || document.body || document;
|
|
458
|
-
if (!root || typeof observer.observe !== 'function') {
|
|
459
|
-
resolve('No root node to observe.');
|
|
460
|
-
} else {
|
|
461
|
-
observer.observe(root, { childList: true, subtree: true });
|
|
462
|
-
}
|
|
463
470
|
});
|
|
464
|
-
})
|
|
471
|
+
} catch (err) {
|
|
472
|
+
if (err.message?.includes('was destroyed')) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
465
477
|
|
|
466
478
|
let finalUrl = page.url();
|
|
467
479
|
const requestLabelUrl = request.label;
|
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import { chromium, Page } from 'playwright';
|
|
3
3
|
import { EnqueueStrategy } from 'crawlee';
|
|
4
4
|
import { createCrawleeSubFolders, splitAuthHeaders, addAuthRouteHandler } from './commonCrawlerFunc.js';
|
|
5
|
-
import constants, { FileTypes, guiInfoStatusTypes, sitemapPaths } from '../constants/constants.js';
|
|
5
|
+
import constants, { FileTypes, guiInfoStatusTypes, RuleFlags, sitemapPaths } from '../constants/constants.js';
|
|
6
6
|
import { consoleLogger, guiInfoLog } from '../logs.js';
|
|
7
7
|
import crawlDomain from './crawlDomain.js';
|
|
8
8
|
import crawlSitemap from './crawlSitemap.js';
|
|
@@ -27,6 +27,7 @@ const crawlIntelligentSitemap = async (
|
|
|
27
27
|
extraHTTPHeaders: Record<string, string>,
|
|
28
28
|
safeMode: boolean,
|
|
29
29
|
scanDuration: number,
|
|
30
|
+
ruleset: RuleFlags[] = [],
|
|
30
31
|
) => {
|
|
31
32
|
const startTime = Date.now(); // Track start time
|
|
32
33
|
|
|
@@ -191,6 +192,7 @@ const crawlIntelligentSitemap = async (
|
|
|
191
192
|
urlsCrawledFromIntelligent: urlsCrawled,
|
|
192
193
|
crawledFromLocalFile: false,
|
|
193
194
|
scanDuration: scanDuration > 0 ? remainingDuration : 0,
|
|
195
|
+
ruleset,
|
|
194
196
|
});
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -222,6 +224,7 @@ const crawlIntelligentSitemap = async (
|
|
|
222
224
|
datasetFromIntelligent: dataset,
|
|
223
225
|
urlsCrawledFromIntelligent: urlsCrawled,
|
|
224
226
|
scanDuration: remainingScanDuration,
|
|
227
|
+
ruleset,
|
|
225
228
|
});
|
|
226
229
|
} else if (!hasDurationRemaining) {
|
|
227
230
|
consoleLogger.info(
|
|
@@ -36,14 +36,10 @@ export class CrawlRateController {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
onFailure(httpStatus: number | undefined, pool?: { maxConcurrency: number }): boolean {
|
|
39
|
-
if (typeof httpStatus !== 'number' || httpStatus < 400) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
this.consecutiveSuccesses = 0;
|
|
44
40
|
this.consecutiveFailures++;
|
|
45
41
|
|
|
46
|
-
if (pool && pool.maxConcurrency > 1) {
|
|
42
|
+
if (typeof httpStatus === 'number' && httpStatus >= 400 && pool && pool.maxConcurrency > 1) {
|
|
47
43
|
pool.maxConcurrency = Math.max(1, Math.floor(pool.maxConcurrency / 2));
|
|
48
44
|
consoleLogger.info(
|
|
49
45
|
`Rate limited (HTTP ${httpStatus}) — reducing concurrency to ${pool.maxConcurrency}`,
|
|
@@ -16,6 +16,7 @@ import constants, {
|
|
|
16
16
|
UrlsCrawled,
|
|
17
17
|
disallowedListOfPatterns,
|
|
18
18
|
FileTypes,
|
|
19
|
+
RuleFlags,
|
|
19
20
|
} from '../constants/constants.js';
|
|
20
21
|
import {
|
|
21
22
|
getLinksFromSitemap,
|
|
@@ -55,6 +56,7 @@ const crawlSitemap = async ({
|
|
|
55
56
|
datasetFromIntelligent = null,
|
|
56
57
|
urlsCrawledFromIntelligent = null,
|
|
57
58
|
crawledFromLocalFile = false,
|
|
59
|
+
ruleset = [],
|
|
58
60
|
}: {
|
|
59
61
|
sitemapUrl: string;
|
|
60
62
|
randomToken: string;
|
|
@@ -76,14 +78,18 @@ const crawlSitemap = async ({
|
|
|
76
78
|
datasetFromIntelligent?: Dataset;
|
|
77
79
|
urlsCrawledFromIntelligent?: UrlsCrawled;
|
|
78
80
|
crawledFromLocalFile?: boolean;
|
|
81
|
+
ruleset?: RuleFlags[];
|
|
79
82
|
}) => {
|
|
80
83
|
const crawlStartTime = Date.now();
|
|
81
84
|
let dataset: crawlee.Dataset;
|
|
82
85
|
let urlsCrawled: UrlsCrawled;
|
|
83
86
|
let durationExceeded = false;
|
|
84
87
|
let isAbortingScan = false;
|
|
88
|
+
const remainingBudget = fromCrawlIntelligentSitemap
|
|
89
|
+
? Math.max(0, maxRequestsPerCrawl - urlsCrawledFromIntelligent.scanned.length)
|
|
90
|
+
: maxRequestsPerCrawl;
|
|
85
91
|
const rateController = new CrawlRateController(
|
|
86
|
-
|
|
92
|
+
remainingBudget,
|
|
87
93
|
specifiedMaxConcurrency || constants.maxConcurrency,
|
|
88
94
|
);
|
|
89
95
|
const initialNoSuccessFailureAbortThreshold = Math.max(5, Math.min(maxRequestsPerCrawl, 25));
|
|
@@ -136,6 +142,8 @@ const crawlSitemap = async ({
|
|
|
136
142
|
retryOnBlocked: true,
|
|
137
143
|
browserPoolOptions: {
|
|
138
144
|
useFingerprints: false,
|
|
145
|
+
retireBrowserAfterPageCount: 500,
|
|
146
|
+
closeInactiveBrowserAfterSecs: 30,
|
|
139
147
|
preLaunchHooks: [
|
|
140
148
|
getPreLaunchHook(userDataDirectory),
|
|
141
149
|
async (_pageId, launchContext) => {
|
|
@@ -191,6 +199,8 @@ const crawlSitemap = async ({
|
|
|
191
199
|
const root = document.documentElement || document.body || document;
|
|
192
200
|
if (!root || typeof observer.observe !== 'function') {
|
|
193
201
|
resolve('No root node to observe.');
|
|
202
|
+
} else {
|
|
203
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
194
204
|
}
|
|
195
205
|
});
|
|
196
206
|
});
|
|
@@ -334,7 +344,7 @@ const crawlSitemap = async ({
|
|
|
334
344
|
return;
|
|
335
345
|
}
|
|
336
346
|
|
|
337
|
-
const results = await runAxeScript({ includeScreenshots, page, randomToken });
|
|
347
|
+
const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
|
|
338
348
|
|
|
339
349
|
// Detect JS redirects that fire during/after axe scan.
|
|
340
350
|
// Listen for navigation, then give a brief window for pending redirects to complete.
|
|
@@ -457,8 +457,13 @@ function hasPointerCursor(node: Node): boolean {
|
|
|
457
457
|
function isElementTooSmall(element: Element) {
|
|
458
458
|
// Get the bounding rectangle of the element
|
|
459
459
|
const rect = element.getBoundingClientRect();
|
|
460
|
-
|
|
461
|
-
//
|
|
460
|
+
|
|
461
|
+
// If either dimension is 0, the element is non-perceivable
|
|
462
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Check if the element has a valid width and height
|
|
462
467
|
if (rect.width > 0 || rect.height > 0) {
|
|
463
468
|
return false; // Element is not too small
|
|
464
469
|
}
|
package/src/utils.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import fs from 'fs-extra';
|
|
5
|
+
import { globSync } from 'glob';
|
|
5
6
|
import axe, { Rule } from 'axe-core';
|
|
6
7
|
import { v4 as uuidv4 } from 'uuid';
|
|
7
8
|
import { getDomain } from 'tldts';
|
|
@@ -382,10 +383,22 @@ export const cleanUp = async (randomToken?: string, isError: boolean = false): P
|
|
|
382
383
|
randomToken = constants.randomToken;
|
|
383
384
|
}
|
|
384
385
|
|
|
385
|
-
if (constants.userDataDirectory)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
if (constants.userDataDirectory) {
|
|
387
|
+
try {
|
|
388
|
+
fs.rmSync(constants.userDataDirectory, { recursive: true, force: true });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Also remove _pool* sibling directories created by browser pool re-launches
|
|
394
|
+
try {
|
|
395
|
+
const poolDirs = globSync(`${constants.userDataDirectory}_pool*`);
|
|
396
|
+
for (const dir of poolDirs) {
|
|
397
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
consoleLogger.warn(`Unable to remove pool directories: ${error.message}`);
|
|
401
|
+
}
|
|
389
402
|
}
|
|
390
403
|
|
|
391
404
|
if (process.env.TMPDIR && fs.existsSync('/.dockerenv')) try {
|
/package/{5708fbbe-2d91-4b1b-90e5-eacbed0a74a8.txt → 0a93900f-3345-4f25-8be7-c2d3748034be.txt}
RENAMED
|
File without changes
|